❯ rotini --documentation█

Using rotini

This page walks through a rotini CLI from install to test. The quick start is the short version; the spec and conf pages list every key.

Install

rotini is one module with two parts: the tool that generates your code and the runtime that code imports. Add both: the tool as a tool dependency, so every developer on the project resolves the same version through go.mod, and the runtime as a regular one:

terminal
mkdir todo
cd todo
go mod init github.com/me/todo
go get -tool github.com/go-rotini/rotini/cmd/rotini@latest
go get github.com/go-rotini/rotini@latest

rotini requires Go 1.27 or later.

The version: key at the top of your spec and conf is the minimum rotini they need. An older rotini, or a different major version, refuses to generate from them.

The files and the loop

rotini init
go tool rotini init todo

It prints the spec and conf it wrote, then the time and how long it took, the same report go generate gives. Along with those two files it writes:

FileWhat it isWho edits it
cmd/todo/.rotini.spec.yamlthe spec: commands, flags, arguments and every other inputyou
cmd/todo/.rotini.conf.yamlthe conf: where code is written, which extras are onyou
cmd/todo/main.gothe entrypoint, with the //go:generate lineyou (created once)
internal/cmd/todo/zz_rotini.gothe generated types and wiringrotini, on every go generate
internal/cmd/todo/todo*.goone handler file per commandyou (created once)

Specs and confs can also be JSON, JSONC or TOML: rotini init todo --format toml.

the loop
go tool rotini validate ./cmd/todo/.rotini.spec.yaml --config ./cmd/todo/.rotini.conf.yaml   # optional
go generate ./...
go build ./cmd/todo

validate checks the spec against its JSON Schema and 43 lint rules and reports each problem with a file:line:col. generate runs the same checks first, so validate is mostly for CI.

Commands, flags and arguments

The root command is the binary; commands: nests sub-commands to any depth. Each command declares its own flags: and arguments:, and each input has a schema: saying its type and rules:

cmd/todo/.rotini.spec.yaml
commands:
  - name: add
    summary: add a task
    aliases: [a]
    arguments:
      - name: title
        summary: the task title
        schema: { type: string, required: true, minLength: 1 }
    flags:
      - name: priority
        summary: how urgent
        identifiers: [-p, --priority]
        schema: { type: string, default: normal, enum: [low, normal, high] }
      - name: tag
        summary: a label (repeatable)
        identifiers: [--tag]
        schema: { type: '[]string' }
  • Types are Go names (string, int, bool, []string, map[string]string) or value types rotini parses for you: duration, date, url, ip, bytesize and more.
  • Rules — required, default, enum, pattern, minimum/maximum, lengths and item counts — are checked before your handler runs, and a bad value is a usage error naming the flag the user typed.
  • A flag works anywhere after the command that declares it, including after a sub-command’s name. A flag written before a sub-command’s name belongs to a parent.

Where values come from

A flag can also be read from an environment variable and a configuration file. Give it a key: and declare the file:

cmd/todo/.rotini.spec.yaml
command:
  name: todo
  env_prefix: TODO
  config_files:
    - name: user
      discover: { strategy: xdg, app: todo, file: config.yaml }
  commands:
    - name: add
      flags:
        - name: priority
          identifiers: [-p, --priority]
          schema: { type: string, default: normal, key: defaults.priority }

--priority now falls back to TODO_DEFAULTS_PRIORITY, then to defaults.priority in ~/.config/todo/config.yaml, then to its default. The command line always wins. Use variable: to name the environment variable exactly instead of deriving it.

Commands can also declare pure env: and config: inputs, and a typed stdin: payload; they all land in the same generated struct.

Handlers

go generate creates one handler file per command, once, and never overwrites it — it is yours. (It is removed if its command leaves the spec; delete its var _ rotini.Handlers line or list it under the conf’s keep: to hold on to it.) Fill in Run:

internal/cmd/todo/todo_add.go
package todo

import (
	"context"
	"fmt"

	"github.com/go-rotini/rotini"
)

var _ rotini.Handlers = (*todoAddHandlers)(nil)

type todoAddHandlers struct {
	rotini.DefaultCascadingPreRun
	rotini.DefaultPreRun
	rotini.DefaultPostRun
	rotini.DefaultCascadingPostRun
}

func (*todoAddHandlers) Run(ctx context.Context, rtx *rotini.Context) {
	// One line reconciles every declared channel: argv, env, config files, stdin
	// and defaults, in the documented precedence order.
	inputs, err := rotini.Collect[TodoAddInputs](rtx)
	if err != nil {
		rtx.HaltWith(err) // record it and stop; the funnel picks the exit code
		return
	}

	fmt.Fprintln(rtx.Stdout, "added:", inputs.TodoAdd.Arguments.Title)
	rtx.RecordSuccess("task added")
}
  • TodoAddInputs is generated from the spec, so the compiler holds the handler to it.
  • Five hooks run for every command: CascadingPreRun (also for each descendant), PreRun, Run, PostRun, CascadingPostRun. The Default* embeds are no-ops; declare a method to use one.
  • Write to rtx.Stdout, not os.Stdout, so tests and REPLs can capture it.
  • Record results and errors (rtx.RecordSuccess, rtx.RecordWarning, rtx.HaltWith) rather than printing them — the runtime reports them once, after teardown.

A service the handlers share — a database, an API client — is bound once in main.go and read in any hook:

sharing a service
var StoreKey = rotini.NewKey[Store]("todo.store")   // in the cmd package

cmd.StoreKey.Provide(cmd.Program, openStore())       // in main.go
store := StoreKey.MustGet(rtx)                       // in a handler

Errors and exit codes

A handler that fails calls rtx.HaltWith(err). By default the runtime prints each recorded error to stderr as Error: … and exits 1. Every error carries a category — rotini.UsageError(err) marks one as the user’s to fix — so a program that wants distinct exit codes installs its own reporting with Program.WithFunnel and maps rotini.CategoryOf(err) to a code. Declare exit_status: in the spec to document a command’s codes in its man and markdown pages.

Help, completion and docs

The conf’s features: turn on output generated from the spec:

FeatureWhat you get
help (on in the conf rotini init writes)Help(path...) pages, printed by --help and help <command>
completionCompletion(shell) scripts for bash, zsh, fish and PowerShell
manMan(path...) man pages
markdownMarkdown(path...) reference pages

A command exposes one with a few lines, e.g. a completion command whose handler prints the script Completion(shell) returns.

Testing

Build a fresh Program per test and run it with arguments — no process, no os.Exit:

internal/cmd/todo/todo_test.go
func TestAdd(t *testing.T) {
	var out bytes.Buffer
	code, err := NewProgram(Handlers()).WithStdout(&out).Run([]string{"add", "buy milk"})
	if err != nil || code != 0 {
		t.Fatalf("code %d, err %v", code, err)
	}
	if !strings.Contains(out.String(), "added: buy milk") {
		t.Errorf("stdout = %q", out.String())
	}
}

Composing CLIs

A command can be another CLI’s spec: - $ref: ../db/.rotini.spec.yaml mounts it as a sub-command, and it still builds and ships on its own. Keys set next to the $ref (a new name, summary, group …) adjust it for its new parent.

Versions

main.go passes version to WithVersion; stamp it at build time:

terminal
go build -ldflags "-X main.version=1.2.3" ./cmd/todo
./todo --version   # 1.2.3