rotini

package module
v1.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 5, 2026 License: MIT Imports: 38 Imported by: 0

README

rotini

A spec-driven codegen package for building CLI programs in Go.

Latest release Documentation Go Reference MIT license

Rotini lets you describe a command-line program in a JSON schema specification format and generates a Go program around it. You provide the implementation for what each command does, and rotini handles the CLI program plumbing. Rather than investing time into writing the plumbing that supports a Go CLI program, you can focus on writing your program-specific logic. However, rotini does not enforce its structure and was written with inversion of control and dependency injection in mind; you can adopt as much or as little of rotini as you want. If you buy in to the lightest commitment of the framework — the spec-driven codegen model — you can bring your own command routing, parsing, input handling, help and error reporting, while rotini still generates the command tree, typed inputs, documentation and shell completion from your spec.

Rotini in a GIF

Create a Go module, add rotini and run rotini init. The program it sets up builds and runs before you change anything. Then work in the loop. Add a command to the spec and save it, and rotini generate --watch regenerates the code, including the new command's handler stub. Implement the handler, build and run.

A split terminal. On the left, a Go module is created, rotini is added and rotini init sets up a program that builds and runs. Then rotini generate --watch starts on the right while a command is added to the spec on the left, its handler is implemented and the program is built and run

Features

Feature What you get
Commands Sub-commands to any depth, with aliases, help groups and hidden commands.
Flags and arguments Short and long flags with GNU-style parsing (-abc, --name=value, --no-x), repeatable flags, and optional and variadic arguments.
Typed values Strings, numbers, booleans, lists and maps, plus value types such as duration, date, url, ip, bytesize and existingfile.
Validation Required values, defaults, enums, patterns, bounds and lengths, plus flags that are mutually exclusive, required together, one-of or at-least-one, and flags that require others. A bad value is a usage error naming the flag the user typed.
Environment variables Any flag can fall back to an environment variable, named from a prefix or set exactly.
Config files Values from YAML, JSON, JSONC, TOML or dotenv files at a fixed path or discovered in the XDG config directory or by walking up from the working directory. The command line always wins.
Stdin A typed payload piped on stdin, as a JSON, YAML, JSONC or TOML document, as text, or as lines.
Secrets Inputs marked secret are redacted from errors and from the record of where each value came from.
Help --help and help <command> pages with usage, examples and see-also links, under headings you can rename, or a page you write yourself.
Version --version and a version command, stamped at build time.
Shell completion Scripts for bash, zsh, fish and PowerShell, with completion hints for values such as files and directories.
Man and markdown pages Roff man pages and markdown reference pages, ready to install or publish.
Structured output A JSON Schema for each command's output and a contract document describing the whole CLI, for scripts and agents.
Errors and exit codes Consistent Error: messages and a non-zero exit code. Every error carries a usage or internal category you can map to your own exit codes, and errors can be reported as JSON for scripts.
Deprecation Deprecated commands, aliases and flags keep working and are marked in help. Each use is reported to your code, which decides whether to warn.
Interrupts and panics Ctrl+C and SIGTERM stop the program cleanly, running its teardown, and a second Ctrl+C exits at once. A panic is reported as an error rather than a stack trace.
Suggestions "Did you mean" suggestions for a mistyped command or flag, opt-in.
Plugins Run separate <app>-<name> programs as sub-commands, declared or discovered. A rotini program can also be a plugin for kubectl, Docker or Flux, completing and showing help the way the host does.
Wrapper commands A command that forwards everything after its name untouched to another program.
Composed CLIs Mount one CLI inside another as a sub-command, from the same module or another, while it still builds and ships on its own.

Quick start

Requires Go 1.27 or later.

Create a Go module and add the rotini tool to it. go tool rotini init <name> then sets up a working program: a spec and a conf, an entrypoint, a handler for each command, and the generated code; go mod tidy records rotini as a direct dependency, since that code imports it. From there, development is a loop: describe a change in the spec (a command, a flag, an input, an output), run go generate ./... to regenerate the typed code, pages and completion, implement the handler for any new command, and build.

Example

1. Create a module and add the rotini tool.

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

2. Set up the program.

go tool rotini init helloworld
go mod tidy
cmd/helloworld/.rotini.spec.yaml     the spec: what the CLI accepts
cmd/helloworld/.rotini.conf.yaml     the conf: what is generated, and where
cmd/helloworld/main.go               the entrypoint
internal/cmd/helloworld/zz_rotini.go generated on every run; do not edit
internal/cmd/helloworld/*.go         one handler per command; yours to edit

3. Declare the command. Add a hello command, with an optional argument and a flag, under commands: in cmd/helloworld/.rotini.spec.yaml:

    - name: hello
      summary: say hello
      arguments:
        - name: name
          summary: who to greet
          schema: { type: string, default: world }
      flags:
        - name: shout
          summary: greet in capitals
          identifiers: [-s, --shout]
          schema: { type: bool }

4. Generate, then implement the handler. go generate ./... writes internal/cmd/helloworld/helloworld_hello.go. Its Run method already answers --help and reads the typed, validated inputs. Replace the line that prints them with the greeting code:

package helloworld

import (
	"context"
	"fmt"
	"strings"

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

var _ rotini.Handler = (*helloworldHelloHandler)(nil)

type helloworldHelloHandler struct {
	rotini.NoCascadingPreRun
	rotini.NoPreRun
	rotini.NoPostRun
	rotini.NoCascadingPostRun
}

func (*helloworldHelloHandler) Run(ctx context.Context, rtx *rotini.Context) {
	if argv, err := rtx.ArgvInputs[HelloworldHelloInputs](); err == nil && argv.Values.Helloworld.Flags.Help {
		fmt.Fprintln(rtx.Stdout, rtx.Help())
		rtx.HaltWithCode(0)
		return
	}

	inputs, err := rtx.Inputs[HelloworldHelloInputs]()
	if err != nil {
		rtx.HaltWith(err)
		return
	}

	greeting := "Hello, " + inputs.HelloworldHello.Arguments.Name + "!"
	if inputs.HelloworldHello.Flags.Shout {
		greeting = strings.ToUpper(greeting)
	}
	fmt.Fprintln(rtx.Stdout, greeting)
}

5. Build and run.

$ go build ./cmd/helloworld
$ ./helloworld hello
Hello, world!
$ ./helloworld hello rotini --shout
HELLO, ROTINI!

Documentation

For more information, see the rotini documentation.

Documentation

Overview

Package rotini is the runtime for rotini-built command-line programs. A CLI is declared in a spec file, generated into Go with the rotini tool, and run on this runtime, which does nothing the spec did not declare; everything beyond dispatch is an explicit, opt-in service.

One module serves two roles at one version:

  • Tool: `go get -tool github.com/go-rotini/rotini/cmd/rotini@latest` installs the codegen binary (`go tool rotini init` / `generate` / `validate`), which compiles a spec into a per-CLI framework file plus one editable handler file per command.
  • Library: `go get github.com/go-rotini/rotini` provides this package, which generated code imports and handlers are written against.

The companion CLI under cmd/rotini is built with rotini; docs/assets/examples/ holds a spec and a conf that use every key.

Declare

A CLI is a .rotini.spec.yaml (or json, jsonc, toml) document declaring the command tree and every input channel — argv flags and positional arguments, the value sentinels (from: file's @path, from: stdin's -), environment variables and flags' env fallbacks, configuration files, a typed stdin payload, declared defaults — plus documentation, shell completion and plugin dispatch.

`rotini validate` checks the spec: the JSON Schema rejects what it can express and lint rules reject the rest, each problem reported at file:line:col. Every schema-accepted key has a consumer.

Generate

`rotini generate` compiles the spec into a framework file — the Definition literal, typed per-command input structs, embedded help, man and markdown pages, and completion scripts — plus one handler stub per command, created once and then owned by the program. `rotini init` scaffolds a working CLI: a spec declaring -h/--help, -v/--version and the help and version commands, a conf with the help feature on and the other three off, an entrypoint, and a stub per command wired to the generated pages and services. All of it may be edited or deleted.

Composition

A command can be composed at any node, so a CLI is assembled from specs as its tree is assembled from commands. Five modes determine where a command's spec and handler code come from:

  1. Standalone — the command's own spec node and generated stub. The default.
  2. Inline with handler passthrough — the command's own spec node, with typed inputs generated locally and handler code from a package named by handler: { import: …, convention: … }. Codegen emits the delegating call and seeds no stub. It does not cascade: an inline sub-command without its own handler gets a stub.
  3. Local composition — a "$ref" to a sibling spec in the same module. The child's tree merges in, the parent winning on an overlapping key, and each composed command delegates to the child's generated package.
  4. Module composition — a "$ref" to mod://<module>@<version>/<path>, resolved through the Go module cache and pinned by go.sum, delegating to that module's generated package.
  5. Declared plugin — a sibling binary <program>-<name>, dispatched at run time rather than composed at codegen; a dispatch failure is a *PluginError. Plugin discovery dispatches an unmatched token to <prefix><token> the same way.

A composed child is generated on its own, so its typed inputs start at its own root and do not include a parent's cascading flags. The parent passes those as a Dependency: the child's package declares it, and the parent, which imports the child, collects its own inputs in CascadingPreRun and sets it. Collecting there validates only the parent's own inputs, so a descendant's --help and required inputs are unaffected:

// package child
var Kubeconfig = rotini.NewDependency[string]("child.kubeconfig")

// package parent, in its CascadingPreRun
in, err := rtx.Inputs[ParentInputs]()
if err != nil { rtx.HaltWith(err); return }
rtx.SetDependency(child.Kubeconfig, in.Parent.Flags.Kubeconfig)

`rotini validate` follows refs, validates each locally composed spec as its own document and checks the assembled tree for collisions, so a duplicate name, a cycle, a missing ref or an error inside a child is caught before codegen. Generation is hermetic: a local ref reads the filesystem, a mod:// ref reads the module cache, and a git:: or raw https:// ref is refused.

The runtime

The generated entrypoint builds a Program with NewProgram and calls Program.Execute, which resolves the invoked command from argv, runs its Handler hooks, and exits. Each invocation carries a Context: the argv, the resolved chain, the program's streams and its dependencies. A handler stops with Context.HaltWith, Context.Halt, Context.HaltWithCode or Context.Exit; recorded errors, recovered panics and detected faults are reported once, after teardown, by the outcome reporter.

The runtime resolves only which command was invoked. Flags and arguments are parsed and validated when a handler calls Context.Inputs (or the per-channel Context.ArgvInputs, Context.EnvInputs, Context.FileInputs and Context.StdinInputs), which the generated stubs do first. A handler that never calls it reads the raw Context.Argv and gets no validation.

The runtime's only built-in behaviors are a default SIGINT/SIGTERM trap (see Program.WithoutSignalHandling and Program.WithSignals), the hidden __complete entry the generated shell scripts call, and os.Exit as the default exit action (see Program.WithExit).

Input values

Input types are declared in the spec and parsed the same way on every channel an input reads: argv, a flag's environment and configuration fallbacks, and env and config inputs.

  • Scalars parse into the generated field's type: string; bool (also boolean) as true/false, yes/no, on/off, y/n, t/f or 1/0 in any case; the integers int, int8, int16, int32, int64 and rune (also integer); the unsigned integers uint, uint8, uint16, uint32, uint64 and byte; the floats float32 and float64 (also number); and any, which holds the raw text. complex64, complex128 and uintptr have no parser and are refused.
  • Value types: duration with Go's units plus d and w (7d, 2w3d); time and datetime (RFC 3339) and date (2026-09-29), each taking `layout:` for another format; url, email, timezone, mac, ip, cidr and hostport; bytesize (ByteSize: 512Mi, 10MB), hexbytes (HexBytes) and base64bytes (Base64Bytes).
  • Path checks: existingfile and existingdir are strings checked at parse time to exist and to be that kind of entry.
  • Shapes: count (flags only) counts occurrences (-vvv is 3); a list ([]T, or array with `items:`) and a map (map[string]T, or map and object) repeat.
  • Any other type parses through its own encoding.TextUnmarshaler, with `import:` naming its package.
  • A list or map flag repeats (--tag a --tag b, --label k=v); with `separator:` one value also splits (--tag a,b), CSV-style, before validation sees the items.
  • `implicit_value:` makes a flag's value optional: bare --color takes it, --color=never sets one, and the next word is never consumed.
  • An `enum` matches exactly, or regardless of case with `ignore_case:`, binding the declared spelling.
  • A flag whose schema is a named object ($ref: '#/schemas/DB') takes a structured value: JSON (--db '{"host":"h","port":5}'), key=value pairs (--db host=h,port=5, dotted keys nesting, quotes keeping a comma), a JSON or YAML file with `from: [file]` (--db @db.yaml), or one field per flag (--db.host=h). Occurrences merge in order, a later key winning; a list of objects takes one element per occurrence. Every form is validated against the named schema. Where the schema does not type a value (inside a free-form map, or in a `dotted_keys:` map), key=value text is read as JSON would read it: true, false, null and JSON numbers are typed, anything else is text, so `-p spec.replicas=5` and `-p '{"spec":{"replicas":5}}'` store the same number.

Slices at the boundary

Outcomes

A handler does not print its outcome; it records it, and one reporter (Program.WithReporter) receives all five channels as an Outcome once the lifecycle finishes:

Recording does not stop the run. The reporter is called only when some channel is non-empty, so a run that records nothing exits silently with code 0 unless a handler set one.

A handler stops in one of four ways:

  • Context.HaltWith records an error and stops forward progress, setting no code. It is the usual way for a hook to fail.
  • Context.Halt stops forward progress without recording anything or setting a code.
  • Context.HaltWithCode stops and sets a code, when the code carries meaning: a filter reporting "no match" as 1, a wrapper passing through a child's status.
  • Context.Exit stops and sets a code, skipping pending teardown.

A hook that records a failure without stopping lets the next hook run, often repeating the same failure.

The default reporter prints infos, warnings, errors, panics, then successes (infos and successes to stdout, the rest to stderr), then applies the exit floor: a recorded error or fault exits 1 unless a handler already set a non-zero code. A custom reporter owns the exit code entirely.

Every failure class is errors.Is-able against the ErrUsage or ErrInternal sentinel, so CategoryOf classifies it (except a plugin timeout, which is CategoryNone), and errors.As-able to a typed value with structured fields. rotini's own messages expose no recon, decode or OS internals and no secret values:

  • *ParseError — the argv channel. ParseError.Kind identifies the failure; Token and Candidates are what a Suggestor turns into "did you mean".
  • *InputError — the env, config, stdin and flag-fallback channels, carrying the channel, the input and a message, with the recon cause reachable via errors.As.
  • *PluginError — a plugin dispatch, recorded as an error. A missing discovered plugin is a usage error; a missing declared plugin, or a plugin that cannot start, is internal; a timeout is neither.
  • *DependencyError and *PanicError arrive as panics, as does a *WiringError from the program's wiring. Context.Inputs returns one *WiringError as an error instead: config inputs requested on a program built without an InputSettings.

rotini prints no "did you mean" suggestions and no help on error; a program that wants either writes its own reporter.

Sharing dependencies between handlers

A value every handler needs (a store, a client, a logger) is a dependency, named by a typed Dependency handle so its name and type cannot drift apart:

// declared once, beside the thing it names
var Store = rotini.NewDependency[*store.Store]("tasks.store")

// main.go — the value's type is checked where it is supplied
cmd.Program.WithDependency(tasks.Store, store.New()).Execute()

// or several at once ([WithDependency] and [Program.With])
cmd.Program.
	With(
		rotini.WithDependency(tasks.Store, store.New()),
		rotini.WithDependency(tasks.Client, client.New()),
	).
	WithVersion(version).
	Execute()

// any handler
s := rtx.MustGetDependency(tasks.Store)

Context.GetDependency reports a miss instead of routing it to the reporter, and Context.SetDependency sets one for the rest of this run only. Context.Command is the command whose hook is running, Context.CommandPath its canonical path ("tasks add"), and Context.CommandChain the full chain with the tokens the user typed.

Opt-in services

Everything else is a function or type a handler calls when it needs it, with nothing to register. Dependencies (Program.WithDependency, Context.GetDependency, Context.MustGetDependency) hold only the program's own values.

rotini's own seams are typed options, not dependencies: Program.WithInputSettings, Program.WithInputReader, Program.WithParser, Program.WithVersion and Program.WithHelp set them; Context.Parser, Context.Version and Context.Help read them. A dependency name or type can therefore never shadow one.

Batteries

rotini does nothing on import, starts no background goroutine and touches no terminal. It ships no styler, table, spinner, prompt, pager, terminal probe or process runner; use golang.org/x/term, os/exec and similar libraries. The one text helper it keeps is needed by its own generated pages:

  • StripANSI removes ANSI escape sequences, making a styled string safe for a man page, a markdown page or a completion description.

Program shapes

An interactive loop, a daemon or a server answering a peer runs the same program by calling Program.Run repeatedly. Each dispatch gets a fresh Context, so nothing carries over between invocations, while dependencies registered up front reach all of them. Run is safe for concurrent use once configuration is complete; the handlers value and the program's streams remain shared, so a concurrent host synchronizes those. See Program.Run.

rotini ships no loop. A host calls Program.RunContext once per line or request; supplying the context leaves signal handling to the host.

What rotini does not ship

Some functionality lives in sibling modules, imported directly:

  • watching files — go-rotini/fs, fs.NewWatcher
  • a single-instance lock — go-rotini/fs, fs.PIDLock
  • caching in a long-running program — go-rotini/memcache

Styling, tables, spinners, prompts, forms and paging are left to the program and to libraries built for them. rotini turns a spec into a parsed, validated, dispatched invocation and hands the handler a Context; what the handler prints, and how, is the program's.

Example (Unopinionated)

Example_unopinionated runs a program without the input helpers: WithArgs supplies argv, WithExit captures the code, and WithoutSignalHandling installs no signal trap.

def := Definition{
	Name: "greet", Handler: "Main",
	Flags: []FlagDef{{Name: "name", Identifiers: []string{"--name"}, Type: "string"}},
}

os.Setenv("GREETING", "hi")
defer os.Unsetenv("GREETING")

code := -1
NewProgram(def, unopinionatedApp{}).
	WithArgs([]string{"--name", "ada"}).
	WithStdout(os.Stdout).
	WithoutSignalHandling().
	WithExit(func(c int) { code = c }).
	Execute()

fmt.Println("exit:", code)
Output:
hi, ada! (command "greet")
exit: 0

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	ErrUsage    = errors.New("usage error")
	ErrInternal = errors.New("internal error")
)

ErrUsage and ErrInternal are the sentinels the categories match on, so a reporter may branch either way:

if errors.Is(err, rotini.ErrUsage) { /* usage */ }
switch rotini.CategoryOf(err) { case rotini.CategoryUsage: /* usage */ }

