Skip to content

LarsArtmann/cmdguard

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

848 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

cmdguard

CI Website Go Reference Go Report Card Coverage License: MIT

Website · Docs · pkg.go.dev


From flag definition to service shutdown — type-safe, validated, zero panics.

cmdguard is the only Go CLI framework that unifies type-safe flags, dependency injection with lifecycle management, and a zero-panic error contract into a single system validated at construction. It wraps Cobra so you keep full compatibility while eliminating its footguns.

API Stability: v3.0.0 is the current major version. The legacy v2 line is in maintenance at v2.10.4. See CHANGELOG.md and the v2→v3 Migration Guide.


Why cmdguard?

Other Go CLI frameworks give you flags. cmdguard gives you flags plus everything production CLIs need: dependency injection, service lifecycle, health checks, graceful shutdown, styled output, and error handling that won't bite you.

The trinity — what no other CLI framework offers together

Capability Cobra Kong urfave/cli cmdguard
Struct-tag flags (no string lookups) Yes Yes
Dependency injection (lazy services, lifecycle) Yes
Graceful shutdown (reverse-order on SIGINT) Yes
Health checks (DoctorCommand, Healthchecker) Yes
Zero panics by construction (no Run, no Must) Yes
Validated at construction (not at runtime) Some Yes
Error printed exactly once + correct exit codes Yes
Styled output by default (fang + lipgloss) Yes
Gradual migration (raw cobra + typed runtime) Yes

Dependency injection — the real differentiator

Register services, invoke them in handlers, and manage their entire lifecycle:

cli, _ := v3.NewCLI[AppConfig]("myapp", "My production CLI", AppConfig{},
    v3.WithGracefulShutdown(), // SIGINT → reverse-order shutdown of all services
)

// Register a database service (lazy — created on first invoke)
v3.Provide(cli.Scope(), func(i do.Injector) (*Database, error) {
    return &Database{DSN: "postgres://..."}, nil
})

// Use it in any command handler
v3.NewCommand("query", v3.NoFlags{},
    func(ctx context.Context, cfg *AppConfig, _ v3.NoFlags) error {
        db, _ := v3.Invoke[*Database](cli.Scope())
        return db.Query(ctx)
    },
)

Services can implement HealthCheck (wired into DoctorCommand) and Shutdown (called on SIGINT/SIGTERM in reverse invocation order).

Type-safe flags — validated at construction

Raw Cobra — flags are strings, validated at runtime:

var name string
var count int
rootCmd.Flags().StringVarP(&name, "name", "n", "World", "Name to greet")
rootCmd.Flags().IntVarP(&count, "count", "c", 1, "Number of greetings")
// Forgot to add "count"? Missing handler? Duplicate command? Runtime surprise.

cmdguard — flags are typed structs, validated at construction:

type GreetFlags struct {
    Name  string `flag:"name"  short:"n" default:"World" help:"Name to greet"`
    Count int    `flag:"count" short:"c" default:"1"    help:"Number of greetings"`
}
// Missing handler? Duplicate command? Invalid name? Caught at AddCommand time.

Zero panics — by construction

Every function returns errors. No Run (panics), no Must* variants. The error is printed exactly once (styled by fang), usage is silenced on error by default, and exit codes are handled automatically:

cli.ExecuteAndExit(context.Background()) // one line, correct exit code

Error handling & exit codes

cmdguard owns the error-output contract so you can't get it wrong:

  • The error is printed exactly once — styled by fang when enabled (the default), or plain by Cobra when disabled.
  • Usage is never printed on error (SilenceUsage: true by default). Use WithoutSilenceUsage() to re-enable usage-on-error.
  • The error returned by Execute is for exit-code mapping only — do not re-print it, or you'll duplicate the output.

Recommended — one line, correct exit code:

cli.ExecuteAndExit(context.Background())

When you need to run code before exiting (flush logs, export an audit log, tear down resources), use ExitCode instead of ExecuteAndExit:

err := cli.Execute(ctx)
// ...flush / export audit log / teardown...
os.Exit(v3.ExitCode(err)) // 0 on success, ExitCoder code or 1 on failure

Pitfall to avoid: if err := cli.Execute(ctx); err != nil { fmt.Fprintln(os.Stderr, err) } re-prints the error that cmdguard already printed.


Quick Start

go get github.com/larsartmann/cmdguard/v3
package main

