Quick Start
This walkthrough builds depl — a small deploy tool with two commands, a command group, configuration layering and a test.
1. Declare options as structs
Each command (a verb, in CommandLineParser terms) gets its own options struct. Shared flags are an embedded struct — explicit, no "persistent flags" inheritance to reason about:
type Globals struct {
Verbose scmd.Count `flag:"verbose" short:"v" help:"-v, -vv, -vvv"`
Output string `flag:"output" short:"o" default:"text" enum:"text,json,yaml" help:"Output format"`
}
type ServeOptions struct {
Globals
Port int `flag:"port" short:"p" default:"8080" conf:"server.port" help:"HTTP port"`
Timeout time.Duration `flag:"timeout" default:"30s" env:"DEPL_TIMEOUT" help:"Request timeout"`
Config string `arg:"config" required:"true" help:"Path to the app config"`
}
type DeployOptions struct {
Globals
Env string `flag:"env" required:"true" enum:"dev,stage,prod" help:"Target environment"`
Labels map[string]string `flag:"label" short:"l" help:"Release labels: -l team=core,tier=1"`
DryRun scmd.Value[bool] `flag:"dry-run" help:"Print the plan only"`
Services []string `arg:"..." name:"services" help:"Services to deploy"`
}2. Add custom validation (optional)
Anything the tags can't express goes into a Validate method. It runs after binding, before the handler; an error is a usage error (exit code 2):
func (o DeployOptions) Validate() error {
if o.Env == "prod" && len(o.Services) > 1 {
return errors.New("deploy one service at a time in prod")
}
return nil
}3. Write handlers and assemble the app
Handlers are plain functions; the options type is inferred from the signature:
func runServe(ctx context.Context, opts ServeOptions) error { /* ... */ return nil }
func runDeploy(ctx context.Context, opts DeployOptions) error { /* ... */ return nil }
func newApp() *scmd.App {
cfg, _ := sconf.New().
AddJSONFile("appsettings.json", sconf.Optional()).
AddEnvironmentVariables("DEPL_").
Build()
return scmd.New("depl", "Deploy utility",
scmd.WithLocale(scmd.LocaleEN),
scmd.WithConfiguration(cfg),
scmd.WithVersion("1.0.0"),
scmd.Cmd("serve", "Run the server", runServe),
scmd.Cmd("deploy", "Deploy services", runDeploy).
With(scmd.Aliases("d")),
scmd.Group("secrets", "Manage secrets",
scmd.Cmd("list", "List secrets", runSecretsList),
scmd.Cmd("set", "Store a secret", runSecretsSet),
),
)
}
func main() {
os.Exit(newApp().Run(context.Background(), os.Args[1:]))
}Note there are no package-level variables and no init() — newApp() builds a fresh, independent app every time it is called. That single property is what makes testing trivial.
4. Run it
$ depl deploy --env stage -l team=core,tier=1 --dry-run api
$ depl d --env=prod api web # alias + validation error, exit code 2
deploy one service at a time in prod
See "depl deploy --help".
$ DEPL_TIMEOUT=1m depl serve app.json # env layer
$ depl sevre # typo
depl: unknown command "sevre"
Did you mean "serve"?Flags may appear before the command name too (depl -v deploy api); a flag value before the command name must use the --flag=value form so it is not mistaken for a command.
5. Test it
scmdtest runs the app with captured output — no os.Stdout juggling, no state reset, parallel-safe:
func TestProdDeploysOneService(t *testing.T) {
t.Parallel()
res := scmdtest.Run(newApp(), "deploy", "--env", "prod", "api", "web")
if res.ExitCode != 2 || !strings.Contains(res.Stderr, "one service at a time") {
t.Fatalf("code %d, stderr: %s", res.ExitCode, res.Stderr)
}
}
// One line proves every tag, default and enum in the whole CLI:
func TestCLIContract(t *testing.T) { newApp() }Where to go next
- Commands — root commands, hidden/deprecated, flag groups, help groups.
- Configuration layers — how argv, env, sconf and defaults interact.
- Tags reference — the full tag and type table.