Skip to content

Port the sandcat CLI to Go (milestones 1–5) - #3

Merged
JEHoctor merged 18 commits into
mainfrom
go-port
Sep 22, 2026
Merged

JEHoctor merged 18 commits into
mainfrom
go-port

Conversation

@JEHoctor

Copy link
Copy Markdown
Owner

What

A full rewrite of the bash CLI under cli/ as a single static Go binary (cmd/sandcat + internal/), landing alongside the bash tree rather than replacing it. Every command is implemented: init (and init settings/init devcontainer), run, compose, attach, destroy, proxy, restart-proxy, cache list|size|rm, edit *, version. The command surface is preserved one-for-one.

GO-PORT-PLAN.md is the plan of record and explains the design decisions; CLAUDE.md covers the branch roles and workflow. Not intended for upstream.

Why the bash tree stays

It is the parity oracle. The generated .devcontainer/ tree carries comments users edit and renders disabled mounts as commented-out YAML, so the port reproduces the yq output byte-for-byte rather than "cleaning it up". That is verified three ways:

  • per-package parity tests shell out to the original bash functions and compare bytes (agents, gitignore, compose, devcontainer, user settings) — 21 compose combinations, 21 whole-tree combinations;
  • scripts/difftest.sh runs sandcat init through both CLIs across 27 option combinations under pinned HOMEs and diffs both the project and home trees — 27/27 identical;
  • each oracle was checked to fail on a deliberate mutation before being relied on.