import (
    "context"
    "fmt"
    "os"
    "strings"

    "github.com/larsartmann/cmdguard/v3/pkg/cmdguard/v3"
)

type AppConfig struct {
    Verbose bool   `flag:"verbose" short:"v" default:"false" help:"Enable verbose output"`
    Output  string `flag:"output" short:"o" default:"text" help:"Output format"`
}

type GreetFlags struct {
    Name  string `flag:"name"  short:"n" default:"World" help:"Name to greet"`
    Shout bool   `flag:"shout" short:"s" default:"false" help:"Uppercase output"`
}

func main() {
    cli, err := v3.NewCLI[AppConfig]("myapp", "My CLI application", AppConfig{})
    if err != nil {
        fmt.Fprintf(os.Stderr, "Failed to create CLI: %v\n", err)
        os.Exit(1)
    }

    greetCmd, err := v3.NewCommand("greet", &GreetFlags{},
        func(ctx context.Context, cfg *AppConfig, flags *GreetFlags) error {
            msg := fmt.Sprintf("Hello, %s!", flags.Name)
            if flags.Shout {
                msg = strings.ToUpper(msg)
            }
            fmt.Println(msg)
            return nil
        },
        v3.WithShort("Greet someone"),
    )
    if err != nil {
        fmt.Fprintf(os.Stderr, "Failed to create command: %v\n", err)
        os.Exit(1)
    }

    v3.AddCommand(cli, greetCmd)
    cli.ExecuteAndExit(context.Background())
}
$ go run main.go greet -n "cmdguard" --shout
HELLO, CMDGUARD!

Features

Category Highlights
Type-safe flags Struct tags (flag, short, default, help, env, required, count) — no string lookups
Per-command flag types Each Command[T, F] has its own F — mix different flag structs freely
Dependency injection Built-in samber/do/v2 with Provide, Invoke, lifecycle hooks
Environment variables env:"DB_HOST" tag with WithEnvPrefix("MYAPP_") prefix support
16 output formats table, JSON, CSV, YAML, Markdown, XML, HTML, D2, Mermaid, JSONL, TOML, PlantUML, and more
Signal handling WithSignalHandling() — Ctrl+C cancels context in all handlers
Typo suggestions "did you mean?" for flags and subcommands (Levenshtein distance)
Constructor validation Missing handlers, duplicate names, invalid flags — caught at AddCommand time
Flow context BranchingFlowContext — track command path and share values across hierarchy
Config files WithConfigFile(paths...) — JSON/YAML/TOML auto-loading with flag override
Counting flags count:"true" for -v/-vv/-vvv verbosity patterns
Extensible types RegisterTypeHandler() for custom flag types with full parse/validate support
Middleware TimingMiddleware, RecoveryMiddleware, or write your own; spinner/telemetry available as sub-modules
Interactive prompts WithPromptOnMissing() with prompt:"Question?" tag via huh
Markdown help glamour.WithHelp() renders Long/Example as styled markdown via glamour
Color control --no-color flag + NO_COLOR env var + cli.NoColor() accessor
Shell completion Dynamic completion via WithCompletion(fn)
Man page generation manpage.GenerateCommand[T](cli) for roff output
Positional args WithExactArgs, WithMinimumArgs, WithRangeArgs, WithNoArgs, or custom
Zero panics All functions return errors; no Must* panic variants
Cobra escape hatch ConfigFromContext[T], WithPostFlagParse, RegisterLocalCommandFlags — raw cobra + cmdguard runtime
Scoped flags local:"true" — root-only flags not inherited by subcommands
Hidden flags hidden:"true" — exclude from --help without losing functionality
1429 test runs 87.6% coverage, race-detected, fuzz-tested

Dependency Injection

Register services on the CLI scope and invoke them in handlers:

cli, _ := v3.NewCLI[AppConfig]("myapp", "...", AppConfig{})
scope := cli.Scope()

// Register (lazy initialization)
v3.Provide(scope, func(i do.Injector) (*Database, error) {
    return &Database{DSN: "postgres://..."}, nil
})

// Invoke in handlers
v3.NewCommand("query", v3.NoFlags{},
    func(ctx context.Context, cfg *AppConfig, flags v3.NoFlags) error {
        db, _ := v3.Invoke[*Database](cli.Scope())
        return db.Query(ctx)
    },
)

Services can implement HealthCheck and Shutdown for lifecycle management.


Environment Variables

