Refactoring Go CLIs with go-subcommand and Agent Feedback Files
A Go CLI often begins with a small main.go, a few flags, and a switch statement. Then it grows.
Before long, cmd/ contains argument parsing, configuration loading, filesystem access, database calls, output formatting, and the actual application logic. Adding a command means copying another block of flag handling. Testing a command means pretending to invoke the entire executable. Changing the CLI framework risks touching the whole program.
[go-subcommand](https://github.com/arran4/go-subcommand) takes a different approach. The functions and their documentation comments define the command grammar, while gosubc generates the executable code under cmd/.
The important distinction is that go-subcommand is a standalone CLI tool used for code generation, not a runtime CLI framework. It is not a dependency, and your application does not need to import it. The generated command implementation is self-contained and dependency-free.
This changes how I structure a Go CLI:
- application logic lives outside
cmd/; - ordinary Go functions are the command entry points;
- comments define the CLI grammar;
cmd/is generated output rather than hand-maintained infrastructure;- GoReleaser configuration, workflows, and man pages can be generated from the same project.
It also works particularly well with coding agents. The source of truth remains small and human-readable, generated files are clearly off limits, and agents can record blockers or discoveries in structured files rather than improvising incorrect changes.
Refactor Before Generating
The first step in converting an existing application is not to generate a new command tree. It is to remove the application from the old command tree.
Consider a backup utility with this layout:
1cmd/
2 vault/
3 main.go
4 backup.go
5 restore.go
6 list.go
A typical backup.go might currently do all of the following:
- declare and parse flags;
- read the configuration file;
- validate paths;
- open the repository;
- create the backup;
- select the output format;
- print progress and errors.
That makes cmd/vault/backup.go both a user-interface adapter and the implementation of the backup system.
Before introducing gosubc, move the actual work into a normal package:
1internal/
2 backup/
3 create.go
4 restore.go
5 list.go
The resulting API might look like this:
1package backup
2
3import "time"
4
5type CreateRequest struct {
6 Config string
7 Source string
8 Destination string
9 Compression string
10 Timeout time.Duration
11 Verbose bool
12}
13
14func Create(request CreateRequest) error {
15 // Validate the request, open the repository, create the archive,
16 // and return an error to the caller.
17 return nil
18}
The command function should translate CLI parameters into this application API. It should not contain the backup implementation itself.
For most applications, implementation code belongs in one of three places:
internal/when it is specific to this repository;pkg/when it is intentionally exposed as a reusable package;- the module root for a small application that does not benefit from another directory layer.
There is no requirement to create an elaborate package hierarchy. The important rule is simply that business logic does not remain trapped inside generated or hand-written executable code.
The Grammar Is Written in Go Comments
go-subcommand does not require a separate YAML file, command registry, or invented grammar language. It reads specially formatted documentation comments attached to Go functions.
The central form is:
1// FunctionName is a subcommand `root parent child`
2func FunctionName(...) error {
3 // ...
4}
The command path inside the backticks defines the hierarchy.
For example:
1// Create is a subcommand `vault backup create`
2func Create(...) error {
3 // ...
4}
This produces the command path:
1vault backup create
A sibling function can define another command:
1// Restore is a subcommand `vault backup restore`
2func Restore(...) error {
3 // ...
4}
The shared vault backup prefix creates the nested command structure. There is no separate section where the parent-child relationship must be registered.
This structure supports sub-sub commands, and beyond; there is no limit to the depth of nesting. You simply add more words to the command path:
1// Set is a subcommand `vault backup config set`
2func Set(...) error {
3 // ...
4}
The Go function signature defines the values that will be passed to the command. The documentation comment describes how CLI arguments map onto those values.
A Practical Backup CLI Grammar
The following example describes a realistic backup application. The command functions are thin adapters around the application implementation.
1package vault
2
3import (
4 "time"
5
6 "example.com/vault/internal/backup"
7)
8
9// Vault is a subcommand `vault` -- Inspect or manage the backup repository.
10//
11// Running vault without a child command displays the current repository
12// status.
13//
14// Flags:
15//
16// config: -c --config (default: "./vault.yaml") Configuration file
17// verbose: -v --verbose Enable verbose logging
18func Vault(config string, verbose bool) error {
19 return backup.Status(config, verbose)
20}
21
22// Create is a subcommand `vault backup create` -- Create a new backup.
23//
24// Creates a backup from a local source directory and writes it to the
25// selected repository destination.
26//
27// Aliases: new
28//
29// Flags:
30//
31// config: (from parent)
32// verbose: (from parent)
33// source: @1 Directory to back up
34// destination: -d --destination (required) Backup destination
35// compression: -z --compression (default: "zstd") Compression format
36// timeout: --timeout (default: 30m) Maximum operation time
37func Create(
38 config string,
39 verbose bool,
40 source string,
41 destination string,
42 compression string,
43 timeout time.Duration,
44) error {
45 return backup.Create(backup.CreateRequest{
46 Config: config,
47 Verbose: verbose,
48 Source: source,
49 Destination: destination,
50 Compression: compression,
51 Timeout: timeout,
52 })
53}
54
55// List is a subcommand `vault backup list` -- List available backups.
56//
57// Flags:
58//
59// config: (from parent)
60// verbose: (from parent)
61// limit: -n --limit (default: 20) Maximum number of results
62// json: --json Write machine-readable JSON
63func List(config string, verbose bool, limit int, json bool) error {
64 return backup.List(backup.ListRequest{
65 Config: config,
66 Verbose: verbose,
67 Limit: limit,
68 JSON: json,
69 })
70}
71
72// Restore is a subcommand `vault backup restore` -- Restore a backup.
73//
74// Restores an entire snapshot, or selected paths when additional positional
75// arguments are supplied.
76//
77// Flags:
78//
79// config: (from parent)
80// verbose: (from parent)
81// snapshot: @1 Snapshot ID to restore
82// target: -t --target (required) Restore destination
83// force: -f --force Replace existing files
84// paths: ... Optional paths within the snapshot
85func Restore(
86 config string,
87 verbose bool,
88 snapshot string,
89 target string,
90 force bool,
91 paths ...string,
92) error {
93 return backup.Restore(backup.RestoreRequest{
94 Config: config,
95 Verbose: verbose,
96 Snapshot: snapshot,
97 Target: target,
98 Force: force,
99 Paths: paths,
100 })
101}
This one file describes:
- the root command;
- nested
backup create,backup list, andbackup restorecommands; - inherited root flags;
- aliases;
- positional arguments;
- required flags;
- default values;
- booleans;
- integers;
- durations;
- variadic positional arguments;
- short descriptions;
- extended command help.
The implementation functions remain normal Go functions. They can be called from tests, another executable, a server, or a scheduled job without constructing fake command-line arguments.
Understanding the Parameter Grammar
A Flags: block maps function parameter names to their CLI representation.
| Syntax | Meaning |
|---|---|
-v --verbose | Short and long flag names |
(default: 20) | Value used when the flag is omitted |
(required) | The command fails if the value is not supplied |
(from parent) | Use a flag declared by an ancestor command |
@1 | First positional argument |
@2 | Second positional argument |
... | Remaining positional arguments |
1...3 | A bounded number of positional arguments |
(parser: ParseValue) | Parse a string using a custom function |
(generator: CurrentUser) | Supply a value from code instead of a flag |
The Go type remains important. A parameter declared as int is parsed as an integer. A bool becomes a switch. A time.Duration accepts values such as 30s, 10m, or 2h.
Pointers preserve the difference between an omitted value and an explicitly supplied zero value. Slices support repeatable flags, while variadic parameters support remaining positional arguments. Returning an error lets the generated executable propagate failures to an appropriate exit status.
For a repository-specific type, a custom parser can keep conversion logic outside the generated command:
1// Deploy is a subcommand `infractl deploy` -- Deploy an environment.
2//
3// Flags:
4//
5// target: --target (required; parser: ParseTarget) Deployment target
6func Deploy(target Target) error {
7 return RunDeployment(target)
8}
A parser from another package can also be referenced with its import path.
Descriptions, Aliases, and Help
The text following the command declaration becomes the short description:
1// Create is a subcommand `vault backup create` -- Create a new backup.
Additional prose becomes extended help:
1// Create is a subcommand `vault backup create` -- Create a new backup.
2//
3// Reads files from the source directory, applies exclusion rules from the
4// configuration, and writes a content-addressed archive to the destination.
Aliases can be declared separately:
1// Aliases: new, add
or inline:
1// Create is a subcommand `vault backup create` (aka: new)
Because this information is kept beside the function, the command declaration, help text, and implementation are less likely to drift apart.
Add the Generator
The gosubc tool must be run from the root of your module, in the same folder as your go.mod file.
You can run it directly from the web without installing using:
1go run github.com/arran4/go-subcommand/cmd/gosubc@latest generate
Or install the generator with:
1go install github.com/arran4/go-subcommand/cmd/gosubc@latest
Then add a generate.go file to the module:
1package vault
2
3//go:generate sh -c "command -v gosubc >/dev/null 2>&1 && gosubc generate || go run github.com/arran4/go-subcommand/cmd/gosubc generate"
The fallback to go run means contributors and CI jobs do not have to install gosubc manually before running generation.
Run:
1go generate ./...
For the example above, the generator creates the executable infrastructure under:
1cmd/
2 vault/
This generated directory contains the command parser, usage output, dispatch logic, and executable entry point. You do not need to maintain a separate main() function or manually register every command.
That does not mean the compiled application lacks a main() function. It means the generator owns it.
Treat cmd/ as Generated Output
Once the migration is complete, cmd/ should be treated like any other generated directory.
Do not fix a parsing problem by editing a generated file. Change the function declaration or grammar comment and regenerate.
Do not implement a feature directly inside cmd/vault. Add or update the application function and regenerate.
A useful generated-code check in CI is:
1go generate ./...
2git diff --exit-code
If generation changes committed files, the source grammar and generated output are out of sync.
Generated output may still be committed to the repository. Committing it makes builds reproducible without requiring the generator at ordinary build time and makes generated changes visible during review. The source of truth, however, remains the application functions and their comments.
Inspect and Validate Before Generating
gosubc provides commands for inspecting the recognised grammar:
1gosubc list
This lists detected commands and is useful when checking whether functions have been discovered under the expected command paths.
Validate the grammar with:
1gosubc validate
Validation should be run before deleting the old executable. It can identify conflicting paths or invalid declarations while the original CLI is still available for comparison.
A practical migration sequence is:
- record the existing
--helpoutput; - add tests around the current command behaviour;
- move implementation logic out of
cmd/; - add command grammar comments to the new entry functions;
- run
gosubc list; - run
gosubc validate; - generate the replacement
cmd/directory; - compare old and new help output;
- run unit, integration, and CLI tests;
- delete the old hand-written command infrastructure.
This sequence separates behavioural refactoring from generator adoption. When something breaks, it is easier to identify whether the problem came from moving the implementation or describing the CLI.
Generate Man Pages and Release Infrastructure
The same grammar can generate Unix man pages:
1gosubc generate --man-dir ./man
Descriptions and extended help from the source comments become part of the generated documentation. That gives another reason to write useful command comments rather than placeholder text.
gosubc can also generate GoReleaser configuration:
1gosubc goreleaser
A GitHub Actions workflow can be included when required:
1gosubc goreleaser --go-releaser-github-workflow
Release generation should still be reviewed against what the repository actually ships. A project that only provides a library should not acquire binary packaging just because a generator supports it. For an actual CLI, however, generating the executable tree and initial release infrastructure from the same project removes a considerable amount of repeated setup.
Why This Structure Works Well with Coding Agents
Generated command code creates an obvious boundary for an agent:
- edit application functions;
- edit command grammar comments;
- do not edit generated
cmd/files; - run validation and generation;
- test the result.
This is much safer than asking an agent to modify a large hand-written command tree where parsing, application logic, and output formatting are mixed together.
There is still a second problem: agents often encounter something that is relevant but cannot safely be resolved inside the current task.
Examples include:
- the old CLI accepts a flag whose behaviour is undocumented;
- an integration test requires unavailable credentials;
- two existing commands use contradictory defaults;
- the generator does not yet support a required parameter pattern;
- the agent notices an unrelated bug while moving the implementation;
- a useful command is discovered but falls outside the requested migration.
Instead of allowing the agent to guess, silently omit behaviour, or expand the task indefinitely, I use structured feedback files.
I described the broader pattern in Using gap.md to Guide LLMs in Complex Projects. For CLI migrations, I normally use three files.
bug.md
Use bug.md for a confirmed defect.
A useful entry includes:
1## Restore overwrites files without --force
2
3Status: Confirmed
4Found while: Migrating `vault backup restore`
5Affected code: `cmd/vault/restore.go`
6Reproduction: `vault backup restore abc123 --target ./existing`
7Expected: Refuse to overwrite unless `--force` is supplied
8Actual: Existing files are replaced
9Evidence: Existing integration test documents the current result but contradicts help output
10Suggested next action: Confirm intended compatibility behaviour before changing it
The important part is distinguishing an existing bug from a regression introduced by the migration.
gap.md
Use gap.md when required information or infrastructure is missing.
1## Destination flag default is unknown
2
3Status: Blocking behavioural parity
4Affected command: `vault backup create`
5Question: Should `--destination` be required, or default to the repository in vault.yaml?
6Why this matters: The current code appears to support both behaviours depending on call path
7Evidence:
8- `cmd/vault/backup.go` marks the flag optional
9- `internal/config/config.go` supplies a configured repository
10- README examples always pass --destination
11Work that can continue: Move backup implementation and define all other flags
12Required decision: Select required flag or configuration fallback
A gap is not necessarily a software defect. It is a statement that the agent lacks enough information to make a reliable decision.
featurerequest.md
Use featurerequest.md for a valid improvement outside the current task.
1## Add `vault backup verify`
2
3Status: Out of scope
4User value: Verify archive integrity without restoring files
5Suggested grammar: `vault backup verify <snapshot>`
6Possible flags:
7- `--full` to read all stored objects
8- `--json` for automation
9Relevant implementation: Existing checksum reader in `internal/storage`
10Compatibility concerns: None identified
This captures the idea without allowing it to derail the migration.
A Reusable Agent Instruction
The following instruction can be placed in a task, AGENTS.md, or repository-specific agent guidance:
1When modifying this Go CLI:
2
31. Treat Go functions and their go-subcommand documentation comments as the
4 source of truth for the CLI grammar.
5
62. Treat files under cmd/ as generated output. Do not hand-edit generated
7 command files. Change the source function or grammar and regenerate.
8
93. Before generating the CLI, move non-CLI implementation out of cmd/. Prefer
10 internal/ for repository-specific code, pkg/ for deliberately reusable
11 packages, or the module root for a small application.
12
134. Preserve existing command paths, flag names, aliases, defaults, positional
14 arguments, help text, exit behaviour, and error behaviour unless the task
15 explicitly requests a compatibility change.
16
175. Run:
18 gosubc list
19 gosubc validate
20 go generate ./...
21 go test ./...
22
236. If a confirmed pre-existing defect is discovered, record it in bug.md with
24 reproduction steps, evidence, scope, and a suggested next action.
25
267. If required information, access, infrastructure, or an API is missing,
27 record it in gap.md. Explain why it blocks the work, what evidence was
28 found, what questions must be answered, and what work can continue safely.
29
308. If a useful but out-of-scope improvement is discovered, record it in
31 featurerequest.md with user value, proposed command grammar, implementation
32 notes, and compatibility concerns.
33
349. Do not invent answers to gaps, silently remove existing behaviour, or
35 implement unrelated feature requests merely to complete the current task.
These files do not have to remain permanent repository documentation. They can be reviewed, converted into GitHub issues, linked from the pull request, and removed once their contents have been resolved.
Their purpose is to give the agent a safe and productive response other than guessing.
The Result
After migration, the repository has a clearer division of responsibility:
1generate.go Generator entry point
2internal/backup/ Application implementation
3commands.go Functions and CLI grammar
4cmd/vault/ Generated executable
5man/ Generated documentation
6.goreleaser.yaml Release configuration, when applicable
7bug.md Confirmed incidental defects
8gap.md Missing decisions or prerequisites
9featurerequest.md Out-of-scope improvements
The application is no longer organised around flag parsing. It is organised around callable Go functions, with a command grammar layered on top.
That provides several practical benefits:
- application logic is easier to test;
- command hierarchy is visible in source comments;
- the generated CLI has no runtime framework dependency;
- nested commands and flags remain consistent;
- man pages and release configuration can be generated;
- agents have an explicit boundary between source and generated output;
- blockers and incidental discoveries are recorded instead of hidden.
The main lesson is not merely to replace one CLI implementation with another. It is to stop treating cmd/ as the application.
Move the application into ordinary Go code, describe its command grammar beside the functions, generate the disposable executable layer, and give both human and automated contributors a structured way to report what they cannot safely finish.