0014 Code layout: thin mains, an importable command tree, black-box tests under test/
Status: Accepted. The owner said yes at Gate 1 on 15 October 2026.
The evidence is in the research note on Go project structure, sections 1, 2.1 to 2.3 and 4.
Context
The repo is about to go public, and the goal asks for a layout of cmd/, internal/, examples/, scripts/, test/e2e/ and testdata/. Four things in today’s tree stand in the way:
- The commands live in
package mainincmd/cli, which holds about 1,250 lines of cobra code. Nothing else can import them. So a generator cannot build the CLI reference and man pages from them, and tests cannot run the CLI in-process. - Test tools sit in a top-level
e2e/folder. It mixes a Python suite with a Go tool (ttyshot) that the screenshots also use. go installnames the clientcli. Go names an installed binary after its folder, and the name may not appear in paths, sogo install <module>/cmd/cli@latestgives a command calledcli. The goal asks forgo installas an install path.- Some files are long. Eight hand-written files are over the 500-line bar, mostly in
internal/host,internal/clientandinternal/tui.
The thirteen repos in the research show two shapes. Small tools keep main.go at the root. Projects with several binaries put each main in cmd/<name>/. Every application keeps its code in internal/. GitHub CLI and Crush keep the cobra tree in an importable package, and GitHub CLI’s cmd/gen-docs builds its manual from it.
Options
- Keep the layout. Add a hidden
docscommand to the client, as Charm’s tools add a hiddenmancommand. - One root
main.gowithinternal/cmd, as Crush does. - Thin mains in
cmd/cliandcmd/host, the command tree ininternal/cli, dev tools as mains incmd/, and black-box tests undertest/. - Option 3, plus a public
pkg/for reusable parts such asprotoandring. - Option 3, plus splitting
internal/host,internal/clientandinternal/tuiinto sub-packages now. - Option 3, with the client’s thin main at the root instead of
cmd/cli, sogo install <module>@latestnames the binary after the module.
Decision
Option 6:
main.go the client's thin main: calls cli.Main (from cmd/cli)
cmd/host/ the host helper (unchanged)
cmd/docgen/ dev tool, never shipped: writes docs/reference/, man pages and completions
internal/cli/ the cobra command tree, moved from cmd/cli
internal/... the other packages, unchanged
test/e2e/ the Python end-to-end suite (from e2e/)
test/ttyshot/ the pseudo-terminal renderer (from e2e/ttyshot)
testdata/ only fixtures shared by several packages, created with the first one
examples/, scripts/, vt/, install.sh unchangedThe reasons:
go install <module>@latestmust give a binary with the product’s name, and the name may only come from the module path. A main at the root is the one place that does this, as Crush, Gum, Glow, lazygit and k9s show. The host helper keepscmd/host: it is never installed by hand.- Option 2’s
internal/cmdwould work too, butinternal/clisays what it holds, and leavescmd/for mains. - An importable command tree is what GitHub CLI uses to generate its docs. It keeps doc code out of the shipped binary, which option 1 would not.
- We have no outside users of our packages, so a public
pkg/(option 4) would only freeze an API. - Splitting packages now (option 5) would be churn. The research mapped how the files use each other:
internal/clientandinternal/tuiare tightly knit. The one cheap later split is the shadow-terminal supervisor ininternal/host, which depends only onRingSize. Long files are split within their packages, as a separate job.
internal/brand stays the only place that spells the product name. cmd/docgen, the site config and the scripts read it from there.
The move happens in the small steps in section 7 of the research note; step 1 puts the main at the root rather than in cmd/cli. Each step is one commit with behaviour unchanged. The --help output of every command, the golden tests and the end-to-end count must match the baseline.
Consequences
- Good, because the CLI reference, man pages and completions can be generated from the code, as the goal asks.
- Good, because the layout matches what contributors know from GitHub CLI, restic and Soft Serve, and what the goal lists.
- Good, because test tooling has one clear home, apart from the product code.
- Bad, because moving
cmd/clitointernal/clitouches every line of blame for those files.git log --followstill finds the history. - Bad, because the build paths in the justfile, GoReleaser and CI change from
./cmd/clito., in the same commit as the move. - Bad, because the scripts that name paths must change in the same commits:
check-name.sh,screenshots.shand thejust e2erecipe. Otherwise the name check could silently skip the moved Python files.