type DBFlags struct {
    Host     string `flag:"host"     env:"DB_HOST"     default:"localhost" help:"Database host"`
    Port     int    `flag:"port"     env:"DB_PORT"     default:"5432"      help:"Database port"`
    Password string `flag:"password" env:"DB_PASSWORD"                     help:"Database password"`
}

cli, _ := v3.NewCLI[AppConfig]("myapp", "...", AppConfig{},
    v3.WithEnvPrefix("MYAPP_"), // reads MYAPP_DB_HOST, MYAPP_DB_PORT, etc.
)

Priority chain: explicit flag → env var → config file → default value.


Rich Output

import "github.com/larsartmann/go-output"

v3.OutputTable(output.FormatTable, headers, rows)  // Aligned terminal table
v3.OutputTable(output.FormatJSON, headers, rows)    // JSON array
v3.OutputTable(output.FormatYAML, headers, rows)    // YAML

format, _ := output.ParseFormat("csv")
v3.OutputTable(format, headers, rows)

All 16 formats: table, json, csv, tsv, markdown, xml, yaml, html, d2, tree, mermaid, dot, jsonl, asciidoc, toml, plantuml.


Subcommands

listCmd, _ := v3.NewCommand("list", v3.NoFlags{}, listHandler,
    v3.WithShort("List users"),
)
createCmd, _ := v3.NewCommand("create", v3.NoFlags{}, createHandler,
    v3.WithShort("Create a user"),
)
userCmd, _ := v3.NewParentCommand[AppConfig]("user",
    "User management", v3.NoFlags{},
    v3.WithSubcommands(listCmd, createCmd),
    v3.WithShort("User management"),
)
v3.AddCommand(cli, userCmd)

Lifecycle Hooks

v3.NewCommand("deploy", &Flags{}, runHandler,
    v3.WithPreRunE[AppConfig, *Flags](func(ctx context.Context, cfg *AppConfig, flags *Flags) error {
        return validateConfig(flags)
    }),
    v3.WithPostRunE[AppConfig, *Flags](func(ctx context.Context, cfg *AppConfig, flags *Flags) error {
        return cleanup()
    }),
)

PostRunE only fires on success — Cobra semantics. For cleanup that must run even on failure, use WithCleanup[T] which fires after every RunE regardless of outcome.


Raw Cobra Subcommands (Escape Hatch)

cmdguard's Command[T,F] is great for new commands, but real apps often mix raw *cobra.Command subcommands (gradual migration, third-party commands, or commands that don't fit the typed-flags pattern). cmdguard provides three APIs to bridge raw cobra commands with the cmdguard runtime:

// 1. Register raw subcommands on cmdguard's root
cli.RootCommand().AddCommand(myRawCmd)

// 2. Access resolved config from any cobra command context
func(cmd *cobra.Command, _ []string) error {
    cfg, ok := v3.ConfigFromContext[AppConfig](cmd.Context())
    if !ok { return errors.New("config not initialized") }
    // use cfg.Field...
}

// 3. Run initialization (DI, logging, session) after flag parsing
cli, _ := v3.NewCLI[AppConfig]("app", "...", AppConfig{},
    v3.WithPostFlagParse[AppConfig](func(cmd *cobra.Command, cfg *AppConfig) error {
        // Flags are parsed, config is resolved, context is stored.
        // Initialize DI, set up logging, store session for subcommands.
        return initDI(cfg)
    }),
)

Scoped flags (local:"true") prevent root-only flags from polluting every subcommand's --help. Use cli.RegisterLocalCommandFlags(cmd) on subcommands that need the root's execution-flag group.


Built-in Value Types

Type Validation
Duration Wraps time.Duration
Enum[T] Validated against allowed values
LogLevel debug / info / warn / error
URL Validated URL string
Email RFC 5322 email validation
Port 1–65535 range
FilePath Path cleaning and existence checks
HostPort host:port validation

Add your own with RegisterTypeHandler():

v3.RegisterTypeHandler(reflect.TypeFor[MyType](), v3.TypeHandlerFunc{
    ParseFunc:    func(value string, _ v3.FlagTag) (any, error) { return MyType{Value: value}, nil },
    DefaultFunc:  func(_ v3.FlagTag) any { return MyType{} },
})

Flag Tags Reference