Prefer the UsageError and InternalError constructors to wrapping these with fmt.Errorf: they tag the category without prepending the sentinel's text to the message.

View Source
var ErrDependencyNotFound = errors.New("rotini: dependency not found")

ErrDependencyNotFound is the sentinel for a dependency that is not registered, or is registered as a different type. Context.MustGetDependency panics with a *DependencyError wrapping it, which the runtime recovers and routes to the reporter.

Functions

func AsCommand added in v1.2.0

func AsCommand(i int, hook func(context.Context, *Context)) func(context.Context, *Context)

AsCommand labels a hook with the chain index of the command it belongs to, so that Context.Command and Context.Inputs inside it refer to that command. The previous label is restored when the hook returns. DefaultLifecycle wraps every hook it plans. An unlabeled hook reports the invoked command.

A nil hook returns nil, which the engine skips.

func DecodeOutput added in v1.2.0

func DecodeOutput[T any](p *Program, data []byte, format string) (T, error)

DecodeOutput decodes a command's output, as captured from its stdout, into T, checking it against the command's declared schema. T is the generated <Prefix>Output type, which names the command; for output written item by item with Context.WriteOutputItem, T is a slice of it and every item is decoded. format is json, yaml or toml. It is for tests:

var stdout bytes.Buffer
p := cmd.NewProgram(cmd.Handlers()).WithStdout(&stdout)
p.Run([]string{"list", "-o", "json"})
list, err := rotini.DecodeOutput[cmd.TaskrListOutput](p, stdout.Bytes(), "json")

func ExitCause added in v1.2.0

func ExitCause(code int) error

ExitCause returns a context-cancellation cause that sets the exit code of the run the cancellation halts:

ctx, cancel := context.WithCancelCause(parent)
prog.WithContext(ctx)
cancel(rotini.ExitCause(3)) // exits 3

A cancellation without an ExitCause also halts the run, and the exit code is resolved as usual. Cancellation never preempts a running hook, and teardown always runs. See Program.WithContext.

func InternalError

func InternalError(err error) error

InternalError tags err as a CategoryInternal error (a bug or misconfiguration) without altering its message. It returns nil when err is nil.

func MergeInputs added in v1.2.0

func MergeInputs[T any](layers ...InputLayer[T]) T

MergeInputs merges layers into one inputs value. Slice order is precedence, low → high: a field set by a later layer wins, a field no layer set stays the zero value, and an unset layer field never clobbers a lower layer's. Overlaying an empty layer is the identity.

func PluginCompletion added in v1.2.0

func PluginCompletion(w io.Writer, result CompletionResult) error

PluginCompletion is the CompletionFormat the plugin hosts kubectl, Docker and Flux read: one candidate per line ("value\tdescription" allowed), then a final ":<directive>" line, a number telling the shell what to do next. kubectl reads it from kubectl_complete-<plugin>, and the Docker and Flux CLIs from the plugin's own __complete (see Program.WithCompletion).

The hint maps onto the directives: kind none to 4, offer no file names; kind directory to 16, directory names only; kind file with extensions to 8, file names with those extensions, which the format carries as the candidates; and kind file or no hint at all to 0, whose fallback is file completion. The hosts read the candidates of the filtering directives as their arguments, so a file or directory hint applies only when there are no candidates. The directive line is always written, since the hosts read the last line as the directive unconditionally. The format belongs to the hosts and is covered by rotini's compatibility promise.

func Ptr

func Ptr[T any](v T) *T

Ptr returns a pointer to v, for the presence-carrying Constraints bounds: Constraints{Minimum: rotini.Ptr(0.0)} declares an enforced >= 0. Prefer the built-in new(v); go fix inlines Ptr to it.

func StripANSI added in v1.2.0

func StripANSI(text string) string

StripANSI removes every ANSI escape sequence from text, SGR styling and OSC alike, leaving the characters a terminal would display. rotini applies it to man pages, markdown pages and completion descriptions, whose consumers would print escapes literally.

func UsageError

func UsageError(err error) error

UsageError tags err as bad input the end-user can correct, without altering its message: the result reads exactly like err but matches ErrUsage, and errors.Is/As still reach err. It returns nil when err is nil.

if id == "" {
    return rotini.UsageError(fmt.Errorf("a widget id is required"))
}

Types

type ArgDef

type ArgDef struct {
	Name     string
	Type     string
	Required bool
	Variadic bool
	Default  string
	Enum     []string
	// IgnoreCase matches a value against Enum without regard to case and binds the declared
	// spelling; see [FlagDef.IgnoreCase].
	IgnoreCase bool
	// Separator splits each value of a variadic argument into several; see [FlagDef.Separator].
	Separator string
	// Layout is how a time argument's value is written; see [FlagDef.Layout].
	Layout string
	// Deprecated is the argument's deprecation message: supplying it reports a [Deprecation]
	// carrying it.
	Deprecated string
	// Complete is the declarative shell-completion hint for this argument's value.
	Complete Completion
	Secret   bool // when true, the value is redacted in usage/validation error output
	Hidden   bool // omitted from completion candidates (it still parses); help omission happens at codegen
	Constraints
}

ArgDef describes a single positional argument of a command. Variadic is true for a trailing slice argument that absorbs the remaining positionals.

type ArgValueCompleter

type ArgValueCompleter interface {
	CompleteArgValue(rtx *Context, arg, partial string) []string
}

ArgValueCompleter is the positional-argument counterpart of FlagValueCompleter: completion calls CompleteArgValue on the invoked command's handler with the argument's logical name and the word being typed. The same contract applies: a nil return falls back to the static enum, and a candidate may carry a "value\tdescription" suffix.

type Base64Bytes

type Base64Bytes []byte

Base64Bytes is binary data written as base64. Standard and URL-safe alphabets are both accepted, padded or not. A spec declares one with `type: base64bytes`.

func (Base64Bytes) MarshalText

func (b Base64Bytes) MarshalText() ([]byte, error)

MarshalText implements encoding.TextMarshaler with the standard, padded alphabet.

func (Base64Bytes) String

func (b Base64Bytes) String() string

String renders the bytes as standard, padded base64.

func (*Base64Bytes) UnmarshalText

func (b *Base64Bytes) UnmarshalText(text []byte) error

UnmarshalText implements encoding.TextUnmarshaler.

type ByteSize

type ByteSize int64

ByteSize is a count of bytes that parses human sizes: `512Mi`, `10MB`, `1.5GiB`, `4096`.

Suffixes follow the SI and IEC standards; the `i` makes a unit binary:

B                  1
K  KB   M  MB  …   1000, 1000², … (decimal)
Ki KiB  Mi MiB …   1024, 1024², … (binary)

Letters are case-insensitive and fractions are allowed (`1.5Gi`); the result is rounded to a whole number of bytes. A bare `m` is decimal (megabytes), unlike some tools that read it as binary.

A spec declares one with `type: bytesize`.

func (ByteSize) MarshalText

func (b ByteSize) MarshalText() ([]byte, error)

MarshalText implements encoding.TextMarshaler, rendering the size as ByteSize.String does.

func (ByteSize) String

func (b ByteSize) String() string

String renders the size in the largest binary unit it is an exact multiple of — `512Mi`, `2Gi` — and in plain bytes otherwise, so it always parses back to the same value.

func (*ByteSize) UnmarshalText

func (b *ByteSize) UnmarshalText(text []byte) error

UnmarshalText implements encoding.TextUnmarshaler.

type Category

type Category int

Category classifies an error by whose fault it is, so a reporter can choose the exit code and message style from one call to CategoryOf. rotini tags its own errors (a missing dependency is CategoryInternal, a parse failure CategoryUsage); user code tags domain errors with UsageError or InternalError.

A category is a label, not an exit code. The default reporter exits 1 for any recorded error or fault; a program that wants distinct codes maps categories in its own reporter.

The constants are ordered by increasing severity (none < usage < internal), so a reporter can keep the worst of several with a plain comparison. The ordering is part of the contract; the numeric values are not.

const (
	// CategoryNone is an unclassified error that rotini cannot attribute.
	CategoryNone Category = iota
	// CategoryUsage is bad input from the end-user: an unknown flag, a missing required
	// argument, a value that fails validation. The user fixes it by changing the command.
	CategoryUsage
	// CategoryInternal is a bug or misconfiguration in the program, such as a missing
	// dependency or a wiring mistake. Only the author can fix it.
	CategoryInternal
)

func CategoryOf

func CategoryOf(err error) Category

CategoryOf returns the Category an error carries, or CategoryNone when it matches neither sentinel:

cmd.Program.WithReporter(func(ctx context.Context, rtx *rotini.Context, out rotini.Outcome) {
    worst := rotini.CategoryNone
    for _, err := range out.Errors {
        fmt.Fprintln(rtx.Stderr, err)
        if c := rotini.CategoryOf(err); c > worst {
            worst = c
        }
    }
    switch worst {
    case rotini.CategoryInternal:
        rtx.Exit(70)
    case rotini.CategoryUsage:
        rtx.Exit(2)
    }
})

Inside a reporter the lifecycle has already settled, so Context.HaltWithCode is a no-op and Context.Exit is the only way to set a code.

CategoryOf classifies a single error and tests ErrUsage first, so an error carrying both sentinels (such as the errors.Join that Program.Run returns for a run that recorded both) reports CategoryUsage. To classify a whole run, walk out.Errors and keep the most severe category, as above.

Example

Errors are tagged with a category at the source and mapped to exit codes in one switch, typically inside a reporter. Here usage errors map to 2.

classify := func(err error) int {
	switch CategoryOf(err) {
	case CategoryUsage:
		return 2
	case CategoryInternal:
		return 70
	default:
		return 1
	}
}

fmt.Println(classify(UsageError(errors.New("unknown flag"))))
fmt.Println(classify(InternalError(errors.New("wiring mismatch"))))
fmt.Println(classify(errors.New("untagged")))
Output:
2
70
1

func (Category) String

func (c Category) String() string

String renders the category as a short, stable label.

type Command added in v1.2.0

