Awesome Go

structcli

CategoryCommand Line
SubcategoryStandard CLI
Stars18

Eliminate Cobra boilerplate: build powerful, feature-rich CLIs declaratively from Go structs

About structcli

This large README is shown as a plain-text preview. Read the full README at the source

[![Coverage](https://img.shields.io/codecov/c/github/leodido/structcli.svg?style=for-the-badge)](https://codecov.io/gh/leodido/structcli) [![Documentation](https://img.shields.io/badge/godoc-reference-blue.svg?style=for-the-badge)](https://godoc.org/github.com/leodido/structcli) [![GoReportCard](https://img.shields.io/badge/go%20report-A+-brightgreen.svg?style=for-the-badge)](https://goreportcard.com/report/github.com/leodido/structcli)

> Human-friendly, AI-native CLIs from Go structs

Declare your CLI contract once in Go structs. `structcli` turns it into flags, env vars, config-file loading, validation, organized help, and machine-readable contracts for agents.

- Less Cobra/Viper boilerplate
- Better CLIs for humans
- Better contracts for automation and LLMs
- Compiles to WASM out of the box

Stop writing plumbing. Start shipping commands.

## ⚡ Quick Start

[![Build with Ona](https://ona.com/build-with-ona.svg)](https://app.ona.com/#https://github.com/leodido/structcli)

Start with a plain Go struct:

```go
package main

import (
	"fmt"

	"github.com/leodido/structcli"
	"github.com/spf13/cobra"
	"go.uber.org/zap/zapcore"
)

type Options struct {
	LogLevel zapcore.Level
	Port     int
}

func main() {
	opts := &Options{}
	cli := &cobra.Command{
		Use: "myapp",
		RunE: func(c *cobra.Command, args []string) error {
			fmt.Println(opts) // already populated

			return nil
		},
	}

	structcli.Bind(cli, opts)
	structcli.ExecuteOrExit(cli)
}
```

`Bind` creates flags and env vars from your struct and registers it for auto-unmarshal. `ExecuteOrExit` hydrates the struct from flags, env vars, config, and defaults before your `RunE` fires.

```bash
❯ go run examples/minimal/main.go --help
# Usage:
#   myapp [flags]
#
# Flags:
#   -h, --help                     help for myapp
#       --loglevel zapcore.Level    {debug,info,warn,error,dpanic,panic,fatal} (default info)
#       --port int
```

Add tags when you want aliases, env vars, shorthand, defaults, and descriptions:

```go
type Options struct {
	LogLevel zapcore.Level `flag:"level" flagdescr:"Set logging level" flagenv:"true"`
	Port     int           `flagshort:"p" flagdescr:"Server port" flagenv:"true" default:"3000"`
}
```

```bash
❯ go run examples/simple/main.go -h
# A simple CLI example
#
# Usage:
#   myapp [flags]
#
# Flags:
#   -h, --help                  help for myapp
#       --level zapcore.Level   Set logging level {debug,info,warn,error,dpanic,panic,fatal} (default info)
#   -p, --port int              Server port (default 3000)
#
# Global Flags:
#       --jsonschema string[="true"]   output JSON Schema and exit (bare: this command, =tree: full subtree)
#       --mcp                          serve MCP over stdio
```

```bash
❯ MYAPP_LOGLEVEL=debug go run examples/simple/main.go
# &{debug 3000}
```

```bash
❯ MYAPP_LOGLEVEL=error MYAPP_PORT=9000 go run examples/simple/main.go --level dpanic
# &{dpanic 9000}
```

Built-in types like `zapcore.Level` are validated automatically too.

Out of the box, your CLI supports:

- 📝 Command-line flags (`--level info`, `-p 8080`)
- 🌍 Environment variables (`MYAPP_PORT=8080`)
- 💦 Options precedence (flags > env vars > config file > defaults)
- ✅ Automatic validation and type conversion
- 📚 Beautiful help output with proper grouping

Add the AI-native wiring below and it also gains machine-readable JSON Schema, structured JSON errors, semantic exit codes, and optional MCP tool-server mode for agents.

## Build AI-Native CLIs

`structcli` does not just generate flags for humans. It can make your CLI legible to agents too.

Instead of scraping `--help` and guessing, an agent can discover the contract, call the command correctly, and recover from structured failures.

```go
structcli.Setup(rootCmd,
    structcli.WithJSONSchema(),
    structcli.WithHelpTopics(helptopics.Options{ReferenceSection: true}),  // "mycli env-vars" and "mycli config-keys"
    structcli.WithFlagErrors(),  // Optional, but recommended for typed flag-parse errors
    structcli.WithMCP(),         // Optional, exposes the CLI as an MCP server over stdio
)
structcli.ExecuteOrExit(rootCmd)
```

With that wiring:

- `--jsonschema` exposes flags, defaults, required inputs, enums, and env bindings for the current command; `--jsonschema=tree` dumps the entire subtree in one call
- `mycli env-vars` and `mycli config-keys` list every environment variable binding and config file key across the command tree
- `HandleError` / `ExecuteOrExit` emit structured JSON errors instead of forcing callers to parse human-oriented output
- `--mcp` exposes the same command tree as MCP tools over stdio, with typed inputs and structured tool-call failures
- semantic exit codes tell the caller whether it should fix input, fix config, retry, or escalate to a human

The same contract spans flags, env vars, config, validation, and enum constraints.

```console
$ mycli srv --jsonschema
{
  "properties": {
    "port": {
      "type": "integer",
      "default": 3000,
      "x-structcli-env-vars": ["MYCLI_SRV_PORT"]
    },
    "secret-key": {
      "type": "string",
      "x-structcli-env-vars": ["MYCLI_SRV_SECRET_KEY"],
      "x-structcli-env-only": true
    }
  },
  "x-structcli-config-flag": "config"
}
```

Use `--jsonschema=tree` to dump the entire command subtree in a single call: no need to invoke each subcommand separately:

```console
$ mycli --jsonschema=tree   # JSON array of schemas for all commands
$ mycli srv --jsonschema=tree  # schemas for srv + its subcommands
```

No `--help` parsing. No guessing what failed. Just a CLI that can explain itself and fail in machine-actionable ways.

Use `exitcode.Category(code)` and `exitcode.IsRetryable(code)` to decide what to do next. See `jsonschema.WithEnumInDescription()` for schema customization, and pass schema options through `WithJSONSchema` with `jsonschema.Options{SchemaOpts: ...}`.

For CLIs that capture output streams during command construction, configure `mcp.Options.CommandFactory` so each MCP tool call builds a fresh command with the tool-call stdout and stderr writers. This keeps MCP protocol output separate from command output while preserving the existing command tree schema. If the command constructor requires stdin, the factory can wire a non-interactive reader such as `strings.NewReader("")`.

For build-time discovery, `generate.WriteAll` produces SKILL.md, llms.txt, and AGENTS.md from the same struct definitions: wire it into `//go:generate` and the files stay in sync automatically.

Read the full [AI-native guide](docs/ai-native.md) or walk through the runnable [structured error example](examples/structerr/README.md).

## ⬇️ Install

```bash
go get github.com/leodido/structcli
```

## 📦 Key Features

### 🧩 Declarative Flags Definition

Define flags once using Go struct tags.

No more boilerplate for `Flags().StringVarP`, `Flags().IntVar`, `viper.BindPFlag`, etc.

Yes, you can _nest_ structs too.

```go
type ServerOptions struct {
	// Basic flags
	Host string `flag:"host" flagdescr:"Server host" default:"localhost"`
	Port int    `flagshort:"p" flagdescr:"Server port" flagrequired:"true" flagenv:"true"`

	// Environment variable binding
	APIKey string `flagenv:"true" flagdescr:"API authentication key"`

	// Network contracts using net families
	BindIP        net.IP     `flag:"bind-ip" flaggroup:"Network" flagdescr:"Bind interface IP" flagenv:"true"`
	BindMask      net.IPMask `flag:"bind-mask" flaggroup:"Network" flagdescr:"Bind interface mask" flagenv:"true"`
	AdvertiseCIDR net.IPNet  `flag:"advertise-cidr" flaggroup:"Network" flagdescr:"Advertised service subnet (CIDR)" flagenv:"true"`
	TrustedPeers  []net.IP   `flag:"trusted-peers" flaggroup:"Network" flagdescr:"Trusted peer IPs (comma separated)" flagenv:"true"`

	// Flag grouping for organized help
	LogLevel zapcore.Level `flag:"log-level" flaggroup:"Logging" flagdescr:"Set log level"`
	LogFile  string        `flag:"log-file" flaggroup:"Logging" flagdescr:"Log file path" flagenv:"true"`

	// Nested structs for organization
	Database DatabaseConfig `flaggroup:"Database"`

	// Enum type (registered via RegisterEnum)
	TargetEnv Environment `flag:"target-env" flagdescr:"Set the target environment" default:"dev"`
}

type DatabaseConfig struct {
	URL      string `flag:"db-url" flagdescr:"Database connection URL"`
	MaxConns int    `flagdescr:"Max database connections" default:"10" flagenv:"true"`
}
```

See [full example](examples/full/cli/cli.go) for more details.

### 🛠️ Automatic Environment Variable Binding

Automatically generate environment variables binding them to configuration files (YAML, JSON, TOML, etc.) and flags.

From the previous options struct, you get the following env vars automatically:

- `FULL_SRV_PORT`
- `FULL_SRV_APIKEY`
- `FULL_SRV_BIND_IP`
- `FULL_SRV_BIND_MASK`
- `FULL_SRV_ADVERTISE_CIDR`
- `FULL_SRV_TRUSTED_PEERS`
- `FULL_SRV_DATABASE_MAXCONNS`
- `FULL_SRV_LOGFILE`, `FULL_SRV_LOG_FILE`

Every struct field with the `flagenv:"true"` tag gets an environment variable (two if the struct field also has the `flag:"..."` tag, see struct field `LogFile`).
Use `flagenv:"only"` for fields that should be settable exclusively via environment variable or config file: CLI usage (`--flag=value`) is rejected at runtime.

The prefix of the environment variable name is the CLI name plus the command name to which those options are attached to.

Environment variables are command-scoped for command-local options.
For example, if `Port` is attached to the `srv` command, `FULL_SRV_PORT` is used (not `FULL_PORT`).

### ⚙️ Configuration File Support

Set up configuration file discovery (flag, environment variable, and fallback paths) via `Setup`:

```go
structcli.Setup(rootCmd,
    structcli.WithAppName("full"),
    structcli.WithConfig(config.Options{}),
)
```

Enable strict config-key validation with:

```go
structcli.Setup(rootCmd,
    structcli.WithAppName("full"),
    structcli.WithConfig(config.Options{ValidateKeys: true}),
)
```

When enabled, `Unmarshal` fails if command-relevant config contains unknown keys.

`WithAppName` sets the env prefix. `WithConfig` registers the `--config` flag and defers config loading to `ExecuteC`'s bind pipeline (before auto-unmarshal). Ordering between `Setup` and `Bind` does not matter: `WithAppName` retroactively patches env annotations on already-defined flags.

Individual `SetupConfig`, `SetupDebug`, etc. remain available for power users who need fine-grained control.

The line above:

- creates `--config` global flag
- creates `FULL_CONFIG` env var
- sets `/etc/full/`, `$HOME/.full/`, `$PWD/.full/` as fallback paths for `config.yaml`

Magic, isn't it?

What's left? Tell your CLI to load the configuration file (if any).

```go
rootC.PersistentPreRunE = func(c *cobra.Command, args []string) error {
	_, configMessage, configErr := structcli.UseConfigSimple(c)
	if configErr != nil {
		return configErr
	}
	if configMessage != "" {
		c.Println(configMessage)
	}

	return nil
}
```

`UseConfigSimple(c)` loads config into the root config scope and merges only the relevant section into `c`'s effective scope.

#### 🧠 Viper Model Scopes

`structcli` uses two different viper scopes on purpose:

- `structcli.GetConfigViper(rootOrLeafCmd)` -> root-scoped **config source** (config file data tree)
- `structcli.GetViper(cmd)` -> command-scoped **effective values** (flags/env/defaults + command-relevant config)

This separation keeps config-file loading isolated from runtime command state.

If you need imperative values in tests or application code, write to the right scope:

```go
// 1) Effective override for one command context
structcli.GetViper(cmd).Set("timeout", 60)

// 2) Config-tree style injection (top-level + command section)
structcli.GetConfigViper(rootCmd).Set("srv", map[string]any{
  "port": 8443,
})
```

Global `viper.Set(...)` is not used by `structcli.Unmarshal(...)` resolution.
Use `GetViper`/`GetConfigViper` instead.

#### 📜 Configuration Is First-Class Citizen

Configuration can mirror your command hierarchy.

Settings can be global (at the top level) or specific to a command or subcommand. The most specific section always takes precedence.

```yaml
# Global settings apply to all commands unless overridden by a specific section.
# `dryrun` matches the `DryRun` struct field name.
dryrun: true
verbose: 1 # A default verbosity level for all commands.

# Config for the `srv` command (`full srv`)
srv:
  # `port` matches the `Port` field name.
  port: 8433
  # Network options
  bind-ip: "10.20.0.10"
  bind-mask: "ffffff00"
  advertise-cidr: "10.20.0.0/24"
  trusted-peers: "10.20.0.11,10.20.0.12"
  # `log-level` matches the `flag:"log-level"` tag.
  log-level: "warn"
  # `logfile` matches the `LogFile` field name.
  logfile: /var/log/mysrv.log

  # Flattened keys can set options in nested structs.
  # `db-url` (from `flag:"db-url"` tag) maps to ServerOptions.Database.URL.
  db-url: "postgres://user:pass@db/prod"

  # Nested keys are also supported.
  database:
    # Struct field key style
    url: "postgres://user:pass@db/prod"
    # Alias key style (from `flag:"db-url"`)
    db-url: "postgres://user:pass@db/prod"

# Config for the `usr` command group.
usr:
  # This nested section matches the `usr add` command (`full usr add`).
  # Its settings are ONLY applied to 'usr add'.
  add:
    name: "Config User"
    email: "[email protected]"
    age: 42
    # Command specific override
    dry: false
# NOTE: Per the library's design, there is no other fallback other than from the top-level.
# A command like 'usr delete' would ONLY use the global keys above (if those keys/flags are attached to it),
# as an exact 'usr.delete' section is not defined.
```

This configuration system supports:

- **Hierarchical Structure**: Nest keys to match your command path (e.g., `usr: { add: { ... } }`).
- **Strict Precedence**: Only settings from the global scope and the exact command path section are merged. There is no automatic fallback to parent command sections.
- **Flexible Keys**: You can use struct field names and aliases (`flag:"..."`) in both flattened and nested forms.
- **Supported Forms for Nested Fields**: `db-url`, `database.url`, `database: { url: ... }`, and `database: { db-url: ... }`.

### ✅ Built-in Validation & Transformation

Supports validation, transformation, and custom flag type definitions through simple interfaces.

Your struct must implement `Options` (via `Attach`) and can optionally implement `ValidatableOptions` and `TransformableOptions`.

```go
type UserConfig struct {
	Email string `flag:"email" flagdescr:"User email" validate:"email"`
	Age   int    `flag:"age" flagdescr:"User age" validate:"min=18,max=120"`
	Name  string `flag:"name" flagdescr:"User name" mod:"trim,title"`
}

func (o *ServerOptions) Validate(ctx context.Context) []error {
    // Automatic validation
}

func (o *ServerOptions) Transform(ctx context.Context) error {
    // Automatic transformation
}
```

See a full working example [here](examples/full/cli/cli.go).

### 🚧 Automatic Debugging Support

Create a `--debug-options` flag (plus a matching env var) for troubleshooting config/env/flags resolution.

```go
structcli.Setup(rootCmd, structcli.WithDebug(debug.Options{}))
```

Or standalone: `structcli.SetupDebug(rootCmd, debug.Options{})`.

The flag accepts `text` (default when used bare) or `json` for machine-readable output. Truthy values like `true`, `1`, `yes` are treated as `text` for backward compatibility.

**Text output**: an aligned table showing each flag's resolved value and where it came from:

```bash
❯ go run examples/full/main.go srv --debug-options --config examples/full/config.yaml -p 3333
# ...
# Command: full srv
#
# Flags:
#   --apikey                 secret-api-key                       (default)
#   --config                 examples/full/config.yaml            (flag)
#   --database.maxconns      3                                    (default)
#   --db-url                 postgres://user:pass@localhost/mydb  (default)
#   --debug-options          text                                 (flag)
#   --host                   production-server                    (default)
#   --log-file               /var/log/mysrv.log                   (default)
#   --log-level              debug                                (default)
#   --port                   3333                                 (flag)
#   --target-env             dev                                  (default)
#   ...
#
# Values:
#   apikey: secret-api-key
#   host: production-server
#   log-level: debug
#   port: 3333
#   ...
```

**JSON output**: structured data for AI agents and tooling:

```bash
❯ go run examples/full/main.go srv --debug-options=json --config examples/full/config.yaml -p 3333
# ...
# {
#   "command": "full srv",
#   "flags": [
#     ...
#     {"name": "config", "value": "examples/full/config.yaml", "default": "", "changed": true, "source": "flag"},
#     {"name": "db-url", "value": "postgres://user:pass@localhost/mydb", "default": "", "changed": false, "source": "default"},
#     {"name": "log-level", "value": "debug", "default": "info", "changed": false, "source": "default"},
#     {"name": "port", "value": "3333", "default": "0", "changed": true, "source": "flag"},
#     ...
#   ],
#   "values": {"apikey": "secret-api-key", "host": "production-server", "log-level": "debug", "port": 3333, ...}
# }
```

Source attribution resolves each flag to `flag` (CLI), `env`, `config`, or `default`. For env-sourced flags, the text output includes the variable name (e.g., `(env: MYAPP_LOG_LEVEL)`).

The flag can also be activated via environment variable: `FULL_DEBUG_OPTIONS=json`.

### 📋 Self-Documenting Help Topics

`WithHelpTopics` (or standalone `SetupHelpTopics`) adds two help topic commands that list every environment variable binding and every valid configuration file key across the command tree.

```go
structcli.Setup(rootCmd, structcli.WithHelpTopics(helptopics.Options{}))
```

Call this after all subcommands and flags are defined (typically right before `ExecuteOrExit`).

By default the commands appear as regular subcommands under "Available Commands:". Set `ReferenceSection: true` to move them into a dedicated "Reference:" section instead.

**Environment variable listing**: `mycli env-vars`:

```
Environment Variables

  mycli (global):
    MYCLI_VERBOSE  --verbose  bool           false

  mycli serve:
    MYCLI_SERVE_HOST  --host  string         localhost
    MYCLI_SERVE_PORT  --port  int            8080
```

**Configuration key listing**: `mycli config-keys`:

```
Configuration Keys

  Config flag: --config
  Supported formats: yaml, json, toml. Searches: $HOME/.mycli, /etc/mycli

  mycli (global):
    output   --output   string         text
    verbose  --verbose  bool           false

  mycli serve:
    host      --host      string         localhost
    port      --port      int            8080
    tls-cert  --tls-cert  string         ""

  Keys can be nested under the command name in the config file.
```

With `ReferenceSection: true`, both topics appear under "Reference:" in `--help` output instead of "Available Commands:". Flags marked `flagenv:"only"` show an `(env-only)` suffix in the env-vars listing and are excluded from config-keys (since they are hidden). Config keys derived from embedded struct field paths appear as aliases (e.g., `database.maxconns` → `alias for --database.maxconns`).

For machine-readable cross-tree data, use `--jsonschema=tree` instead: it provides the same information in structured JSON.

### ↪️ Sharing Options Between Commands

In complex CLIs, multiple commands often need access to the same global configuration and shared resources (like a logger or a database connection). `structcli` provides a pattern using the [`ContextInjector`](/contract.go) interface to achieve this without resorting to global variables, by propagating a single "source of truth" through the command context.

The pattern allows you to:

Frequently Asked Questions

What is structcli?

structcli is a Command Line library for the Go programming language. Eliminate Cobra boilerplate: build powerful, feature-rich CLIs declaratively from Go structs

How do I install structcli?

Install structcli with the Go module system using `go get leodido/structcli`. Check the repository for the current installation instructions.

What category does structcli belong to?

structcli is listed under Command Line, specifically Standard CLI.

← Back to Standard CLI