type Flags struct {
    Name    string `flag:"name"    short:"n" default:"World"  help:"Name"`
    Verbose int    `flag:"verbose" short:"v" help:"Verbosity" count:"true"`
    Host    string `flag:"host"             default:"localhost" env:"DB_HOST" help:"DB host"`
    Mode    string `flag:"mode"  required:"true"                help:"Required!"`
    Build   string `flag:"build" local:"true"  default:"full"   help:"Root-only flag"`
    Debug   string `flag:"debug" hidden:"true"                  help:"Hidden from --help"`
}
Tag Purpose Example
flag Flag name (required) flag:"name"
short Short flag short:"n"
default Default value default:"World"
help Help text help:"Name to greet"
env Environment variable env:"DB_HOST"
required Mark as required required:"true"
count Counting flag count:"true"
local Root-only, not inherited by subcommands local:"true"
hidden Exclude from --help but stay functional hidden:"true"

Command Options

Option Purpose
WithShort(short) Short description
WithLong(long) Long description
WithExample(example) Example usage
WithAliases(aliases...) Alternative names
(flags passed positionally to NewCommand)
WithPreRunE[T, F](fn) Pre-validation hook
WithPostRunE[T, F](fn) Post-success cleanup
WithHidden(bool) Hide from help
WithDeprecated(msg) Deprecation message
WithGroupID(id) Help group name
WithExactArgs(n) Require exactly n positional args
WithMinimumArgs(n) Require at least n positional args
WithMaximumArgs(n) Allow at most n positional args
WithValidArgs(args...) Restrict args to allowed values
WithSubcommands(cmds...) Attach child commands (parent)
WithRangeArgs(min, max) Require between min and max args
WithNoArgs() Reject any positional args
WithCompletion(fn) Dynamic shell completion

CLI Options

cli, _ := v3.NewCLI[AppConfig]("myapp", "My app", AppConfig{},
    v3.WithCLIVersion("1.0.0"),
    v3.WithEnvPrefix("MYAPP_"),
    v3.WithSignalHandling(),
    v3.WithFang(true),                  // Styled help output
    v3.WithMiddleware[AppConfig](myMiddleware),     // Wrap all handlers
    v3.WithStrictValidation(),           // Require WithShort on commands
    v3.WithConfigValidation[AppConfig](validateFn), // Validate config after parsing
    v3.WithPostFlagParse[AppConfig](initFn),        // DI init / session storage after flags
)
Option Purpose
WithCLIVersion(v) Version string
WithCLILong(desc) Long description
WithSilenceErrors() Suppress error printing (advanced; fang handles this)
WithSilenceUsage() Suppress usage on error (default; kept for compatibility)
WithoutSilenceUsage() Re-enable usage-on-error (opt-in)
WithFang(bool) Styled help output
WithEnvPrefix(prefix) Prefix for env vars
WithSignalHandling() Cancel context on SIGINT/SIGTERM
WithMiddleware[T](mw...) Middleware for all commands
WithGroup(id, title) Help group on root
WithConfigValidation[T](fn) Validate config after flag parsing
WithPostFlagParse[T](fn...) Post-parse hook: DI init, session storage
WithCleanup[T](fn...) Post-RunE cleanup that fires even when RunE errors
WithStrictValidation() Require WithShort on all commands
WithDraconianValidation() Strict + require WithExample on leaf commands
WithConfigFile(paths...) Auto-load JSON config from first found path
WithConfigFileLoader(l, paths...) Load config with custom loader (YAML/TOML)
glamour.WithHelp() Render markdown in command help text (glamour sub-module)
telemetry.WithTelemetry[T](tracer) OpenTelemetry spans for all commands (telemetry sub-module)

Error Handling

// All v3 functions return errors — zero panics in library code
cli, err := v3.NewCLI[Config]("app", "...", Config{})
cmd, err := v3.NewCommand("test", NoFlags{}, handler)

// Sentinel errors for errors.Is()
errors.Is(err, v3.ErrInvalidCommand)
errors.Is(err, v3.ErrMissingHandler)
errors.Is(err, v3.ErrDuplicateCommand)

// Rich error types with context
v3.NewCommandError(name, err)
v3.NewFlagError(name, err)
v3.NewFlagErrorWithSuggestion(name, err, suggestion) // includes typo fix
v3.NewExitError(code, err)                            // custom exit code

// ExitCoder interface — check with errors.As
var exitCoder v3.ExitCoder
errors.As(err, &exitCoder)
exitCoder.ExitCode() // returns custom exit code

Config Files

JSON (built-in)

cli, _ := v3.NewCLI[AppConfig]("myapp", "...", AppConfig{},
    v3.WithConfigFile("~/.config/myapp/config.json", "/etc/myapp/config.json"),
)

