A tool to help skaffold and maintain a Go CLI program
Project description
climax
A scaffold generator for Go CLI applications built with peterbourgon/ff/v4.
Climax generates the boilerplate for a structured, idiomatic CLI: one package per command, flags-first configuration (every setting is a registered ff flag, discoverable via -h), a shared root Config that threads stdin/stdout/stderr through the whole tree, signal-safe shutdown, and a dispatcher that routes arguments to the matching command. New commands can be added at any time — climax uses AST analysis to register them correctly even if you've edited the generated files or renamed the dispatcher.
Install
go install github.com/StevenACoffman/climax@latest
Or, if you have uv installed, run climax without a separate Go toolchain:
uvx --from climaxgo climax
To install it persistently as a uv tool:
uv tool install climaxgo
climax init
Quick start
# Create a new module
mkdir myapp && cd myapp
go mod init github.com/yourname/myapp
# Scaffold the application
climax init
# Run it immediately
go run . --help
# Add commands
climax add serve
climax add config
climax add create --parent config # nested under config
# Add a man page command
climax mango
go get github.com/StevenACoffman/mango-ff github.com/muesli/roff
# Build
go build -o myapp .
Commands
climax init [FLAGS] [path]
Generates a complete CLI application skeleton at path (default: current directory). The path must be inside an existing Go module.
Generated files:
myapp/
main.go # entry point with signal-safe shutdown
cmd/
cmd.go # dispatcher: routes args to commands
root/
root.go # shared Config (Stdin, Stdout, Stderr, ff.Command)
version/
version.go # version command (skip with --no-version)
Example output:
initialized climax app at /home/user/myapp (import: github.com/yourname/myapp)
created main.go
created cmd/cmd.go
created cmd/root/root.go
created cmd/version/version.go
Flags:
| Flag | Default | Description |
|---|---|---|
--name |
last import path segment | CLI name used in usage strings (allows hyphens) |
--short |
"TODO: describe <name> here" |
ShortHelp for the root command |
--long |
(omitted) | LongHelp for the root command |
--root-pkg |
root |
Go package name for the root config package |
--env-prefix |
name uppercased, - and . replaced with _ |
Env var prefix for the generated app's flags |
--no-env-prefix |
false | Use ff.WithEnvVars() (no prefix) instead of a prefix |
--no-version |
false | Skip generating cmd/version/version.go |
--env-prefix and --no-env-prefix are mutually exclusive. When neither is set, the generated app defaults to the app name uppercased (e.g. a flag --log-level in an app named myapp is readable as MYAPP_LOG_LEVEL).
climax add [FLAGS] <name> [path]
Adds a new command package at cmd/<name>/<name>.go and registers it in the dispatcher. The path must be the root of an application created by climax init.
Registration uses AST analysis to locate the correct insertion point, so it works reliably even if you've removed the generated marker comments, restructured the dispatcher, or renamed it from cmd.go to command.go.
Example output:
added command "serve"
created cmd/serve/serve.go
modified cmd/cmd.go
Flags:
| Flag | Default | Description |
|---|---|---|
--name |
same as <name> |
ff.Command.Name in the generated file (allows hyphens) |
--short |
"<name> command" |
ShortHelp for the generated command |
--long |
"<Name> is a new command." |
LongHelp for the generated command |
-p, --parent |
root package | Go package name of the parent command |
What gets generated:
climax add serve produces this file, ready to edit:
// Package serve implements the "serve" CLI command.
package serve
import (
"context"
"fmt"
"github.com/peterbourgon/ff/v4"
"github.com/yourname/myapp/cmd/root"
)
// Config holds the configuration for the serve command.
type Config struct {
*root.Config
Flags *ff.FlagSet
Command *ff.Command
}
// New creates and registers the serve command with the given parent config.
func New(parent *root.Config) *Config {
var cfg Config
cfg.Config = parent
cfg.Flags = ff.NewFlagSet("serve").SetParent(parent.Flags)
// bind flags: cfg.Flags.StringVar(&cfg.SomeFlag, 0, "some-flag", "", "description")
cfg.Command = &ff.Command{
Name: "serve",
Usage: "myapp serve [FLAGS]",
ShortHelp: "serve command",
LongHelp: "Serve is a new command.",
Flags: cfg.Flags,
Exec: cfg.exec,
}
parent.Command.Subcommands = append(parent.Command.Subcommands, cfg.Command)
return &cfg
}
func (cfg *Config) exec(_ context.Context, _ []string) error {
// TODO: implement serve.
// Rename the second parameter from _ to args to access positional arguments.
_, _ = fmt.Fprintln(cfg.Stdout, "serve: not yet implemented")
return nil
}
The exec stub is adapted to the root Config shape: if Stdout/Stderr fields are present, the stub uses fmt.Fprintln(cfg.Stdout, ...) and imports "fmt". If a logger field is detected, it uses cfg.<Logger>.Info(...). Otherwise it returns nil with no imports.
Nesting commands:
Use the Go package name (not a variable name) as the --parent value:
climax add config
climax add create --parent config # creates cmd/create/create.go under config
climax mango [FLAGS] [path]
Adds a man subcommand to the climax application at path (default: current directory). The generated command prints the application's man page in roff format to stdout using github.com/StevenACoffman/mango-ff, which derives flag entries, subcommand sections, and help text directly from the ff.Command tree — no additional configuration required.
After running climax mango, add the required dependencies to the target application:
go get github.com/StevenACoffman/mango-ff github.com/muesli/roff
Then build and use the man page:
myapp man # print roff to stdout
myapp man | man -l - # view in the man pager
Example output:
added man page command
created cmd/man/man.go
modified cmd/cmd.go
Add dependencies:
go get github.com/StevenACoffman/mango-ff github.com/muesli/roff
View the man page:
go run . man | man -l -
Flags:
| Flag | Default | Description |
|---|---|---|
--section |
1 |
Man page section number (1–8) |
--authors |
(none) | Bake a WithSection("Authors", ...) call into the generated command |
--copyright |
(none) | Bake a WithSection("Copyright", ...) call into the generated command |
climax lint [path]
Checks a climax-based application for structural drift from the current scaffold templates. The path defaults to the current directory, which must be the root of an application created by climax init.
Each issue is shown as a focused unified diff:
⚠ 1 structural issue(s) found in /home/user/myapp
── cmd/cmd.go: stdin io.Reader parameter in Run, stdin forwarded to root.New
--- a/cmd/cmd.go
+++ b/cmd/cmd.go (expected per climax template)
@@ structural pattern @@
-func Run(ctx context.Context, args []string, stdout, stderr io.Writer) error {
- r := root.New(stdout, stderr)
+func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
+ r := root.New(stdin, stdout, stderr)
When no issues are found:
✓ No structural drift found.
Structural groups checked:
| File | Properties |
|---|---|
main.go |
signal.NotifyContext for graceful shutdown; separate run(ctx) function for testability; os.Stdin passed explicitly to cmd.Run |
cmd/cmd.go |
stdin io.Reader parameter in Run; stdin forwarded to root.New |
cmd/<root>/<root>.go |
Stdin io.Reader field in Config; stdin io.Reader parameter in New; cfg.Stdin = stdin assignment |
Expected patterns are derived directly from the embedded template files in pkg/scaffold/templates/, so the lint checks automatically stay in sync whenever templates are updated.
Exits with status 1 when any issues are found, making it suitable for CI checks on generated apps.
climax version [--json]
Prints build and version information for the climax binary, read from the module's embedded build info:
GitVersion: v0.4.0
GitCommit: a1b2c3d4e5f6...
GitTreeState: clean
BuildDate: 2025-11-01T12:00:00
BuiltBy: unknown
GoVersion: go1.24.0
Compiler: gc
ModuleSum: h1:...
Platform: darwin/arm64
Use --json for machine-readable output:
climax version --json
{
"gitVersion": "v0.4.0",
"gitCommit": "a1b2c3d4e5f6...",
"gitTreeState": "clean",
"buildDate": "2025-11-01T12:00:00",
"builtBy": "unknown",
"goVersion": "go1.24.0",
"compiler": "gc",
"moduleChecksum": "h1:...",
"platform": "darwin/arm64"
}
climax update [--apply] [path]
Climax development tool only. This command detects drift in climax's own source and templates. It refuses to run against any other module. Do not use it on applications generated by climax.
Detects structural drift between climax's own source files and the scaffold template files in pkg/scaffold/templates/. Run it after changing a structural pattern in main.go, cmd/cmd.go, cmd/root/root.go, cmd/version/version.go, or cmd/mango/mango.go to check whether the templates need updating.
File mapping (source → template):
| Source | Template |
|---|---|
main.go |
main.go.tmpl |
cmd/cmd.go |
cmd.go.tmpl |
cmd/root/root.go |
root.go.tmpl |
cmd/version/version.go |
version.go.tmpl |
cmd/mango/mango.go |
man.go.tmpl |
Drift detected: 3 item(s) (3 auto-fixable with --apply)
✗ main signal.NotifyContext
✗ main run() separation
✗ cmd stdin io.Reader parameter in Run
Without --apply it reports drift and exits non-zero (useful in CI). With --apply it patches the template files in place for each auto-fixable item. Items where the template has a property the source does not are flagged for manual review and not auto-patched.
Generated application structure
After climax init followed by climax add serve, climax add config, climax add create --parent config, and climax mango:
myapp/
main.go
cmd/
cmd.go
root/
root.go
version/
version.go
serve/
serve.go
config/
config.go
create/
create.go
man/
man.go
Run any command:
go run . serve
go run . config
go run . config create
go run . help config create
go run . man | man -l -
Generated patterns
Flags-first configuration
Every knob that affects behaviour is a registered flag on an ff.FlagSet. This means running any command (or subcommand) with -h reveals its complete configuration surface area — nothing is hidden behind hard-coded values or environment variables that aren't also flags.
ff parses each flag from three sources, highest precedence first:
- CLI args (
--port 8080) - Environment variables —
ffuppercases the prefix and replaces hyphens and dots with underscores, so--log-levelwith prefixMYAPPbecomesMYAPP_LOG_LEVEL - Config files (TOML, JSON, INI, or
.envformat)
Because the flag is the single source of truth for each setting, you get all three sources for free without extra code:
// cmd/serve/serve.go — in New()
cfg.Flags.IntVar(&cfg.Port, 0, "port", 8080, "port to listen on")
# All three lines set the same flag:
myapp serve --port 9090
MYAPP_PORT=9090 myapp serve
# serve.toml: port = 9090
The generated cmd/cmd.go includes a doc comment listing the env var prefix rule so it's always discoverable:
// Every flag can be set via a MYAPP_-prefixed environment variable.
// The mapping rule: prepend MYAPP_, uppercase, replace dashes with underscores.
See the peterbourgon/ff documentation for details on config file formats and full precedence rules.
Signal-safe shutdown
main.go uses signal.NotifyContext so Ctrl-C and SIGTERM cancel the context cleanly:
func main() {
ctx, stop := signal.NotifyContext(context.Background(),
os.Interrupt, // SIGINT = Ctrl+C
syscall.SIGQUIT, // Ctrl-\
syscall.SIGTERM, // polite termination request
)
code := run(ctx)
stop()
os.Exit(code)
}
run is intentionally separated from main so test harnesses can call it directly with a controlled context.
Dispatcher error handling
The generated dispatcher in cmd/cmd.go distinguishes three error paths:
| Returned from exec | What happens |
|---|---|
nil, ff.ErrHelp, ff.ErrNoExec |
Exit 0. ff.ErrNoExec fires when a parent command is invoked without a subcommand — help is shown but the process exits cleanly. |
root.ExitError(N) |
Exit N. No "error: ..." line is printed. Use this when the command has already reported the outcome (e.g. lint found issues). |
Any other error |
The selected command's help is printed to stderr, then "error: <message>", then exit 1. |
Parse errors (bad flags) follow the same path as other errors: help is shown before the error message.
Shared I/O
stdin, stdout, and stderr are passed explicitly from main through the dispatcher down to every command's Config. Nothing writes to os.Stdout directly after main.go. This makes commands testable without capturing global state:
// cmd/cmd.go
func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
r := root.New(stdin, stdout, stderr)
...
}
// cmd/root/root.go
type Config struct {
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
Flags *ff.FlagSet
Command *ff.Command
}
Testing
Because all I/O goes through injected writers, commands are testable by calling cmd.Run with bytes.Buffer values:
func TestServeCommand(t *testing.T) {
var stdout, stderr bytes.Buffer
err := cmd.Run(
context.Background(),
[]string{"serve", "--port", "9090"},
strings.NewReader(""),
&stdout,
&stderr,
)
if err != nil {
t.Fatalf("serve: %v\nstderr: %s", err, &stderr)
}
// assert stdout.String() contains expected output
}
No global state is captured or restored. The test binary itself is not invoked.
One package per command
Each command lives in its own package and embeds *root.Config to inherit the shared I/O:
// cmd/serve/serve.go
type Config struct {
*root.Config
Port int
Flags *ff.FlagSet
Command *ff.Command
}
func New(parent *root.Config) *Config { ... }
func (cfg *Config) exec(ctx context.Context, args []string) error { ... }
New and Config are the only exported identifiers in the package. Flag values are bound to Config fields inside New, not inside exec — by the time exec runs, flags are already parsed.
Nested commands
A child command embeds its parent's Config instead of *root.Config, giving it access to both the shared I/O and any flags the parent defines:
// cmd/create/create.go
type Config struct {
*config.Config // embeds the config command's Config
Flags *ff.FlagSet
Command *ff.Command
}
The root Config is accessible transitively via the embedded chain.
Exit codes without error messages
root.ExitError lets a command exit with a specific non-zero code without printing an error: ... line:
// In any command's exec function:
if noIssues {
return nil // exit 0
}
return root.ExitError(1) // exit 1, no "error:" printed
Use this when the command has already communicated its outcome through its own output — for example, climax lint prints the diff before returning ExitError(1), so a redundant error line would be noise.
Version embedding
The generated cmd/version/version.go reads the module version automatically from the Go toolchain's embedded build info. When a binary is installed via go install or built from a tagged release, the version is set without any extra build flags:
go install github.com/yourname/myapp@v1.2.3
myapp version # prints v1.2.3
myapp version --json # machine-readable output
For local or untagged builds, the version defaults to "dev". Override it at link time if needed:
go build -ldflags "-X 'github.com/yourname/myapp/cmd/version.Version=v1.2.3'" -o myapp .
var Version = "dev" is a deliberate exception to the no-globals rule: the Go linker's -ldflags "-X <pkg>.Version=<val>" mechanism requires a package-level var, not a constant or local variable.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distributions
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file climaxgo-0.6.0-py3-none-win_arm64.whl.
File metadata
- Download URL: climaxgo-0.6.0-py3-none-win_arm64.whl
- Upload date:
- Size: 3.1 MB
- Tags: Python 3, Windows ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: Go-http-client/2.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
21f9c17625466edccd972b6502f2ebae2d85f604421be0f19083f747ad04d456
|
|
| MD5 |
83e35458d8810a50191940edb822c21f
|
|
| BLAKE2b-256 |
9796ae314899f9aaf98e7bbb0d931b65be5d16bfb3fa9859e1bf501c102a14e7
|
File details
Details for the file climaxgo-0.6.0-py3-none-win_amd64.whl.
File metadata
- Download URL: climaxgo-0.6.0-py3-none-win_amd64.whl
- Upload date:
- Size: 3.3 MB
- Tags: Python 3, Windows x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: Go-http-client/2.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d297bea05fe1492f6cb028c125a843aa6ff9028307dd96e38cb108c7f6a66ac5
|
|
| MD5 |
0ffd8923a872f056e1a72bebc094f136
|
|
| BLAKE2b-256 |
25db83682d0f01a34c8a63ca1473d05413674e26e402dbd2d6e45b83c27c1bd3
|
File details
Details for the file climaxgo-0.6.0-py3-none-musllinux_1_2_x86_64.whl.
File metadata
- Download URL: climaxgo-0.6.0-py3-none-musllinux_1_2_x86_64.whl
- Upload date:
- Size: 3.2 MB
- Tags: Python 3, musllinux: musl 1.2+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: Go-http-client/2.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cce50bfcd9ca192a3d567904a69fce42b2bed411f2b01793d0d2cc63ae6f4349
|
|
| MD5 |
3731809dac09b6148a7c790509936612
|
|
| BLAKE2b-256 |
812fbf541530c67013e8f3e0a809b0aec7eff7859c65883b1e45137c2447d522
|
File details
Details for the file climaxgo-0.6.0-py3-none-musllinux_1_2_aarch64.whl.
File metadata
- Download URL: climaxgo-0.6.0-py3-none-musllinux_1_2_aarch64.whl
- Upload date:
- Size: 3.0 MB
- Tags: Python 3, musllinux: musl 1.2+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: Go-http-client/2.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0c161ee6c1ccb6f3d300cab60899dda5e9b869531315c20580819b4ecb699420
|
|
| MD5 |
dc22a4d9480bbce24bb4eead3557acc9
|
|
| BLAKE2b-256 |
a1f4d78e24c948014250f19e26d49a7664f1f2da4c1376ee57fa65cf34690bc1
|
File details
Details for the file climaxgo-0.6.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.
File metadata
- Download URL: climaxgo-0.6.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- Upload date:
- Size: 3.2 MB
- Tags: Python 3, manylinux: glibc 2.17+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: Go-http-client/2.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b05c1acba03fd5d8f0e5dd305fcaa4bd25f9c73ea9b1e117348da69bb88e9ef2
|
|
| MD5 |
0311d3473fccb420d4590bf3a46dba23
|
|
| BLAKE2b-256 |
514d835251e9728298a24d66868cfbbf3d1d20937810bb3282b4c492acfeaee3
|
File details
Details for the file climaxgo-0.6.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.
File metadata
- Download URL: climaxgo-0.6.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
- Upload date:
- Size: 3.0 MB
- Tags: Python 3, manylinux: glibc 2.17+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: Go-http-client/2.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1a5e79657a53e1672b114afa37d601823be13b4da803ef5e189a9b94b73fc4d7
|
|
| MD5 |
14218df523f5524b607560b9144e6649
|
|
| BLAKE2b-256 |
c458e77af62f2c7ec29d479489d11ac1428ba5fa471484353277412535804943
|
File details
Details for the file climaxgo-0.6.0-py3-none-macosx_11_0_arm64.whl.
File metadata
- Download URL: climaxgo-0.6.0-py3-none-macosx_11_0_arm64.whl
- Upload date:
- Size: 3.1 MB
- Tags: Python 3, macOS 11.0+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: Go-http-client/2.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
32bfd9731376be12c17589b27a3821f26340138bbdcec8fa58ad28bf2c3aae7d
|
|
| MD5 |
17b567420b3d7261116f341f8e32a552
|
|
| BLAKE2b-256 |
af303483c2b3e4d22b9c93b552804b683806688368cb8b01a8628b5b7c2f017f
|
File details
Details for the file climaxgo-0.6.0-py3-none-macosx_10_9_x86_64.whl.
File metadata
- Download URL: climaxgo-0.6.0-py3-none-macosx_10_9_x86_64.whl
- Upload date:
- Size: 3.2 MB
- Tags: Python 3, macOS 10.9+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: Go-http-client/2.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1f9a38eaf7ca046efdcd7096f625c34f0bb64740d6a1723c7f7f849113c94ed6
|
|
| MD5 |
5fd08cf8c75cc4f4d815451c99740f85
|
|
| BLAKE2b-256 |
1a6c60ff05d794b1303bf15b81af7b332e20eecffa23b4905d5ddb8da7547f75
|