Skip to content

Go Framework

Development preview

The Go framework is not ready for adoption or testing. These pages describe work in progress; APIs, generated code, and behavior may change. See the overview for the current scope and limitations.

The Go framework builds your CLI from a usage spec — but unlike most Go CLI libraries, your shipped binary never parses the spec. usage generate go lowers the KDL into plain Go tables, typed structs, and a Parse function at build time. The result:

  • One small dependency. The module is github.com/jdx/usage/go; beyond the standard library it imports only expr, which evaluates a spec's validation expressions.
  • Zero-allocation routing. The low-level event parser allocates nothing on success or failure — roughly 57–110ns per parse on mise's real 211-command spec. Generated Parse adds typed binding, defaults, and validation on top.
  • Linker-friendly. Parse tables, validation metadata, and help text are three separate tables; the linker drops the ones you don't reference. No init functions.
  • One source of truth. The same KDL spec generates your completions, docs, and manpages.

Parser overhead

What parsing mise use -g node@20 costs each framework, against a shadow of mise's CLI: 211 commands, 711 flags. The usage-go, cobra, urfave/cli and kong programs are generated from the same checked-in spec.

usage-go vs cobra, urfave/cli, kong

wall time, in-process parse throughput How this is measured The same way as the Rust card, by a harness written to match it: each parser runs repeatedly inside one process and the fastest per-parse time from many short rounds is reported, with the collector run between rounds rather than inside them. Process startup is excluded — a Go process is about a millisecond old before main, which no parser can touch. Whole-process cost, and instructions for one parse: go/README.md.

usage-go5.9 µs
cobra110 µs· ~18× more
urfave/cli v3200 µs· ~34× more
kong3.0 ms· ~500× more

usage-go binds against package-level tables the linker laid out before main. cobra and urfave build a command tree per process and kong reflects over a struct, none of which a spec-driven parser has to do. Instructions for the same parse: 123k vs cobra's 2.8M, urfave/cli's 5.8M, kong's 66.7M — and 1,955 for the binder under usage-go's typed front door.

Methodology and raw numbers: go/README.md

The generated package contains plain command and flag tables that the linker lays out before main; there is no spec parser, reflection, command-tree builder, or init function in the shipped program. The event parser keeps its state and 16-entry command stack inline, borrows values from argv, and scans only the flags in scope. Those are the zero-allocation, 57–110ns measurements. Generated Parse does more work to produce the typed result shown below, so the zero-allocation claim deliberately does not apply to that higher layer — and it is Parse the chart above measures, at about 5.9µs on mise's spec, because binding to a filled struct is the whole of what cobra, urfave/cli and kong each do in one call. The Go README has both numbers, what a whole process costs, and where Parse spends its time.

Quick start

Write a spec:

kdl
name "ex"
bin "ex"
version "1.0.0"
flag "-v --verbose" global=#true help="be loud"
flag "-j --jobs <n>" help="how many jobs"
arg "<file>" help="the file to process"
cmd "install" help="install a tool" {
    alias "i"
    flag "-f --force"
    arg "<pkg>"
}

Generate the Go code:

go
//go:generate usage generate go -f ex.usage.kdl -o tables.go -p ex

Parse:

go
package main

import (
	"fmt"
	"os"

	"github.com/jdx/usage/go/argv"
)

func main() {
	cli, err := ex.Parse(os.Args[1:])
	if err != nil {
		exit(err.(*argv.Error))
	}
	if cli.Install != nil {
		install(cli.Install.Pkg, cli.Install.Force, cli.Verbose)
		return
	}
	process(cli.File)
}

Parse returns a typed struct per command — cli.Install is nil unless install (or its alias i) was invoked — with flags bound, env/default fallbacks applied, and required, choices, var_min/var_max, and flag relations enforced.

Handling help, version, and failures

Unlike the Rust framework's parse(), the generated Go Parse never prints or exits — help and version requests come back as errors with Code set, and rendering is yours to invoke. The standard exit function looks like this:

go
func exit(e *argv.Error) {
	pos := argv.Walk(ex.Root, os.Args[1:])
	path := []string{"ex"}
	for _, c := range pos.Chain[1:] {
		path = append(path, c.Name)
	}
	switch e.Code {
	case argv.CodeHelp:
		if e.Long {
			fmt.Print(argv.LongHelp(ex.HelpMeta, path, pos.Chain, ex.HelpText))
		} else {
			fmt.Print(argv.ShortHelp(ex.HelpMeta, path, pos.Chain, ex.HelpText))
		}
		os.Exit(0)
	case argv.CodeVersion:
		fmt.Println("ex " + ex.Version)
		os.Exit(0)
	default:
		fmt.Fprint(os.Stderr, argv.Render(e, path, pos.Chain, ex.HelpText))
		os.Exit(2)
	}
}

The rendered pages match usage-lib's byte for byte, and the failure messages are clap-shaped — see Help and errors.

Why generation instead of a runtime spec?

The Go module has no KDL parser, on purpose. Lowering a spec is usage-cli's job, done once at build time; the shipped binary carries tables the linker can lay out as data. Building tables at runtime is not supported — generation is the only path, and the point.

Everything is verified against the reference implementation: a shared JSON conformance corpus (all vectors passing) covers the parsing grammar, and all 211 of mise's usage lines, -h pages, and --help pages are compared byte-for-byte against usage-lib's rendering in CI.

Where to go next

Current limitations

The current implementation has these limitations:

  • overrides is not enforced by generated Parse. conflicts, required_if, and required_unless are; a spec relying on last-one-wins overrides semantics needs to call argv.ApplyOverrides itself, and argv.CheckDisplaced on each key it reports — a flag that lost is out of the running for required, env, and default, but the word it was typed is still judged against its choices.
  • Fields are string, bool, []string, or int (for counts). A spec says what a value is called, never what type it is — convert with argv.Int, argv.Duration, etc.
  • complete run-scripts, config nodes, group, value_hint, and mount are not carried into the generated tables. Completions still offer choices and derive file, directory, executable, and command completion from complete types and value names — only run= shell-out completers are unsupported, since they mean running a subprocess on every Tab. See Completions. Config resolution is not implemented.
  • Command trees deeper than 16 levels are rejected (CodeTooDeep); short flags must be ASCII.
MIT LicenseCopyright © 2026jdx.dev