Paths are tried in order; missing files are silently skipped. Supports $ENV and ~ expansion.

YAML / TOML (custom loaders)

import "github.com/larsartmann/cmdguard/v3/pkg/cmdguard/v3/configload"

cli, _ := v3.NewCLI[AppConfig]("myapp", "...", AppConfig{},
    v3.WithConfigFileLoader(configload.YAML(), "config.yaml"),
)

configload.YAML() and configload.TOML() return ConfigFileLoader implementations. See pkg/cmdguard/v3/configload/ for available loaders.

Precedence: explicit flag → env var → config file → default value (highest to lowest priority).


Man Page Generation

import "github.com/larsartmann/cmdguard/manpage"

manCmd, err := manpage.GenerateCommand[AppConfig](cli)
if err != nil {
    log.Fatal(err)
}
v3.AddCommand(cli, manCmd)
// $ myapp man

Generates roff-formatted man pages from your command structure.


Optional Sub-Modules

cmdguard's core stays lean — five optional features live in standalone sub-modules so you import only what you need:

Sub-module Import path Provides
glamour github.com/larsartmann/cmdguard/glamour Markdown help rendering (WithHelp, RenderMarkdown)
manpage github.com/larsartmann/cmdguard/manpage Man page generation (GenerateCommand, Write)
prompts github.com/larsartmann/cmdguard/prompts Interactive prompts via huh (Register)
spinner github.com/larsartmann/cmdguard/spinner Terminal spinner middleware (Middleware)
telemetry github.com/larsartmann/cmdguard/telemetry OpenTelemetry spans (WithTelemetry, Middleware)
import (
    "github.com/larsartmann/cmdguard/spinner"
    "github.com/larsartmann/cmdguard/telemetry"
)

cli, _ := v3.NewCLI[Config]("app", "...", Config{},
    telemetry.WithTelemetry[Config](tracer),
    v3.WithMiddleware[Config](spinner.Middleware[Config]("Working...")),
)

BranchingFlowContext

Track the command execution path and share values across the hierarchy:

func handler(ctx context.Context, cfg *AppConfig, flags *Flags) error {
    bfc, ok := v3.GetBranchingFlowContext(ctx)
    if ok {
        fmt.Println("Path:", bfc.PathString()) // "myapp.resource.list"
        bfc.SetValue("key", "value")              // propagates to children
        val, _ := bfc.GetValue("key")             // looks up hierarchy
        _ = val
    }
    return nil
}

Color Output

cmdguard uses fang for styled help output via lipgloss. A --no-color flag is registered by default — pass it to disable color output. Lipgloss also respects the NO_COLOR environment variable automatically.

// Check if color is disabled
if cli.NoColor() {
    // use plain output
}
cli, _ := v3.NewCLI[AppConfig]("myapp", "...", AppConfig{},
    v3.WithFang(true),   // styled help (default)
    v3.WithFang(false),  // plain text help
)

Version Command

cli, _ := v3.NewCLI[AppConfig]("myapp", "...", AppConfig{},
    v3.WithCLIVersion("1.0.0"),
)

versionCmd, err := v3.VersionCommand[AppConfig](cli)
if err != nil {
    log.Fatal(err)
}
v3.AddCommand(cli, versionCmd)
// $ myapp version

Test Helpers

The testutil package provides panic and assertion helpers for testing cmdguard CLIs:

import "github.com/larsartmann/cmdguard/v3/pkg/testutil"

testutil.AssertNoError(t, err)
testutil.AssertErrorIs(t, err, v3.ErrInvalidCommand)
testutil.AssertPanics(t, func() { /* ... */ })

See examples/taskctl/main_test.go for integration test patterns using ExecuteWithArgs.


Examples

See examples/taskctl/ — a production-grade task manager CLI demonstrating all features: DI, typed flags, middleware, subcommands, config files, rich output, and more.


Development

# Enter dev shell (Go 1.26, gopls, golangci-lint)
nix develop

# Run tests
go test ./... -count=1 -timeout 120s -race

# Lint
golangci-lint run ./...

# Format (Nix + Go via treefmt)
nix fmt

# Check everything
nix flake check

See CONTRIBUTING.md for full contribution guidelines.


Documentation

Full docs at cmdguard.lars.software

Local docs: Tutorial, Quick Start, Framework Comparison, Performance, CLI Design Principles.


License

MIT