type Command struct {
	Name                  string
	Handler               string   // ProgramHandlers method for this command; see [CommandDef.Handler]
	Matched               string   // the argv token that resolved this command (name or an alias); "" for the root
	DeprecatedIdentifiers []string // aliases of this command that are deprecated
	Deprecated            string   // the command's deprecation message, when it is deprecated as a whole
	Flags                 []FlagDef
	Arguments             []ArgDef
	FlagGroups            []FlagGroup
	FlagDependencies      []FlagDependency
	Commands              []CommandDef        // sub-commands; empty for a leaf
	Plugins               []PluginDef         // co-located plugin binaries dispatched as sub-commands
	PluginDiscovery       *PluginDiscoveryDef // plugin auto-discovery (nil = off)
	// PluginPath is an extra directory searched for this command's declared and discovered
	// plugin binaries, with a leading ~ and $VAR references already expanded. Empty means only
	// the host binary's directory and PATH are searched.
	PluginPath  string
	Passthrough bool       // every token after this command is a raw positional (no flag parsing)
	Output      *OutputDef // what the command writes to stdout (nil = not declared); see [Context.WriteOutput]

	// Invoked reports whether this is the command the user invoked: the last command in the
	// chain. Exactly one entry of [Context.CommandChain] has it set; in a cascading hook,
	// rtx.Command().Invoked distinguishes the invoked command from its ancestors.
	Invoked bool
}

Command is one node on the invoked command path, root → leaf, as resolved for this invocation and exposed via Context.CommandChain. For a statically composed child, the chain is its full path under the parent.

Fields copy the matching Definition (root) or CommandDef fields; an empty slice means the command declares none of that kind.

func (Command) DiscoveredPlugins added in v1.2.0

func (cmd Command) DiscoveredPlugins() []DiscoveredPlugin

DiscoveredPlugins returns each plugin discovered for cmd — an executable "<prefix>foo" found next to the binary, in the plugin path, or on PATH — deduped and sorted by name, with any name colliding with a declared sub-command, declared plugin or alias removed. It returns nil when cmd has no discovery or discovery is hidden. rotini renders nothing; a handler lists the result itself:

chain := rtx.CommandChain()
for _, p := range chain[len(chain)-1].DiscoveredPlugins() {
	fmt.Fprintf(out, "  %s\t%s\n", p.Name, p.Path)
}

It reads the filesystem on every call and is best-effort: an unreadable directory contributes nothing. Command.PluginDiscoveryErrors reports failures of the configured plugin path.

func (Command) PluginBinary added in v1.2.0

func (cmd Command) PluginBinary(name string) (string, bool)

PluginBinary reports the executable the named plugin of cmd would run, and whether it resolves. It searches where dispatch searches, in the same order: next to the host binary, then the command's plugin path, then PATH. Use it, not exec.LookPath, to check which plugins are installed.

name may be a declared plugin's name or alias, or a discovered plugin's token. It returns "", false when cmd declares no such plugin and has no discovery, or when the binary is not found. It reads the filesystem on every call.

func (Command) PluginDiscoveryErrors added in v1.2.0

func (cmd Command) PluginDiscoveryErrors() []error

PluginDiscoveryErrors returns the problems encountered scanning cmd's configured plugin path — typically that it is unreadable or not a directory — and nil when there is no discovery, no plugin path, discovery is hidden, or the path scanned cleanly. A path that does not exist is not a problem. The directory of the binary and the $PATH entries are not reported. rotini prints no warning itself, since that would corrupt completion output. Each error carries the path and cause, so errors.Is(err, fs.ErrPermission) classifies it.

type CommandDef

type CommandDef struct {
	Name                  string
	Aliases               []string
	Summary               string   // one-line description (completion candidates carry it as "name\tsummary")
	Handler               string   // ProgramHandlers method, e.g. "RotiniGenerate"
	Hidden                bool     // omitted from completion candidates (it still dispatches); help omission happens at codegen
	DeprecatedIdentifiers []string // aliases (subset of Aliases) that [Deprecations] reports when used to invoke
	// Deprecated is the command's deprecation message: invoking it by any name reports a
	// [Deprecation] carrying it. Empty means the command is not deprecated as a whole.
	Deprecated       string
	Flags            []FlagDef
	Arguments        []ArgDef
	FlagGroups       []FlagGroup      // cross-flag presence rules validated at parse time
	FlagDependencies []FlagDependency // conditional cross-flag requirements validated at parse time
	Commands         []CommandDef
	Plugins          []PluginDef         // plugin binaries dispatched as sub-commands of this command
	PluginDiscovery  *PluginDiscoveryDef // plugin auto-discovery on this command (nil = off)
	PluginPath       string              // extra directory searched for BOTH this command's declared plugins and its discovered plugins
	Passthrough      bool                // every token after this command is a raw positional (no flag parsing)
	Output           *OutputDef          // what the command writes to stdout (nil = not declared)
}

CommandDef describes one command node within a Definition. Handler is the ProgramHandlers method name the runtime invokes to obtain this command's Handler.

type Completion

type Completion struct {
	// Kind is "file", "directory", or "none". Empty means no hint: the shell applies its
	// own default, which for bash and zsh is file completion. "none" suppresses that
	// default, for opaque values such as resource IDs.
	Kind string
	// Extensions narrows Kind "file" to these suffixes, written without a dot
	// ("yaml", "json"). Empty offers every file.
	Extensions []string
}

Completion is a declarative hint about what an input's value is, for the shell to complete. It reaches the shell as a directive on the last line of the hidden __complete output, and each generated script translates it into that shell's own path completion. A dynamic completer (FlagValueCompleter, ArgValueCompleter) wins when it answers; the hint is the fallback.

type CompletionCandidate added in v1.1.1

type CompletionCandidate struct {
	Value       string
	Description string
}

CompletionCandidate is one offered value and its optional one-line description.

type CompletionFormat added in v1.1.1

type CompletionFormat func(w io.Writer, result CompletionResult) error

CompletionFormat writes a CompletionResult to w in one completion protocol, the wire shape a completing host expects. rotini computes the answer; the format only encodes it. PluginCompletion is the built-in for the plugin hosts kubectl, Docker and Flux.

A format is called once per request and must write only the answer, since the host parses all of it.

type CompletionResult added in v1.1.1

type CompletionResult struct {
	// Candidates are the offered values, in order — sub-commands, flags, enum values, or what a
	// handler's [FlagValueCompleter] or [ArgValueCompleter] returned — already filtered by the
	// typed prefix, with hidden inputs left out.
	Candidates []CompletionCandidate
	// Hint is the spec's declared `complete:` hint for the input being completed, or the zero
	// value when it declares none. A completer that answers still wins over it: the hint is the
	// fallback for when Candidates is empty, except kind "none", which also means "never offer
	// files" when there are candidates.
	Hint Completion
}

CompletionResult is one completion answer, before any wire format: what a program offers for the word being completed. It is what a CompletionFormat renders.

type ConfigFile

type ConfigFile struct {
	Name string // logical name
	// Scope is the command path this source is declared on. Sources cascade: one is in
	// scope for the invoked chain when its Scope is one of the chain's commands. "" is
	// unscoped, in scope for every command; generated descriptors always set it.
	Scope    string
	Path     string       // fixed file path (may contain ~)
	Format   string       // "json" | "yaml" | "toml"; "" lets the input reader infer from the extension
	Discover *DiscoverDef // run-time location strategy, instead of a fixed Path
	PathFrom *PathFromDef // runtime inputs that supply/override the path (spec config_source)
	// Schema is the self-contained JSON Schema the input reader validates the loaded document
	// against at bind time; "" is none. The file that actually resolved is the one
	// validated, and an absent optional file passes vacuously.
	Schema string
}

ConfigFile is one configuration-file source the input reader reads (reconciled by recon). Exactly one of Path and Discover locates the file (the spec enforces this).

type Constraints

type Constraints struct {
	Minimum          *float64 // inclusive numeric lower bound; nil = unset
	Maximum          *float64 // inclusive numeric upper bound; nil = unset
	ExclusiveMinimum *float64 // strict numeric lower bound (value must be >); nil = unset
	ExclusiveMaximum *float64 // strict numeric upper bound (value must be <); nil = unset
	MultipleOf       *float64 // the value must be an integer multiple (strictly positive); nil = unset
	MinLength        int      // minimum string length in runes; 0 = unset
	MaxLength        int      // maximum string length in runes; 0 = unset
	MinItems         int      // minimum item count (repeatable flag / variadic argument); 0 = unset
	MaxItems         int      // maximum item count; 0 = unset
	Pattern          string   // regular expression the value must contain (string types); "" = unset
	PatternMessage   string   // what a Pattern failure tells the user, in place of the regex; "" = show the regex
}

Constraints carries the validation bounds a spec may declare on a flag or argument. The parser enforces them after reconciliation, so a value supplied via env or config is checked too. The numeric bounds are presence-carrying pointers — nil is unset, so `minimum: 0` is a real, enforced bound. The length and count bounds keep the zero-sentinel convention: a 0 minimum is vacuous and a 0 maximum is not expressible.

type Context

type Context struct {

	// Stdin, Stdout and Stderr are the program's streams ([Program.WithStdin] and siblings).
	// Handlers use them instead of os.Std* so tests can substitute streams. They are set before
	// dispatch and never nil.
	//
	// Assigning one is not supported: the default reporter writes to the Program's streams, so
	// reassigning rtx.Stdout redirects only the handler's own writes. Redirect a run with
	// [Program.WithStdout] and its siblings.
	Stdin  io.Reader
	Stdout io.Writer
	Stderr io.Writer

	// Argv is the raw argument vector for this invocation, command names and flags included,
	// for a handler that runs its own parser. It is the live slice, not a copy: mutating it
	// changes what later parses see. A command's declared positionals are the Arguments field
	// of its generated inputs.
	Argv []string
	// contains filtered or unexported fields
}

Context is rotini's per-invocation context: the program's dependencies and streams, the raw argument vector (Context.Argv) and the resolved command chain (Context.CommandChain). One is built per Program.Run and passed to every hook of that run, so hooks share its dependencies, records and exit state, and nothing carries over between runs.

The surface groups into eight jobs:

Nothing is parsed or validated until a handler calls an inputs method; a handler with its own parser reads Context.Argv instead.

A Context is safe for concurrent use by the goroutines a hook starts. Context.Command reflects the step running when it is called (see its doc). A Context must not be copied.

Methods do not check for a nil receiver, with these exceptions: the With setters do nothing and return nil, the inputs methods return an error, and Deprecations returns none.

func NewContextFor

func NewContextFor(def Definition, argv []string) *Context

NewContextFor builds a Context with argv resolved against def, as the runtime does before dispatch, using the os streams and no seams. It serves tests of the Parser, the Context.Inputs family, or a single hook:

def := rotini.Definition{Name: "app", Handler: "App", Commands: []rotini.CommandDef{ … }}
rtx := rotini.NewContextFor(def, []string{"build", "x.yaml"})
var in appInputs
err := rtx.Parser().Parse(rtx, &in)

To test a generated program end to end, build it with the generated NewProgram and run it with Program.WithExit and captured streams.

A plugin token resolves to the chain that precedes it; no plugin is executed.

func (*Context) ArgvInputs added in v1.2.0

func (rtx *Context) ArgvInputs[T any]() (InputLayer[T], error)

ArgvInputs parses the command line only — flags and positionals across the resolved chain, exactly as supplied: no defaults, no fallback, and no required or enum validation. Validate the overlaid result with InputReport.Validate, so a required flag satisfied by another layer passes. Parse failures are *ParseError values.

func (*Context) CheckOutput added in v1.2.0

func (rtx *Context) CheckOutput(v any) error

CheckOutput checks v against the invoked command's declared output schema, returning an internal error that names each field that does not match:

taskr list: output does not match its contract: output.tasks[2].status: value is not in enum

It writes nothing. Program.WithOutputChecks makes every WriteOutput call check.

func (*Context) Command

func (rtx *Context) Command() Command

Command returns the command whose hook is running.

In PreRun, Run and PostRun that is the command the user invoked. A cascading hook runs for every command in the chain, and there it is the command the hook belongs to:

$ mig db status

hook                                 Command()     Command().Invoked
────                                 ─────────     ─────────────────
mig's CascadingPreRun                mig           false
db's CascadingPreRun                 db            false
the leaf's PreRun / Run / PostRun    status        true
db's CascadingPostRun                db            false
mig's CascadingPostRun               mig           false

Invoked distinguishes the command the user ran from its ancestors:

func (*songsHandler) CascadingPreRun(ctx context.Context, rtx *rotini.Context) {
    if rtx.Command().Invoked {
        // `musak songs`: this command is the invocation.
        return
    }
    // `musak songs list`: a sub-command is running.
}

Context.Inputs, Context.CommandPath and Context.Help all describe the command Command returns. Outside a lifecycle step (a Context from NewContextFor, or in the reporter), Command returns the invoked command.

Goroutines

Command reports the step running when it is called, not the hook that started the caller. A goroutine that outlives its hook may therefore see a later command, and inputs it reads anchor there too. Capture what it needs before starting it:

cmd := rtx.Command()
go func() { log.Println(cmd.Name) }()

func (*Context) CommandChain added in v1.2.0

func (rtx *Context) CommandChain() []Command

CommandChain returns every command of this invocation from the root (index 0) to the invoked command (the last entry, the only one with Invoked set).

The slice is a copy; changing it does not affect the run. The copy is shallow: each command's Flags, Arguments and Commands are the Definition's own slices, shared by every run, and must be treated as read-only.

func (*Context) CommandPath

func (rtx *Context) CommandPath() string

CommandPath returns the path of Context.Command, space-joined from the root: "tasks add" for a sub-command, "tasks" for the root. In the root's cascading hook during `tasks add` it is "tasks".

The names are canonical, not aliases; each command's Matched field in Context.CommandChain holds the token the user typed.

func (*Context) DefaultInputs added in v1.2.0

func (rtx *Context) DefaultInputs[T any]() (InputLayer[T], error)

DefaultInputs synthesizes the spec's declared defaults as an explicit layer, conventionally the lowest. Flag and argument defaults come from the resolved chain; env and config defaults from their recon tags.

func (*Context) EnvInputs added in v1.2.0

func (rtx *Context) EnvInputs[T any]() (InputLayer[T], error)

