Vibe Engineering a Personal Tool

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/dest

This is essentially what kubectl cp does 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:

  • Gobubbletea/bubbles for the TUI, and you can use client-go to talk to the API directly (including the exec/SPDY streaming) instead of shelling out. Natural fit since kubectl is Go.
  • Pythontextual for the TUI is very fast to build in; shell out to kubectl or use the kubernetes client library. Lowest effort to a prototype.
  • Rustratatui if 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 kubectl and 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 (run exec.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-go directly — no kubectl dependency, cleaner streaming, but client-go has 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 for client-go later 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 Transfer implementation?

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 Transfer

Goal

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 tar streamed over kubectl exec so that timestamps and file permissions are preserved.

Ship as a single static binary.

Non-Goals (v1)

  • No client-go integration. v1 shells out to the user’s existing kubectl. (Keep the transfer layer behind an interface so client-go can 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: kubectl must be on the user’s PATH and configured (valid kubeconfig / current context). The target pod must have tar available 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.go

Core interface

The transfer layer is abstracted so the kubectl implementation can later be swapped for a client-go one:

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 the internal/local helpers directly. Only the remote panel uses Transfer.


Transfer Mechanics (the important bit)

All remote operations shell out to kubectl. Build commands with os/exec.CommandContext and 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 to ls -la and accept coarser/absent mtimes rather than erroring out.
  • Skip the total N first line.
  • Always synthesize a .. entry for navigation (unless at /).
  • Detect directories from the leading d in 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 c on the remote side, piped to tar xp locally (-p preserves mode + mtime). --no-same-owner is the default for pulling — see “tar flags & ownership” below for why.
  • Run the local tar via exec.Command and connect the kubectl stdout to its stdin with an io.Pipe (or cmd.StdoutPipe()cmd2.Stdin).
  • Wrap the pipe in a counting io.Reader to drive the progress callback.

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 -i on kubectl exec so stdin is forwarded.
  • --no-same-owner again 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 x runs without CAP_CHOWN (extracting on your local machine as a normal user, or inside a rootless pod), the chown() calls fail with EPERM. 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-owner on 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-ownership flag to k8tc. When set, use tar --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/EACCES on 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

KeyAction
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 a tea.Cmd that runs the Pull/Push in a goroutine and returns a transferDoneMsg{err} (and intermediate transferProgressMsg{n} via a channel + tea.Tick or 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 as kubectl -n
  • --container / -c → passed through as kubectl exec -c; if omitted, let kubectl pick the default container
  • --remote-path initial remote dir (default /)
  • --local-path initial local dir (default .)
  • --preserve-ownership attempt 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 --pod is omitted, via kubectl get pods -o json.)


Error Handling & Edge Cases

The agent must handle these explicitly, surfacing errors in the status line rather than crashing:

  1. kubectl not found on PATH → fail fast at startup with a clear message.
  2. tar missing in the pod (distroless/scratch images) → detect the exec failure and show: “pod has no tar; cannot transfer.” Do not hang.
  3. Multi-container pod with no --container → kubectl will error; surface its message and hint to pass -c.
  4. Permission denied on read (local or remote) → show per-transfer error, keep the UI alive.
  5. BusyBox ls lacking --full-time → fall back gracefully (see Listing).
  6. Broken pipe / context cancel mid-transfer → clean up both processes (CommandContext + cmd.Wait() on both ends; kill the partner on failure).
  7. Empty directories and the root / (no ..).
  8. Large files → never read fully into memory; always stream.
  9. Spaces / special chars in paths → pass paths as separate exec.Command args (no shell string interpolation); when piping two exec.Cmds, do it in Go via pipes, not via a sh -c "... | ..." string.

Suggested Build Order (milestones)

  1. Transfer interface + kubectl List. CLI prints a remote ls. Verify parsing against a real pod.
  2. Local List. Mirror the same FileInfo for the local FS.
  3. Static two-panel render (Lip Gloss) with both panels populated, no interaction.
  4. Navigation: focus switching, cursor movement, Enter to descend/ascend, scroll offset, refresh.
  5. Pull (pod → local) synchronous first, then move it onto the async tea.Cmd pattern with a status line.
  6. Push (local → pod) same shape as Pull.
  7. Progress reporting via counting reader → status line.
  8. Edge-case hardening from the list above (tar-missing, busybox ls, cancellation).
  9. Polish: styling, help footer, --namespace/--container plumbing.

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 stat on both ends). Owner UID/GID is best-effort: preserved only with --preserve-ownership against 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 tar in 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 Transfer for a client-go implementation (exec via remotecommand SPDY) to drop the kubectl runtime 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.

Initial implementation

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 the transf er package.

There are two separate things in transfer.go

  1. A shared data layer — the FileInfo struct (transfer.go:15) and the Sort helper (transfer.go:40). Both panels use these, by design. That’s why FileInfo’s own comment says “on either the local or the remote side.”
  2. The Transfer interfaceList/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 the List/Pull/Push method 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/Push of its own. Look at the signatures: Pull(pod, container, remotePath, localPath, ...) and Push(pod, container, localPath, remotePath, ...) — the Kubectl value already straddles both ends; it reads/writes local paths itself (it’s the local tar process in pi pe). So Transfer isn’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 over os.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/Sort live 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 hoist FileInfo+Sort into a small shared package (e.g. fileinfo) that both transfer and local import — 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 uses filepath (OS separators) and remote uses path (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/Push method set” instead of “this interface” (to kill the ambiguity you hit), or sketch the fileinfo extraction 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 FileInfofile.Info. file.FileInfo would stutter; file.Info is the idiomatic Go form. Sort keeps its name (file.Sort). If you’d rather preserve the FileInfo name, say so and I’ll rename — it’s a mechanical change.
  • Reworded the Transfer doc 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 between local.List and parseLS is still there — that’s the filepath-vs-path split we discussed, and it’s ortho gonal to this refactor. file would 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.go uses both packages, deliberately. The remote (pod) side uses path (lines 141, 149–150) and the local side of the very same transfer uses filepath (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 used filepath on 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 through path; host paths must go through filepath.

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 rootstring(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 file extraction 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/container are set once in New (model.go:66–67) and only ever read after that. They’re session-constant, yet they’re threaded as arguments into every single List/Pull/Push call. That detail is the whole crux of your suggestion. Here’s the discussion.

Your instinct is right — for List specifically

The two operations in Transfer have fundamentally different shapes:

  • List is 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/Push are 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 Transfer today 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 pulling file out: separate the thing that’s shared from the thing that isn’t.

The obstacle: the signature carries pod/container

Why local can’t satisfy Transfer.List as written:

Kubectl: List(pod, container, path string) ([]file.Info, error)   // 3 path-ish args
local:   List(path string)                 ([]file.Info, error)   // 1

The local side has no pod or container, so it can’t match. But — as the grep above shows — pod/container are 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{} whose List(path) wraps today’s local.List.
  • remote: a tiny adapter holding pod/container, whose List(path) calls kubectl.List(pod, container, path).

What it buys, concretely

loadPanel loses its which == focusLocal fork (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 Panel becomes 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 — a Lister fake is one method, versus today’s fakeTransfer that must stub Pull/Push even for a listing test.

Where I’d put the interface

The idiomatic Go move: define Lister in the consumer (ui), not the producer. Then local.FS satisfies it structurally without importing anything, and local stays fully decoupled (no transfer import — which we just worked to remove). The remote adapter can live in ui too. transfer.Transfer keeps List as the backend capability (the future client-go backend must still list), and the adapter just curries pod/container onto it.

Two honest caveats

  1. It doesn’t unify everything. Pull/Push stay asymmetric and the push-vs-pull decision stays in the UI (model.go:289/292). The Lister cut only simplifies browsing. That’s fine — but don’t expect it to collapse the transfer logic.
  2. It doesn’t touch the ../filepath-vs-path duplication. Each side’s List still synthesizes .. its own way. Orthogonal to this.

My recommendation

Worth doing, at the lightweight end: introduce a consumer-side Lister, adapt both sides, give each Panel its own. It’s contained, reversible, and improves the conceptual model.

I’d stop short of the maximal version (ripping pod/container out of the Transfer interface entirely and binding them at Kubectl construction). That’s arguably “more correct” — it’d kill the per-call threading on Pull/Push too — but it turns Kubectl from 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 Panel to “own how it lists itself”? If yes, the Lister is the clean expression of that. If the 6-line branch in loadPanel doesn’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 bound pod/container (killing the per-call threading you spotted). Knows nothing about tar, ls, or file.Info.
  • parser — pure []byte → []file.Info. Remote-only, as you said (locally os.ReadDir already hands back structured entries; nothing to parse).
  • two Listers — local (os.ReadDir) and remote (kube + parser), both satisfying one List(path) → []file.Info.
  • transfer — the Pull/Push manager: tar create/extract + the streaming pipe, using kube for the remote endpoint and local exec for 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), and fakeKubectl (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 kube package

You 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 the transfer manager.

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 kube owns tar, you haven’t isolated the swappable part; you’ve just renamed Kubectl.

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

pipe wires 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 a remotecommand executor you hand Stdin/Stdout/Stderr io streams. So today’s Transfer interface 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 kube seam is expressed as streams (Run(cmd, stdin io.Reader, stdout, stderr io.Writer) error) rather than *exec.Cmd, then pipe connects 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

  1. Granularity. Four responsibilities ≠ four packages. The parser is ~45 lines of pure code — I’d keep it as parse.go inside 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 are file, 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: one pod/remote package exposing both, since they share the kube client and the “pod-ness” — but your capability-split is defensible).
  2. Cost is front-loaded in pipe. Everything else (extract kube, bind pod/container, add Listers) is mechanical and low-risk. Re-expressing pipe around streams instead of *exec.Cmd is 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: extract kube as an *exec.Cmd factory (still removes the threading, still separates concerns) and leave pipe alone. 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:

  1. Extract kube (exec primitive, pod/container bound) — removes the per-call threading.
  2. Introduce the Lister interface + local/remote impls — collapses the loadPanel branch.
  3. Move transfer to Pull/Push-only on top of kube; isolate the parser into the remote lister.
  4. Optional, later: re-express pipe/kube around streams when (if) client-go becomes real.

Net: I agree with your four-way split, with the single amendment that tar lives with transfer, not kube — 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 ;-)

Final result