[KongBaseSetup]
Kong models the entire command tree as nested Go structs — there is no imperative flag registration. The struct is the schema. This document covers the entrypoint, dependency injection via BindTo, the command tree layout, and how to extend it.
// 01CLI Framework
scut uses kong for CLI parsing. Kong models the entire command tree as nested Go structs — there is no imperative flag registration. The struct is the schema.
// 02Entrypoint
cmd/scut/main.go defines the root cli struct, builds the parser with kong.Must, then parses os.Args[1:] in a separate step so parse failures can be logged before the process exits:
type cli struct { Version versionFlag `name:"version" help:"Print version and exit." short:"v"` Claude claude.Cmd `cmd:"claude" help:"Claude Code agent commands — hooks, status line, and configuration."` Format formatcmd.Cmd `cmd:"format" help:"Format source code files."` Logging loggingcmd.Cmd `cmd:"logging" help:"Manage scut log files."` } func main() { var c cli parser := kong.Must(&c, kong.Name("scut"), kong.Description("CLI tool for managing AI coding agents. Called as a subprocess by agent hooks — reads JSON from stdin, writes JSON to stdout."), kong.Vars{"version": version.String()}, kong.BindTo(os.Stdin, (*io.Reader)(nil)), kong.BindTo(os.Stdout, (*io.Writer)(nil)), kong.BindTo(afero.NewOsFs(), (*afero.Fs)(nil)), kong.HelpOptions{ NoExpandSubcommands: true, FlagsLast: true, Compact: true, }, ) ctx, err := parser.Parse(os.Args[1:]) if err != nil { logging.LogParseError(os.Args, err) parser.FatalIfErrorf(err) } logger, logCloser := c.Claude.OpenLogger(ctx.Command()) if logCloser != nil { defer logCloser.Close() } logger.Debug("invoked", "args", os.Args, "command", ctx.Command()) ctx.FatalIfErrorf(ctx.Run(logger)) }
kong.Must builds a *kong.Kong parser without parsing arguments. The separate parser.Parse(os.Args[1:]) call returns a *kong.Context plus an error — splitting parse from build lets us run logging.LogParseError so bad hook invocations are diagnosable from the log file (see logging). ctx.Run() finds the selected command's Run() method and calls it, injecting any bound dependencies as arguments.
// 03Dependency Injection via Bind
We use kong.BindTo to wire dependencies that commands receive through their Run() method signature. Kong matches Run() parameters by type — if a parameter is io.Reader, Kong looks up what was bound to that interface and injects it.
Current bindings registered at parse time:
| Binding | Interface | Concrete Value | Purpose |
|---|---|---|---|
| kong.BindTo(os.Stdin, (*io.Reader)(nil)) | io.Reader | os.Stdin | Commands read input (e.g., hook JSON payloads) from stdin |
| kong.BindTo(os.Stdout, (*io.Writer)(nil)) | io.Writer | os.Stdout | Commands write output (e.g., hook JSON responses) to stdout |
| kong.BindTo(afero.NewOsFs(), (*afero.Fs)(nil)) | afero.Fs | afero.OsFs | Commands that need filesystem access (e.g., post-tool-use formatting) |
The (*io.Reader)(nil) syntax is a nil pointer used only for its type — Kong reflects on it to determine the interface. The concrete value (os.Stdin) is what gets injected at runtime.
Run-time bindings
The *slog.Logger binding is created after parsing, based on --log / --log-level flags, and passed as an extra binding to ctx.Run():
logger, logCloser := c.Claude.OpenLogger(ctx.Command()) if logCloser != nil { defer logCloser.Close() } logger.Debug("invoked", "args", os.Args, "command", ctx.Command()) ctx.FatalIfErrorf(ctx.Run(logger))
The logger.Debug("invoked", ...) call records every invocation at Debug level — useful when diagnosing whether a hook subprocess ran at all.
This makes commands testable without touching real stdio — pass logging.Discard as the logger:
func TestPreToolUse(t *testing.T) { input := strings.NewReader(`{"session_id":"test",...}`) var output bytes.Buffer cmd := &preToolUseCmd{} if err := cmd.Run(input, &output, logging.Discard); err != nil { t.Fatal(err) } // assert on output.Bytes() }
Adding New Bindings
To add a new dependency available to all commands:
- Register it in
main()withkong.BindTo(impl, (*InterfaceType)(nil))for interfaces,kong.Bind(value)for concrete types, or pass as an extra binding toctx.Run(value). - Add the type as a parameter to any command's
Run()method. Kong resolves it automatically.
// In main() — static binding: kong.Bind(logger) // In main() — run-time binding: ctx.FatalIfErrorf(ctx.Run(logger)) // In a command: func (c *myCmd) Run(stdin io.Reader, stdout io.Writer, log *slog.Logger) error {
Version Flag
The --version / -v flag uses Kong's BeforeReset lifecycle hook. When the flag is set, BeforeReset fires before any command resolution — it prints the version string and exits. This avoids requiring a dedicated version subcommand.
Kong Vars
kong.Vars{"version": version.String()} registers key-value pairs available to struct tags via ${version} interpolation and to lifecycle hooks via the kong.Vars map. Currently used only for version output.
// 04Command Structure
Layout
Commands live under internal/cmd/ organized by agent, then by capability:
cmd/scut/main.go # Entrypoint, root cli struct, bindings internal/cmd/claude/claude.go # "scut claude" agent group (--log, --log-level flags) internal/cmd/claude/hook/hook.go # "scut claude hook" subcommands internal/cmd/claude/config/config.go # "scut claude config" subcommands (install, uninstall, status) internal/cmd/format/format.go # "scut format" group (formatter commands) internal/cmd/logging/logging.go # "scut logging" group (clean command)
Each level exports a Cmd struct that the parent embeds with a cmd:"" tag.
How Commands Map to Structs
Kong's command tree is a direct reflection of struct nesting:
scut claude hook pre-tool-use
│ │ │ │
cli │ │ └─ preToolUseCmd (leaf — has Run())
│ └─ hook.Cmd struct field
└─ claude.Cmd struct field
- Group nodes (claude, hook) are exported
Cmdstructs withcmd:""tagged fields for their children. They have noRun()method. - Leaf commands (pre-tool-use, session-start, etc.) are unexported structs with a
Run(...) errormethod. They are the actual execution targets.
// 05Adding Commands
Adding a leaf command to an existing group
-
Define an unexported struct in the group's package:
type myNewCmd struct{} func (c *myNewCmd) Run(stdin io.Reader, stdout io.Writer) error { // implementation return nil }
-
Add it as a field on the group's
Cmdstruct:type Cmd struct { // ... existing commands MyNew myNewCmd `cmd:"my-new" help:"Does the new thing."` }
The
cmd:"my-new"tag sets the CLI name. Kong lowercases and hyphenates automatically if you omit the tag value, but explicit names are clearer.
Adding a new command group
-
Create a new package under
internal/cmd/:package mygroup type Cmd struct { SubCmd subCmd `cmd:"sub" help:"A subcommand."` } type subCmd struct{} func (c *subCmd) Run(stdin io.Reader, stdout io.Writer) error { return nil }
-
Add the group to its parent's
Cmdstruct:// In cmd/scut/main.go or the parent group package: type cli struct { // ... existing MyGroup mygroup.Cmd `cmd:"my-group" help:"My new group."` }
// 07Build
See magefiles/ for build targets. mage build compiles with ldflags that inject build metadata into internal/version. All mage targets set GOEXPERIMENT=jsonv2 via init() in magefiles/helpers.go.
go test, go build, or go vet directly — always use the corresponding Mage target. Magefiles set GOEXPERIMENT=jsonv2 and other required environment configuration automatically. Running Go toolchain commands directly will produce incorrect results or miss build tags.