EnvInputs acquires the environment channel: every Env input, by its explicit variable or the SNAKE_UPPER projection of its key, plus the env fallback of any flag that declares a recon key. A missing required input or a constraint violation is this call's error.

func (*Context) Exit

func (rtx *Context) Exit(code int)

Exit sets the exit code and stops the lifecycle immediately, skipping every pending teardown hook, as os.Exit skips deferred calls. Prefer Context.HaltWithCode for an orderly stop. It records no error. A panic recovered before Exit still reaches the reporter.

During the lifecycle the first non-zero code wins; inside the reporter, Exit overrides any code already set.

func (*Context) Failed

func (rtx *Context) Failed() bool

Failed reports whether this run has so far recorded an error or captured a fault. A teardown hook uses it to decide between committing and rolling back:

func (*migrateHandlers) CascadingPostRun(ctx context.Context, rtx *rotini.Context) {
    if rtx.Failed() {
        tx.Rollback()
        return
    }
    tx.Commit()
}

The errors themselves reach only the reporter, through Outcome. Once true, Failed stays true for the rest of the run.

func (*Context) FileInputs added in v1.2.0

func (rtx *Context) FileInputs[T any]() (InputLayer[T], error)

FileInputs acquires the configuration-files channel: every Config input from the InputSettings sources, declared order being precedence, plus the config fallback of any flag that declares a recon key. Required and constraint failures are this call's errors.

func (*Context) GetDependency added in v1.2.0

func (rtx *Context) GetDependency[T any](dep Dependency[T]) (T, bool)

GetDependency returns the value registered under dep and whether one is registered as type T. It never panics.

client, ok := rtx.GetDependency(tasks.Client)

func (*Context) Halt

func (rtx *Context) Halt()

Halt stops the lifecycle's forward progress without setting an exit code or recording anything. Teardown is unaffected: every PostRun and CascadingPostRun whose setup hook began still runs, in reverse. Under the default plan:

Hook                Effect of Halt
────                ──────────────
CascadingPreRun     no further setup, no PreRun, no Run
PreRun              Run is skipped
Run                 none: Run is the last forward step
PostRun             none: teardown runs to completion
CascadingPostRun    none

Use Halt to stop when nothing failed:

if !inputs.Force && !confirmed {
    rtx.Halt()
    return
}

Use Context.HaltWith to fail, Context.HaltWithCode to stop with a specific code, and Context.Exit to stop without teardown. Halt is a no-op inside the reporter.

func (*Context) HaltWith

func (rtx *Context) HaltWith(err error)

HaltWith records err and stops the lifecycle's forward progress: Context.RecordError and Context.Halt in one call. It sets no exit code; the reporter decides it.

if err := store.Save(task); err != nil {
    rtx.HaltWith(err)
    return
}

It is the standard way for any hook to fail. Where Halt has no effect (Run and the teardown hooks), HaltWith only records. A nil err records nothing and still halts. To record and continue, use RecordError alone.

Inside the reporter it has no effect: the halt is a no-op and the record is dropped.

func (*Context) HaltWithCode

func (rtx *Context) HaltWithCode(code int)

HaltWithCode sets the exit code and stops the lifecycle's forward progress, for a code that carries meaning: a filter reporting "no match" as 1, a wrapper passing through a child's status. It records no error.

Teardown is unaffected, as with Context.Halt. The first non-zero code wins, so a later HaltWithCode or Context.Exit does not replace an earlier code.

Halt()              stop
HaltWith(err)       stop and record err
HaltWithCode(n)     stop and set exit code n
Exit(n)             stop, set exit code n, and skip pending teardown

It is a no-op inside the reporter; the reporter sets the code with Context.Exit.

func (*Context) Help

func (rtx *Context) Help() string

Help returns the help page of Context.Command from Program.WithHelp, as a generated --help prints it, or "" when there is none. A cascading hook gets its own command's page. The page is the running program's, so a composed command shows its full path and inherited flags.

func (*Context) Inputs added in v1.2.0

func (rtx *Context) Inputs[T any]() (T, error)

Inputs acquires every declared channel (argv, environment, configuration files, the stdin payload, defaults), reconciles them in the standard precedence defaults < files < env < argv, validates the result, and returns it:

inputs, err := rtx.Inputs[MycliDeployInputs]()

Stdin is outside that order because it never competes: it fills only the leaf command's payload field, which no other channel writes.

T must be the inputs type generated for the command whose hook is running (Context.Command), in any hook. Its last field describes that command and the preceding fields its ancestors, so the struct is anchored on the running command regardless of how deep the invocation went. A type that cannot be anchored there is an error, not a silent zero value.

Inputs delegates to InputReader.Read; errors are *ParseError and *InputError values. On error the returned T is partially filled and must not be used. Inputs stops at the first argv error, before the environment and configuration channels are read; use Context.InputsWithReport for a merged value and per-field provenance alongside the error.

func (*Context) InputsWithReport added in v1.2.0

func (rtx *Context) InputsWithReport[T any]() (T, InputReport, error)

InputsWithReport is Context.Inputs with provenance: the same reconciled, validated inputs plus an InputReport giving each field's Winner and History. It acquires each channel separately and overlays the layers in the standard precedence. An acquisition error returns the zero T; a validation error returns the merged inputs and report alongside it, so the caller can see which layer supplied the offending value.

func (*Context) MustGetDependency added in v1.2.0

func (rtx *Context) MustGetDependency[T any](dep Dependency[T]) T

MustGetDependency returns the value registered under dep, or panics with a *DependencyError when it is missing or not a T. The runtime recovers the panic during dispatch and routes it to the reporter.

s := rtx.MustGetDependency(tasks.Store)
Example

A typed handle names a dependency once, and every read is typed. GetDependency reports absence; MustGetDependency panics, and in a hook that panic reaches the reporter as a *PanicError after teardown.

type apiClient struct{ baseURL string }
api := NewDependency[*apiClient]("api")

rtx := NewContextFor(Definition{Name: "app", Handler: "App"}, nil)
rtx.SetDependency(api, &apiClient{baseURL: "https://api.example"})

client := rtx.MustGetDependency(api)
fmt.Println(client.baseURL)

if _, ok := rtx.GetDependency(NewDependency[*apiClient]("other")); !ok {
	fmt.Println("nothing registered as \"other\"")
}
Output:
https://api.example
nothing registered as "other"

func (*Context) Parser

func (rtx *Context) Parser() *Parser

Parser returns the Parser set by Program.WithParser, or a new default parser. It never returns nil.

func (*Context) RecordError

func (rtx *Context) RecordError(err error)

RecordError records err as one of this run's errors. It neither prints nor stops the lifecycle, so a handler can record several errors before stopping, or leave the decision to a later hook that checks Context.Failed. A nil err is ignored.

for _, path := range inputs.Check.Arguments.Paths {
    if err := validate(path); err != nil {
        rtx.RecordError(err)
    }
}

To record and stop in one call, use Context.HaltWith.

func (*Context) RecordInfo

func (rtx *Context) RecordInfo(msg string)

RecordInfo records msg as an informational message for the reporter. Like every record method it neither prints nor stops the lifecycle. An empty msg is ignored.

func (*Context) RecordSuccess

func (rtx *Context) RecordSuccess(msg string)

RecordSuccess records msg as a success message for the reporter. An empty msg is ignored. The reporter runs after the lifecycle, so records appear after anything a handler wrote directly to Context.Stdout.

func (*Context) RecordWarning

func (rtx *Context) RecordWarning(warn error)

RecordWarning records warn as a non-fatal warning: a deprecation, a fallback, a skipped item. It is an error so a reporter can inspect it with errors.As or CategoryOf; it never affects the exit code. A nil warn is ignored.

func (*Context) SetDependency added in v1.2.0

func (rtx *Context) SetDependency[T any](dep Dependency[T], value T)

SetDependency registers value under dep for this run, replacing any existing value, so hooks that run later see it. Use it for per-run values such as a client built from the inputs; program-wide values belong in Program.WithDependency. It is safe for concurrent use.

func (*Context) SetDependencyIfAbsent added in v1.2.0

func (rtx *Context) SetDependencyIfAbsent[T any](dep Dependency[T], value T)

SetDependencyIfAbsent registers value under dep for this run only if nothing is registered there yet, atomically. A handler can register its real implementation this way while a test that registered a double under the same name keeps the double.

rtx.SetDependencyIfAbsent(Clock, time.Now)
now := rtx.MustGetDependency(Clock)

func (*Context) StdinInputs added in v1.2.0

func (rtx *Context) StdinInputs[T any]() (InputLayer[T], error)

StdinInputs acquires the stdin channel: the leaf command's typed payload, decoded per its declared format, schema-validated, and honoring required.

func (*Context) Version

func (rtx *Context) Version() string

Version returns the version set by Program.WithVersion, or "" if none was set.

func (*Context) WithHelp

func (rtx *Context) WithHelp(help HelpFunc) *Context

WithHelp sets where Context.Help finds pages. See Program.WithHelp. It is for a Context built with NewContextFor; during a run, the change lasts for the rest of that run. A nil help is ignored.

func (*Context) WithInputReader added in v1.2.0

func (rtx *Context) WithInputReader(fn func(InputSettings) *InputReader) *Context

WithInputReader replaces the input reader Context.Inputs uses. See Program.WithInputReader. It is for a Context built with NewContextFor; during a run, the change lasts for the rest of that run. A nil fn is ignored.

func (*Context) WithInputSettings added in v1.2.0

func (rtx *Context) WithInputSettings(meta InputSettings) *Context

WithInputSettings supplies the generated descriptor Context.Inputs reads from. See Program.WithInputSettings. It is for a Context built with NewContextFor; during a run, the change lasts for the rest of that run.

func (*Context) WithParser

func (rtx *Context) WithParser(parser *Parser) *Context

WithParser sets the parser Context.Parser returns. See Program.WithParser. It is for a Context built with NewContextFor; during a run, the change lasts for the rest of that run. A nil parser is ignored.

func (*Context) WithVersion

func (rtx *Context) WithVersion(version string) *Context

WithVersion sets what Context.Version reports. See Program.WithVersion. It is for a Context built with NewContextFor; during a run, the change lasts for the rest of that run.

func (*Context) WriteOutput added in v1.2.0

func (rtx *Context) WriteOutput[T any](v T, format string, render func(io.Writer, string, T) error) error

WriteOutput writes v, the invoked command's output, to rtx.Stdout in format: indented json, yaml or toml by rotini, any other format by render, which may be nil when the handler only ever passes those three. An empty format means json.

It returns an internal error when v is not the type the command declares as its output, when no renderer is passed for a format rotini does not write, or, with Program.WithOutputChecks, when v does not match the declared shape. Nothing is written then. A renderer's error is returned unwrapped. A command that declares no output may still use it; nothing is checked.

func (*Context) WriteOutputItem added in v1.2.0

func (rtx *Context) WriteOutputItem[T any](item T, format string, render func(io.Writer, string, T) error) error

WriteOutputItem writes one item of a stream to rtx.Stdout, for a command that writes its output item by item as each is ready: compact json, one value per line; a yaml document starting "---"; any other format by render. toml cannot be streamed. Its checks are WriteOutput's, with the item checked against the declared shape, which for such a command is one item.

type Definition

type Definition struct {
	Name             string
	Handler          string // ProgramHandlers method for the root command, e.g. "Rotini"
	Flags            []FlagDef
	Arguments        []ArgDef
	FlagGroups       []FlagGroup      // cross-flag presence rules validated at parse time
	FlagDependencies []FlagDependency // conditional cross-flag requirements validated at parse time
	Commands         []CommandDef
	Plugins          []PluginDef         // co-located plugin binaries dispatched as sub-commands of the root
	PluginDiscovery  *PluginDiscoveryDef // plugin auto-discovery on the root command (nil = off)
	PluginPath       string              // extra directory searched for BOTH declared and discovered plugins
	Passthrough      bool                // every token after the program name is a raw positional (no flag parsing)
	Output           *OutputDef          // what the root command writes to stdout (nil = not declared)
}

Definition is the compiled command tree of a generated rotini program. Codegen emits it as a Go literal; the runtime parses argv, dispatches and completes against it. Help pages are rendered at codegen and supplied through Program.WithHelp. The Definition types are data only, with no behavior.

type Dependency added in v1.2.0

type Dependency[T any] struct {
	// contains filtered or unexported fields
}

Dependency is a typed handle for one of the program's dependencies, pairing the name it is stored under with the type it is stored as, so a handler needs neither a string key nor a type assertion:

// declared once, beside the thing it names
var Store = rotini.NewDependency[*store.Store]("taskr.store")

// main.go: the value's type is checked at compile time here
cmd.Program.WithDependency(tasks.Store, store.New()).Execute()

// any handler
s := rtx.MustGetDependency(tasks.Store)

Registration happens at run time, so a missing registration is not a compile error; it is reported when a handler requests the dependency.

Dependencies have two scopes:

A handle whose name is known only at run time can be built as rotini.NewDependency[any](name).

The zero Dependency is named "" and is shared by every zero Dependency of any type; build handles with NewDependency.

func NewDependency added in v1.2.0

func NewDependency[T any](name string) Dependency[T]

NewDependency returns a typed handle for a dependency stored under name. The name must be unique within a program; an empty name is accepted and refers to the same entry as the zero Dependency.

func (Dependency[T]) Name added in v1.2.0

func (d Dependency[T]) Name() string

Name returns the name the dependency is stored under.

func (Dependency[T]) String added in v1.2.0

func (d Dependency[T]) String() string

String implements fmt.Stringer, returning the dependency's name.

type DependencyError added in v1.2.0

type DependencyError struct {
	Name string // the name the dependency was requested under
	// contains filtered or unexported fields
}

DependencyError reports a dependency that was requested but not registered, or registered as a different type. It unwraps to ErrDependencyNotFound; recover the name with errors.As.

func (*DependencyError) Error added in v1.2.0

func (e *DependencyError) Error() string

Error returns a one-line message stating whether the dependency is missing or registered as a different type.

func (*DependencyError) Unwrap added in v1.2.0

func (e *DependencyError) Unwrap() []error

Unwrap returns ErrDependencyNotFound and ErrInternal, so CategoryOf classifies the error as CategoryInternal.

type Deprecation

type Deprecation struct {
	Kind       string // "flag", "argument" or "command"
	Name       string // the input's logical name (the flag/argument/command name)
	Identifier string // the token actually used on argv (e.g. "--conf", "build"); an argument's <name>
	Message    string // the spec's `deprecated:` message, when the input is deprecated as a whole
}