The bash tree is removed only after the upstream sync (6b) and the podman rework (6c) have run against this harness — see plan §4.1. Multi-line Dockerfile/shell fragments are embedded from internal/*/blocks/, regenerated from bash by scripts/dump-bash-blocks.sh, so upstream drift in those shows up as a git diff.

Notable findings recorded in the plan

  • yaml.v3 with SetIndent(2) matches yq byte-for-byte (yq is built on it).
  • The sed post-pass in customize_compose_file is load-bearing, just not in the case its comment describes — --ide jetbrains needs it. Reproduced in stripBlankBeforeIndented.
  • sjson/gjson (named in the original plan) would have been wrong for JSON: yq -o json rewrites the whole layout. Replaced by a small order-preserving model in internal/jsonfile.
  • Bash-3.2 compatibility shims were not ported.

CI

Adds .github/workflows/go.yml (gofmt, vet, test, difftest) on PRs and pushes to main. Note the pre-existing upstream workflows trigger on master, which is not this fork's default; left untouched here, worth a follow-up.

Not in this PR

Install/release tooling (6d), removing bash (6e), relaxing yq parity (6f). install.sh still installs the bash CLI.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YKma722KMnAX3LcDLUYDQ5

JEHoctor and others added 12 commits September 15, 2026 00:34
Architecture, package layout, milestones, and risk register for replacing
the bash CLI under cli/ with a single static Go binary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Milestone 1 of the Go port. Establishes the skeleton every later
milestone builds on; all command bodies return errNotImplemented.

- embed.go embeds cli/templates at the module root, since //go:embed
  cannot traverse "..". Keeping the templates in place means the bash
  and Go CLIs read identical bytes, which the differential harness
  depends on.
- internal/cli mirrors the libexec module/command dispatch one-for-one.
  compose, attach, and run set DisableFlagParsing so docker's own flags
  survive; every module but version prints the version banner first.
- internal/log reproduces logging.bash's "HH:MM:SS [LABEL] msg" format,
  including per-line prefixing of multi-line messages.
- internal/prompt ports select.bash, keeping prompts on stderr.
- internal/version reads the toolchain's VCS stamps instead of shelling
  out to git, with -ldflags injection for release builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Milestone 2 of the Go port: the pure-logic layer, with no dependency on
docker or the template tree.

The bash originals dispatch each property through its own `case`
statement; these collapse into single tables, so adding an agent or stack
is one entry rather than an edit to nine parallel functions.

Multi-line Dockerfile/shell/JSONC fragments are embedded as files under
blocks/ rather than Go string literals: several contain backticks (ruling
out raw strings) and all contain significant tabs, so hand-transcription
is exactly how byte-level drift would creep in. scripts/dump-bash-blocks.sh
regenerates them from cli/lib, and re-running it after an upstream change
surfaces the drift as a git diff.

Parity is checked against the bash source of truth rather than against a
transcription: the agents and gitignore tests shell out to the original
functions and compare byte-for-byte, covering 3 agents x 11 properties.
Confirmed the oracle fails on a deliberate mutation before relying on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Milestone 3's hard half, and the plan's biggest risk — now retired.

The generated compose files carry comments users are meant to read and
edit, and disabled optional mounts are rendered as commented-out YAML
inside a foot comment so they can be enabled by uncommenting. That rules
out struct marshaling; this parses once into a yaml.Node tree, mutates in
memory, and writes once, replacing ~20 `yq -i` subprocesses per init.

parity_test.go runs the original bash functions and the Go ones over the
same fixtures and requires byte-identical output: 21 combinations of
agent, IDE, stack set, and mount toggle, plus the proxy TUI and secret
provider mutations. All pass.

Two findings, recorded in the plan:
- SetIndent(2) is the whole of what was needed to match yq's layout,
  unsurprising given yq is itself built on yaml.v3.
- The sed post-pass in customize_compose_file IS load-bearing. It is inert
  in the arrangement it reads like it was written for, but required when a
  disabled mount is followed by an active one carrying a head comment —
  which --ide jetbrains hits. Reproduced in stripBlankBeforeIndented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes Milestone 3. internal/devcontainer.Generate is the Go
equivalent of cli/libexec/init/devcontainer: it copies the embedded
template tree and expands every placeholder in the bash order, which is
load-bearing — __DEVBOX_INSTALL__ must expand before the agent Dockerfile
placeholders, and services.agent.environment must land before Customize
appends working_dir, or keys come out reordered.

devcontainer.json stays line-edited on purpose: it is JSONC with comment
markers, and a JSON parser would strip the comments users read.

The devbox Dockerfile block is an unquoted heredoc that interpolates a
single-line jq merge program, so it is captured from bash into
internal/devbox/blocks/ like the agent fragments.

parity_test.go runs the original libexec script and Generate over 21
option combinations and diffs the whole generated .devcontainer tree;
all byte-identical. Confirmed the oracle fails on a deliberate mutation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First half of Milestone 4. internal/config ports create_user_settings,
the per-agent default seeding, secret-provider token seeding, and the
project settings copy from cli/libexec/init/init.

The plan named sjson/gjson for the JSON edits; that was the wrong call.
Those preserve a file's existing formatting, but `yq -o json` rewrites the
whole document into its own layout, so the first init would have produced
different bytes from the bash. internal/jsonfile is instead a small
order-preserving document model whose encoder reproduces yq's style.

The seeding helpers mirror the jq idioms: `//` treats null and false as
absent, except for cursor.cli.network.useHttp1ForAgent which the bash
deliberately keys on presence so a user's explicit false survives —
covered by a parity case. yq's unique/unique_by turned out to preserve
first-occurrence order, which the tests also pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes Milestone 4. internal/initialize is the `sandcat init` flow
from cli/libexec/init/init: flags or prompts for every choice, user
settings seeding, host config pre-creation, project settings, the
.devcontainer tree, the .gitignore block, and the next-steps summary.
`init settings` and `init devcontainer` are wired as subcommands.

scripts/difftest.sh is the end-to-end oracle from the plan: it runs
init through the bash CLI and the Go binary under pinned HOMEs across
27 option combinations and diffs both the project and home trees. All
27 are identical — user settings, pre-created agent config files,
.gitignore and every generated .devcontainer file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Milestone 5: run, compose, attach, destroy, proxy, restart-proxy, cache
(list/size/rm) and edit (compose/dockerfile/project-settings/
user-settings). No command body returns "not implemented" any more.

internal/dockercli shells out to the docker CLI as the bash did, so
contexts, credential helpers and compose plugin resolution behave
identically. The Runner interface lets tests record argv instead of
needing a daemon, which is what the bats-mock tests covered; the
passthrough tests now assert the exact docker command line rather than
just that cobra didn't eat the flags.

Interactive children get SIGINT ignored in the parent while they run,
the closest equivalent to the bash `exec`: Ctrl-C reaches docker, and
its exit status propagates through dockercli.ExitError without a second
error message. Probes the bash silenced with 2>/dev/null go through
OutputQuiet so a stopped stack doesn't print docker noise.

Smoke-tested against the real daemon: compose passthrough, proxy and
restart-proxy on a stopped stack, cache list/size, and exit code
propagation from a failing compose subcommand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The original plan ended with one cutover that deleted the bash tree
immediately. Decided 2026-09-14 that this is the wrong order: the bash
tree is the parity oracle, and the two pieces of work still queued — a
sync of 23 upstream commits (~900 lines across cli/lib, libexec and
templates) and a rework of the podman engine branch — are exactly what
the oracle exists to catch. So the branch lands with bash and Go side by
side, the upstream sync and podman rework run against the harness, and
bash is removed only afterwards. Byte-level replication of yq's output
becomes negotiable once no oracle demands it.

Also records the install story for a binary: goreleaser-published
Releases as the base tier, `go install`, then the package managers
goreleaser targets natively, then PyPI/npm as wrapper packages built from
the release artifacts. install.sh is retired rather than replaced — its
env-var surface describes a clone-the-tree install that has no meaning
for a binary — and Flatpak is flagged as a poor fit for a tool whose job
is driving the host container socket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YKma722KMnAX3LcDLUYDQ5
Records that main is the fork's default branch and master tracks
VirtusLab's master for reference only, so the two diverging is expected
rather than a mistake to fix. Points at GO-PORT-PLAN.md and the parity
tooling, and notes that the sandcat on PATH is the upstream bash
checkout, not this repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YKma722KMnAX3LcDLUYDQ5
Runs gofmt, vet, go test and scripts/difftest.sh on every PR and on
pushes to main. The parity tooling (bash originals, Mike Farah's yq, jq)
is verified up front so a skipped parity test can't pass as a green run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YKma722KMnAX3LcDLUYDQ5
Comment thread CLAUDE.md Outdated
Comment thread CLAUDE.md Outdated
Comment thread CLAUDE.md Outdated
Comment thread CLAUDE.md Outdated
Comment thread CLAUDE.md Outdated
Comment thread CLAUDE.md Outdated
Comment thread CLAUDE.md Outdated
Review feedback on #3: the agent notes mixed checkout- and machine-
specific facts (a pointer to a personal handbook, remote URLs, the
worktree layout, an untracked scratch file, which binary is on PATH)
into a file that every checkout loads. Those are gone. Branch roles
stay, since they are a property of the project; remotes go, since
they are configured per checkout. The PATH note is replaced with a
generic warning that a sandcat may already be installed, so agents
distinguish it from the two in-tree versions.

Plans get a home: plans/, named YYYY-MM-DD-<topic>.md by the date they
were formulated so they sort chronologically. The port plan moves
there (dated from its first commit) and CLAUDE.md only points at the
directory. docs/ was not an option — upstream builds it with Sphinx.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YKma722KMnAX3LcDLUYDQ5
Comment thread go.mod Outdated
Comment thread .github/workflows/go.yml Outdated
JEHoctor and others added 2 commits September 21, 2026 18:31
Review feedback on #3. gopkg.in/yaml.v3 is imported directly by
internal/compose; the `// indirect` marker was a leftover from
`go get` running before the import existed, and `go mod tidy` was never
re-run. Now it is.

The workflow's actions are pinned to the commits the v4/v5 tags
currently resolve to, with the human-readable version in a trailing
comment. A moving major tag lets a compromised or mistaken tag push
change what runs on every PR; a commit does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YKma722KMnAX3LcDLUYDQ5
Dependabot covers both ecosystems weekly, grouped so a batch of minor
bumps arrives as one PR each. It understands SHA-pinned actions and bumps
the commit together with the version comment, so the pinning added in
6b8dc17 costs nothing to maintain.

The tidiness check makes the go.mod fix from the same review structural:
CI runs `go mod tidy` and fails on any diff, so a dependency can't be
left mislabelled as indirect again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YKma722KMnAX3LcDLUYDQ5
JEHoctor and others added 3 commits September 21, 2026 21:03
The bash dispatcher's own usage text advertises this form; under cobra
it failed with "unknown command". A trailing bare `help` on a
non-passthrough command is now rewritten to `--help`. A `help` that is a
flag value (`--agent help`) or that belongs to docker or the container
(`compose help`, `attach help`) is left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YKma722KMnAX3LcDLUYDQ5
The `<module> help` form exists only in the bash dispatcher's own usage
string — the README and docs never mention it — and supporting it under
cobra meant special-casing passthrough commands and flag values for a
convention nobody is documented to rely on. `--help` and
`sandcat help <cmd>` are the surface; a fresh binary install is the
natural moment to drop the legacy form rather than carry it.

This reverts commit 31366a2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YKma722KMnAX3LcDLUYDQ5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YKma722KMnAX3LcDLUYDQ5
@JEHoctor
JEHoctor merged commit 79b8a7d into main Sep 22, 2026
1 check passed
@JEHoctor
JEHoctor deleted the go-port branch September 22, 2026 03:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant