There are two approaches when it comes to using modern coding agents. On one end of the spectrum is pure vibe coding: you let the agent build and never look at the code. The other end has been dubbed agentic engineering: you treat the agent as a co-developer, and you discuss and review all changes in detail.
At CosmoCode, we usually use the latter approach. We want to own and understand the code we deliver. Coding agents can help us deliver better code, not necessarily more code or code built faster.
But if the approach to coding agents is a spectrum, then there should be a middle ground as well. I like to call it “vibe engineering”. Vibe engineering is a more relaxed approach to coding agents: you still review the code it generates, but you don’t scrutinize every line of it.
I like this approach for personal tools that aren’t purely one-off scripts, but also aren’t production code. These are tools that I want to use for myself, but that I don’t necessarily want to turn into real “projects”.
One of my colleagues asked me if I could describe how I approach vibe engineering for such a personal tool, using a real example. This is easier said than done, but I’ll try anyway. I will give some examples of the kind of interactions I have with the agent without reproducing the entire conversation. I’ll hide the full Claude answers behind a details tag, so you can drill into them if you want to see the full context. I will still omit the tool calls.
Step 1: Brainstorming
We’re running most of our services in a Kubernetes cluster, and sometimes you need to move files to or from a storage volume mounted to a specific pod. This can be done with kubectl cp. However, kubectl cp does not take care of copying last modified dates. The workaround is to use kubectl exec and tar to stream to stdout, then use a second tar to read stdin on the other side: classic Unix tooling. But constructing the right kubectl exec call is non-trivial.
I wanted a simple, Midnight Commander-like, two-panel TUI interface for copying files. One side shows my local computer and the pod’s filesystem is shown on the other side. Files can simply be selected and copied by the press of a button.
To figure out how feasible this is, I actually started in Claude chat. I find that the chat interface is a better way to do initial brainstorming than jumping right into Claude Code - especially when I am not even sure I want to pursue the project in the end. I can ask questions, get suggestions, and iterate on the idea without constantly having to tell Claude not to start coding. But yes, I could also have used Claude Code’s plan mode.
question: how complicated would it be to create a simple TUI tool that uses kubectl and tar to allow for easy file transfer between the local machine and a kubernetes pod using a two panel layout? (tar to preserve timestamps and file permissions)
Claude explains how it could work and offers three approaches
Not very complicated. A working version is a weekend project; a polished one is maybe a week.Here’s the breakdown:
The core mechanics are easy. kubectl already does the heavy lifting:
- List remote files:
kubectl exec <pod> -- ls -la --time-style=full-iso /path(parse the output)- Pull a file/dir preserving metadata:
kubectl exec <pod> -- tar cf - -C /parent target | tar xf - -C /local/dest- Push:
tar cf - -C /local target | kubectl exec -i <pod> -- tar xf - -C /remote/destThis is essentially what
kubectl cpdoes internally (it’s tar-based), but you get to control flags like--numeric-owner,-p, etc. yourself.The TUI is the bulk of the work. A two-panel layout (local on left, remote on right, Tab to switch focus, arrows to navigate, Enter to descend, F5/F6 to copy — basically Midnight Commander) is a well-trodden pattern. The real effort goes into directory navigation state, scrolling, selection, and async transfers so the UI doesn’t freeze.
Recommended stack:
- Go —
bubbletea/bubblesfor the TUI, and you can useclient-goto talk to the API directly (including the exec/SPDY streaming) instead of shelling out. Natural fit since kubectl is Go.- Python —
textualfor the TUI is very fast to build in; shell out tokubectlor use thekubernetesclient library. Lowest effort to a prototype.- Rust —
ratatuiif you want a single static binary and don’t mind more work.The fiddly parts (where the time actually goes):
- Pods often lack
tar(especially distroless/scratch images) — you need a fallback or a clear error.- Multi-container pods need
-c <container>.- Async progress reporting for large transfers without blocking the event loop.
- Error handling for permissions, broken pipes, missing paths.
- Context/namespace selection.
If you shell out to
kubectland use Textual or Bubbletea, a usable single-pod-context prototype is genuinely a 1–2 day effort. Going to API-direct streaming, progress bars, multi-namespace browsing, and the tar-missing fallbacks is what stretches it toward a week.Want me to sketch out a starter implementation in one of these stacks?
Hmm I would tend towards go because I like the single binary and I know a bit of go. But you say it’s the most difficult option?
Claude suggests to keep things simple
Not the most difficult — I’d put it in the middle. The ordering I had in mind:
- Python/Textual — fastest to a prototype, but you don’t get the single binary you want (you’re shipping a Python env or doing PyInstaller gymnastics).
- Go/Bubbletea — slightly more upfront ceremony than Python, but you get the single static binary, and you already know some Go. This is the sweet spot for your constraints.
- Rust/ratatui — most work (borrow checker + more manual TUI plumbing), only worth it if you specifically want it.
So Go isn’t the hard option — it’s the right one for what you care about. Python only “wins” on raw speed-to-prototype, and it loses the thing you explicitly said you like.
One more decision inside the Go path that affects difficulty:
Shell out to
kubectl(runexec.Command("kubectl", ...)) — much simpler. You let the user’s existing kubeconfig/context do its thing, and you just pipe tar streams around. This keeps you firmly in the “1–2 day prototype” zone.Use
client-godirectly — no kubectl dependency, cleaner streaming, butclient-gohas a real learning curve (the exec/SPDY/remotecommand setup is notoriously fiddly, and kubeconfig loading has its own boilerplate). This is what pushes things toward the week.My suggestion: start by shelling out to
kubectl. You get a working tool fast, and you can swap the transfer layer forclient-golater without touching the TUI — if you keep a clean interface like:type Transfer interface { List(pod, path string) ([]FileInfo, error) Pull(pod, remotePath, localPath string) error Push(pod, localPath, remotePath string) error }Then the kubectl-shelling implementation and a future client-go implementation are just two structs satisfying the same interface.
Want me to put together a starter Bubbletea skeleton with the two-panel layout and a kubectl-backed
Transferimplementation?
can you create a PLAN file that I can pass to a coding agent?
At that point, Claude created a plan artifact, which I read through and addressed my concerns about certain details.
one question about the numeric UIDs. when the pod container runs rootless, you probably can not change UIDs?
After Claude clarified, I asked it to update the plan, and then I was ready to start coding.
PLAN.md:
PLAN:
k8tc— A Two-Panel TUI for Kubernetes Pod File TransferGoal
Build a terminal UI tool, written in Go, that lets a user browse the local filesystem and a Kubernetes pod’s filesystem side-by-side in a two-panel (Midnight Commander style) layout, and transfer files/directories between them. Transfers use
tarstreamed overkubectl execso that timestamps and file permissions are preserved.Ship as a single static binary.
Non-Goals (v1)
- No
client-gointegration. v1 shells out to the user’s existingkubectl. (Keep the transfer layer behind an interface soclient-gocan be added later without touching the TUI.)- No editing of remote files in-place.
- No multi-pod parallel transfers.
- No Windows-specific polish (target Linux/macOS; it may work on Windows but that is not a v1 requirement).
Tech Stack
- Language: Go (1.22+)
- TUI framework: Bubble Tea (
github.com/charmbracelet/bubbletea) with Bubbles components and Lip Gloss for styling.- External dependency at runtime:
kubectlmust be on the user’sPATHand configured (valid kubeconfig / current context). The target pod must havetaravailable in the chosen container.Architecture
Package layout
cmd/k8tc/main.go # entrypoint, flag parsing, bubbletea program start internal/transfer/ # the Transfer interface + kubectl implementation transfer.go # interface + shared types (FileInfo) kubectl.go # kubectl-backed implementation internal/local/ # local filesystem browsing (List/Stat helpers) internal/ui/ # bubbletea model, panels, key handling, rendering model.go panel.go keys.go styles.goCore interface
The transfer layer is abstracted so the kubectl implementation can later be swapped for a
client-goone:package transfer import "time" type FileInfo struct { Name string Size int64 Mode string // e.g. "drwxr-xr-x" IsDir bool ModTime time.Time } type Transfer interface { // List returns directory contents at path inside the pod. List(pod, container, path string) ([]FileInfo, error) // Pull copies remotePath (file or dir) from the pod to localPath, preserving metadata. Pull(pod, container, remotePath, localPath string, progress func(n int64)) error // Push copies localPath (file or dir) into the pod at remotePath, preserving metadata. Push(pod, container, localPath, remotePath string, progress func(n int64)) error }The local filesystem panel does not go through
Transfer; it uses theinternal/localhelpers directly. Only the remote panel usesTransfer.Transfer Mechanics (the important bit)
All remote operations shell out to
kubectl. Build commands withos/exec.CommandContextand stream stdin/stdout — never buffer whole files in memory.Listing remote files
kubectl exec <pod> [-c <container>] -- ls -la --full-time <path>Parse the output into
[]FileInfo. Notes:
- Use
--full-time(GNU coreutils) for a parseable ISO timestamp. If that fails (BusyBox), fall back tols -laand accept coarser/absent mtimes rather than erroring out.- Skip the
total Nfirst line.- Always synthesize a
..entry for navigation (unless at/).- Detect directories from the leading
din the mode string.Pull (pod → local), metadata-preserving
kubectl exec <pod> [-c <container>] -- tar cf - -C <remoteParent> <remoteBase> \ | tar xpf - --no-same-owner -C <localDest>
tar con the remote side, piped totar xplocally (-ppreserves mode + mtime).--no-same-owneris the default for pulling — see “tar flags & ownership” below for why.- Run the local
tarviaexec.Commandand connect the kubectl stdout to its stdin with anio.Pipe(orcmd.StdoutPipe()→cmd2.Stdin).- Wrap the pipe in a counting
io.Readerto drive theprogresscallback.Push (local → pod), metadata-preserving
tar cf - -C <localParent> <localBase> \ | kubectl exec -i <pod> [-c <container>] -- tar xpf - --no-same-owner -C <remoteDest>
- Note the
-ionkubectl execso stdin is forwarded.--no-same-owneragain by default — in a rootless container the extract cannot chown anyway (see below); this makes the intent explicit and avoids warnings.- Same counting-reader trick for progress.
tar flags & ownership
Mode bits and mtime are reliably preserved without privilege. Owner UID/GID is not — treat it as best-effort.
When
tar xruns withoutCAP_CHOWN(extracting on your local machine as a normal user, or inside a rootless pod), thechown()calls fail withEPERM. GNU tar’s default for a non-root extract is to silently drop ownership restore and create files owned by the extracting user — it does not hard-fail. So a blanket--numeric-owneron extract buys nothing in the common case: it only controls how a UID is chosen (by number vs. name lookup), not whether tar is allowed to apply it.Defaults, therefore:
- Create side:
tar --numeric-owner -cf - ...Numeric is harmless here and avoids name-lookup surprises when packing.- Extract side (default):
tar -xpf - --no-same-owner ...Preserves mode + mtime, and explicitly tells tar not to attempt chown. This is the right default for both directions:- Pulling to local: you almost never want the pod’s UIDs applied on your machine anyway (UID 1000 in the pod ≠ you).
- Pushing to a rootless pod: the chown would no-op regardless, so don’t pretend otherwise.
Opt-in ownership preservation: add a
--preserve-ownershipflag tok8tc. When set, usetar --same-owner --numeric-owner -xpf - ...on the extract side. This only does anything useful when the extracting end is privileged (root in the container, or root locally); otherwise it degrades to the same best-effort behavior. Document this clearly so users aren’t surprised when UIDs don’t carry across into a rootless target.What actually hard-fails is unrelated to ownership: writing into a directory you lack write permission for, or a restored directory mode that locks tar out mid-extract. Those surface as
EPERM/EACCESon the file ops themselves and should be reported per-transfer (see Error Handling).TUI Behavior
Layout
Two equal-width panels filling the terminal, a header line, and a footer/status line.
┌─ LOCAL: /home/user/project ──┐┌─ POD nginx-abc:/var/www ──────┐ │ .. ││ .. │ │ > src/ ││ index.html │ │ README.md ││ assets/ │ │ go.mod ││ │ │ ││ │ └──────────────────────────────┘└────────────────────────────────┘ Tab: switch ↑↓: move ⏎: open F5: copy q: quit [status...]
- The focused panel has a highlighted border; the cursor row is highlighted.
- Each panel maintains its own
cwd, file list, cursor index, and scroll offset.Keybindings
Key Action TabSwitch focus between local and remote panel ↑/↓Move cursor PgUp/PgDnPage cursor EnterIf dir: descend; if ..: go up; if file: no-op (v1)F5/cCopy highlighted entry from focused panel → other panel’s cwd rRefresh focused panel q/Ctrl+CQuit Async transfers
Transfers must not block the event loop. Use the Bubble Tea pattern:
- On
F5, dispatch atea.Cmdthat runs thePull/Pushin a goroutine and returns atransferDoneMsg{err}(and intermediatetransferProgressMsg{n}via a channel +tea.Tickor a custom message pump).- While in flight, show progress/byte-count in the status line and disable further copy actions.
- On completion, refresh the destination panel and clear status.
CLI
k8tc --pod <name> [--namespace <ns>] [--container <name>] [--remote-path <path>] [--local-path <path>]
--pod(required for v1)--namespace/-n→ passed through askubectl -n--container/-c→ passed through askubectl exec -c; if omitted, let kubectl pick the default container--remote-pathinitial remote dir (default/)--local-pathinitial local dir (default.)--preserve-ownershipattempt to restore owner UID/GID on extract (--same-owner --numeric-owner). Off by default; only effective when the extracting end is privileged. See “tar flags & ownership.”(Stretch: a pod picker if
--podis omitted, viakubectl get pods -o json.)Error Handling & Edge Cases
The agent must handle these explicitly, surfacing errors in the status line rather than crashing:
kubectlnot found on PATH → fail fast at startup with a clear message.tarmissing in the pod (distroless/scratch images) → detect the exec failure and show: “pod has notar; cannot transfer.” Do not hang.- Multi-container pod with no
--container→ kubectl will error; surface its message and hint to pass-c.- Permission denied on read (local or remote) → show per-transfer error, keep the UI alive.
- BusyBox
lslacking--full-time→ fall back gracefully (see Listing).- Broken pipe / context cancel mid-transfer → clean up both processes (
CommandContext+cmd.Wait()on both ends; kill the partner on failure).- Empty directories and the root
/(no..).- Large files → never read fully into memory; always stream.
- Spaces / special chars in paths → pass paths as separate
exec.Commandargs (no shell string interpolation); when piping twoexec.Cmds, do it in Go via pipes, not via ash -c "... | ..."string.Suggested Build Order (milestones)
- Transfer interface + kubectl
List. CLI prints a remotels. Verify parsing against a real pod.- Local
List. Mirror the sameFileInfofor the local FS.- Static two-panel render (Lip Gloss) with both panels populated, no interaction.
- Navigation: focus switching, cursor movement,
Enterto descend/ascend, scroll offset, refresh.Pull(pod → local) synchronous first, then move it onto the asynctea.Cmdpattern with a status line.Push(local → pod) same shape as Pull.- Progress reporting via counting reader → status line.
- Edge-case hardening from the list above (tar-missing, busybox ls, cancellation).
- Polish: styling, help footer,
--namespace/--containerplumbing.Milestones 1–6 are the usable prototype. 7–9 are the path to “done.”
Acceptance Criteria
- Launches with
k8tc --pod <p>and shows local + remote panels.- Tab switches focus; arrows + Enter navigate both filesystems.
- F5 copies the highlighted file or directory in the focused panel into the other panel’s current directory.
- Transferred files retain original mtime and permission (mode) bits (verify with
staton both ends). Owner UID/GID is best-effort: preserved only with--preserve-ownershipagainst a privileged extract target.- Directory transfers are recursive and also preserve metadata.
- A transfer of a large file does not freeze the UI and shows progress.
- Missing
tarin the pod produces a clear error, not a hang or panic.- Builds to a single static binary:
CGO_ENABLED=0 go build.Future (post-v1, do not build now)
- Swap the kubectl-backed
Transferfor aclient-goimplementation (exec viaremotecommandSPDY) to drop thekubectlruntime dependency.- Pod/namespace picker UI.
- Multi-select and queued transfers.
- File preview / view pane.
- Delete / rename / mkdir operations.
The main takeaway here is not to start the implementation in the chat. That’s the entirely wrong environment. Get a PLAN artifact and take it to the code environment.
Step 2: Initial Implementation
It was time to move to Claude Code. I run Claude in a custom sandbox using bubblewrap on Linux. This allows me to safely run in auto mode, which means Claude is running mostly autonomously with an LLM classifier as the only safeguard.
In this concrete example, the sandbox prevented Claude from accessing my kubectl config and thus from running kubectl commands against our cluster unchecked.
I added the PLAN.md to a new repository and let Claude one-shot the initial implementation.
read @PLAN.md and implement it
About 20 minutes later, Claude reported that it was done. Time to give the tool a quick test.

It worked just as specified ✨️. I could have stopped here if this were an one-off tool.
Step n: Review
Before I decided to add more features, I wanted to review the code.
Another reason why I decided to use Go is that the code is usually more straightforward and easier to review than Python or Rust. Go tends to have only one way to do things, which makes it easier for the agent to generate code that is correct and idiomatic. And that in turn makes it easier for me to review the code and understand what it does.
When vibe engineering, I mostly look at architectural decisions and code structure. I don’t want to nitpick every line of code, but I want to make sure that the overall design makes sense and that the code is organized in a way that is maintainable.
As an example, here is the review session I had with Claude right after the initial implementation.
Let’s review the current state. I see that we have a FileInfo struct and a Transfer interface. The comment says that this only covers the remote side and that the local side does not use the interface. But then looking at local.go it uses transfer.FileInot and transfer.Sort so it seems the two sides are not that different?
Claude gives an explanation that doesn’t fully satisfy me
I read all four files. Here’s what’s actually going on — the tension you spotted is real but it’s conflating two different things that live in thetransf erpackage.There are two separate things in
transfer.go
- A shared data layer — the
FileInfostruct (transfer.go:15) and theSorthelper (transfer.go:40). Both panels use these, by design. That’s whyFileInfo’s own comment says “on either the local or the remote side.”- The
Transferinterface —List/Pull/Push(transfer.go:25). This is what the local side doesn’t use.The comment at transfer.go:23-24 is talking strictly about #2. And it’s literally accurate: the local panel calls
local.List(p)— a plain package functi on (model.go:109) — whereas the remote panel dispatches through the interface,t.List(...)(model.go:111). So when the comment says “does not go through this interface,” it means local doesn’t implement theList/Pull/Pushmethod set, not that local is walled off from the package.So your instinct is half right: the display side is barely different (shared types + shared sort), but the transfer side is fundamentally asymmetr ic.
Why local isn’t behind the interface
Two reasons, and the second is the deeper one:
- Nothing to swap. The package doc says the interface exists “so the kubectl implementation can later be swapped for a client-go one.” The local files ystem has no such alternative — it’s always
os.ReadDir. An interface with exactly one possible implementation is just ceremony.- The local side has no
Pull/Pushof its own. Look at the signatures:Pull(pod, container, remotePath, localPath, ...)andPush(pod, container, localPath, remotePath, ...)— theKubectlvalue already straddles both ends; it reads/writes local paths itself (it’s the localtarprocess inpi pe). SoTransferisn’t really “the remote filesystem” — it’s “the thing that bridges local↔remote.” The only purely-local operation is listing a direc tory, and that’s a one-liner overos.ReadDir. There’s no “local Transfer” concept to even abstract.That’s the real answer to “are the two sides that different?”: for browsing, no; for transferring, there is no local side — it’s subsumed into
Kubectl.Two minor warts worth noting in the review
FileInfo/Sortlive in the package named after the remote side, yet are used by both. It’s pragmatic (local imports transfer, not vice versa), but it’s why the comment has to keep clarifying “this is also used locally.” If you wanted stricter cohesion you’d hoistFileInfo+Sortinto a small shared package (e.g.fileinfo) that bothtransferandlocalimport — but that’s arguably over-engineering for two callers.- The
../root-synthesis logic is duplicated — local.go:36-38 vs kubectl.go:139-141. The duplication is justified: local usesfilepath(OS separators) and remote usespath(always/, inside a Linux pod), so they can’t trivially share it. But it’s worth a one-line comment on each noting the deliberate split, since they’ll need to stay in sync.If you want, I can tighten the interface comment to say “the
List/Pull/Pushmethod set” instead of “this interface” (to kill the ambiguity you hit), or sketch thefileinfoextraction so you can judge whether it’s worth it. Which direction do you want to take?
I feel like if there are two concerns (transfer and file representation) then the two should be separated. It would make it less weird that local reuses the file representation then
Claude refactors and mentions another issue
Decisions I made
- Renamed
FileInfo→file.Info.file.FileInfowould stutter;file.Infois the idiomatic Go form.Sortkeeps its name (file.Sort). If you’d rather preserve theFileInfoname, say so and I’ll rename — it’s a mechanical change.- Reworded the
Transferdoc comment to describe it as a local↔remote bridge (List reads the pod; Pull/Push move trees between pod and disk), which is the accurate framing we landed on in the previous discussion — and it no longer needs to awkwardly disclaim the shared type, since the type now visibl y lives in its own package.Still untouched (deliberately)
The
../root-synthesis duplication betweenlocal.ListandparseLSis still there — that’s thefilepath-vs-pathsplit we discussed, and it’s ortho gonal to this refactor.filewould be the natural home for a shared helper if you ever want to collapse it, but I left it alone since it’d need the sepa rator parameterized. Want me to tackle that too?
I don’t remember discussing filepath-vs-path - what’s that about?
Claude explains the issue and recommends to ignore it
Fair — I overstated it as “discussed.” It was a one-line aside in my earlier review, not something we actually worked through. Here’s what I meant.It’s about two different standard-library packages in Go:
path— manipulates always-slash-separated paths (/). It’s what you use for URLs and anything that is defined to use forward slashes regardless of the host OS.path/filepath— manipulates paths using the host OS’s rules:/on Linux/macOS,\on Windows.In k8tc the two panels live on different sides of that line:
The local panel walks the filesystem of whatever machine k8tc runs on, so it correctly uses
filepath:That last grep is the clearest illustration:
kubectl.gouses both packages, deliberately. The remote (pod) side usespath(lines 141, 149–150) and the local side of the very same transfer usesfilepath(lines 158–159).The reason: a pod is a Linux container, so its filesystem paths are always
/-separated, no matter what OS k8tc itself is running on. If you usedfilepathon a pod path while running k8tc on Windows, you’d get backslash semantics applied to a path that is genuinely slash-based — wrong. So pod paths must go throughpath; host paths must go throughfilepath.Now, the
..-synthesis duplication I flagged. The two snippets are:// local.go:36 — host filesystem if filepath.Clean(dir) != string(filepath.Separator) { // kubectl.go:141 — pod filesystem if path.Clean(dir) != "/" {They’re structurally identical (“if this isn’t the root, prepend a
..entry”), but they differ in exactly the two spots where the path-flavor matters:
- which
Clean— OS-aware vs. always-slash- what counts as root —
string(filepath.Separator)(the host’s root) vs. the literal"/"(the pod’s root)So you can’t just lift the snippet into one shared function and call it from both sides — you’d have to parameterize it with “which path semantics,” e.g. pass in the clean-func and the root sentinel. That’s why I called the duplication “justified”: collapsing it would trade two obvious 3-line blocks for one indirection-laden helper, and arguably make it less clear that one side is host-pathed and the other is pod-pathed.
So nothing for you to do here — I just wanted to flag that the duplication is intentional, not an oversight, in case it caught your eye during review. It’s unrelated to the
fileextraction we just did.
Hmm to me it sound like local and remote should implement at least Transfer.List (don’t implement, discuss)
Claude suggests different ways to address the issue
Good instinct — and it points at a real seam. Let me lay out the case for and against, because there’s a genuine obstacle that’s worth seeing clearly. First let me confirm one thing the argument hinges on:Confirmed what I needed:
pod/containerare set once inNew(model.go:66–67) and only ever read after that. They’re session-constant, yet they’re threaded as arguments into every singleList/Pull/Pushcall. That detail is the whole crux of your suggestion. Here’s the discussion.Your instinct is right — for
ListspecificallyThe two operations in
Transferhave fundamentally different shapes:
Listis symmetric and one-sided:(path) → []file.Info. Both panels do exactly this. There’s nothing pod-specific about the operation itself — “read a directory, give me the entries.”Pull/Pushare asymmetric and two-sided: they move a tree across the local↔pod boundary. There is no “local Pull”; the kubectl process is inherently both ends at once. These genuinely belong only to the bridge.So
Transfertoday actually bundles two responsibilities — browsing and moving — and you’ve spotted that browsing is the half that’s symmetric. This is the same kind of cut we just made pullingfileout: separate the thing that’s shared from the thing that isn’t.The obstacle: the signature carries
pod/containerWhy local can’t satisfy
Transfer.Listas written:Kubectl: List(pod, container, path string) ([]file.Info, error) // 3 path-ish args local: List(path string) ([]file.Info, error) // 1The local side has no pod or container, so it can’t match. But — as the grep above shows —
pod/containerare session-constant. They don’t need to be per-call args; they’re only per-call because the interface made them so. Bind them once and the signatures converge:type Lister interface { List(path string) ([]file.Info, error) }
- local: a zero-field
local.FS{}whoseList(path)wraps today’slocal.List.- remote: a tiny adapter holding
pod/container, whoseList(path)callskubectl.List(pod, container, path).What it buys, concretely
loadPanelloses itswhich == focusLocalfork (model.go:108–112):// before if which == focusLocal { files, err = local.List(p) } else { files, err = t.List(pod, container, p) } // after — each Panel owns its own lister files, err = m.panelPtr(which).lister.List(p)Each
Panelbecomes self-sufficient: it knows how to load itself, and the Model stops caring whether a panel is local or remote when listing. That’s a real improvement in the model’s coherence, and it makes test fakes trivial — aListerfake is one method, versus today’sfakeTransferthat must stubPull/Pusheven for a listing test.Where I’d put the interface
The idiomatic Go move: define
Listerin the consumer (ui), not the producer. Thenlocal.FSsatisfies it structurally without importing anything, andlocalstays fully decoupled (notransferimport — which we just worked to remove). The remote adapter can live inuitoo.transfer.TransferkeepsListas the backend capability (the future client-go backend must still list), and the adapter just curriespod/containeronto it.Two honest caveats
- It doesn’t unify everything.
Pull/Pushstay asymmetric and the push-vs-pull decision stays in the UI (model.go:289/292). TheListercut only simplifies browsing. That’s fine — but don’t expect it to collapse the transfer logic.- It doesn’t touch the
../filepath-vs-pathduplication. Each side’sListstill synthesizes..its own way. Orthogonal to this.My recommendation
Worth doing, at the lightweight end: introduce a consumer-side
Lister, adapt both sides, give eachPanelits own. It’s contained, reversible, and improves the conceptual model.I’d stop short of the maximal version (ripping
pod/containerout of theTransferinterface entirely and binding them atKubectlconstruction). That’s arguably “more correct” — it’d kill the per-call threading onPull/Pushtoo — but it turnsKubectlfrom a reusable engine into a session-scoped object, and the blast radius is much larger for a payoff that’s mostly aesthetic.The deciding question is really: do you want each
Panelto “own how it lists itself”? If yes, theListeris the clean expression of that. If the 6-line branch inloadPaneldoesn’t bother you, the status quo is defensible too — the asymmetry it encodes (only the remote side is swappable) is real.
TBH your “maximal version” sounds more correct. I feel like there are distinct responsibilities. A kubernetes package which wraps around kubectl and handling the tar commands. A ls parser (used probably remotely only). A Transfer manager that implements the push/pull mechanism. And remote/local implementations of the same “virtual” file system list. Would you agree?
Claude comes up with a detailed proposal for what I suggested
Yes — broadly I agree. Those are real, separable responsibilities, and the fact that you can name them this cleanly is a good sign the seams are real and not invented. But I’d refine one of your four boundaries, and in doing so I think the decomposition gets sharper than what you sketched. Let me give you the version I’d actually defend.The dependency graph I’d aim for
file Info + Sort (done; depends on nothing) ▲ ▲ │ └──────────────┐ parser local Lister kube ← the swap seam (ls -la → []Info) (os.ReadDir) ▲ ▲ ▲ │ │ └────── remote Lister ─────────────────┘ │ (ls via kube + parse) │ transfer (Pull/Push) (tar streaming over kube)
file— representation. Done.kube— the exec/stream primitive: “run this command in this pod/container, give me stdin/stdout/stderr.” Holds the boundpod/container(killing the per-call threading you spotted). Knows nothing about tar, ls, orfile.Info.- parser — pure
[]byte → []file.Info. Remote-only, as you said (locallyos.ReadDiralready hands back structured entries; nothing to parse).- two Listers — local (
os.ReadDir) and remote (kube+ parser), both satisfying oneList(path) → []file.Info.transfer— the Pull/Push manager: tar create/extract + the streaming pipe, usingkubefor the remote endpoint and localexecfor the local one.No cycles, clean DAG. Each node has an independent test story — and notably the tests you already have fall out naturally:
parse_test.go(pure parser), andfakeKubectl(which makes the “pod” just the local fs) is really a test of the transfer streaming independent of real k8s.Where I’d push back: tar does not belong in the
kubepackageYou said the kubernetes package wraps kubectl and the tar commands. I’d split those. Put only the exec/stream primitive in
kube; keep tar (createArgs/extractArgs/pipe/classify) in thetransfermanager.The reason is your own swappability rationale. The package doc promises “swap the kubectl implementation for a client-go one without touching the TUI.” The thing that differs between kubectl and client-go is how you exec into a pod and get a byte stream — not tar. tar runs identically either way. So the swap seam is the exec primitive, and it must be tar-agnostic, or every transfer detail leaks across the seam. If
kubeowns tar, you haven’t isolated the swappable part; you’ve just renamedKubectl.The non-obvious consequence — and why this is the version that makes the doc true
Here’s the part I think is the real payoff, and it’s hidden in
pipe:func pipe(src, dst *exec.Cmd, progress func(int64)) error
pipewires together two*exec.Cmd. That’s fine for kubectl (both endpoints are local processes —kubectl exec …is itself a local process). But client-go does not give you an*exec.Cmd— it gives you aremotecommandexecutor you handStdin/Stdout/Stderrio streams. So today’sTransferinterface claims client-go swappability while its internals (two*exec.Cmd) would actively fight it. The swappability is currently aspirational, not real.The decomposition you’re proposing fixes exactly that — if the
kubeseam is expressed as streams (Run(cmd, stdin io.Reader, stdout, stderr io.Writer) error) rather than*exec.Cmd, thenpipeconnects two stream endpoints, the local tar side is one impl, the pod side is another, and kubectl-vs-client-go becomes a true drop-in. So this isn’t just tidying — it’s the refactor that would make the package doc’s promise honest. That’s the strongest argument for going maximal.Two cautions
- Granularity. Four responsibilities ≠ four packages. The parser is ~45 lines of pure code — I’d keep it as
parse.goinside the remote-listing package with its existing test, not a package of its own. A package per function is its own smell. The boundaries that earn a package arefile,kube,local,transfer, and the remote side; whether remote-List and Pull/Push are one “pod” package or two is a coin-flip I’d decide on cohesion (I lean: onepod/remotepackage exposing both, since they share thekubeclient and the “pod-ness” — but your capability-split is defensible).- Cost is front-loaded in
pipe. Everything else (extractkube, bind pod/container, add Listers) is mechanical and low-risk. Re-expressingpipearound streams instead of*exec.Cmdis the one genuinely fiddly change, and it only pays off the day someone actually writes the client-go backend. If that day is hypothetical, you can stop short: extractkubeas an*exec.Cmdfactory (still removes the threading, still separates concerns) and leavepipealone. You lose nothing you have today and can tighten the seam later.So, sequencing
If you want to do this, I’d go in dependency order, each step independently shippable and green:
- Extract
kube(exec primitive, pod/container bound) — removes the per-call threading.- Introduce the
Listerinterface + local/remote impls — collapses theloadPanelbranch.- Move
transferto Pull/Push-only on top ofkube; isolate the parser into the remote lister.- Optional, later: re-express
pipe/kubearound streams when (if) client-go becomes real.Net: I agree with your four-way split, with the single amendment that tar lives with
transfer, notkube— and the headline is that doing this is what would finally make the “swap to client-go” sentence in the package doc actually true rather than aspirational.Want me to start at step 1?
I like your analysis and corrections. It seems like a sound plan. Implement the changes fully
The final result was a lot cleaner than the original first version. At the same time, I had gotten a rough overview of the codebase.
As you can see, I often try to give Claude open-ended questions to coax it to “think” through the problem and come up with its own analysis, rather than just asking for a specific change. Only when I am confident that I have understood the issue and solution well enough do I come in with specific guidance. A question: prefix or an explicit discuss, don't code can keep it from jumping to code before the design is clear without having to switch back to planning mode.
The example above also shows the importance of pushing back on the agent’s suggestions when they don’t feel right. Claude often tries to take the lazy way out ;-)
Step n+1: Add Features
With a first prototype and a clearer understanding of the codebase, I can now start adding features. I won’t go through all the details here, but the process is similar to what we did before: identify a feature, discuss it with Claude, and then ask it to implement it.
Currently only a single item can be copied. I would like to be able to mark multiple files or directories and then copy them over. Before the copy starts, a confirmation dialog should be shown. While the copy is running a progress dialog (with abort button) should be shown instead of using the status line for progress tracking. Can you come up with a plan first?
Once a feature is planned and implemented, it’s a good time to review the code again and make more architectural improvements if needed. Repeat until you’re happy.
I usually start a new session for each feature and for each review.
Final Result
The final tool is available at its GitHub repository. Following the commits, you can see the sequence of adding new features and refactorings in between.
The final tool has the following features:
- selecting multiple files and directories
- copying files and directories
- deleting files and directories
- creating new directories
- confirmation dialogs for copy and delete operations
- abortable progress dialog for copy and delete operations
I might add a pod/container selector on startup in the future, but for now it’s doing exactly what I wanted it to do, and the whole process took about four hours of work. Incidentally, writing this blog post took nearly the same amount of time ;-)