Deprecation is a deprecated token found in this invocation's argv: the identifier used, the kind of input, its logical name, and the spec's `deprecated:` message. It implements error so it can be recorded or printed directly.

func Deprecations

func Deprecations(rtx *Context) []Deprecation

Deprecations returns each deprecated token this invocation used: a command invoked via a deprecated alias or deprecated as a whole, a flag set via a deprecated identifier, or a deprecated argument given a value. rotini prints nothing; the handler decides what to do:

for _, d := range rotini.Deprecations(rtx) {
	rtx.RecordWarning(fmt.Errorf("%w — use %q instead", d, d.Name))
}

It needs no Parser: it reads the resolved chain and argv from rtx, and never reads a file or stdin. It returns nil for a nil rtx. When argv does not parse, only command deprecations are reported.

func (Deprecation) Error

func (d Deprecation) Error() string

Error renders the deprecation notice as a single line, with the author's message when there is one: `flag "--conf" is deprecated: use --config`.

type DiscoverDef

type DiscoverDef struct {
	Strategy string // "walk-up" (working directory up to the filesystem root) | "xdg" ($XDG_CONFIG_HOME/<app>, default ~/.config/<app>)
	File     string // the file name looked for in each searched directory
	App      string // the application directory under the XDG config root (xdg only)
}

DiscoverDef locates a configuration file at run time. The strategy orders the directories searched for File; the first containing it wins, and a file found nowhere is absent.

type DiscoveredPlugin

type DiscoveredPlugin struct {
	// Name is the token a user types — "foo" for an executable "<prefix>foo".
	Name string
	// Path is the executable dispatch would run for Name right now: the first "<prefix>foo"
	// in search order (next to the binary, then the plugin path, then PATH), so a copy
	// shadowed by an earlier one is not the one listed.
	Path string
}

DiscoveredPlugin is one plugin discovery found: the token it is invoked by and the binary that token runs.

type FieldPath

type FieldPath string

FieldPath identifies one leaf field of a generated inputs struct by its dot-joined Go field path, e.g. "RotiniGenerate.Flags.ConfFilePath".

type FlagDef

type FlagDef struct {
	Name        string
	Identifiers []string // CLI forms, e.g. {"--loud", "-l"}
	Summary     string   // one-line description (completion candidates carry it as "identifier\tsummary")
	Type        string   // resolved Go type, e.g. "bool", "string", "[]string", "int", "time.Duration"
	Required    bool
	Default     string
	// Defaults is the multi-value default for a REPEATABLE input (a `[]…` or map type):
	// each element is seeded as one occurrence, exactly as if the user had repeated the
	// flag. It is used only when Default is empty, and only when the input is unset from
	// every channel — a default never merges with a supplied value.
	Defaults []string
	Enum     []string
	// IgnoreCase matches a value against Enum without regard to case (`--mode FAST` against
	// fast/slow) and binds the declared spelling, so a handler compares against one form.
	IgnoreCase bool
	// Separator splits each value of a list or map flag into several (`--tags a,b` is two
	// tags), CSV-style: a quoted item keeps the separator (`--tags '"a,b",c'`). Empty means
	// one value per occurrence.
	Separator string
	// ImplicitValue is the value a flag takes when given without one (`--color` means
	// "always"), making its value optional: a value must then be attached (`--color=never`),
	// since the next argument is never consumed. Empty means the flag requires a value.
	ImplicitValue string
	// Layout is how a time input's value is written: a Go reference-time layout
	// ("2006-01-02", "Jan 2 2006 15:04"), or "unix" / "unixmilli" for a timestamp. Empty means
	// RFC 3339. `type: date` gets "2006-01-02".
	Layout string
	// ObjectSchema is the JSON Schema of an object-valued flag's value — set when the spec's
	// schema is a named object (`$ref: '#/schemas/DB'`), or a list of them. The flag then
	// takes JSON, key=value pairs, a YAML @file, or one field per flag (--db.host=…); see
	// "Input values" in the package documentation.
	ObjectSchema          string
	Secret                bool     // when true, the value is redacted in usage/validation error output
	Hidden                bool     // omitted from completion candidates (it still parses); help omission happens at codegen
	DeprecatedIdentifiers []string // identifiers (subset of Identifiers) that [Deprecations] reports when used
	// Deprecated is the flag's deprecation message: setting it by any identifier reports a
	// [Deprecation] carrying it. Empty means the flag is not deprecated as a whole.
	Deprecated string
	// Negatable adds a "--no-<x>" form for every long identifier of a bool flag, which sets
	// it false, overriding a true default, config value or environment variable.
	Negatable  bool
	DottedKeys bool     // map flag whose key=value keys are '.'-separated paths into nested maps (spec dotted_keys)
	KeyPaths   []string // a map flag's declared key paths (from its schema's properties), completed up to the '='
	From       []string // extra acquisition modes (spec from:): "file" resolves @path values, "stdin" resolves a bare "-"
	// Complete is the declarative shell-completion hint for this flag's value (spec
	// complete:). The zero value means no hint.
	Complete Completion
	Constraints
}

FlagDef describes a single flag of a command. Name is the logical name and matches the `rotini:"<name>"` tag on the corresponding generated input field.

type FlagDependency

type FlagDependency struct {
	When     string   // the flag whose presence triggers the requirement
	Requires []string // flags that must also be set when When is set
}

FlagDependency is a conditional cross-flag requirement: when the When flag is set on argv, every flag in Requires must be too. "Set" follows the same convention as FlagGroup.

type FlagGroup

type FlagGroup struct {
	Kind  FlagGroupKind
	Flags []string // logical flag names that make up the group
}

FlagGroup constrains which of a command's flags may, or must, appear together. Flags are referenced by their logical Name, and "set" means explicitly provided on argv — a default or fallback does not count. The Parser enforces it; a violation is a usage error.

type FlagGroupKind

type FlagGroupKind string

FlagGroupKind names a cross-flag presence rule. The value is the spec's `kind`.

const (
	// FlagGroupMutuallyExclusive: at most one of the group's flags may be set.
	FlagGroupMutuallyExclusive FlagGroupKind = "mutually_exclusive"
	// FlagGroupRequiredTogether: set all of the group's flags, or none.
	FlagGroupRequiredTogether FlagGroupKind = "required_together"
	// FlagGroupOneOf: exactly one of the group's flags must be set.
	FlagGroupOneOf FlagGroupKind = "one_of"
	// FlagGroupAtLeastOne: at least one of the group's flags must be set.
	FlagGroupAtLeastOne FlagGroupKind = "at_least_one"
)

type FlagValueCompleter

type FlagValueCompleter interface {
	CompleteFlagValue(rtx *Context, flag, partial string) []string
}

FlagValueCompleter is an optional interface a command's handler may implement to supply dynamic completion candidates for one of its flags' values. Completion resolves the handler of the command that declares the flag and, if it implements this interface, calls CompleteFlagValue with the flag's logical name and the word being typed. A nil return falls back to the flag's static enum; a non-nil return, empty included, is authoritative.

rtx carries the resolved chain, the completion words in Context.Argv, and the dependencies registered with Program.WithDependency.

The line is half-typed, so Context.Inputs would fail validation. To read what has been typed so far, merge the lenient per-channel layers, which bind without validating. Flags of an ancestor are read with that ancestor's inputs type, running as that ancestor:

var in AppInputs
rotini.AsCommand(0, func(_ context.Context, rtx *rotini.Context) {
	env, _ := rtx.EnvInputs[AppInputs]()
	argv, _ := rtx.ArgvInputs[AppInputs]()
	in = rotini.MergeInputs(env, argv) // argv wins, as it would at run time
})(context.Background(), rtx)

A panic in a completer is not recovered. It may run on every keystroke, so it must be read-only and fast.

A candidate may carry a one-line description after a tab, "value\tdescription": zsh, fish and powershell render it beside the value, and bash strips it.

type Handler added in v1.2.0

type Handler interface {
	CascadingPreRun(ctx context.Context, rtx *Context)
	PreRun(ctx context.Context, rtx *Context)
	Run(ctx context.Context, rtx *Context)
	PostRun(ctx context.Context, rtx *Context)
	CascadingPostRun(ctx context.Context, rtx *Context)
}

Handler is the lifecycle interface every command's handler implements. The runtime invokes the hooks in order, sharing one Context across the chain. Embed the No* types below to declare only the hooks a command uses.

Instance lifetime

Per run, the runtime asks the handler set given to NewProgram (the generated ProgramHandlers) once for each command in the chain, and that value serves the command's hooks for the run:

  • A field carries state between one command's own hooks. The leaf's PreRun, Run and PostRun share one value, as do a command's CascadingPreRun and CascadingPostRun.
  • A field cannot cross commands. State that travels down the chain is a dependency.

Where the state flows decides the tool:

state flows…                                  use
────────────                                  ───
between one command's own hooks               a field on the handler
between different commands in the chain       rtx.SetDependency(dep, v)
across every run of the program               p.WithDependency(dep, v)

Hand-written handler sets

Generated wiring methods return a new handler per call, so fields are per-run state. A hand-written wiring method that returns a shared value makes that handler's fields shared across runs, and a data race under concurrent Program.Run. Handlers that keep state in fields must be returned fresh on every call.

type HelpFunc

type HelpFunc func(path ...string) (string, error)

HelpFunc returns the help page of the command named by path (canonical names below the root; none for the root), or an error when there is no such command. Codegen generates a Help function of this type.

type HexBytes

type HexBytes []byte

HexBytes is binary data written as hexadecimal, with or without a `0x` prefix: `deadbeef`, `0xDEADBEEF`. A spec declares one with `type: hexbytes`.

func (HexBytes) MarshalText

func (h HexBytes) MarshalText() ([]byte, error)

MarshalText implements encoding.TextMarshaler as lowercase hex without a prefix.

func (HexBytes) String

func (h HexBytes) String() string

String renders the bytes as lowercase hexadecimal.

func (*HexBytes) UnmarshalText

func (h *HexBytes) UnmarshalText(text []byte) error

UnmarshalText implements encoding.TextUnmarshaler.

type InputError added in v1.2.0

type InputError struct {
	Channel string // one of "env", "config", "stdin", "flag"
	Input   string // the offending input key/path, when a single one is known (else "")
	Msg     string // a clean, non-leaky, rotini-owned message
	Cause   error  // the underlying recon/decode/OS error, reachable via errors.As (may be nil)
	// contains filtered or unexported fields
}

InputError reports a failure acquiring or decoding one of a command's non-argv input channels: environment variables, configuration files, a typed stdin payload, or a flag's env/config fallback. It is the counterpart of the argv channel's *ParseError.

Error names the channel, the input, and what went wrong. The underlying recon, decode or OS Cause is reachable via errors.As but kept out of the message, and the value of an input marked secret is redacted.

A bad value, a missing required input, or a malformed document the user supplied is CategoryUsage; a registry build, schema compile, IO read, or codegen mismatch is CategoryInternal.

var be *rotini.InputError
if errors.As(err, &be) {
    fmt.Fprintf(os.Stderr, "bad %s input %q: %s\n", be.Channel, be.Input, be.Error())
}

func (*InputError) Error added in v1.2.0

func (e *InputError) Error() string

Error returns Msg: the channel, the input, and what went wrong. A secret input's value is redacted.

func (*InputError) Unwrap added in v1.2.0

func (e *InputError) Unwrap() []error

Unwrap exposes the Cause and the category sentinel, so errors.Is/As reach both the original recon error and ErrUsage/ErrInternal.

type InputLayer added in v1.2.0

type InputLayer[T any] struct {
	Name   string
	Values T
	Set    Presence
	// contains filtered or unexported fields
}

InputLayer is one input channel's view of the inputs type T: the values it supplied (all other fields are zero) and which fields those are. Layers from rotini's channel parsers also carry unexported data that InputReport.Validate uses; a hand-built InputLayer participates in overlay and provenance but contributes nothing to validation. A nil or empty Set means the layer supplied nothing: overlaying it leaves every field as it was.

type InputReader added in v1.2.0

type InputReader struct {
	// contains filtered or unexported fields
}

InputReader is the default multi-source input reader. It fills a command's typed inputs from argv (via a default Parser) and from the non-argv channels — environment variables, configuration files, and the leaf command's typed stdin payload — reconciled and decoded by recon. It is the engine behind Context.Inputs; Context.ArgvInputs and its siblings expose the channels individually.

reader := rotini.NewInputReader(settings)
var in WidgetCreateInputs
if err := reader.Read(rtx, &in); err != nil { /* handler owns it */ }

Env values fill the generated <Prefix>Env struct and config-file values <Prefix>Config. The two channels use independent registries, so an env var never leaks into a config field or the reverse. Flags may additionally fall back to env and config.

func NewInputReader added in v1.2.0

func NewInputReader(meta InputSettings) *InputReader

NewInputReader returns the default input reader, configured from the generated InputSettings.

func (*InputReader) Read added in v1.2.0

func (b *InputReader) Read(rtx *Context, out any) error

Read fills out, a non-nil pointer to the generated inputs struct, from every channel. Flag and argument validation runs once over the reconciled values, so a required flag is satisfiable from env or config and an env- or config-supplied value is still enum-checked.

It returns the first error: a *ParseError from the argv channel or an *InputError from the others, with the recon cause reachable via errors.As, or a *WiringError when a command declares config inputs but the program has no InputSettings.

Example

InputReader.Read reconciles every declared channel in one call; here a flag unset in argv is read from its environment fallback.

def := Definition{
	Name: "app", Handler: "App",
	Flags: []FlagDef{
		// The recon key gives the flag fallbacks: $SERVER_PORT, then any
		// declared config files, then the default.
		{Name: "port", Identifiers: []string{"--port"}, Type: "int", Default: "8080"},
	},
}
var inputs struct {
	App struct {
		Flags struct {
			Port int `rotini:"port" recon:"server.port"`
		}
		Arguments struct{}
	}
}

os.Setenv("SERVER_PORT", "9090")
defer os.Unsetenv("SERVER_PORT")

rtx := NewContextFor(def, nil) // --port absent from argv
if err := NewInputReader(InputSettings{}).Read(rtx, &inputs); err != nil {
	fmt.Println("bind:", err)
	return
}
fmt.Println("port:", inputs.App.Flags.Port)
Output:
port: 9090

type InputReport added in v1.2.0

type InputReport struct {
	// contains filtered or unexported fields
}

