██████╗ ██╗ ██╗ ██╗ ██╗
██╔════╝ ██║ ██║ ╚██╗██╔╝
██║ ██║ ██║ ╚███╔╝
██║ ██║ ██║ ██╔██╗
╚██████╗ ███████╗ ██║ ██╔╝ ██╗
╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝
clix is an opinionated, batteries-optional framework for building nested CLI applications using plain Go. It provides a declarative API for describing commands, flags, and arguments while handling configuration hydration, interactive prompting, and contextual execution hooks for you. The generated Go reference documentation lives on pkg.go.dev, which always reflects the latest published API surface.
clix would not exist without the great work done in other CLI ecosystems. If you need a different mental model or additional batteries, definitely explore:
- spf13/cobra – the battle-tested, code-generation friendly CLI toolkit
- peterbourgon/ff – fast flag parsing with cohesive env/config loading
- manifoldco/promptui – elegant interactive prompts for Go CLIs
clix is designed to be simple by default, powerful when needed—starting with core functionality and allowing optional features through an extension system.
clix follows a few core behavioral principles that ensure consistent and intuitive CLI interactions:
-
Groups show help: Commands with children but no Run handler (groups) display their help surface when invoked, showing available groups and commands.
-
Commands with handlers execute: Commands with Run handlers execute when called. If they have children, the handler executes when called without arguments, or routes to child commands when a child name is provided.
-
Actionable commands prompt: Commands without children that require arguments will automatically prompt for missing required arguments, providing a smooth interactive experience.
-
Help flags take precedence: Global and command-level
-h/--helpflags always show help information, even if arguments are missing. -
Configuration precedence: Values are resolved in the following order (highest precedence first): Command flags > App flags > Environment variables > Config file > Defaults
- Command-level flag values (flags defined on the specific command)
- App-level flag values (flags defined on the root command, accessible via
app.Flags()) - Environment variables matching the flag's
EnvVaror the default patternAPP_KEY - Entries in
~/.config/<app>/config.yaml - Flag defaults defined on the command or app flag set
- Minimal overhead for simple apps: Core library is lightweight, with optional features via extensions
- Declarative API: Describe your CLI structure clearly and concisely
- Consistent behavior: Predictable help, prompting, and command execution patterns
- Great developer experience: Clear types, helpful defaults, and comprehensive examples
- Batteries-included when needed: Extensions provide powerful features without polluting core functionality
- Structured output support: Built-in JSON/YAML/text formatting for machine-readable output
- Interactive by default: Smart prompting that guides users through required inputs
- Everything as a dependency: Features like help commands, autocomplete, and version checking are opt-in via extensions
- Complex state management:
clixfocuses on CLI structure, not application state - Built-in templating or rich output: While styling is supported,
clixdoesn't include markdown rendering or complex UI components by default
clix is a good fit if you want:
- A strict tree of groups and commands – Clear hierarchy where groups organize commands and commands execute handlers
- Built-in prompting and config precedence – Automatic prompting for missing arguments and consistent flag/env/config/default resolution
- Extensions instead of code generation – Optional features via extensions rather than codegen tools
- Declarative API – Describe your CLI structure clearly with structs, functional options, or builder-style APIs
If you need code generation, complex plugin systems, or a more flexible command model, consider Cobra or ff.
- Command auto-generation: You explicitly define your command tree
- Automatic flag inference: Flags must be explicitly declared (though defaults, env vars, and config files reduce boilerplate)
Applications built with clix work best when the executable wiring and the command implementations live in separate packages. A minimal layout looks like:
demo/
cmd/demo/main.go
cmd/demo/app.go
internal/greet/command.go
cmd/demo/main.go bootstraps cancellation, logging, and error handling for the process:
// cmd/demo/main.go
package main
import (
"context"
"fmt"
"os"
)
func main() {
app := newApp()
if err := app.Run(context.Background(), nil); err != nil {
fmt.Fprintln(app.Err, err)
os.Exit(1)
}
}cmd/demo/app.go owns the clix.App and root command definition while delegating child commands to the internal/ tree:
// cmd/demo/app.go
package main
import (
"clix"
"clix/ext/help"
"example.com/demo/internal/greet"
)
func newApp() *clix.App {
app := clix.NewApp("demo")
app.Description = "Demonstrates the clix CLI framework"
var project string
app.Flags().StringVar(clix.StringVarOptions{
FlagOptions: clix.FlagOptions{
Name: "project",
Usage: "Project to operate on",
EnvVar: "DEMO_PROJECT",
},
Value: &project,
Default: "sample-project",
})
root := clix.NewCommand("demo")
root.Short = "Root of the demo application"
root.Run = func(ctx *clix.Context) error {
return clix.HelpRenderer{App: ctx.App, Command: ctx.Command}.Render(ctx.App.Out)
}
root.Children = []*clix.Command{
greet.NewCommand(&project),
}
app.Root = root
// Add optional extensions
app.AddExtension(help.Extension{})
return app
}The implementation of the greet command (including flags, arguments, and handlers) lives in internal/greet:
// internal/greet/command.go
package greet
import (
"fmt"
"clix"
)
func NewCommand(project *string) *clix.Command {
cmd := clix.NewCommand("greet")
cmd.Short = "Print a friendly greeting"
cmd.Arguments = []*clix.Argument{{
Name: "name",
Required: true,
Prompt: "Name of the person to greet",
}}
cmd.PreRun = func(ctx *clix.Context) error {
fmt.Fprintf(ctx.App.Out, "Using project %s\n", *project)
return nil
}
cmd.Run = func(ctx *clix.Context) error {
fmt.Fprintf(ctx.App.Out, "Hello %s!\n", ctx.Args[0])
return nil
}
cmd.PostRun = func(ctx *clix.Context) error {
fmt.Fprintln(ctx.App.Out, "Done!")
return nil
}
return cmd
}When no positional arguments are provided, clix will prompt the user for any required values. For example demo greet will prompt for the name argument before executing the command handler. Because the root command's Run handler renders the help surface, invoking demo on its own prints the full set of available commands.
The full runnable version of this example (including additional flags and configuration usage) can be found in examples/basic.
Commands can contain nested children (groups or commands), forming a tree structure. clix distinguishes between:
- Groups: Commands with children but no Run handler (interior nodes that show help)
- Commands: Commands with Run handlers (executable, may have children or be leaf nodes)
Each command supports:
- Aliases: Alternative names for the same command
- Usage metadata: Short descriptions, long descriptions, examples
- Visibility controls: Hidden commands for internal or experimental features
- Execution hooks:
PreRun,Run, andPostRunhandlers
Creating groups and commands:
// Create a group (organizes child commands, shows help when called)
users := clix.NewGroup("users", "Manage user accounts",
clix.NewCommand("create"), // child command
clix.NewCommand("list"), // child command
)
// Or create a command with both handler and children
auth := clix.NewCommand("auth")
auth.Short = "Authentication commands"
auth.Run = func(ctx *clix.Context) error {
fmt.Println("Auth handler executed!")
return nil
}
auth.AddCommand(clix.NewCommand("login"))
auth.AddCommand(clix.NewCommand("logout"))
// Groups show help, commands with handlers execute
// cli users -> shows help
// cli auth -> executes auth handler
// cli auth login -> executes login childGlobal and command-level flags support:
- Environment variable defaults: Automatically read from environment
- Config file defaults: Persistent configuration in
~/.config/<app>/config.yaml - Flag variants: Long (
--flag), short (-f), with equals (--flag=value) or space (--flag value) - Type support: String, bool, int, int64, float64
- Precedence: Command flags > App flags > Environment variables > Config file > Defaults
var project string
app.Flags().StringVar(clix.StringVarOptions{
FlagOptions: clix.FlagOptions{
Name: "project",
Short: "p",
Usage: "Project to operate on",
EnvVar: "MYAPP_PROJECT",
},
Value: &project,
Default: "default-project",
})When you read persisted configuration directly, you can use typed helpers:
if retries, ok := app.Config.Int("project.retries"); ok {
fmt.Fprintf(app.Out, "Retry count: %d\n", retries)
}
if enabled, ok := app.Config.Bool("feature.enabled"); ok && enabled {
fmt.Fprintln(app.Out, "Feature flag is on")
}If you want cli config set to enforce types, register an optional schema:
app.Config.RegisterSchema(
clix.ConfigSchema{
Key: "project.retries",
Type: clix.ConfigInt,
Validate: func(value string) error {
if value == "0" {
return fmt.Errorf("retries must be positive")
}
return nil
},
},
clix.ConfigSchema{
Key: "feature.enabled",
Type: clix.ConfigBool,
},
)With a schema in place, cli config set project.retries 10 is accepted, while non-integer input is rejected with a clear error. Schemas are optional—keys without entries continue to behave like raw strings.
Commands can define required or optional positional arguments with:
- Automatic prompting: Missing required arguments trigger interactive prompts
- Validation: Custom validation functions run before execution
- Default values: Optional arguments can have defaults
- Smart labels: Prompt labels default to title-cased argument names
cmd.Arguments = []*clix.Argument{{
Name: "email",
Prompt: "Email address",
Required: true,
Validate: func(value string) error {
if !strings.Contains(value, "@") {
return fmt.Errorf("invalid email")
}
return nil
},
}}clix provides several prompt types:
- Text input: Standard text prompts with validation
- Select: Navigable single-selection lists (requires prompt extension)
- Multi-select: Multiple selection lists (requires prompt extension)
- Confirm: Yes/no confirmation prompts
Prompts automatically use raw terminal mode when available (for arrow key navigation) and fall back to line-based input otherwise.
The prompt API supports both struct-based and functional options patterns. The struct-based API (using PromptRequest) is the primary API and is consistent with the rest of the codebase.
Struct-based API (recommended):
// Basic text prompt
result, err := ctx.App.Prompter.Prompt(ctx, clix.PromptRequest{
Label: "Enter name",
Default: "unknown",
})
// Select prompt
result, err := ctx.App.Prompter.Prompt(ctx, clix.PromptRequest{
Label: "Choose an option",
Options: []clix.SelectOption{
{Label: "Option A", Value: "a"},
{Label: "Option B", Value: "b"},
},
})
// Confirm prompt
result, err := ctx.App.Prompter.Prompt(ctx, clix.PromptRequest{
Label: "Continue?",
Confirm: true,
})Functional options API:
// Basic text prompt
result, err := ctx.App.Prompter.Prompt(ctx,
clix.WithLabel("Enter name"),
clix.WithDefault("unknown"),
)
// Select prompt (requires prompt extension)
import "clix/ext/prompt"
result, err := ctx.App.Prompter.Prompt(ctx,
clix.WithLabel("Choose an option"),
prompt.Select([]clix.SelectOption{
{Label: "Option A", Value: "a"},
{Label: "Option B", Value: "b"},
}),
)
// Confirm prompt
result, err := ctx.App.Prompter.Prompt(ctx,
clix.WithLabel("Continue?"),
clix.WithConfirm(),
)Both APIs can be mixed - functional options can be combined with PromptRequest structs, with later options overriding earlier values.
Global --format flag supports json, yaml, and text output formats:
// In your command handler
return ctx.App.FormatOutput(data) // Uses --format flag automaticallyCommands like version and config list automatically support structured output for machine-readable workflows.
Optional styling hooks allow integration with packages like lipgloss:
import "github.com/charmbracelet/lipgloss"
style := lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
app.DefaultTheme.PrefixStyle = clix.StyleFunc(style.Render)
app.Styles.SectionHeading = clix.StyleFunc(style.Render)
// Style app-level vs. command-level flags differently if desired
app.Styles.AppFlagName = clix.StyleFunc(style.Render)
app.Styles.CommandFlagName = clix.StyleFunc(style.Render)Key style hooks:
AppFlagName/AppFlagUsage– app-level flags defined onapp.Flags()CommandFlagName/CommandFlagUsage– command-level flags defined oncmd.Flags()FlagName/FlagUsage– base styles applied when the more specific hooks are unsetChildName/ChildDesc– section entries under GROUPS and COMMANDS
Styling is optional—applications without styling still work perfectly.
Command handlers receive a *clix.Context that embeds context.Context and provides:
- Access to the active command and arguments
- Application instance and configuration
- Hydrated flag/config values via type-specific getters:
String(),Bool(),Int(),Int64(),Float64() - Argument access:
Arg(index),ArgNamed(name),AllArgs() - Standard output/error streams
- Standard context.Context functionality (cancellation, deadlines, values)
All getter methods follow the same precedence: command flags > app flags > env > config > defaults
Context Layering:
App.Run(ctx context.Context, ...)accepts a standardcontext.Contextfor process-level cancellation and deadlines- For each command execution, clix builds a
*clix.Contextthat embeds the originalcontext.Contextand adds CLI-specific data - Within handlers, pass
*clix.Contextdirectly to functions that acceptcontext.Context(likePrompter.Prompt) - no need to usectx.Context
cmd.Run = func(ctx *clix.Context) error {
// Access CLI-specific data via type-specific getters
// All methods follow the same precedence: command flags > app flags > env > config > defaults
if project, ok := ctx.String("project"); ok {
fmt.Fprintf(ctx.App.Out, "Using project %s\n", project)
}
if verbose, ok := ctx.Bool("verbose"); ok && verbose {
fmt.Fprintf(ctx.App.Out, "Verbose mode enabled\n")
}
if port, ok := ctx.Int("port"); ok {
fmt.Fprintf(ctx.App.Out, "Port: %d\n", port)
}
if timeout, ok := ctx.Int64("timeout"); ok {
fmt.Fprintf(ctx.App.Out, "Timeout: %d\n", timeout)
}
if ratio, ok := ctx.Float64("ratio"); ok {
fmt.Fprintf(ctx.App.Out, "Ratio: %.2f\n", ratio)
}
// Use context.Context functionality (cancellation, deadlines)
select {
case <-ctx.Done():
return ctx.Err()
default:
}
// Pass ctx directly to Prompter (it embeds context.Context)
value, err := ctx.App.Prompter.Prompt(ctx, clix.PromptRequest{
Label: "Enter value",
})
if err != nil {
return err
}
return nil
}Extensions provide optional "batteries-included" features that can be added to your CLI application without adding overhead for simple apps that don't need them.
This design is inspired by goldmark's extension system, which allows features to be added without polluting the core library.
Simple by default, powerful when needed. clix starts with minimal overhead:
- Core command/flag/argument parsing
- Flag-based help (
-h,--help) - always available - Prompting UI
- Configuration management (API only, not commands)
Everything else is opt-in via extensions, including:
- Command-based help (
cli help) - Config management commands (
cli config) - Shell completion (
cli autocomplete) - Version information (
cli version) - And future extensions...
Add extensions to your app before calling Run():
import (
"clix"
"clix/ext/autocomplete"
"clix/ext/config"
"clix/ext/help"
"clix/ext/version"
)
app := clix.NewApp("myapp")
app.Root = clix.NewCommand("myapp")
// Add extensions for optional features
app.AddExtension(help.Extension{}) // Adds: myapp help [command]
app.AddExtension(config.Extension{}) // Adds: myapp config, myapp config list, etc.
app.AddExtension(autocomplete.Extension{}) // Adds: myapp autocomplete [shell]
app.AddExtension(version.Extension{ // Adds: myapp version
Version: "1.0.0",
Commit: "abc123", // optional
Date: "2024-01-01", // optional
})
// Flag-based help works without extensions: myapp -h, myapp --help
app.Run(context.Background(), nil)Adds command-based help similar to man pages:
cli help- Show help for the root commandcli help [command]- Show help for a specific command
Note: Flag-based help (-h, --help) is handled by the core library and works without this extension. This extension only adds the help command itself.
Adds configuration management commands using dot-separated key paths (e.g. project.default):
cli config- Show help for config commands (group)cli config list- List persisted configuration as YAML (respects--format=json|yaml|text)cli config get <key_path>- Print the persisted value atkey_pathcli config set <key_path> <value>- Persist a new valuecli config unset <key_path>- Remove the persisted value (no-op if missing)cli config reset- Remove all persisted configuration from disk (flags/env/defaults still apply)
Optional schemas can enforce types when setting values:
app.Config.RegisterSchema(clix.ConfigSchema{
Key: "project.retries",
Type: clix.ConfigInt,
})With a schema, config set project.retries 5 succeeds while config set project.retries nope fails with a helpful error.
Adds shell completion script generation:
cli autocomplete [bash|zsh|fish]- Generate completion script for the specified shell- If no shell is provided, shows help
Adds version information:
cli version- Show version information, including Go version and build info (supports--format=json|yaml|text)- Global
--version/-vflag - Show simple version string (e.g.,cli --versionshows "cli version 1.0.0")
app.AddExtension(version.Extension{
Version: "1.0.0",
Commit: "abc123", // optional
Date: "2024-01-01", // optional
})Zero overhead if not imported: Extensions only add commands when imported and registered. Simple apps that don't import them pay zero cost.
Extensions implement the clix.Extension interface:
package myextension
import "github.com/SCKelemen/clix"
type Extension struct {
// Optional: extension-specific configuration
}
func (e Extension) Extend(app *clix.App) error {
// Add commands, modify behavior, etc.
if app.Root != nil {
app.Root.AddCommand(MyCustomCommand(app))
}
return nil
}Extensions are applied lazily when Run() is called, or can be applied early with ApplyExtensions().
For more details, see ext/README.md.
The App struct represents a runnable CLI application and wires together the root command, global flag set, configuration manager, and prompting behavior.
type App struct {
Name string
Version string
Description string
Root *Command
Config *ConfigManager
Prompter Prompter
Out io.Writer
Err io.Writer
In io.Reader
EnvPrefix string
DefaultTheme PromptTheme
Styles Styles
}Key methods:
NewApp(name string) *App- Construct a new applicationRun(ctx context.Context, args []string) error- Execute the applicationAddExtension(ext Extension)- Register an extensionApplyExtensions() error- Apply all registered extensionsOutputFormat() string- Get the current output format (json/yaml/text)FormatOutput(data interface{}) error- Format data using the current format
A Command represents a CLI command. Commands can contain nested children (groups or commands), flags, argument definitions, and execution hooks.
A Command can be one of three types:
- Group: has children but no Run handler (interior node, shows help when called)
- Leaf Command: has a Run handler but no children (executable leaf node)
- Command with Children: has both a Run handler and children (executes Run handler when called without args, or routes to child commands when a child name is provided)
type Command struct {
Name string
Aliases []string
Short string
Long string
Usage string
Example string
Hidden bool
Flags *FlagSet
Arguments []*Argument
Children []*Command // Children of this command (groups or commands)
Run Handler
PreRun Hook
PostRun Hook
}Key methods:
NewCommand(name string) *Command- Construct a new executable commandNewGroup(name, short string, children ...*Command) *Command- Construct a group (interior node)AddCommand(cmd *Command)- Register a child command or groupIsGroup() bool- Returns true if command is a group (has children, no Run handler)IsLeaf() bool- Returns true if command is executable (has Run handler)Groups() []*Command- Returns only child groupsCommands() []*Command- Returns only executable child commandsVisibleChildren() []*Command- Returns all visible child commands and groupsPath() string- Get the full command path from root
An Argument describes a positional argument for a command.
type Argument struct {
Name string
Prompt string
Default string
Required bool
Validate func(string) error
}Methods:
PromptLabel() string- Get the prompt label (defaults to title-cased name)
A PromptRequest carries the information necessary to display a prompt. This is the primary, struct-based API for prompts, consistent with the rest of the codebase (similar to StringVarOptions, BoolVarOptions, etc.). PromptRequest implements PromptOption, so it can be used alongside functional options.
type PromptRequest struct {
Label string
Default string
Validate func(string) error
Theme PromptTheme
// Options for select-style prompts
Options []SelectOption
// MultiSelect enables multi-selection mode
MultiSelect bool
// Confirm is for yes/no confirmation prompts
Confirm bool
// ContinueText for multi-select prompts
ContinueText string
}Functional Options API:
For convenience, clix also provides functional options that can be used instead of or alongside PromptRequest:
Core options (available in clix package):
WithLabel(label string)- Set the prompt labelWithDefault(def string)- Set the default valueWithValidate(validate func(string) error)- Set validation functionWithTheme(theme PromptTheme)- Set the prompt themeWithConfirm()- Enable yes/no confirmation promptWithCommandHandler(handler PromptCommandHandler)- Register handler for special key commandsWithKeyMap(keyMap PromptKeyMap)- Configure keyboard shortcuts and bindingsWithNoDefaultPlaceholder(text string)- Set placeholder text when no default exists
Advanced options (require clix/ext/prompt extension):
prompt.Select(options []SelectOption)- Create a select promptprompt.MultiSelect(options []SelectOption)- Create a multi-select promptprompt.WithContinueText(text string)- Set continue button text for multi-selectprompt.Confirm()- Alias forWithConfirm()(for convenience)
Both APIs can be mixed. For example:
// Mix struct with functional options
result, err := prompter.Prompt(ctx,
clix.PromptRequest{Label: "Name"},
clix.WithDefault("unknown"),
)The Extension interface allows optional features to be added to an application.
type Extension interface {
Extend(app *App) error
}examples/basic: End-to-end application demonstrating commands, flags, prompting, and configuration usage.examples/gh: A GitHub CLI-style hierarchy with familiar groups, commands, aliases, and interactive prompts.examples/gcloud: A Google Cloud CLI-inspired tree with large command groups, global flags, and configuration interactions.examples/lipgloss: Demonstrates prompt and help styling usinglipgloss, including select, multi-select, and confirm prompts.examples/multicli: Demonstrates sharing command implementations across multiple CLI applications with different hierarchies, similar to Google Cloud's gcloud/bq pattern.
We welcome issues and pull requests. To keep the review cycle short:
- Fork the repo and create a feature branch.
- Format Go sources with
gofmt(orgo fmt ./...). - Run
go test ./...to ensure tests and examples stay green. - Include tests and docs for new behavior.
If you plan a larger change, feel free to open an issue first so we can discuss the approach.
We use semantic versioning and Git tags so Go tooling (and Dependabot) can pick up new versions automatically. When you’re ready to cut a release:
- Ensure
mainis green:gofmt ./... && go test ./.... - Update any docs or examples that mention the version (e.g., changelog snippets if applicable).
- Tag the release using Go-style semver and push the tag:
git tag -a v1.0.0 -m "clix v1.0.0" git push origin v1.0.0 - GitHub Actions (
release.yml) will build/test and publish a GitHub Release for that tag. pkg.go.dev and Dependabot will automatically index the new version.
Following this flow keeps the module path (github.com/SCKelemen/clix) stable and gives downstream consumers reproducible builds.
Whenever we tag a new version (e.g. v1.0.0, v1.1.0, …) the Go module proxy and Dependabot can see it automatically because the module path stays github.com/SCKelemen/clix. To have Dependabot open upgrade PRs in your application:
- Import clix via the module path:
import "github.com/SCKelemen/clix"
- Pin a version in your
go.mod:Dependabot will bump this line when a newer semver-compatible version exists.require github.com/SCKelemen/clix v1.0.0
- Add
.github/dependabot.ymlwith a Go entry:version: 2 updates: - package-ecosystem: "gomod" directory: "/" # path containing go.mod schedule: interval: "weekly" # or daily/monthly
That’s it—each time clix tags a new release, Dependabot compares the version in your go.mod with the latest tag and submits a PR if they differ. If you want to limit updates (e.g. only patches), you can use Dependabot’s allow/ignore rules, but the basic config above is enough for automatic PRs.
clix is available under the MIT License.