Conversation
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
This was referenced Sep 15, 2026
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
JEHoctor
commented
Sep 21, 2026
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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(andinit 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.mdis the plan of record and explains the design decisions;CLAUDE.mdcovers 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 theyqoutput byte-for-byte rather than "cleaning it up". That is verified three ways:scripts/difftest.shrunssandcat initthrough both CLIs across 27 option combinations under pinnedHOMEs and diffs both the project and home trees — 27/27 identical;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 byscripts/dump-bash-blocks.sh, so upstream drift in those shows up as a git diff.Notable findings recorded in the plan
yaml.v3withSetIndent(2)matches yq byte-for-byte (yq is built on it).sedpost-pass incustomize_compose_fileis load-bearing, just not in the case its comment describes —--ide jetbrainsneeds it. Reproduced instripBlankBeforeIndented.sjson/gjson(named in the original plan) would have been wrong for JSON:yq -o jsonrewrites the whole layout. Replaced by a small order-preserving model ininternal/jsonfile.CI
Adds
.github/workflows/go.yml(gofmt, vet, test, difftest) on PRs and pushes tomain. Note the pre-existing upstream workflows trigger onmaster, 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.shstill installs the bash CLI.🤖 Generated with Claude Code
https://claude.ai/code/session_01YKma722KMnAX3LcDLUYDQ5