InputReport is the merged provenance of one overlay: which layer won each field, every layer that set it (low → high), and validation over the merged values. The zero InputReport reports no fields, and its Validate returns nil.

func MergeInputsWithReport added in v1.2.0

func MergeInputsWithReport[T any](layers ...InputLayer[T]) (T, InputReport)

MergeInputsWithReport is MergeInputs plus the merged InputReport: which layer won each field, the full per-field history, and Validate over the merged result.

Example

ExampleOverlayInputsP shows the merge and provenance contract with two hand-built layers: order is precedence, and the Report names the winner.

type inputs struct {
	App struct {
		Flags struct {
			Color string `rotini:"color"`
		}
	}
}
var defaults, env inputs
defaults.App.Flags.Color = "blue"
env.App.Flags.Color = "teal"

merged, report := MergeInputsWithReport(
	InputLayer[inputs]{Name: "defaults", Values: defaults, Set: Presence{"App.Flags.Color": {Layer: "defaults", Raw: "blue"}}},
	InputLayer[inputs]{Name: "env", Values: env, Set: Presence{"App.Flags.Color": {Layer: "env", Raw: "teal"}}},
)
win, _ := report.Winner("App.Flags.Color")
fmt.Println(merged.App.Flags.Color, "from", win.Layer)
Output:
teal from env

func (InputReport) Fields added in v1.2.0

func (r InputReport) Fields() []FieldPath

Fields returns every field any layer set, sorted.

func (InputReport) History added in v1.2.0

func (r InputReport) History(path FieldPath) []InputSource

History returns every layer that set path, low → high precedence; the last element is the winner.

func (InputReport) Validate added in v1.2.0

func (r InputReport) Validate() error

Validate runs the same declarative checks Parser.Parse applies — required, enum, constraints, flag groups and dependencies — with "explicitly set" meaning set by the argv layer. Run it after the overlay so a required flag satisfied by any layer passes.

It checks what the layers supplied, not the merged struct field by field:

  • Presence rules fire on absence: a required input no layer supplied is an error.
  • Value rules fire only on a supplied value: an unsupplied field's zero value is not checked against its enum or bounds.

A merge that omits Context.DefaultInputs can therefore yield an enum-constrained flag as "" without error. Hand-built layers contribute values but nothing to validate.

func (InputReport) Winner added in v1.2.0

func (r InputReport) Winner(path FieldPath) (InputSource, bool)

Winner returns the provenance of the layer that supplied path's final value.

type InputSettings added in v1.2.0

type InputSettings struct {
	ConfigFiles []ConfigFile // per-command config_files sources, each tagged with its Scope; the input reader scopes them to the invoked chain (cascade, nearest-wins)
	// EnvPrefix scopes every derived env-var name under "<EnvPrefix>_". Explicit variable
	// names are exempt, and with a prefix set the unprefixed names no longer bind.
	EnvPrefix string
	// Sources are custom recon sources — a secrets manager, a remote config service —
	// joined into the config precedence after the declared config_files, so explicit
	// files beat ambient services. Codegen never emits one; the program appends its own.
	// A per-input `file:` pin stays a config_files anchor and cannot name a custom
	// source, and a source name colliding with a declared file is rejected.
	Sources []recon.Source
	// StdinSchemas maps a command's stdin payload type name ("<Prefix>Stdin") to a
	// self-contained JSON Schema the input reader validates the decoded payload against.
	StdinSchemas map[string]string
}

InputSettings is the generated descriptor the InputReader consumes to fill the non-argv input channels. It carries the document-level concerns the dispatch-time Definition omits.

type InputSource added in v1.2.0

type InputSource struct {
	Layer string // the supplying layer's name: "defaults", "files", "env", "argv", "stdin", or custom
	Raw   string // the supplied text, a list's values joined with ", " whichever layer supplied it ("" when non-textual, e.g. a decoded stdin document); "[redacted]" for secrets
}

InputSource records which layer supplied a field's value and the raw text it supplied. Raw is pre-redacted for inputs the spec marks secret.

type Lifecycle

type Lifecycle func(chain []Command, handlers []Handler) []LifecycleStep

Lifecycle is the run phase's planner: given the resolved chain and each command's Handler, index-aligned, it returns the ordered steps the engine executes. Handlers are resolved before it is called. See DefaultLifecycle.

A plan built from scratch should wrap each hook in AsCommand so Context.Command and Context.Inputs anchor on the hook's own command; an unwrapped hook reports the invoked command, which is wrong for a cascading hook.

type LifecycleStep

type LifecycleStep struct {
	Name string                                  // diagnostic label, e.g. "cascading:app", "prerun:build", "run:build"
	Do   func(ctx context.Context, rtx *Context) // the forward hook
	Undo func(ctx context.Context, rtx *Context) // the paired teardown; nil for none
}

LifecycleStep pairs one forward hook with its teardown; a plan is a slice of them.

Either half may be nil. A nil Undo is a step with no teardown, like the default plan's Run step; a nil Do is a teardown-only step. A step counts as begun when the engine reaches it, so a teardown-only step still unwinds.

func DefaultLifecycle

func DefaultLifecycle(chain []Command, handlers []Handler) []LifecycleStep

DefaultLifecycle is rotini's run-phase plan, exported for a custom Lifecycle to wrap: one CascadingPreRun/CascadingPostRun pair per command, root → leaf, then the leaf's PreRun/PostRun pair, then the leaf's Run with no teardown. Every hook is wrapped in AsCommand.

type NoCascadingPostRun added in v1.2.0

type NoCascadingPostRun struct{}

NoCascadingPostRun is an embeddable no-op Handler.CascadingPostRun. NoHooks embeds all four no-ops at once.

func (NoCascadingPostRun) CascadingPostRun added in v1.2.0

func (NoCascadingPostRun) CascadingPostRun(ctx context.Context, rtx *Context)

CascadingPostRun does nothing.

type NoCascadingPreRun added in v1.2.0

type NoCascadingPreRun struct{}

NoCascadingPreRun is an embeddable no-op Handler.CascadingPreRun. NoHooks embeds all four no-ops at once.

func (NoCascadingPreRun) CascadingPreRun added in v1.2.0

func (NoCascadingPreRun) CascadingPreRun(ctx context.Context, rtx *Context)

CascadingPreRun does nothing.

type NoHooks added in v1.2.0

NoHooks embeds the four no-op hooks, for a hand-written handler:

type handlers struct{ rotini.NoHooks }

func (*handlers) Run(ctx context.Context, rtx *rotini.Context) { … }

A method declared on the outer type takes precedence over the promoted no-op.

NoHooks does not supply Run, so a handler with a missing or misspelled Run fails the `var _ rotini.Handler` assertion at compile time. Generated stubs embed the four types individually.

type NoPostRun added in v1.2.0

type NoPostRun struct{}

NoPostRun is an embeddable no-op Handler.PostRun. NoHooks embeds all four no-ops at once.

func (NoPostRun) PostRun added in v1.2.0

func (NoPostRun) PostRun(ctx context.Context, rtx *Context)

PostRun does nothing.

type NoPreRun added in v1.2.0

type NoPreRun struct{}

NoPreRun is an embeddable no-op Handler.PreRun. NoHooks embeds all four no-ops at once.

func (NoPreRun) PreRun added in v1.2.0

func (NoPreRun) PreRun(ctx context.Context, rtx *Context)

PreRun does nothing.

type Option

type Option func(*Program)

Option is one configuration step as a value, applied by Program.With. WithDependency returns one; a program can define its own:

func devDefaults() rotini.Option {
	return func(p *rotini.Program) { p.WithStdout(os.Stderr).WithoutSignalHandling() }
}

func WithDependency added in v1.2.0

func WithDependency[T any](dep Dependency[T], value T) Option

WithDependency returns an Option that registers value under dep for the whole program; it is the composable form of Program.WithDependency, for use with Program.With:

cmd.Program.
	With(
		rotini.WithDependency(tasks.Store, store),
		rotini.WithDependency(tasks.Client, client),
	).
	WithVersion(version).
	Execute()

type Outcome

type Outcome struct {
	// Infos are [Context.RecordInfo] messages: neutral output, no bearing on the exit code.
	Infos []string
	// Successes are [Context.RecordSuccess] messages.
	Successes []string
	// Warnings are [Context.RecordWarning] values: non-fatal, never raising the exit code.
	Warnings []error
	// Errors are [Context.RecordError] values.
	Errors []error
	// Panics are recovered panics and rotini-detected faults, captured by the runtime; there
	// is no record call for them.
	Panics []*PanicError
}

Outcome is everything a run recorded, handed to the Reporter. Each slice is in recording order and is a copy. Context exposes no other way to read the records.

func (Outcome) Empty

func (o Outcome) Empty() bool

Empty reports whether the run recorded nothing. The runtime does not call the reporter for an empty Outcome.

func (Outcome) Failed

func (o Outcome) Failed() bool

Failed reports whether the run recorded an error or a panic, the condition for the default reporter's exit floor.

type OutputDef added in v1.2.0

type OutputDef struct {
	// Type is the generated <Prefix>Output type: the whole output, or one item when the command
	// writes a stream of them.
	Type reflect.Type
	// Schema is the shape as a self-contained JSON Schema, which [Program.WithOutputChecks],
	// [Context.CheckOutput] and [DecodeOutput] check values against. "" when there is none.
	Schema string
}

OutputDef is what a command declares it writes to stdout when it succeeds: the spec's `output:`, recorded by `rotini generate`. A nil OutputDef means the command declares none.

type Page added in v1.2.0

type Page struct {
	// Name is the command path joined with "-" and lowercased ("taskr-add"): the name `man`
	// looks the page up by, and the man page's file name without its extension.
	Name string
	// Path is the command path below the root, ["add"]; nil for the root command.
	Path []string
	// Content is the page itself: roff for a man page, markdown for a markdown page.
	Content string
}

Page is one generated documentation page for one command: a man page or a markdown reference page. With the man feature on, codegen emits a ManPages function returning every command's man page; with the markdown feature on, MarkdownPages. Both list the visible commands in tree order, root first.

for _, p := range cmd.ManPages() {
	path := filepath.Join(dir, p.Name+"."+cmd.ManSection)
	if err := os.WriteFile(path, []byte(p.Content), 0o644); err != nil {
		return err
	}
}

type PanicError

type PanicError struct {
	Value any
	Stack []byte
}

PanicError carries a panic recovered from a lifecycle hook to the reporter: Value is the value passed to panic, Stack the goroutine stack captured at recovery. Error renders Value alone.

It also carries faults rotini detects without a panic, such as a *WiringError or a resolver failure, with the error as Value and a nil Stack.

func (*PanicError) Error

func (e *PanicError) Error() string

Error renders the panic value without the stack.

func (*PanicError) Unwrap

func (e *PanicError) Unwrap() []error

Unwrap returns the panic value when it is an error, followed by ErrInternal, so a panic is CategoryInternal unless its error value classifies otherwise (CategoryOf tests ErrUsage first).

type ParseError

type ParseError struct {
	Kind       ParseKind // what went wrong, for branching without matching Msg (zero = ParseKindUnspecified)
	Msg        string    // the human-readable failure, opinion-free
	Command    string    // the command in whose scope parsing failed ("" when not command-scoped)
	Flag       string    // the flag involved, by the identifier or label used ("" when not flag-related)
	Token      string    // the offending argv token or value ("" when none)
	Candidates []string  // the vocabulary Token failed against — sibling commands, declared flags, enum members (nil when none applies)
}

ParseError is a parse-time failure caused by bad input. Its message carries no suggestions or usage text; the structured fields let a handler compose its own response: switch on Kind, pass Token and Candidates to a Suggestor, or render help for Command. It unwraps to ErrUsage (ErrInternal for ParseKindInternal), so CategoryOf can classify it. The default reporter exits 1; a program wanting exit code 2 for usage errors maps it in its own reporter (see Category).

var pe *rotini.ParseError
if errors.As(err, &pe) {
    switch pe.Kind {
    case rotini.ParseKindUnknownFlag, rotini.ParseKindUnknownCommand:
        // pe.Token + pe.Candidates feed a Suggestor's "did you mean"
    case rotini.ParseKindMissingRequired:
        // prompt, or point at the help for pe.Command
    }
}

func (*ParseError) Error

func (e *ParseError) Error() string

Error renders the parse failure as a single, user-facing line.

func (*ParseError) Unwrap

func (e *ParseError) Unwrap() error

Unwrap returns the category sentinel: ErrInternal for ParseKindInternal and ErrUsage for every other kind.

type ParseKind

type ParseKind int

ParseKind classifies a *ParseError so a reporter can branch on the failure without matching the message. Every kind is the end-user's to fix except ParseKindInternal, a misuse of the parser API by the author.

const (
	// ParseKindUnspecified is the zero value: a [*ParseError] whose construction
	// site did not classify it (a hand-built error that sets no Kind).
	ParseKindUnspecified         ParseKind = iota
	ParseKindUnknownFlag                   // an argv token looked like a flag no command on the chain declares
	ParseKindUnknownCommand                // a stray positional on a branch-only command (a mistyped sub-command)
	ParseKindNeedsValue                    // a value-taking flag was given no value
	ParseKindInvalidValue                  // a value could not be coerced/resolved (bad type, unreadable @file, malformed map, value on a no-value flag)
	ParseKindEnumViolation                 // a value was not one of a declared enum's members
	ParseKindConstraintViolation           // a declared bound or flag-group/dependency rule was violated
	ParseKindMissingRequired               // a required flag or argument was absent
	ParseKindNoArguments                   // a positional was given to a command that accepts none
	ParseKindTooManyArguments              // more positionals than the command's declared (non-variadic) arity
	ParseKindInternal                      // a parser API misuse: nil parser/context, a bad out argument, or an inputs type that does not describe the running command
)

The parse failure kinds.

func (ParseKind) String

func (k ParseKind) String() string

String renders the kind as a short, stable label (for logs and tests).

type Parser

type Parser struct{}

Parser is rotini's argv parser: it parses and validates the command line against what the resolved chain declares (GNU/POSIX grammar, typed coercion, enum and constraint checks), failing with a *ParseError.

Parsing is opt-in: a CLI that wants raw argv reads Context.Argv instead. Supply a parser with Program.WithParser; a handler reads it with Context.Parser:

parser := rtx.Parser()
var in MycliInputs
err := parser.Parse(rtx, &in)

func NewParser

func NewParser() *Parser

NewParser returns rotini's default Parser.

func (*Parser) Parse

func (p *Parser) Parse(rtx *Context, out any) error

Parse binds the running command's arguments into out — a non-nil pointer to the generated inputs struct — from the resolved chain and raw argv on rtx, in the json.Unmarshal style:

var in MycliInputs
if err := parser.Parse(rtx, &in); err != nil { /* handler owns it */ }

It applies declared defaults, fills out by reflection from the `rotini:"…"` struct tags, then validates. Built-ins and any encoding.TextUnmarshaler are coerced, and a trailing []string absorbs the remaining positionals. Parse does not consult env or config fallbacks; Context.Inputs does.

It returns a *ParseError when out is not a non-nil pointer, a flag is unknown or missing its value, a value cannot be coerced, a required input is absent, a value falls outside a declared enum, or any declared constraint, flag group or flag dependency is violated.

Unlike Context.Inputs and the per-channel layer methods, Parse does not check that out describes the running command: it binds what fits and leaves the rest zeroed, so one struct can span a whole tree. A handler reading its own inputs should use Context.Inputs.

Example

Parser.Parse fills the generated input struct from argv alone, with typed coercion, defaults, and enum and constraint checks, failing with a *ParseError.

def := Definition{
	Name: "app", Handler: "App",
	Commands: []CommandDef{{
		Name: "deploy", Handler: "AppDeploy",
		Arguments: []ArgDef{{Name: "service", Type: "string", Required: true}},
		Flags: []FlagDef{
			{Name: "env", Identifiers: []string{"--env"}, Type: "string", Default: "dev", Enum: []string{"dev", "prod"}},
			{Name: "loud", Identifiers: []string{"--loud", "-l"}, Type: "count"},
		},
	}},
}
// The shape codegen emits: one field per command on the resolved path.
var inputs struct {
	App struct {
		Flags     struct{}
		Arguments struct{}
	}
	Deploy struct {
		Flags struct {
			Env  string `rotini:"env"`
			Loud int    `rotini:"loud"`
		}
		Arguments struct {
			Service string `rotini:"service"`
		}
	}
}

rtx := NewContextFor(def, []string{"deploy", "api", "--env", "prod", "-ll"})
if err := NewParser().Parse(rtx, &inputs); err != nil {
	fmt.Println("usage:", err)
	return
}
fmt.Printf("%s → %s (verbosity %d)\n",
	inputs.Deploy.Arguments.Service, inputs.Deploy.Flags.Env, inputs.Deploy.Flags.Loud)
Output:
api → prod (verbosity 2)

type PathFromDef

type PathFromDef struct {
	Flag string // logical flag name searched across the resolved chain
	Env  string // environment variable read directly; comma-separated names: the first one set wins
}

PathFromDef names the runtime inputs that supply a ConfigFile's path — the declarative two-phase parse, where argv and env are read first and the file channel then opens whatever they pointed at. Precedence: the flag set on argv, then the env variable, then the flag's default, then the entry's own path or discover. A path supplied this way must exist.

type PluginDef added in v1.2.0

type PluginDef struct {
	Name    string
	Aliases []string
	Summary string        // one-line description (completion candidates carry it as "name\tsummary")
	Binary  string        // expected executable name, e.g. "kubectl-ctx"
	Timeout time.Duration // 0 means no timeout
}

PluginDef describes a co-located sub-command, kubectl/git plugin style: invoking it execs the sibling Binary with the remaining arguments passed through.

type PluginDiscoveryDef added in v1.2.0

type PluginDiscoveryDef struct {
	Prefix string // executable-name prefix, e.g. "acme-"
	Hidden bool   // dispatch discovered plugins but omit them from completion listings
}

PluginDiscoveryDef enables plugin discovery on a command: an unmatched token execs the sibling binary Prefix+<token>, and `<Prefix>*` executables are offered as completion candidates unless Hidden. A nil pointer means discovery is off for that command.

type PluginDispatch added in v1.2.0

type PluginDispatch struct {
	Def  PluginDef
	Args []string
	Dir  string
	// Discovered marks a plugin-discovery dispatch rather than a declared plugin, which
	// decides the error category when the binary cannot be resolved.
	Discovered bool
}

PluginDispatch is a resolved plugin invocation, declared or discovered: Def.Binary run with Args. Dir is the command's plugin path, searched after the host binary's own directory and before PATH; empty means none.

type PluginError added in v1.2.0

type PluginError struct {
	Name    string          // the declared plugin name (or discovery token)
	Binary  string          // the plugin binary that was sought or spawned
	Kind    PluginErrorKind // what went wrong
	Timeout time.Duration   // the elapsed deadline, for Kind == PluginTimeout (else 0)
	Cause   error           // the underlying OS/exec error, reachable via errors.As (may be nil)
	Msg     string          // the human-readable failure
	// contains filtered or unexported fields
}

PluginError reports a failure by rotini to carry out a plugin dispatch. The plugin's own non-zero exit is not a PluginError; its exit code passes through unchanged.

var re *rotini.PluginError
if errors.As(err, &re) && re.Kind == rotini.PluginTimeout {
    fmt.Fprintf(os.Stderr, "%s timed out after %s\n", re.Name, re.Timeout)
}

A missing binary is CategoryUsage when discovered (a mistyped token) and CategoryInternal when declared (an install problem); a spawn failure is CategoryInternal; a timeout is CategoryNone.

func (*PluginError) Error added in v1.2.0

func (e *PluginError) Error() string

Error renders the plugin-dispatch failure as a single, user-facing line.

func (*PluginError) Unwrap added in v1.2.0

func (e *PluginError) Unwrap() []error

Unwrap exposes the Cause (when present) and the category sentinel (ErrUsage/ErrInternal) so errors.Is/As reach both; a timeout adds no sentinel, so CategoryOf reports CategoryNone.

type PluginErrorKind added in v1.2.0

type PluginErrorKind int

PluginErrorKind classifies a plugin-dispatch failure: the plugin binary could not be located, it exceeded its declared timeout, or it could not be spawned.

const (
	// PluginNotFound: no binary was found next to the executable, in the
	// plugin path, or on PATH.
	PluginNotFound PluginErrorKind = iota
	// PluginTimeout: the plugin ran past its declared timeout and was killed.
	PluginTimeout
	// PluginStartFailed: the binary was found but could not be started (a fork,
	// exec or pipe failure). The plugin's own non-zero exit is not an error kind.
	PluginStartFailed
)

func (PluginErrorKind) String added in v1.2.0

func (k PluginErrorKind) String() string

String renders the kind as a short, stable label.

type Presence

type Presence map[FieldPath]InputSource

Presence maps each field a layer supplied to its provenance. Overlay copies only these fields, so a layer's absent fields never overwrite a lower layer's values.

type Program

type Program struct {
	// contains filtered or unexported fields
}

Program is a rotini CLI ready to run: the compiled command tree (Definition), the handlers that implement it, and the seams around them. The generated entrypoint builds one with NewProgram and calls Program.Execute. Every With method returns the receiver, so calls chain.

The surface groups into eight jobs:

A setting that rotini reads (the runtime or generated code) is a typed option on the Program; a setting only the program's own code reads is a dependency. Typed options cannot be shadowed by a dependency name, and a wrong type is a compile error.

A Program is reusable: Program.Run dispatches one invocation and returns, giving each call a fresh Context.

The With methods are not synchronized: configure before the first run. Applied between sequential runs, they take effect on the next one.

Methods do not check for a nil receiver. The zero value is not usable; start from NewProgram.

func NewProgram

func NewProgram(def Definition, handlers any) *Program

NewProgram wires a generated command tree and its aggregate handler set to the runtime. Dispatch calls the method of handlers named by each Command.Handler in def to obtain that command's Handler. A nil handlers value is accepted here; a run that reaches dispatch then fails with a *WiringError.

func (*Program) Complete added in v1.1.1

func (p *Program) Complete(words []string, format CompletionFormat) (int, error)

Complete answers one shell-completion request in the given format and returns the exit code, the completion counterpart of Program.Run. It serves hosts that do not call the hidden __complete entry; kubectl, for example, runs a separate kubectl_complete-<plugin> with only the plugin's words:

if strings.Contains(filepath.Base(os.Args[0]), "_complete-") {
	code, _ := cmd.Program.Complete(os.Args[1:], rotini.PluginCompletion)
	os.Exit(code)
}

words are the words after the program's own name; the last is the word being completed, empty when the cursor starts a new one, and no words at all completes a new first word. The answer is what __complete computes, written to the program's stdout by format. A nil format uses rotini's own format, the __complete default, which is private to rotini's generated scripts.

The exit code is 0, or 1 when the format fails to write, with its error.

func (*Program) Execute

func (p *Program) Execute() error

Execute runs the arguments set by Program.WithArgs (default os.Args[1:]) through Program.Run and passes the resulting code to the exit action (os.Exit by default; see Program.WithExit).

The returned error joins every Context.RecordError value and every captured fault with errors.Join, so errors.Is and errors.As reach each one. It is returned only when the exit action returns; under os.Exit the process ends first. The reporter has already reported the outcome by then; the error lets an embedding host act on the failure.

Signals

With no Program.WithContext, rotini traps os.Interrupt and syscall.SIGTERM. The first signal halts the lifecycle as Context.HaltWithCode does (forward progress stops, every begun teardown hook runs) with exit code 128+signum. A second signal calls the exit action with 130 immediately. See Program.WithoutSignalHandling and Program.WithSignals.

func (*Program) Run

func (p *Program) Run(argv []string) (int, error)

Run dispatches one invocation of argv and returns its exit code and error (see Program.Execute). It resolves the invoked command, executes a declared plugin if one was selected, and otherwise runs the lifecycle; inputs are parsed only when a handler calls Context.Inputs. Run never ends the process.

Each call gets a fresh Context. Dependencies registered with Program.WithDependency are seeded into every run; one set with Context.SetDependency stays local to its run.

With no supplied context, Run installs and removes the signal trap on every call (about 30µs). A host dispatching in a loop uses Program.RunContext or Program.WithoutSignalHandling.

Concurrency

Run is safe for concurrent use once configuration is complete. Each run's records, exit state and run-local dependencies are its own. Shared, and synchronized by the host:

  • The handlers value given to NewProgram, whose methods are called from each run's goroutine.
  • The program's streams.

With no supplied context, each concurrent run installs its own trap and all of them observe a signal. A concurrent host passes its own context (Program.RunContext) or disables the trap with Program.WithoutSignalHandling.

func (*Program) RunContext

func (p *Program) RunContext(ctx context.Context, argv []string) (int, error)

RunContext is Program.Run under ctx, for this invocation only; the program is not modified. As with Program.WithContext, supplying a context leaves signal handling to the caller unless Program.WithSignals was set. A nil ctx returns exit code 1 and an ErrInternal error without running.

func (*Program) With

func (p *Program) With(opts ...Option) *Program

With applies each Option in order and returns the program:

cmd.Program.
	With(
		rotini.WithDependency(tasks.Store, store),
		rotini.WithDependency(tasks.Client, client),
	).
	WithVersion(version).
	Execute()

A later Option registering the same dependency replaces an earlier one. A nil Option is skipped.

func (*Program) WithArgs

func (p *Program) WithArgs(args []string) *Program

WithArgs sets the argument vector Program.Execute runs (defaults to os.Args[1:]).

Only Execute reads it. Program.Run and Program.RunContext use the argv they are passed, so `p.WithArgs(x).Run(nil)` runs with no arguments.

A nil args is ignored; pass []string{} to run with none.

func (*Program) WithCompletion added in v1.1.1

func (p *Program) WithCompletion(format CompletionFormat) *Program

WithCompletion sets the CompletionFormat the hidden __complete entry answers in, in place of rotini's own. It serves a plugin whose host completes it by calling the plugin's __complete and reading the host's format, as the Docker CLI (`docker-<name> __complete <name> …`) and the Flux CLI (`flux-<name> __complete …`) do with the format PluginCompletion writes:

cmd.Program.WithCompletion(rotini.PluginCompletion).Execute()

rotini's generated completion scripts read rotini's own format, so a standalone CLI leaves this unset. A host that runs a separately named completer without a __complete word, such as kubectl's kubectl_complete-<name>, is served by calling Program.Complete from main.

A nil format restores rotini's own.

func (*Program) WithContext

func (p *Program) WithContext(ctx context.Context) *Program

WithContext sets the base context threaded to every lifecycle hook, the reporter, and any plugin exec, so a caller can cancel or time-bound the whole run. A nil context is ignored.

Cancellation is cooperative: it does not preempt a running hook, but once the context is canceled no further forward hook starts, and the teardown of every begun setup hook runs in reverse. ExitCause attaches an exit code; without one the code is resolved as usual.

Supplying a context disables rotini's signal trap by default, leaving signals to the caller (typically via signal.NotifyContext). Program.WithSignals re-enables the trap on top of a supplied context; Program.WithoutSignalHandling disables it without one.

func (*Program) WithDependency added in v1.2.0

func (p *Program) WithDependency[T any](dep Dependency[T], value T) *Program

WithDependency registers value under dep for the whole program, so every run sees it, and returns p for chaining:

cmd.Program.
	WithDependency(tasks.Store, store).
	WithVersion(version).
	Execute()

A later registration under the same name replaces an earlier one.

Go infers T from both arguments, so for a handle declared over an interface the value must already have that interface type. Convert the value, or name the type:

var Store = rotini.NewDependency[store.Store]("taskr.store") // an interface

p.WithDependency(Store, store.Store(store.NewMem()))
p.WithDependency[store.Store](Store, store.NewMem())

The dependency namespace belongs to the application. rotini's own seams (input reader, parser, version, help) are typed Program options, so no dependency name can shadow them.

func (*Program) WithExit

func (p *Program) WithExit(fn func(int)) *Program

WithExit overrides what Program.Execute does with the resolved exit code (default os.Exit). The same function receives the forced exit code when a second trapped signal arrives. A nil function is ignored.

When fn returns, Execute returns the run's error to its caller; under os.Exit it never does. This makes an end-to-end test or an embedding host possible:

code := -1
err := cmd.Program.
	WithArgs(argv).WithStdout(&out).WithStderr(&errs).
	WithExit(func(c int) { code = c }).
	Execute()

func (*Program) WithHelp

func (p *Program) WithHelp(help HelpFunc) *Program

WithHelp sets where Context.Help finds a command's help page. The generated NewProgram passes its own Help function. A nil help is ignored.

Help is a program-level seam so that a command composed from another spec prints the page of the program it runs in, with that program's full command path and inherited flags.

func (*Program) WithInputReader added in v1.2.0

func (p *Program) WithInputReader(fn func(InputSettings) *InputReader) *Program

WithInputReader replaces the input reader that Context.Inputs and the per-channel methods use. fn receives the program's InputSettings, so a replacement starts from the generated descriptor and keeps the declared configuration sources:

p.WithInputReader(func(meta rotini.InputSettings) *rotini.InputReader {
	meta.Sources = append(meta.Sources, mySource)
	return rotini.NewInputReader(meta)
})

A nil fn is ignored.

func (*Program) WithInputSettings added in v1.2.0

func (p *Program) WithInputSettings(meta InputSettings) *Program

WithInputSettings supplies the generated input descriptor: the configuration sources, the env prefix and the stdin schemas Context.Inputs reads from. The generated NewProgram calls it; a hand-built program calls it to enable those channels.

func (*Program) WithLifecycle

func (p *Program) WithLifecycle(fn Lifecycle) *Program

WithLifecycle overrides the run phase's plan: which hooks run, in what pairing and order (see Lifecycle and DefaultLifecycle). Halting, the reverse teardown unwind, panic handling and exit-code resolution are unchanged. A nil lifecycle is ignored.

Example

A lifecycle that wraps DefaultLifecycle and swaps the cascading pairs, so CascadingPostRun unwinds root→leaf. Halting, unwind and panic handling are unchanged.

NewProgram(exampleDef(), exHandlers{}).
	WithArgs([]string{"status"}).
	WithExit(func(int) {}).
	WithLifecycle(func(chain []Command, hs []Handler) []LifecycleStep {
		steps := DefaultLifecycle(chain, hs)
		for i, j := 0, len(hs)-1; i < j; i, j = i+1, j-1 {
			steps[i].Undo, steps[j].Undo = steps[j].Undo, steps[i].Undo
		}
		return steps
	}).
	Execute()
Output:
app.CascadingPreRun
status.CascadingPreRun
status.PreRun
status.Run
status.PostRun
app.CascadingPostRun
status.CascadingPostRun

func (*Program) WithOutputChecks added in v1.2.0

func (p *Program) WithOutputChecks() *Program

WithOutputChecks makes every Context.WriteOutput and Context.WriteOutputItem call check its value against the command's declared output schema before writing it. A value that does not match is an internal error naming each field at fault, and nothing is written. It is off by default; enable it in tests or debug builds:

p := cmd.NewProgram(cmd.Handlers()).WithOutputChecks()

Only output written through WriteOutput and WriteOutputItem is checked. To check captured stdout, use DecodeOutput.

func (*Program) WithPanicRecover

func (p *Program) WithPanicRecover(enabled bool) *Program

WithPanicRecover controls where a hook panic goes. The default, true, recovers it and routes it to the reporter as a *PanicError. False re-raises it to the caller, for an embedding host's own recover, a crash reporter, or debugging.

Combined with Program.WithTeardownOnPanic:

  • recover=true: the panic reaches the reporter; teardown runs per WithTeardownOnPanic.
  • recover=false, teardown=true: teardown runs, then the panic is re-raised (its stack starts at the re-raise, not the original site).
  • recover=false, teardown=false: the hook runs unguarded, so the panic propagates immediately with its original stack and no teardown.

Only panics on the hook goroutine are recovered; a panic in a goroutine a handler started crashes the process.

func (*Program) WithParser

func (p *Program) WithParser(parser *Parser) *Program

WithParser replaces the Parser that Context.Parser returns. Context.Inputs and the InputReader always use the default parser, so this changes only what a handler gets from Context.Parser. A nil parser is ignored.

func (*Program) WithReporter added in v1.2.0

func (p *Program) WithReporter(fn Reporter) *Program

WithReporter sets the program's outcome reporter. See Reporter.

The default prints infos, warnings, errors, panics, then successes (infos and successes to stdout, the rest to stderr), and applies an exit floor: a recorded error or panic exits 1 unless a handler already set a non-zero code. A custom reporter owns the exit code entirely.

A nil fn restores the default reporter.

func (*Program) WithResolver

func (p *Program) WithResolver(fn Resolver) *Program

WithResolver overrides the resolve phase: argv to invocation target, plus the argv the parsers later see. A resolver that rewrites tokens should rewrite argv and delegate to DefaultResolver, so routing and parsing agree. A resolver error is reported as a fault (CategoryInternal unless the error carries a category) and fails the run.

Completion walks the Definition, so an alias known only to the resolver is dispatchable but not completed. A nil resolver is ignored.

Example

A resolver that adds a routing alias: "st" rewrites to "status" and delegates to DefaultResolver, so routing and parsing agree on the rewritten argv. A resolver alias is not completed; declare aliases in the spec for that.

NewProgram(exampleDef(), exHandlers{}).
	WithArgs([]string{"st"}).
	WithExit(func(int) {}).
	WithResolver(func(def Definition, argv []string) (Resolution, error) {
		if len(argv) > 0 && argv[0] == "st" {
			argv = append([]string{"status"}, argv[1:]...)
		}
		return DefaultResolver(def, argv)
	}).
	Execute()
Output:
app.CascadingPreRun
status.CascadingPreRun
status.PreRun
status.Run
status.PostRun
status.CascadingPostRun
app.CascadingPostRun

func (*Program) WithSignals

func (p *Program) WithSignals(sigs ...os.Signal) *Program

WithSignals enables rotini's signal trap for the given signals, whether or not a context was supplied. With a supplied context the trap cancels a derived child, never the caller's context. The first signal halts the run (teardown runs, exit 128+signum); a second exits immediately with 130 through the exit action. An empty list is ignored; use Program.WithoutSignalHandling to disable the trap.

func (*Program) WithStderr

func (p *Program) WithStderr(w io.Writer) *Program

WithStderr overrides the program's standard error (default os.Stderr), where the default reporter writes warnings, errors and panics. A nil writer is ignored.

func (*Program) WithStdin

func (p *Program) WithStdin(r io.Reader) *Program

WithStdin overrides the program's standard input (default os.Stdin): the reader exposed as Context.Stdin and decoded for stdin inputs. A nil reader is ignored.

func (*Program) WithStdout

func (p *Program) WithStdout(w io.Writer) *Program

WithStdout overrides the program's standard output (default os.Stdout), where the runtime writes completion candidates and the default reporter writes infos and successes. A nil writer is ignored.

func (*Program) WithTeardownOnPanic

func (p *Program) WithTeardownOnPanic(enabled bool) *Program

WithTeardownOnPanic controls whether teardown runs when a hook panics. The default, true, halts forward progress and still runs the teardown of every begun setup hook, as deferred calls run during a panic. False skips the remaining teardown, as Context.Exit does.

Where the panic goes is controlled separately by Program.WithPanicRecover.

func (*Program) WithVersion

func (p *Program) WithVersion(version string) *Program

WithVersion sets the program's version string, read with Context.Version and printed by the generated version command and --version flag.

var version = "0.0.0" // go build -ldflags "-X main.version=1.2.3"
cmd.Program.WithVersion(version).Execute()

func (*Program) WithoutSignalHandling

func (p *Program) WithoutSignalHandling() *Program

WithoutSignalHandling disables rotini's signal trap; of it and Program.WithSignals, the later call wins. rotini still owns the run context but calls no signal.Notify, so the program's own handling is the only one (signal.Notify registrations are additive).

rotini exposes no cancel in this mode, so the program's signal handler cannot halt the run gracefully; for that, use Program.WithContext with signal.NotifyContext.

type Reporter added in v1.2.0

type Reporter func(ctx context.Context, rtx *Context, out Outcome)

Reporter is the program's outcome reporter. The runtime calls it once per run, after the lifecycle and its teardown finish, when the Outcome is not empty.

The reporter decides what to print and where, and sets the final exit code: Context.Exit inside it overrides the code the lifecycle set, and Context.HaltWithCode is a no-op.

The error Program.Run returns is built before the reporter is called, so modifying the Outcome does not change it. Records made inside the reporter are dropped.

A panic inside a reporter is not recovered. A reporter that can fail handles its own failure, for example by writing to rtx.Stderr and setting a code with Context.Exit.

func StructuredReporter added in v1.2.0

func StructuredReporter(structured func(rtx *Context) bool) Reporter

StructuredReporter returns a Reporter for programs whose output scripts read. When structured reports true for the run, it writes each thing the run recorded to stderr as one JSON object per line, and leaves stdout alone, so a partial result there is never interleaved with an error:

{"error":{"message":"missing required input: <title>","category":"usage","kind":"missing-required","command":"taskr add","exit_code":1}}
{"warning":{"message":"the cache is stale","command":"taskr list"}}

Infos and successes are written the same way, as {"info":{…}} and {"success":{…}}, rather than to stdout. A field is present only when rotini knows it: kind, flag and token come from a *ParseError, kind from a *PluginError too. Each line's shape is described by schema-error.json in the rotini repository, and the contract document includes it.

When structured reports false, or is nil, it reports as the default reporter does. Either way the exit code follows the default reporter's rule.

structured is the program's own rule, typically whether its format flag asks for json. The run may have failed in parsing, so the rule should read Context.Argv, not validated inputs:

cmd.Program.WithReporter(rotini.StructuredReporter(func(rtx *rotini.Context) bool {
    return slices.Contains(rtx.Argv, "--json")
})).Execute()

type Resolution

type Resolution struct {
	// Chain is the resolved command path the run phase dispatches (when
	// Plugin is nil). It must be non-empty — the root command is always there.
	Chain []Command
	// Plugin, when non-nil, short-circuits local dispatch: the runtime execs this binary
	// instead, stdio passed through and context honored.
	Plugin *PluginDispatch
	// Argv is the vector the run phase exposes as [Context.Argv]. A resolver that rewrites
	// tokens returns the rewritten vector here so parsing agrees with its routing; nil keeps
	// the original argv.
	Argv []string
}

Resolution is the outcome of the resolve phase: the invoked command path (root → leaf), or a plugin dispatch that replaces local execution.

func DefaultResolver

func DefaultResolver(def Definition, argv []string) (Resolution, error)

DefaultResolver is rotini's resolve phase, exported for a custom Resolver to wrap. It descends sub-commands by name or alias, skips flags and their values, stops at the first positional, and diverts to a plugin dispatch for declared and discovered plugins. It does not validate input and never returns an error.

type Resolver

type Resolver func(def Definition, argv []string) (Resolution, error)

Resolver is the resolve phase: it matches argv against the Definition to decide what this invocation targets. An error is reported as a fault and fails the run. See DefaultResolver.

The Definition is passed by value, but its slices are the program's own and shared by every run. A resolver must treat it as read-only; writing through it changes every later run of the Program.

type Suggestor

type Suggestor struct {
	// contains filtered or unexported fields
}

Suggestor ranks a possibly-mistyped token against a list of candidates by optimal string alignment distance. It prints nothing and holds no state beyond its configuration. rotini never suggests on its own; a program opts in by calling a Suggestor, typically from its reporter with Suggestor.For:

var suggestor = rotini.NewSuggestor()

func reporter(_ context.Context, rtx *rotini.Context, out rotini.Outcome) {
	for _, err := range out.Errors {
		fmt.Fprintf(rtx.Stderr, "Error: %s\n", err)
		if hits := suggestor.For(err); len(hits) > 0 {
			fmt.Fprintf(rtx.Stderr, "Did you mean %q?\n", hits[0])
		}
	}
}

Matching is always case-insensitive. A configured Suggestor is safe for concurrent use; finish configuring it before sharing it across goroutines.

func NewSuggestor

func NewSuggestor() *Suggestor

NewSuggestor returns a Suggestor with a minimum score of 0.75 and at most 3 results. The With methods return the receiver, so they chain. The zero value is not usable.

func (*Suggestor) Closest

func (s *Suggestor) Closest(input string, candidates []string) (suggestion string, ok bool)

Closest returns the single best suggestion for input, or ok=false when input exactly matches a candidate or nothing clears the minimum score.

func (*Suggestor) For

func (s *Suggestor) For(err error) []string

For returns the suggestions for the token a *ParseError rejected, ranked against its Candidates, nearest first. It returns nil for an error that is not a *ParseError, one carrying no Token or no Candidates, and one whose token is near nothing.

func (*Suggestor) Suggest

func (s *Suggestor) Suggest(input string, candidates []string) []string

Suggest ranks candidates by nearness to input, nearest first, keeping those at or above the minimum score and at most the configured number of results.

An input that matches a candidate byte-for-byte yields nil; a case-only difference ("--VERBOSE" for "--verbose") is treated as a typo.

Ties prefer the candidate sharing the longer common prefix with input, then break lexicographically, so the result is deterministic. Empty candidates are skipped and duplicates collapse to the first occurrence.

func (*Suggestor) WithMaxResults

func (s *Suggestor) WithMaxResults(n int) *Suggestor

WithMaxResults caps how many suggestions Suggestor.Suggest and Suggestor.For return (default 3). Zero or less means no cap.

func (*Suggestor) WithMinScore

func (s *Suggestor) WithMinScore(score float64) *Suggestor

WithMinScore sets the similarity a candidate must reach to be offered, in [0,1]. A value outside that range is ignored. Below about 0.7 the ranker starts offering unrelated words; above 0.75 it no longer matches one-edit typos of four-character commands.

type WiringError

type WiringError struct {
	Command string // the command whose handler wiring is broken
	Handler string // the handler method name the Definition referenced
	Msg     string // the human-readable failure
}

WiringError reports that the generated Definition and the handler set are out of sync — a resolved command names a handler method that does not exist, or whose return value does not implement Handler. It is always CategoryInternal.

Command and Handler are empty when NewProgram was given a nil handlers value.

func (*WiringError) Error

func (e *WiringError) Error() string

Error renders the mismatch as a single line.

func (*WiringError) Unwrap

func (e *WiringError) Unwrap() error

Unwrap reports ErrInternal: a wiring mismatch is the author's bug, never the user's.

Directories

Path Synopsis
cmd
rotini command
internal
cmd/rotini
Code generated by rotini; DO NOT EDIT.
Code generated by rotini; DO NOT EDIT.
codegen
Package codegen turns a CLI definition (a .rotini.spec and .rotini.conf) into a Go program.
Package codegen turns a CLI definition (a .rotini.spec and .rotini.conf) into a Go program.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL