rotini

package module
v1.0.0 Latest Latest
Warning

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

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

README

rotini

Rotini is a spec-driven CLI package for Go, with a codegen tool and a runtime library. You declare your commands, flags and arguments in a spec file (YAML, JSON, JSONC or TOML). Rotini validates it and generates the typed Go, the command tree and a handler stub per command. You write each command's handler, and the runtime parses and validates input before calling it. The loop is: edit the spec, go generate, provide/update handler implementations, build.

Quick Start

Requires Go 1.27 or later.

1. Initialize
mkdir helloworld
cd helloworld
go mod init github.com/me/helloworld

go get -tool github.com/go-rotini/rotini/cmd/rotini@latest
go get github.com/go-rotini/rotini@latest
go tool rotini init helloworld

This creates:

cmd/helloworld/
  .rotini.spec.yaml        the spec (what the CLI accepts)
  .rotini.conf.yaml        the conf  (where generated code goes)
  .rotini-schema.*.json    schemas for editor completion
  main.go                  the entrypoint
internal/cmd/helloworld/
  zz_rotini.go             generated on every `go generate`; do not edit
  helloworld.go            one handler file per command; yours to edit
  helloworld_help.go
  helloworld_version.go
2. Review the generated spec file and modify
$schema: ./.rotini-schema.spec.json
version: 0.0.0
command:
  name: helloworld
  summary: TODO — what helloworld does, in one line
  description: TODO — the paragraph at the top of `helloworld --help`
  footer: Use "helloworld help <command>" for more information about a command.
  flags:
    - name: help
      summary: print help
      identifiers: [-h, --help]
      schema: { type: bool }
    - name: version
      summary: print version
      identifiers: [-v, --version]
      schema: { type: bool }
  commands:
    - name: help
      summary: print help
      description: Print help for a command.
      arguments:
        - name: command
          summary: the command path to print help for
          schema: { type: '[]string' }
      flags:
        - name: help
          summary: print help
          identifiers: [-h, --help]
          schema: { type: bool }
    - name: version
      summary: print version
      description: Print the helloworld version.
      flags:
        - name: help
          summary: print help
          identifiers: [-h, --help]
          schema: { type: bool }

Fill in the TODOs, then add commands under commands: — for example:

    - 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 }
        - name: help
          summary: print help
          identifiers: [-h, --help]
          schema: { type: bool }
3. Review the generated conf file and modify
$schema: ./.rotini-schema.conf.json
version: 0.0.0
generate:
  schemas:
    conf:
      file: cmd/helloworld/.rotini-schema.conf.json
    spec:
      file: cmd/helloworld/.rotini-schema.spec.json
  packages:
    - type: main
      file: cmd/helloworld/main.go
    - type: cmd
      file: internal/cmd/helloworld/zz_rotini.go
  features:
    - type: help
      enabled: true
    - type: completion
      enabled: false
    - type: man
      enabled: false
    - type: markdown
      enabled: false
validate:
  fail: collect

Enable completion, man or markdown to generate those from the spec too.

4. Review the generated handler file and modify

go generate ./... creates a handler file for each new command — here internal/cmd/helloworld/helloworld_hello.go. Replace its TODO with the command's work, and add "strings" to its imports:

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

	inputs, err := rotini.Collect[HelloworldHelloInputs](rtx)
	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. Review the generated main file and modify
//go:generate go tool rotini generate ./.rotini.spec.yaml --config ./.rotini.conf.yaml
package main

import (
	cmd "github.com/me/helloworld/internal/cmd/helloworld"
)

// go build -ldflags "-X main.version=1.2.3" ./cmd/...
var version = "0.0.0"

func main() {
	cmd.Program.
		WithVersion(version).
		Execute()
}
6. Generate, build, and run
go generate ./...
go build ./cmd/... # or: go install ./cmd/...

./helloworld hello --shout rotini   # HELLO, ROTINI
./helloworld --help

Change the spec, go generate ./..., fill in any new handler, build — that's the whole loop.

Documentation

For more information, see the rotini documentation.

Documentation

Overview

Package rotini is the runtime for rotini-built command-line programs: declare the CLI in a spec file, generate the program with the rotini tool, and run it on a runtime that does nothing the spec did not declare — everything beyond dispatch is an explicit, opt-in service.

One module serves two faces at one version:

  • As a tool — `go get -tool github.com/go-rotini/rotini/cmd/rotini@latest` — it 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.
  • As a library — `go get github.com/go-rotini/rotini` — it is this package: the runtime that generated code imports and handlers are written against.

The companion CLI under cmd/rotini is built with rotini itself and is the worked example; 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: the command tree, 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 doc fields, shell completion and plugin dispatch, all as data.

`rotini validate` is the gate: the JSON Schema rejects what it can express and lint rules reject the rest, with problems named to file:line:col. Nothing schema-accepted is silently ignored — a key either has a consumer or validation rejects it.

Generate

`rotini generate` compiles the spec into a framework file — the Definition literal, typed per-command input structs, embedded help/man/markdown pages and completion scripts — plus one handler stub per command, created once and then yours. `rotini init` scaffolds a working CLI — a spec declaring -h/--help, -v/--version and the conventional help and version commands, a conf with the help feature on and the other three off, an entrypoint, and a stub per command already wired to the pages and services codegen produced. Every line of it is yours to delete; the remaining features are conf toggles you turn on from there.

Composition

rotini is commands all the way down: a command is composable at any node, so a CLI is assembled from specs the way its tree is assembled from commands. Five modes span where a command's spec and its handler code come from:

  1. Standalone — an own spec node with its own generated stub. The default.
  2. Inline with handler passthrough — an own spec node whose structure and typed inputs are generated locally, but whose handler code comes 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 still gets one.
  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 auto-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. Remote command — a sibling binary <program>-<name>, dispatched at run time rather than composed at codegen; a dispatch failure is a *RemoteError. 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: its handlers cannot see a parent's cascading flags through them. Hand those across with a Key: the child's package declares it, and the parent — which imports the child, never the other way — collects its own inputs in CascadingPreRun and binds them. That is the one place the parent's inputs type describes the running command, and collecting there judges only the parent's own inputs, so a descendant's --help and required inputs are unaffected:

// package child
var KubeconfigKey = rotini.NewKey[string]("child.kubeconfig")

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

`rotini validate` follows refs, validates each locally composed spec as its own document and collision-checks the assembled tree, so a duplicate name, a cycle, a missing ref or a mistake inside a child is caught before codegen. Generation is hermetic: rotini has no fetcher, 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: resolve the invoked command from argv, run its Handlers hooks, and exit. Each invocation carries a Context — the argv, the resolved chain, the program's streams, and the service registry. Stopping is deliberate (Context.HaltWith, Context.Halt, Context.HaltWithCode, Context.Exit), and a recorded error, recovered panic or detected fault is reported once, after teardown, through the outcome funnel.

The runtime's only built-in behaviors, documented as the exceptions they 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 (capture it with Program.WithExit).

Input values

What a user can type is 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: the int, uint and float families; bool as true/false, yes/no, on/off, y/n, t/f or 1/0 in any case; durations with Go's units plus d and w (7d, 2w3d); and the value types url, email, timezone, mac, ip, cidr, hostport, bytesize (ByteSize: 512Mi, 10MB), hexbytes (HexBytes) and base64bytes (Base64Bytes). Any other type parses through its own encoding.TextUnmarshaler.
  • 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 spelling is validated against the named schema, the one a stdin payload of that shape meets. Where the schema says nothing about a value — inside a free-form map, or in a `dotted_keys:` map — key=value text is read as its JSON spelling would be: true, false, null and JSON numbers are typed, anything else stays text. So `-p spec.replicas=5` and `-p '{"spec":{"replicas":5}}'` store the same number.

Slices at the boundary

One rule, because the two directions differ and the difference has bitten:

  • A slice rotini RETURNS is a copy. Context.Chain and every Outcome channel hand back their own, so sorting, reslicing or editing one cannot reach the run. Chain used to be the live slice with a doc asking callers to treat it as read-only, and a single assignment through it silently rewrote Context.CommandPath for the rest of the invocation.

  • A slice you PASS IN is kept, not copied. Program.WithArgs, Program.WithSignals and the slices inside a BindMeta are held by reference, so mutating yours afterwards changes the program. Copying them defensively would cost every caller for a mistake almost nobody makes; saying so costs nothing.

Context.Argv is the deliberate exception in the first group: it is documented as the live argv precisely so a handler can run its own parser over it.

Outcomes

A run reports through one funnel (Program.WithFunnel), handed all five recorded channels at once as an Outcome, fired once after the lifecycle settles. A handler does not print — it records, and the runtime reports:

  • Context.RecordInfo — neutral informational output.
  • Context.RecordSuccess — what went right.
  • Context.RecordWarning — non-fatal: a deprecation, a fallback. Never changes the code.
  • Context.RecordError — the end-user's own failures: a bad input, a domain error.
  • Recovered panics and rotini-detected faults. There is no record call: the lifecycle captures them, and the funnel receives them as its panics slice.

Recording is non-halting: a handler records any number of times across any hook, then stops independently, or simply returns. The funnel fires only when some channel is non-empty, so a run that records nothing is a silent success.

There are four ways to stop, and which one to reach for is decided by whether something failed and whether the exit code is the point:

  • Context.HaltWith records an error and stops forward progress as one act, claiming no code. This is the commonest stop — a hook that has failed — and the one to prefer when a program centralizes its exit policy in a funnel.
  • Context.Halt stops forward progress with nothing to record and claims NO code, leaving the verdict to what the run recorded and to the funnel.
  • Context.HaltWithCode stops AND claims a code, for when the number is the point: a filter reporting "no match" as 1, a wrapper passing a child's status through.
  • Context.Exit stops immediately and skips pending teardown, for when remaining cleanup must not run.

Halting matters as much as recording. A hook that records a failure and returns without stopping lets the next hook collect the same inputs, hit the same validation and record the same error again.

The default funnel prints info → warning → error → panic → success, infos and successes to stdout and the rest to stderr, then applies the exit floor: a recorded error or fault exits 1 unless a handler already set a deliberate code, which it never downgrades. The funnel is the final authority, so a custom one owns the exit entirely. rotini holds no named exit-code constants.

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

  • *ParseError — the argv channel. ParseError.Kind branches it without matching the message; Token and Candidates are what a Suggestor turns into "did you mean".
  • *BindError — the env, config, stdin and flag-fallback channels, carrying the channel, the input and a clean message, with the recon cause reachable via errors.As.
  • *RemoteError — a plugin dispatch, recorded as an error. A discovered plugin that is missing is a usage error (the user's typo); a declared one that is missing, or a plugin that cannot start, is internal (an install problem); a timeout is neither.
  • *ServiceError and *PanicError arrive as panics, and so does a *WiringError from the program's own wiring. The one *WiringError Collect returns — config inputs on a program built without a BindMeta — comes back as an error instead.

rotini ships no opinions on top: no "did you mean", no help dump on error. A program that wants either writes its own funnel.

Sharing dependencies between handlers

The store, client or logger every handler needs rides the registry, reached by a typed Key so the name and the type cannot drift apart:

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

// main.go — the value's type is checked here, where it is supplied
tasks.StoreKey.Provide(cmd.Program, tasks.NewStore()).Execute()

// or, for several at once, without leaving the chain ([Provide] and [Program.With])
cmd.Program.
	With(
		rotini.Provide(tasks.StoreKey, tasks.NewStore()),
		rotini.Provide(tasks.ClientKey, tasks.NewClient()),
	).
	WithVersion(version).
	Execute()

// any handler — no string, no type assertion, no miss check
store := tasks.StoreKey.MustGet(rtx)

For a one-off lookup, rtx.Get[T](key) and rtx.MustGet[T](key) supply the type at the call site. A handler that needs to know which command it is asks the context: Context.Command is the resolved leaf, Context.CommandPath the canonical invocation ("tasks add"), and Context.Chain the full chain with the tokens the user actually typed.

Opt-in services

Everything else is a function or type a handler calls when it wants it. None of it needs binding: the registry — Program.Bind to provide, Context.Get or Context.MustGet to consume — holds only the program's own services.

rotini's OWN seams are not in that registry. Program.WithBindMeta, Program.WithBinder, Program.WithParser, Program.WithVersion and Program.WithHelp supply them; Context.Parser, Context.Version and Context.Help read them back. The registry is yours alone, so nothing rotini depends on can be shadowed by a name you chose or a type you got wrong:

Batteries

Beyond the runtime, rotini carries a short shelf of things a binary keeps needing that are awkward to write and easy to get wrong. Importing rotini wires none of them, starts no goroutine and touches no terminal.

The shelf is deliberately SHORT. rotini ships no styler, no table, no spinner, no prompt and no pager, because drawing to a terminal is a solved problem with better libraries behind it than a CLI framework should be writing on the side. What stays here is the part underneath those choices: platform questions the standard library will not answer, and process work that is subtly wrong in most hand-rolled versions.

  • Subprocess wraps os/exec with environment, working-directory and timeout control; a non-zero exit is a *SubprocessError quoting the child's stderr, and Subprocess.Lines streams tagged output as an iterator you can break out of.
  • TerminalSize reports the terminal's width and height, honoring COLUMNS and LINES and saying plainly when there is no answer rather than inventing one. IsTerminal and EnvNoColor answer the two questions that come before any styling decision; rotini auto-detects nothing.
  • ReadSecret reads one line with terminal echo off and puts the echo back on every path, including a panic — the failure nobody notices until their next shell command.
  • Strip removes ANSI escape sequences, which is what makes a styled string safe to put in a man page, a markdown page or a completion description.

Program shapes

A rotini binary is not always a one-shot command. These run the same program in a different shape, all resting on Program.Run being re-entrant — each dispatch gets a fresh Context, so nothing leaks between invocations while services bound once up front reach all of them. Run is also safe to call CONCURRENTLY once configuration is done; the handlers value and the program's streams stay shared, so a concurrent host synchronizes those. See Program.Run.

  • REPL runs a Program as an interactive loop, dispatching each typed line against the same Definition the binary uses. A failing command is reported and the loop continues. rotini owns the dispatch — REPL.Complete answers what your command tree would complete, which no line editor can — and leaves reading the line to whatever you plug into REPL.WithLineReader.
  • Service runs long-lived workers until the context ends or one fails, with ordered shutdown hooks that run in every case. Since the runtime already cancels the run context on SIGINT/SIGTERM, a Service built on that ctx gets graceful shutdown for free.

What rotini deliberately does not ship

Some of it lives elsewhere in the same ecosystem:

  • 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

Import them directly. A re-export would give each API two names, put its documentation in the wrong package, and pull another module's surface inside rotini's compatibility promise.

The rest is not rotini's to ship at all. Styling, tables, spinners, prompts, forms and paging are how a program DRAWS, and that is a design decision belonging to the program and to libraries built for it. A framework that shipped its own would either be worse than they are or grow into a second product; either way its users would end up with two vocabularies for the same screen. rotini's job is turning a spec into a parsed, bound, dispatched invocation, and handing your handler a Context that knows what the user asked for. What the handler prints, and how, is yours.

Example (Unopinionated)

Example_unopinionated drives the bare program end-to-end through the real Program surface — WithArgs feeds argv, WithExit captures the code without os.Exit, and the handler's Context.Stdout is the example's output. No opt-in input helper is imported; WithoutSignalHandling keeps the program minimal (rotini still owns the context, just 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 funnel 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 over wrapping these with fmt.Errorf directly: they tag the category without prepending the sentinel's text to your message.

View Source
var ErrInterrupted = errors.New("interrupted")

ErrInterrupted reports that the user interrupted at the prompt — a ^C with a half-typed line, rather than an end of session.

A REPL.WithLineReader returns it to say "discard this line and prompt again"; the session survives, which is what every interactive shell does. chzyer/readline's ErrInterrupt maps onto it directly.

It is NOT how a session ends. That is end of input (ErrNotInteractive or io.EOF), an exit word, or the session context finishing.

View Source
var ErrNotInteractive = UsageError(errors.New("no input available (not interactive)"))

ErrNotInteractive reports that input reached EOF without an answer. It is what makes an interactive step safe in a pipeline or CI job: the run fails fast and says why instead of blocking on a stdin nobody is typing into. It is an ErrUsage — the environment, not the program, is wrong.

A REPL line reader returns it to end the session cleanly, and a program driving its own prompts should adopt the same contract.

View Source
var ErrServiceNotFound = errors.New("rotini: service not found")

ErrServiceNotFound is the sentinel reported when a registry key is unbound, or bound to a value of the wrong type, which Context.MustGet cannot hand back either. MustGet panics a *ServiceError wrapping it, which the runtime recovers and routes to the funnel.

View Source
var ErrShutdownTimeout = InternalError(errors.New("shutdown timed out"))

ErrShutdownTimeout reports that a service did not tear itself down within the shutdown budget — either its workers did not stop, or its shutdown hooks did not finish. The budget covers both halves (see Service.WithShutdownTimeout) and so does this error, because the question a caller is asking is the same one in both cases: did teardown complete, or is this process exiting with work possibly unflushed? A supervisor acts on that, not on which half ran long.

The service returns rather than hanging, so a supervisor's own kill timer is never the thing that ends the process.

When workers are what overran, the error NAMES THEM — "shutdown timed out: worker \"indexer\" did not stop". An operator reading a log at 3am needs to know which worker to go and fix, and "shutdown timed out" on its own sends them to read the whole binary.

Functions

func AtFrame

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

AtFrame labels a hook with the chain index of the command it belongs to, so that Context.Frame can answer "which command am I?" inside it and Collect can anchor an inputs struct on that command. DefaultLifecycle wraps every hook it plans; a custom Lifecycle that wraps DefaultLifecycle inherits this and needs to do nothing.

A custom Lifecycle that builds steps from scratch should wrap its own hooks the same way. One that does not is not broken: an unlabeled hook reports the LEAF, which is what every non-cascading hook wants. The cost of skipping it falls only on a cascading hook that collects its own inputs.

The previous frame is restored on return, so nesting — a hook that drives another hook — does not leave the Context describing the wrong command.

A nil hook yields a nil step half, which the engine skips.

func Collect

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

Collect is the typical handler's entire input story: every declared channel — argv, environment, configuration files, the stdin payload, defaults — acquired, reconciled in the standard precedence (defaults < files < env < argv), and validated, in one call:

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

The stdin channel is not in that order because it never competes: it fills the leaf command's declared payload field, which no other channel writes. It is overlaid last, and where it sits makes no difference.

On failure the returned T holds whatever was filled before the failure, including the value that failed. **It is not a result — check the error and stop.** It is deliberately weaker than CollectP's: Collect stops at the first argv problem, before the environment and configuration channels are read at all, because an argv error is the one worth reporting; so a config-supplied default that CollectP would show is simply absent here. CollectP hands its merged value back on purpose, paired with the Report that explains it.

**A handler collects the type generated for its own command, in any hook.** That is the whole rule. An inputs struct's last field describes the collecting command and the fields before it describe its ancestors, so Collect anchors the struct on Context.Frame — the command whose hook is running. A leaf's Run, a cascading hook three frames up, a composed child mounted under someone else's umbrella: same call, correct in each. Anchoring on the running frame, rather than inferring it from the struct's shape, is what makes the answer independent of how deep this invocation went; a type that cannot sit there is an error, never a silent zero. A caller who wants to read the chain directly has Context.Chain.

It is Binder.Bind under the hood, so errors are the same data-shaped [*ParseError]s and [*BindError]s. Use CollectP when "where did this value come from" matters.

func DiscoveryDiagnostics

func DiscoveryDiagnostics(cmd ResolvedCommand) []error

DiscoveryDiagnostics returns the problems encountered while scanning cmd's author-configured discovery path — typically that it is unreadable, or not a directory — and nil when there is no discovery, none is configured, discovery is hidden, or the path scanned cleanly. A path that does not exist is not a problem: it is where plugins go once one is installed, and before that it is empty. The incidental locations, next to the binary and the entries of $PATH, are deliberately not reported: a missing $PATH entry is normal, not a misconfiguration.

It is the data feed for a doctor or completion handler that wants to tell the author their discovery path is wrong; rotini prints no warning itself, which would corrupt completion output. Each error carries the offending path and cause, so a caller can classify with errors.Is(err, fs.ErrPermission).

func EnvNoColor

func EnvNoColor() bool

EnvNoColor reports whether the environment asks for no color, honoring the NO_COLOR convention and its CLICOLOR_FORCE override (a non-empty, non-"0" CLICOLOR_FORCE wins, meaning "color anyway").

func ExitCode

func ExitCode(code int) error

ExitCode returns a context-cancellation cause that sets the process exit code used when that cancellation halts the run:

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

Canceling without an ExitCode cause still halts cleanly, with the code falling through to the normal resolution. 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 IsTerminal

func IsTerminal(stream any) bool

IsTerminal reports whether stream is a terminal rather than a pipe, a regular file or a buffer. It takes the streams a handler holds — rtx.Stdin, rtx.Stdout, rtx.Stderr — as they are, so a prompt guard needs no type assertion: a stream that is not a file (a test's bytes.Buffer, a nil) is not a terminal. It is the check behind "is anyone watching this?": paging, animating and prompting all become wrong when the answer is no.

It checks for a character device, so /dev/null — also a character device — reports true.

if !rotini.IsTerminal(rtx.Stdin) {
	return rotini.ErrNotInteractive
}

func OverlayInputs

func OverlayInputs[T any](layers ...Layer[T]) T

OverlayInputs 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 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. It is superseded by the built-in new(v), which go fix inlines it to.

func ReadSecret

func ReadSecret(r io.Reader) ([]byte, error)

ReadSecret reads one line from r without echoing it, for a password, token or passphrase.

This is the one piece of interactive input rotini keeps, because it is the one the standard library cannot do and a program cannot safely fake: it needs a termios ioctl to clear the ECHO bit, and it must put the bit back on every path. The failure mode is not a wrong value — it is A SHELL LEFT WITH ECHO OFF, which survives the process and confuses the user's next command.

Echo is restored before returning, including on error. When r is not a terminal — a pipe, a test's buffer, a CI runner — there is no echo to disable and the line is read normally, which keeps a secret-reading command testable and scriptable with the same code: pass rtx.Stdin.

Echo control needs a termios ioctl, which rotini wires on Linux, macOS and the BSDs. On any other platform (Windows among them) the line is read normally and the terminal echoes it.

The trailing newline is consumed and not returned. Nothing is written to the screen, so a caller that printed a prompt should print its own newline afterwards: the user's Enter was not echoed either.

fmt.Fprint(rtx.Stdout, "token: ")
secret, err := rotini.ReadSecret(rtx.Stdin)
fmt.Fprintln(rtx.Stdout)

func RemoteBinaryPath

func RemoteBinaryPath(cmd ResolvedCommand, name string) (string, bool)

RemoteBinaryPath reports the executable the named remote sub-command of cmd would run, and whether it resolves at all. It searches exactly where dispatch searches, in the same order, which is the entire reason it exists.

A plugin host's first extra command is always a doctor — "what is installed, what is missing" — and without this it has to reimplement rotini's search order from the outside. That order is three steps, the same for both kinds of remote: next to the host binary, then the command's plugin_path, then PATH. Reaching for exec.LookPath, which is the obvious thing, reports every plugin installed beside the host binary as missing — the git/kubectl convention and the first location rotini tries.

name may be a declared remote's name or one of its aliases, or a discovered plugin's token. It returns "", false when cmd declares no such remote and has no discovery to fall back on.

Like DiscoveredPlugins, this touches the filesystem on every call and answers about right now: a plugin installed after it returns false will still dispatch.

func Strip

func Strip(text string) string

Strip removes every ANSI escape sequence from text, SGR styling and OSC alike, leaving the characters a terminal would display. It is what a program applies when a consumer asked for no styling, and what codegen applies to man and markdown pages.

func TerminalSize

func TerminalSize(stream any) (cols, rows int, ok bool)

TerminalSize reports the size of the terminal behind stream, in character cells. Like IsTerminal, it takes a handler's streams as they are — rtx.Stdout, not a type assertion.

It answers ok=false when stream is not a terminal, when the platform has no way to ask, or when the answer would be nonsense — so a caller can always write:

cols, _, ok := rotini.TerminalSize(rtx.Stdout)
if !ok {
    cols = 80
}
fmt.Fprintln(rtx.Stdout, wrap(text, cols))

**COLUMNS and LINES win when set.** Those are the conventional override — `COLUMNS=40 mycli` is how a user asks for a narrower render, and how a test pins one — so they are consulted before the kernel. A value that is not a positive integer is ignored rather than honored as zero.

Measure the stream you are about to write to. A program piping stdout to a file while a human watches stderr has two different answers, and only the caller knows which one matters.

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 see through to 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 the optional "value\tdescription" shape is available.

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 BindError

type BindError 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
}

BindError 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 bind channels' answer to the argv channel's *ParseError: a typed, categorized, non-leaky error a funnel can branch on.

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

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.BindError
if errors.As(err, &be) {
    fmt.Fprintf(os.Stderr, "bad %s input %q: %s\n", be.Channel, be.Input, be.Error())
}

func (*BindError) Error

func (e *BindError) Error() string

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

func (*BindError) Unwrap

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

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

type BindMeta

type BindMeta struct {
	ConfigFiles []ConfigFile // per-command config_files sources, each tagged with its Scope; the binder 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 loudly.
	Sources []recon.Source
	// StdinSchemas maps a command's stdin payload type name ("<Prefix>Stdin") to a
	// self-contained JSON Schema the binder validates the decoded payload against.
	StdinSchemas map[string]string
}

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

type Binder

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

Binder is the default multi-source input binder: it fills a command's typed inputs from argv (via a default Parser) and from the non-argv channels — environment variables, configuration files, and a leaf command's typed stdin payload — reconciled and decoded by recon. It is the engine behind Collect; the à-la-carte per-channel surface is in overlay.go.

binder := rotini.NewBinder(meta)
var in WidgetCreateInputs
if err := binder.Bind(rtx, &in); err != nil { /* handler owns it */ }

Env values fill the generated <Prefix>Env struct, 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 NewBinder

func NewBinder(meta BindMeta) *Binder

NewBinder returns the default binder, configured from the generated BindMeta descriptor.

func (*Binder) Bind

func (b *Binder) Bind(rtx *Context, out any) error

Bind fills out — a non-nil pointer to the generated inputs struct — from every wired channel. Validation of the argv channel runs once over the fully-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 a *BindError from the others, both categorized and non-leaky, with the recon cause reachable via errors.As — or a *WiringError when a command declares config: inputs but the program was built without a BindMeta.

Example

Binder.Bind reconciles every declared channel in one call — here a flag satisfied from its environment fallback because argv didn't set it.

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 := NewBinder(BindMeta{}).Bind(rtx, &inputs); err != nil {
	fmt.Println("bind:", err)
	return
}
fmt.Println("port:", inputs.App.Flags.Port)
Output:
port: 9090

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, so the `i` is what makes a unit binary:

B                  1
K  KB   M  MB  …   1000, 1000², … (decimal, as kubectl reads them)
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. Note that some tools — docker's `-m 512m`, notably — read a bare `m` as binary. rotini follows the standard; write `512Mi` when a mebibyte is what you mean.

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 funnel can decide the exit code and message style from one call to CategoryOf. rotini tags its own errors — a missing service is CategoryInternal, a parse or bind failure CategoryUsage — and user code tags its domain errors with UsageError or InternalError.

rotini labels; the funnel decides what to do with the label. There are no named exit-code constants and no forced category→code mapping: the default funnel exits 1 for any recorded error or fault, and a program that wants distinct codes maps them in its own funnel.

The constants are declared in increasing severity — none < usage < internal — so a funnel summarizing several errors can keep the worst with a plain comparison. That ordering is part of the contract; the numbers are not.

const (
	// CategoryNone is an unclassified error — a plain error 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 can fix it by changing the command.
	CategoryUsage
	// CategoryInternal is a bug or misconfiguration in the program: a missing bound
	// service, a wiring mistake. The end-user cannot fix it; the author must.
	CategoryInternal
)

func CategoryOf

func CategoryOf(err error) Category

CategoryOf returns the Category an error carries, or CategoryNone when it matches neither sentinel — the single classification call a funnel makes:

cmd.Program.WithFunnel(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)
    }
})

Note Context.Exit rather than Context.HaltWithCode: inside a funnel the lifecycle has already settled, so HaltWithCode is a no-op and Exit is the only way to claim a code.

It answers for ONE error, and usage wins a tie

An error can carry both sentinels — errors.Join of a user's bad input and an internal bug is exactly what Program.Run returns for a run that recorded both. CategoryOf tests ErrUsage first, so such a value reports CategoryUsage.

That is the right answer for a single error and a poor summary of a whole run: "the user can fix this" is misleading when a bug is also in the pile. A funnel classifying a run should walk out.Errors and keep the MOST SEVERE category, as above — the constants are ordered none < usage < internal so that a comparison does it.

Example

The category taxonomy: tag errors at the source, map them to exit codes in one switch — typically inside Program.WithFunnel. Here usage errors take the common 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 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
	Remotes          []RemoteDef         // co-located remote binaries dispatched as sub-commands of this command
	Discovery        *RemoteDiscoveryDef // plugin auto-discovery on this command (nil = off)
	PluginPath       string              // extra directory searched for BOTH this command's declared remotes and its discovered plugins
	Passthrough      bool                // every token after this command is a raw positional (no flag parsing)
}

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

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" is not the same as empty. It SUPPRESSES the shell's default, which is how an
	// opaque identifier — a container id, an API resource name — stops a shell offering
	// the contents of the current directory as if they were plausible values.
	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 covers the case between a static Enum and a FlagValueCompleter: "this is a file", which is the commonest value shape there is and the one that previously required writing Go. The hint 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 still wins when it answers — the hint is the fallback, not a ceiling.

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 binder 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 binder 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 binder 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, mirroring [Program.WithStdin] and
	// friends. A handler reads and writes through these rather than os.Std* directly, so the
	// same handler code can be driven by a test that configures the Program's streams. They
	// are set before dispatch, never mutated by rotini thereafter, and never nil.
	//
	// They are for READING. Assigning one is not supported and does not do what it looks like:
	// the default funnel reports through the PROGRAM's streams, so a hook that swaps
	// rtx.Stdout redirects its own writes and nothing else — the run's errors still go where
	// they were always going. To redirect a whole invocation, configure the Program
	// ([Program.WithStdout]) or give the run its own ([Program.RunContext] on a Program built
	// for it).
	Stdin  io.Reader
	Stdout io.Writer
	Stderr io.Writer

	// Argv is the raw argument vector for this invocation, with everything after the resolved
	// command path still present, so a handler can run its own parser instead of
	// [Parser.Parse]. It is the live slice, not a copy: a handler that mutates it changes what
	// every later read sees, including the Parser and Binder.
	//
	// Argv, not Args: these are the invocation's raw tokens, command names and flags included.
	// A command's DECLARED positionals are the generated inputs' Arguments field, already
	// parsed, typed and validated — a different thing that a handler reaches for far more often.
	Argv []string
	// contains filtered or unexported fields
}

Context is rotini's per-invocation context: the service registry, the program's streams, and what the runtime resolved before dispatch — the raw argument vector (Context.Argv) and the resolved command chain (Context.Chain). One is built per Program.Run and passed to every hook, so all hooks share the same bindings and exit state and no records leak between invocations.

The surface groups into six jobs, and nothing outside them is worth hunting for:

Inputs are NOT on this list. A handler reads them with Collect, which takes the Context rather than hanging off it, because parsing is opt-in: a CLI that wants raw argv never calls it and reads Context.Argv.

The registry is the dependency-injection seam: bind a service with Context.Bind and retrieve it with Context.Get, Context.MustGet or the raw Context.Value. Bindings last the lifetime of the Context.

A Context is safe for concurrent registry access — a handler may read it, record outcomes and reach its seams from goroutines it spawned. Always pass it as a pointer; it must not be copied.

Safe is not the same as unchanging. Context.Frame tracks the lifecycle's progress, so a goroutine that outlives the hook that spawned it reads the step running when it looks rather than the step that started it — see Frame. Everything else a handler reads here is fixed for the run.

A nil *Context is a caller bug, not a state to handle: the runtime always hands a real one to every hook, and NewContextFor never returns nil, so the methods a handler reads and records through dereference rather than check. That is the same rule Program follows, and for the same reason: a nil that reports "nothing recorded" or "no chain" hides the mistake and surfaces it somewhere later, at a call that was not wrong. The standalone setters (Context.WithVersion and its siblings) are the exception — on a nil Context they do nothing and return nil.

rotini's own entry points that ACCEPT a Context from a caller — Deprecations, Collect and the per-channel functions — still check it: Collect and the per-channel functions report a nil one as an error, and Deprecations reports none. The guard belongs at the boundary, not on every method behind it.

func NewContextFor

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

NewContextFor builds a Context with argv resolved against an explicit def — the same context the runtime hands a handler at dispatch. Use it to exercise the Parser or the Collect family, or a single hook, against a Definition you construct:

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 drive a whole generated program end to end — the usual handler test — construct it with the generated NewProgram and run it under a recording exit and captured streams instead; the generated command tree is unexported.

A remote token resolves to as much of the chain as precedes it. NewContextFor does not exec the sibling binary the runtime would.

func (*Context) Bind

func (rtx *Context) Bind(key string, value any) *Context

Bind associates value with key, overwriting any prior binding, and returns the receiver so calls chain. It is safe for concurrent use.

func (*Context) BindIfAbsent

func (rtx *Context) BindIfAbsent(key string, value any) *Context

BindIfAbsent binds value under key only if key is not already bound, atomically. It is the registered-default form of Context.Bind: a handler registers the real implementation of a dependency, but a test that bound a double under the same key earlier keeps it. Either way the dependency is resolvable from the registry rather than hidden inline.

rtx.BindIfAbsent("clock", time.Now)
now := rtx.MustGet[func() time.Time]("clock")

MustGet matches the bound value's exact type, so a value bound as a plain func is only found under a func type, or an alias of one — not under a named func type.

func (*Context) Chain

func (rtx *Context) Chain() []ResolvedCommand

Chain returns the resolved command path for this invocation, root → leaf. The Parser and Binder read it to bind inputs against the command whose handler ran.

The slice is a COPY, so reordering, reslicing or replacing a frame is a caller's own business and cannot reach the run — Context.CommandPath, Context.Command, the binder's frame alignment and configuration-file scoping all read the run's own chain. The outcome channels are copied for the same reason.

The copy is one level deep, which is the boundary that exists to defend. A frame's Flags, Arguments and Commands are the Definition's own slices, shared program-wide and read-only across every run by the same convention that lets one Program serve a REPL; Chain neither widens nor narrows that.

func (*Context) Command

func (rtx *Context) Command() ResolvedCommand

Command returns the command this invocation resolved to — the leaf of the chain, whose Run is executing, or the root for a bare root invocation:

rtx.RecordError(fmt.Errorf("%s: %w", rtx.Command().Name, err))

Context.Chain has the ancestors and the argv token that matched each one.

func (*Context) CommandPath

func (rtx *Context) CommandPath() string

CommandPath returns the invoked command path, space-joined — "tasks add" for a sub-command, "tasks" for a bare root invocation.

The names are canonical, not the tokens the user typed, so an invocation through an alias reports the real command name and a path is stable to log and aggregate on. Each frame's Matched token in Context.Chain is what the user actually typed.

CommandPath, not Path: in this API "path" already means a filesystem location (RemoteBinaryPath, a command's PluginPath) and a route through an inputs struct (FieldPath). This one is neither.

func (*Context) Exit

func (rtx *Context) Exit(code int)

Exit records the program's exit code and stops the lifecycle immediately — every pending teardown hook is skipped. Use it where remaining cleanup must not run; prefer Context.HaltWithCode for an orderly stop. The first non-zero code wins, and Exit is a deliberate stop, not an error.

It skips teardown, not fault reporting: a panic recovered before Exit still reaches the funnel, so a handler cannot silently swallow one — though the funnel, as the final authority, may.

Inside the funnel, Exit overrides any code the lifecycle set; during the lifecycle it keeps first-non-zero-wins.

func (*Context) Failed

func (rtx *Context) Failed() bool

Failed reports whether this invocation has recorded an error or suffered a fault SO FAR.

It exists for teardown. A PostRun or CascadingPostRun that owns a resource has exactly one decision to make — commit or roll back, keep or discard, publish or delete — and it cannot make it without knowing whether the work it was bracketing succeeded:

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

The Outcome a funnel receives answers the same question, but a funnel runs AFTER every teardown has finished — the right place to report a failure and much too late to undo one.

It is deliberately one bit and not the errors themselves. A teardown that could read them would be tempted to print them, and the whole point of the funnel is that a run reports its outcome exactly once, in one place, after everything has settled. Faults count: a panic in the bracketed work is a failure, and a rollback is even more clearly right there.

Read from a forward hook it is also meaningful — an earlier hook in the chain may already have recorded an error — but the answer only grows over a run, so a false is never a promise about what comes next.

func (*Context) Frame

func (rtx *Context) Frame() ResolvedCommand

Frame returns the command whose hook is currently running.

This is not always Context.Command, and the difference is the whole point. Command is the command the user INVOKED — the leaf of the chain — and it is the same value in every hook of the run. Frame is the command this particular hook belongs to:

$ mig db status        — Command() is the leaf, "status", in every hook below

hook                                 Frame()
────                                 ───────
mig's CascadingPreRun                mig
db's CascadingPreRun                 db
the leaf's PreRun / Run / PostRun    status
db's CascadingPostRun                db
mig's CascadingPostRun               mig

Collect anchors an inputs struct on this frame, which is what makes it correct in every hook — including a composed child's cascading hook reading its own flags.

Outside a lifecycle step — a Context from NewContextFor, or one reaching a funnel after the run has settled — there is no hook, and Frame reports the leaf.

It describes the step running NOW, not the one that spawned you

The frame moves as the lifecycle advances, so Frame answers for whichever step is running when it is called — not for the hook that happens to be on the stack. A goroutine a hook spawns and does not wait for therefore reads whatever step the run has reached by the time it looks:

func (*h) CascadingPreRun(ctx context.Context, rtx *rotini.Context) {
    go func() {
        // The run has moved on. This may report the leaf, not this command.
        log.Println(rtx.Frame().Name)
    }()
}

It is not a data race — the frame is mutex-guarded and every read is consistent — but the ANSWER is timing-dependent, and Collect anchors on it, so a goroutine collecting inputs may anchor somewhere its spawning hook did not intend. Capture what you need before spawning:

frame := rtx.Frame()                       // or collect the inputs here
go func() { log.Println(frame.Name) }()

A goroutine the hook WAITS for, before returning, sees its spawner's frame.

func (*Context) Get

func (rtx *Context) Get[T any](key string) (T, bool)

Get returns the service bound under key as T, reporting ok=false when nothing is bound there or the bound value is not a T. It never panics; use Context.MustGet to route a miss through the funnel instead of handling it inline, or a typed Key, which supplies T for you.

store, ok := rtx.Get[Store]("store")

func (*Context) Halt

func (rtx *Context) Halt()

Halt stops the lifecycle's FORWARD progress without claiming an exit code, leaving the verdict to whatever else the run records and to the funnel. Teardown is unaffected: every PostRun and CascadingPostRun whose paired setup hook began still runs, in reverse.

Forward progress is the operative word, and it makes Halt load-bearing in two of the five hooks and a no-op in the other three:

Hook                What Halt does there
────                ────────────────────
CascadingPreRun     stops the run: no further frame's setup, no PreRun, no work
PreRun              stops the run: the leaf's Run never happens
Run                 NOTHING — this is the last forward step of the default plan
PostRun             NOTHING — the unwind runs to completion; only Exit cuts it short
CascadingPostRun    NOTHING — likewise

So Halt is how a SETUP hook refuses to let the command proceed. It is safe to call anywhere and its own failure mode is omission, not misuse — which is why Context.HaltWith exists: it records an error and halts as one act, is correct in all five hooks, and cannot be half-forgotten the way `RecordError` followed by a `Halt` that is never written can be.

Prefer Halt on its own when there is nothing to record — a deliberate, unremarkable stop:

if !inputs.Force && !confirmed {
    rtx.Halt()          // nothing failed; there is simply nothing more to do
    return
}

Context.HaltWithCode does two jobs at once — claim the code AND stop — so a program that centralizes its exit policy in a funnel would have to write a number it did not mean purely to stop, and explain in a comment that the number was a lie. Worse, the number then reads as redundant: deleting it looks like tidying and silently removes the halt, so the next hook collects the same inputs, hits the same validation and records the same error again. That is not hypothetical — it is how one bad flag came to be reported three times, with a fourth misleading error on top, while this example was being written.

Halt claims nothing, so it cannot be mistaken for policy and cannot be deleted as redundant. Reach for Context.HaltWith to fail, Context.HaltWithCode when the code IS the point, and Context.Exit when pending teardown must not run.

Like HaltWithCode it is a no-op inside the funnel, where the lifecycle has already run.

func (*Context) HaltWith

func (rtx *Context) HaltWith(err error)

HaltWith records err and stops the lifecycle's forward progress — Context.RecordError and Context.Halt as one act. It claims no exit code: the funnel decides what the failure costs.

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

This is the spelling to reach for when a hook has failed, and the reason it exists is that the two-part version can be half-written. Failing used to be "record, then stop", and the stop is the half that decides anything — in a setup hook, omitting it lets the command do the work it just established it must not do. The exit code and stderr are IDENTICAL either way, so nothing in the output says the work ran, and no test that asserts on output catches it. A single call cannot be half-forgotten.

HaltWith is correct in all five hooks. Where Halt is a no-op (see its table) HaltWith degrades to recording alone, which is what a failure in Run or a teardown hook wants anyway — so a handler never has to know which hook it is in to fail correctly.

A nil err records nothing and still halts, so a caller need not guard.

To record a problem and CONTINUE — collecting several before anything stops, or leaving the decision to a later hook that gates on Context.Failed — use RecordError on its own. That remains a supported choice; HaltWith exists so it is a deliberate one rather than what omission gives you.

Inside the funnel it does nothing at all, and does not report that it did nothing. The halt is a no-op there, as Context.Halt's is, and the recorded error is dropped: the Outcome was snapshotted before the funnel was called, so nothing re-reads the channels afterwards. A funnel that fails while reporting should write to rtx.Stderr and set a code with Context.Exit — it is the final authority by then, and recording has no one left to tell.

func (*Context) HaltWithCode

func (rtx *Context) HaltWithCode(code int)

HaltWithCode claims the program's exit code and stops the lifecycle's forward progress — for when the NUMBER is the point: a filter reporting "no match" as 1, a wrapper passing a child's status through. It records no error; it is a deliberate verdict, not a failure.

Teardown is unaffected: every PostRun and CascadingPostRun whose paired setup hook began still runs, in reverse. The first non-zero code wins, so a later HaltWithCode cannot overrule an earlier one.

It is one of the Halt family, and the family is the thing to learn:

Halt()              stop
HaltWith(err)       stop, and record err          — the way a hook fails
HaltWithCode(n)     stop, and claim exit code n    — the way a hook renders a verdict
Exit(n)             stop, claim n, and SKIP pending teardown

Everything named Halt* leaves teardown intact. Context.Exit is the one that does not, which is the whole distinction and the reason it is spelled like os.Exit, whose deferred functions do not run either.

It is a no-op inside the funnel, where the lifecycle has already run; Context.Exit is how the funnel sets the code.

func (*Context) Help

func (rtx *Context) Help() string

Help is the help page of the command being run, from Program.WithHelp: what a generated `--help` prints. It is "" when the program has no pages or none for this command.

The page is the RUNNING program's, not the one the handler was generated with. A command composed from another spec therefore shows its full path under the parent and the flags the parent passes down, the same page `help <command>` shows.

func (*Context) IsLeaf

func (rtx *Context) IsLeaf() bool

IsLeaf reports whether Context.Frame is the command the user invoked — whether this hook belongs to the leaf of the chain, or to one of its ancestors.

In PreRun, Run and PostRun it is always true: those hooks only ever run for the leaf. It is a real question in a cascading hook, which runs at every depth:

func (*songsHandlers) CascadingPreRun(ctx context.Context, rtx *rotini.Context) {
    if rtx.IsLeaf() {
        // `musak songs` — this command IS the invocation; print help rather than defer.
        return
    }
    // `musak songs list` — a sub-command is running; set up for it.
}

It exists because the obvious spelling does not compile: ResolvedCommand holds slices, so rtx.Frame() == rtx.Command() is not a legal comparison, and comparing their Names is unsound when a chain repeats one.

Outside a lifecycle step the frame is the leaf, so it reports true.

func (*Context) MustGet

func (rtx *Context) MustGet[T any](key string) T

MustGet returns the service bound under key as T, or panics with a *ServiceError when it is absent or not a T. The panic is intentional: the runtime recovers it inside dispatch and routes it through the funnel, so a handler that cannot run without a service reaches for MustGet rather than handling a miss inline.

store := rtx.MustGet[Store]("store")
Example

The registry: anything bound on the Program (or Context) is fetched typed. Get reports absence; MustGet panics — and that panic reaches the Program.WithFunnel funnel as a *PanicError in its panics slice, teardown already done.

type apiClient struct{ baseURL string }

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

client := rtx.MustGet[*apiClient]("api")
fmt.Println(client.baseURL)

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

func (*Context) Parser

func (rtx *Context) Parser() *Parser

Parser is the Parser for this run: the one Program.WithParser supplied, or the default.

It never returns nil. Parsing is not optional — Collect uses a parser whether or not the entrypoint supplied one — so a handler that wants to parse argv itself should not have to ask whether one exists, nor bind one to make the answer yes.

func (*Context) RecordError

func (rtx *Context) RecordError(err error)

RecordError records err as one of this run's errors — the end-user's own failures. It neither prints nor stops the lifecycle: a handler accumulates errors across any number of calls and hooks, then chooses how to stop, and the funnel receives them once the run settles either way. A nil err is ignored.

Recovered panics and rotini-detected faults are not recorded here; the lifecycle captures them as the funnel's panics slice.

Use it on its own when the run should CONTINUE — to collect several problems before anything stops, or to leave the decision to a later hook that gates on Context.Failed:

for _, path := range inputs.Check.Arguments.Paths {
    if err := validate(path); err != nil {
        rtx.RecordError(err) // report them all, not just the first
    }
}

To fail and stop in one act, use Context.HaltWith. Pairing RecordError with a separate Context.Halt does the same thing, and is the form whose second half can go missing.

func (*Context) RecordInfo

func (rtx *Context) RecordInfo(msg string)

RecordInfo records msg as an informational message of this run — neutral output such as progress or context, distinct from a success message only by intent. Like every record call it neither prints nor stops the lifecycle: the funnel receives the infos once the run settles. An empty msg is ignored.

func (*Context) RecordSuccess

func (rtx *Context) RecordSuccess(msg string)

RecordSuccess records msg as a success message of this run, for the funnel to present. An empty msg is ignored.

The funnel reports after the lifecycle settles, so recorded outcomes appear after anything a handler wrote directly to Context.Stdout during Run.

func (*Context) RecordWarning

func (rtx *Context) RecordWarning(warn error)

RecordWarning records warn as a non-fatal warning of this run — a deprecation, a fallback, a skipped item. It is an error value so it can be typed and branched on with errors.As and so secrets stay redacted, but it never raises the exit code. A nil warn is ignored.

Why the Record family splits its parameter type, since the names do not say: the two SEVERITY-bearing channels take an error, because a warning or a failure is something a funnel may want to branch on — categorize it with CategoryOf, match it with errors.As, redact it. Context.RecordInfo and Context.RecordSuccess take a string, because neither carries severity and there is nothing to inspect. The asymmetry is deliberate, and the compiler tells you which one you are in.

func (*Context) Value

func (rtx *Context) Value(key string) any

Value returns the service bound under key, or nil if none is bound — the raw accessor, mirroring context.Context.Value. It never panics.

It is the fallback, not a peer of the typed readers. Reach for a Key and its Get/MustGet when the key is known at compile time, which is nearly always; Context.Get and Context.MustGet when you have the name but want the type checked; and Value only when the key itself is computed and there is no type to assert.

func (*Context) Version

func (rtx *Context) Version() string

Version is what the program reports as its version, from Program.WithVersion. It is "" if the entrypoint set none, which is the honest answer rather than a guess.

func (*Context) WithBindMeta

func (rtx *Context) WithBindMeta(meta BindMeta) *Context

WithBindMeta supplies the generated descriptor Collect reconciles from. See Program.WithBindMeta.

For a Context you built yourself. One handed to a hook is already seeded from the Program, and this is not scoped to the current hook: every later hook of THIS run sees the change. It does not outlive the run — the next invocation is seeded from the Program again.

func (*Context) WithBinder

func (rtx *Context) WithBinder(fn func(BindMeta) *Binder) *Context

WithBinder replaces the binder Collect uses, built from the meta. See Program.WithBinder.

For a Context you built yourself. One handed to a hook is already seeded from the Program, and this is not scoped to the current hook: every later hook of THIS run sees the change. It does not outlive the run — the next invocation is seeded from the Program again.

func (*Context) WithHelp

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

WithHelp sets where Context.Help finds pages. See Program.WithHelp.

For a Context you built yourself. One handed to a hook is already seeded from the Program, and this is not scoped to the current hook: every later hook of THIS run sees the change. It does not outlive the run — the next invocation is seeded from the Program again.

func (*Context) WithParser

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

WithParser sets the parser Context.Parser returns. See Program.WithParser.

For a Context you built yourself. One handed to a hook is already seeded from the Program, and this is not scoped to the current hook: every later hook of THIS run sees the change. It does not outlive the run — the next invocation is seeded from the Program again.

func (*Context) WithVersion

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

WithVersion sets what Context.Version reports. See Program.WithVersion.

For a Context you built yourself. One handed to a hook is already seeded from the Program, and this is not scoped to the current hook: every later hook of THIS run sees the change. It does not outlive the run — the next invocation is seeded from the Program again.

type DefaultCascadingPostRun

type DefaultCascadingPostRun struct{}

DefaultCascadingPostRun is an embeddable no-op Handlers.CascadingPostRun. Embed DefaultHooks instead to take all four no-ops at once, which is what a hand-written handler usually wants.

func (DefaultCascadingPostRun) CascadingPostRun

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

CascadingPostRun does nothing.

type DefaultCascadingPreRun

type DefaultCascadingPreRun struct{}

DefaultCascadingPreRun is an embeddable no-op Handlers.CascadingPreRun. Embed DefaultHooks instead to take all four no-ops at once, which is what a hand-written handler usually wants.

func (DefaultCascadingPreRun) CascadingPreRun

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

CascadingPreRun does nothing.

type DefaultHooks

DefaultHooks is the four no-ops above in one embeddable, for a handler written by hand:

type handlers struct{ rotini.DefaultHooks }

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

It supplies CascadingPreRun, PreRun, PostRun and CascadingPostRun, and a method declared on the outer type still wins over the one promoted through here — so implementing a hook is the same act it always was: declare a method with that name, and leave this embedded.

It does not supply Run, on purpose

There is no DefaultRun and DefaultHooks does not invent one. A command whose Run is missing or misspelled therefore fails the `var _ rotini.Handlers` assertion every stub carries, at compile time, by name. That property is why `rotini generate`'s hook audit does not have to check Run at all, and collapsing the embeds must not cost it.

When to reach for it

Generated stubs keep the four embeds written out: the stub is where the hook vocabulary is introduced, and four named types show a reader the menu that one name hides. DefaultHooks is for the handler you write yourself — a package behind a spec's `handler: {import, convention}`, shared by several CLIs, where the author already knows the menu and the four lines are noise.

type DefaultPostRun

type DefaultPostRun struct{}

DefaultPostRun is an embeddable no-op Handlers.PostRun. Embed DefaultHooks instead to take all four no-ops at once, which is what a hand-written handler usually wants.

func (DefaultPostRun) PostRun

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

PostRun does nothing.

type DefaultPreRun

type DefaultPreRun struct{}

DefaultPreRun is an embeddable no-op Handlers.PreRun. Embed DefaultHooks instead to take all four no-ops at once, which is what a hand-written handler usually wants.

func (DefaultPreRun) PreRun

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

PreRun does nothing.

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
	RemoteCommands   []RemoteDef         // co-located plugin binaries dispatched as sub-commands of the root
	Discovery        *RemoteDiscoveryDef // plugin auto-discovery on the root command (nil = off)
	PluginPath       string              // extra directory searched for BOTH declared remotes and discovered plugins
	Passthrough      bool                // every token after the program name is a raw positional (no flag parsing)
}

Definition is the compiled command tree for a generated rotini program: codegen emits it as a Go literal, and the runtime parses argv, dispatches and completes against it (help pages are rendered at codegen and supplied through Program.WithHelp). Every type in this file is data only, with no behavior, which is what lets the generated file read as a description of the CLI rather than as code.

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 CLI token found in this invocation's argv: the identifier used, the kind of input, and that input's logical name. rotini attaches only the spec's own `deprecated:` message and does nothing else with it. It implements error so it can be returned or printed directly.

func Deprecations

func Deprecations(rtx *Context) []Deprecation

Deprecations returns each deprecated token this invocation actually used — a command invoked via a deprecated alias, or a flag set via a deprecated identifier. It is a data feed only: rotini prints nothing, and the handler decides what to do with each:

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

It is a function rather than a method on Parser because it needs no parser: everything it reports is already on the Context — the resolved chain and the argv that produced it. As a method it forced a handler to pull a *Parser out of the registry to obtain a receiver it never used, which in turn made a parser binding look mandatory in every entrypoint. Supply a Parser when you want to override the default or call Parser.Parse yourself; deprecation reporting needs neither.

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 simply 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.

func DiscoveredPlugins

func DiscoveredPlugins(cmd ResolvedCommand) []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, remote command or alias removed. It returns nil when cmd has no discovery or discovery is hidden.

It is the data feed for surfacing runtime plugins in help or a `plugin list`, which codegen cannot know about. rotini renders nothing itself; a handler formats the result however it likes:

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

It touches the filesystem on every call and is best-effort: an unreadable directory contributes nothing rather than erroring. See DiscoveryDiagnostics to learn whether the author-configured path itself failed.

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". The channel parsers and the overlay derive it from the same type, so the two can never disagree.

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.
	//
	// It exists because Default is one string: before it, a repeatable flag could not
	// express a multi-value default at all, and the only advice was to seed it in the
	// handler, which is the one thing declaring inputs in a spec exists to avoid.
	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. It is how an author expresses "turn this off for one run" when a default, a
	// config file or an environment variable already turned it on — the direction a plain
	// bool cannot express at all.
	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 every service bound on the Program, so a completer can reach a bound API client or the filesystem.

The line is half-typed, so a completer cannot Collect: required inputs are missing and validation would fail. To read what the user has said so far — a --kubeconfig on the line, or the environment variable that flag falls back to — overlay the lenient layers, which bind without validating. Flags of an ancestor (a root's global flags, say) are read with that ancestor's type at that ancestor's frame:

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

It is entirely opt-in, a panic in it is not recovered, and it may be called on every keystroke — so it must be read-only and fast.

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

type FunnelFunc

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

FunnelFunc is the program's outcome funnel. The runtime calls it once, after the lifecycle and its teardown settle, with everything the run recorded (see Outcome).

The funnel decides what to print, where, in what order, and the final exit code: it is the last authority, so Context.Exit inside it overrides whatever the lifecycle set (Context.HaltWithCode is a no-op here).

It is the last authority on the CODE, not on what the run recorded. The Outcome is the funnel's own copy to read; the error Program.Run returns is built before the funnel is called, so editing the slices it was handed changes nothing but the funnel's own view.

Nothing recovers a panic from inside a funnel — it is the last thing a run does, and a funnel for the funnel is not a thing. A funnel that can fail should handle its own failure, write to rtx.Stderr and set a code with Context.Exit; recording there is dropped, because the Outcome was snapshotted before it ran.

type Handlers

type Handlers 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)
}

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

Instance lifetime

The runtime asks your ProgramHandlers — the aggregate interface codegen generates, and the value passed to NewProgram — for a command's handler ONCE PER FRAME, PER RUN, and the value it gets back serves that frame's hooks for that run. Two consequences are worth knowing before reaching for the registry:

  • A FIELD carries state between one command's own hooks. The leaf's PreRun, Run and PostRun share one value, and any frame's CascadingPreRun and CascadingPostRun share one value. A transaction opened in PreRun and committed in PostRun can simply live in a field: no key, no lookup, and the compiler checks the type.
  • A field CANNOT cross frames. `db`'s hooks and `db migrate`'s hooks are different values, so state that travels down the chain belongs in the registry — Key.BindTo for one run, Provide for every run.

Where the state goes decides which tool fits:

state flows…                                  use
────────────                                  ───
between one command's own hooks               a field on the handler
between different commands in the chain       Key.BindTo(rtx, v)
across every run of the program               Provide / Program.Bind

If you supply your own ProgramHandlers

Whether each run gets a FRESH handler is decided by your wiring method, not by the runtime — the runtime calls it and uses whatever it returns. Generated code returns a new value per call, so generated programs get a fresh handler per run and fields are per-run state.

A wiring method that returns a SHARED value instead — a field on your aggregate, a package variable — makes that handler's fields shared across runs. For a stateless handler that is harmless and common. For one that keeps state in fields it is a bug, and under concurrent runs (a REPL, or Program.Run from several goroutines) it is a data race.

So: if you write your own ProgramHandlers and your handlers keep state in fields, return a new value per call.

type HelpFunc

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

HelpFunc returns the help page of the command named by path — the canonical names below the root, none for the root itself — or an error when there is no such command. It is the shape of the Help function codegen generates.

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 Key

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

Key names a service in the registry and the type it is bound as, so the string and the type assertion cannot drift apart and a handler needs neither:

// package-level, declared once beside the thing it names
var StoreKey = rotini.NewKey[Store]("store")

// main.go — the type is checked here, where the value is supplied
tasks.StoreKey.Provide(cmd.Program, tasks.NewMemStore()).Execute()

// any handler — no string, no assertion, no comma-ok
store := tasks.StoreKey.MustGet(rtx)

The registry is populated at run time, so a key cannot make a forgotten Provide a compile error. What it removes is the duplicated string literal, the type assertion, and the per-handler miss check — and a value bound as the wrong type now fails at the binding site rather than inside a handler. The untyped Context.Bind remains for dynamic cases.

The zero Key names the registry entry "" — usable, but shared by every zero Key of any type; build keys with NewKey.

func NewKey

func NewKey[T any](name string) Key[T]

NewKey returns a typed registry key. name is what the value is stored under, so it must be unique within a program; an empty name is not rejected, but is the same entry as the zero Key.

func (Key[T]) BindTo

func (k Key[T]) BindTo(rtx *Context, value T)

BindTo binds value under k on this invocation's context, for a service a hook computes per run. Use Key.Provide for program-wide services, so they are seeded into every run.

func (Key[T]) Get

func (k Key[T]) Get(rtx *Context) (T, bool)

Get returns the value bound under k, and whether one was bound as type T.

func (Key[T]) MustGet

func (k Key[T]) MustGet(rtx *Context) T

MustGet returns the value bound under k, or routes a miss to the funnel as a *ServiceError — Context.MustGet's contract, with the type supplied by the key.

func (Key[T]) Name

func (k Key[T]) Name() string

Name returns the underlying registry key, for interoperating with the untyped Context.Bind / Context.Get surface.

func (Key[T]) Provide

func (k Key[T]) Provide(p *Program, value T) *Program

Provide binds value on the program's registry, so every invocation sees it, and returns the program so it chains like Program.Bind:

tasks.StoreKey.Provide(cmd.Program, store).
	WithVersion(version).
	Execute()

The type is checked here, at the one place the value is supplied. Because the key fixes T, any value assignable to T is accepted, so a constructor returning a concrete type satisfies a key declared over an interface.

func (Key[T]) String

func (k Key[T]) String() string

String implements fmt.Stringer, so a key renders as its name in a message.

type Layer

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

Layer is one input channel's view of the inputs type T: the values it supplied — everything else is T's zero value — and exactly which fields those are. Layers from rotini's channel parsers also carry unexported data that Report.Validate uses; a hand-built Layer 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.

func Defaults

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

Defaults synthesizes the spec's declared defaults as an explicit layer — conventionally layer 0, which makes "no input at all" visible and testable. Flag and argument defaults come from the resolved chain; env and config defaults from their recon tags.

func ParseArgv

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

ParseArgv 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 Report.Validate, so a required flag satisfied by another layer passes. Parse failures are *ParseError values.

func ParseEnv

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

ParseEnv 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 ParseFiles

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

ParseFiles acquires the configuration-files channel: every Config input from the BindMeta 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 ParseStdin

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

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

type Lifecycle

type Lifecycle func(chain []ResolvedCommand, handlers []Handlers) []LifecycleStep

Lifecycle is the run phase's planner: given the resolved chain and each frame's Handlers, index-aligned, it returns the ordered step plan the engine executes. It orders and pairs the declared hooks; the handler wiring rules hold before it is consulted. See DefaultLifecycle.

A plan built by wrapping DefaultLifecycle needs nothing further. One built from scratch should wrap each hook in AtFrame so Context.Frame — and therefore Collect's anchor — knows which command the hook belongs to; an unlabeled hook reports the leaf, which is right for a leaf's own hooks and wrong for a cascading one.

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 — the unit of the run phase's plan.

Either half may be nil. A nil Undo is a step with no teardown, as the default plan's Run step is; a nil Do is a step with no forward work, which is how a hand-built plan registers a teardown that pairs with nothing. A step counts as begun either way, so a teardown-only step still unwinds.

func DefaultLifecycle

func DefaultLifecycle(chain []ResolvedCommand, handlers []Handlers) []LifecycleStep

DefaultLifecycle is rotini's run-phase plan, exported so a custom Lifecycle can wrap it: one CascadingPreRun/CascadingPostRun pair per frame root → leaf, then the leaf's PreRun/PostRun pair, then the leaf's Run with no teardown. With the engine's reverse unwind this yields exactly the contract table above.

Every hook is wrapped in AtFrame, which is what lets a cascading hook know which command it belongs to — see Context.Frame — and what lets Collect anchor an inputs struct on that command instead of guessing from its field count.

type Line

type Line struct {
	Stream Stream
	Text   string
}

Line is one line of a subprocess's output, tagged with the stream it came from.

type Option

type Option func(*Program)

Option is one configuration step, in a form that composes. Every seam below is also a method, and for a single step the method reads better; Option exists for steps that are values — passed around, collected into a slice, or grouped under one Program.With.

The typed registry is the main user: Provide returns an Option binding a Key's value, so several type-checked binds sit together in one With call inside the chain.

An Option is an ordinary function, so a program can carry its own:

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

func Provide

func Provide[T any](k Key[T], value T) Option

Provide is the composable form of Key.Provide: it returns an Option that binds value under k, for Program.With to apply.

Options compose: several services go into one Program.With call and read as a group, and a helper can hand back a set of them for a caller to apply:

cmd.Program.
	With(
		rotini.Provide(tasks.StoreKey, store),
		rotini.Provide(tasks.ClientKey, client),
	).
	WithVersion(version).
	Execute()

The type is checked at this call, where the value is supplied, exactly as Key.Provide checks it. Reach for Key.Provide when there is one service and no chain to keep.

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 — the end user's own failures.
	Errors []error
	// Panics are recovered panics and rotini-detected faults. There is no record call for
	// these: the lifecycle captures them, so a handler cannot fake or suppress one.
	Panics []*PanicError
}

Outcome is everything a run recorded, handed to the funnel in one value. Each slice is in recording order, and this struct is the only way the records surface — Context keeps them private so nothing can read a partial run.

It is a struct rather than five parameters for two reasons, both of which matter to code that will be written against a frozen v1: at a call site the channels are named, so Infos and Successes (both []string) and Warnings and Errors (both []error) cannot be silently transposed; and a channel added later is an additive field rather than a breaking change to every custom funnel in existence.

func (Outcome) Empty

func (o Outcome) Empty() bool

Empty reports whether the run recorded nothing at all — a silent success. The runtime skips the funnel entirely in that case, so a funnel never sees an empty Outcome.

func (Outcome) Failed

func (o Outcome) Failed() bool

Failed reports whether the run recorded an error or a panic — what the default funnel's exit floor keys on.

type PanicError

type PanicError struct {
	Value any
	Stack []byte
}

PanicError carries a panic recovered from a lifecycle hook to the funnel: Value is what was passed to panic, Stack the goroutine stack captured at the recovery point. Error renders Value alone, so default output stays one line; a funnel that wants the stack asks for it with errors.As.

It also carries the faults rotini detects rather than recovers — a *WiringError, a resolver failure — with the error as Value and a nil Stack, since nothing was unwound.

func (*PanicError) Error

func (e *PanicError) Error() string

Error renders the panic value as a single line; the captured stack is deliberately not printed.

func (*PanicError) Unwrap

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

Unwrap exposes a panicked error value so errors.Is/As and CategoryOf see through it, and always exposes ErrInternal underneath.

The floor matters. A recovered panic is a bug in the program by definition — CategoryInternal is literally "the end-user cannot fix it; the author must" — but a panic value is usually not an error at all (panic("boom")), and without the floor CategoryOf reported CategoryNone for it. That is not merely uninformative: none sorts BELOW usage, so a funnel keeping the most severe category across a run would rank a crash under a mistyped flag.

A panicked error value still wins the classification, because CategoryOf tests ErrUsage before ErrInternal — panicking a UsageError reports usage, the floor only catches what nothing else classifies.

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. It is data, not presentation: the message offers no suggestions and no usage dump, and the structured fields let a handler compose its own response — switch on Kind, pair Token with Candidates and a Suggestor for "did you mean", or render help for Command. It unwraps to ErrUsage, so CategoryOf reports CategoryUsage.

That is a label, not an exit code. rotini forces no category→code mapping and the default funnel exits 1 for any failure; a program that wants the common "2 means the command line was wrong" convention maps it in its own funnel. 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 exposes the category sentinel, so CategoryOf and errors.Is reach it: ErrInternal for ParseKindInternal — the author's bug, not the user's — and ErrUsage for every other kind.

type ParseKind

type ParseKind int

ParseKind classifies a *ParseError so a funnel can branch on the failure without matching the human message. Most kinds are the end-user's to fix; ParseKindInternal is a misuse of the parser API itself — surfaced as a *ParseError for uniformity, but the author's bug.

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. Branch on these rather than on a message: the message is presentation, the kind is data.

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: given a resolved chain, it parses and validates the command line against what those commands declare — GNU/POSIX grammar, typed coercion, enum and constraint checks — failing with a *ParseError.

Parsing is opt-in: a CLI that wants raw argv never calls it and reads Context.Argv. 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.

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.

It does not check that out describes the running command

Parse is the mechanism; Collect is the contract. Collect and the per-channel layer functions reject a struct that cannot describe the caller's own command — one covering more commands than the caller is deep — because a handler asking for its own inputs can only have meant one thing. Parse binds what fits and leaves the rest zeroed, which is what lets a caller drive it with a struct spanning a whole tree and reuse it across several argv shapes.

That is a deliberate split, not an oversight, and it is the only place in the input surface where a mismatched struct passes quietly. A handler collecting its own inputs should reach for Collect and get the check.

Example

Parser.Parse fills the generated input struct from argv alone: typed coercion, defaults, enum and constraint checks — failing with a data-shaped *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 Presence

type Presence map[FieldPath]Provenance

Presence maps each field a layer actually supplied to its provenance. It is what makes overlay precedence real: a layer's absent fields are skipped, never copied.

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 they chain.

The surface groups into seven jobs, and nothing outside them is worth hunting for:

Which of the two a setting is follows one rule: if rotini itself reads it — the runtime or the code it generates — it is a typed option on the Program; if only your code reads it, it is a registry binding. rotini's own settings are typed so that a key you choose can never shadow one of them, and a wrong type is a compile error rather than an input channel that quietly stops working. Your services live in the registry because rotini has no business knowing their types.

A Program is reusable: Program.Run dispatches one invocation and returns instead of exiting, giving each call a fresh Context. That is what lets a REPL, a test, or a server answering a peer drive the same program many times.

Configure before the first run. Every With method and Program.Bind mutates the Program without synchronization, so a concurrent host finishes configuring, then dispatches. Applied between sequential runs they simply take effect on the next one.

A nil *Program is a caller bug, not a state to handle: NewProgram never returns one, and every method here dereferences rather than checking. That is deliberate — returning the nil receiver instead would carry it silently down the chain and panic somewhere later, which is strictly harder to debug than panicking at the call that was wrong.

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. handlers is any so the runtime need not import the generated package; dispatch resolves the per-command handlers from it by the Handler names recorded in def. A nil handlers value is not rejected here; a run that reaches handler dispatch then fails with a *WiringError.

func (*Program) Bind

func (p *Program) Bind(key string, value any) *Program

Bind registers a service on the program's registry under key, overwriting any prior binding. It is the dependency-injection seam: bind a real implementation in production or a double in tests, and handler code retrieves either through Context.Get or Context.MustGet.

It is YOUR namespace. rotini's own seams — the binder, the parser, the version, the help pages, the generated BindMeta — are typed options on the Program, not entries here, so a key you choose can never shadow one of them and a type you get wrong can never degrade an input channel in silence.

func (*Program) Execute

func (p *Program) Execute() error

Execute resolves the command, runs its lifecycle, and ends with the resulting status code via the program's exit action (os.Exit by default; see Program.WithExit).

The returned error, and when it can arrive

The error is the run's own failure: every Context.RecordError value and every recovered fault, joined with errors.Join — so errors.Is and errors.As reach each one, and a caller can branch on a *ParseError or a *BindError rather than on text.

It is reachable only when the exit action RETURNS. Under the default action, os.Exit, the process is already gone by then and the return statement never runs, which is why the generated entrypoint discards it:

cmd.Program.WithVersion(version).Execute()   // the error cannot arrive here

Supply a Program.WithExit that returns — a test capturing the code, a host embedding the CLI — and it does:

code := -1
err := cmd.Program.WithExit(func(c int) { code = c }).Execute()

The error is not the reporting channel. By the time Execute returns, the funnel has already printed everything the run recorded (Program.WithFunnel). The return exists so an embedder can ACT on the failure — retry, wrap, classify with CategoryOf — without re-deriving it from what was written to a stream. A caller that only wants the number can use Program.Run, which returns both and never exits.

Signals

With no Program.WithContext, Execute installs rotini's signal trap: the first os.Interrupt or syscall.SIGTERM halts the lifecycle like Context.HaltWithCode — forward progress stops, every begun teardown hook still runs — and exits 128+signum. A second signal forces exit immediately, so a handler that ignores the context can still be interrupted. 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 — the re-entrant core Program.Execute is built on. It resolves the invoked command (flag parsing stays the handler's opt-in via Parser.Parse), execs a remote sub-command if one was selected, and otherwise dispatches the lifecycle.

Unlike Execute, Run never ends the process, which is what makes a Program reusable: a REPL, a daemon or a test can call it once per line and inspect the code.

Each call gets a fresh Context, so one invocation never inherits the previous one's records or status. Services bound with Program.Bind are seeded into every run; one a handler binds mid-run stays local to that run.

For hosts that dispatch in a loop: with no supplied context Run installs and tears down the signal trap on every call, about 30µs — negligible once per process, but roughly 20x the dispatch itself when repeated. Prefer Program.RunContext or Program.WithoutSignalHandling, as REPL does.

Concurrency

Run is safe to call concurrently once the program is configured — every With* option and Program.Bind must happen before the first run, since none of them is synchronized. Each concurrent run has its own Context, so records, exit state and mid-run bindings never cross between them.

Two things stay SHARED, and a concurrent host owns both:

  • The handlers value given to NewProgram. rotini calls its methods from each run's goroutine, so mutable handler state needs its own synchronization.
  • The program's streams. os.Stdout is safe for concurrent writes; an unguarded bytes.Buffer in a test is not.

Signal trapping is per-run: with no supplied context, every concurrent run installs its own handler and all of them observe one signal. A concurrent host passes its own context (Program.RunContext) or turns the trap off with Program.WithoutSignalHandling.

func (*Program) RunContext

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

RunContext is Program.Run under an explicit context, for this invocation only. Unlike Program.WithContext it does not modify the program, so a host dispatching many invocations can scope each one without permanently changing how the program handles signals.

Supplying a context defers signal handling to the caller, as Program.WithContext does. ctx must not be nil; a nil context is reported as an ErrInternal.

func (*Program) With

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

With applies each Option in order and returns the program, so configuration that cannot be a method still chains with the configuration that can:

cmd.Program.
	With(
		rotini.Provide(tasks.StoreKey, store),
		rotini.Provide(tasks.ClientKey, client),
	).
	WithVersion(version).
	Execute()

Options are applied left to right, so a later one overwrites an earlier one binding the same key — the same rule Program.Bind follows. 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:]).

Execute is the ONLY entry point that consults it. Program.Run and Program.RunContext take argv as a parameter and use exactly what they were given — `p.WithArgs(x).Run(nil)` runs with no arguments, not with x. That is deliberate rather than an oversight: Run is the re-entrant core a REPL or a stdio server calls once per line, where the argv differs every time and silently inheriting a program-level default would be a bug that prints the wrong answer. A caller who wants the configured vector passes it: `p.Run(argv)`.

A nil args is ignored, so the default survives; pass []string{} to run with none.

func (*Program) WithBindMeta

func (p *Program) WithBindMeta(meta BindMeta) *Program

WithBindMeta supplies the generated binding descriptor — the configuration sources, the env prefix and the stdin schemas Collect reconciles from. The generated NewProgram calls it; a hand-built program calls it when it wants those channels.

It is a description of the program, like Definition, not a dependency — which is why it travels as a typed option rather than as a registry entry.

func (*Program) WithBinder

func (p *Program) WithBinder(fn func(BindMeta) *Binder) *Program

WithBinder replaces the binder Collect and the per-channel helpers use. fn receives the program's BindMeta, so a custom binder is built FROM the generated descriptor rather than having to reproduce it:

p.WithBinder(func(meta rotini.BindMeta) *rotini.Binder {
	meta.Sources = append(meta.Sources, mySource)
	return rotini.NewBinder(meta)
})

That signature is the point: a replacement binder starts from the descriptor, so it cannot silently lose the configuration files the spec declared. A nil fn is ignored.

func (*Program) WithContext

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

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

Cancellation is cooperative — it cannot preempt a hook that ignores it — but once the context is canceled rotini starts no further forward hook, and teardown for every begun setup hook still runs in reverse. Attach the process exit code with ExitCode; without one the code falls through to the normal resolution. To stop without canceling the context, use Context.HaltWithCode or Context.Exit.

Supplying a context also opts out of rotini's signal trap by default, on the assumption that the caller owns signals (typically via signal.NotifyContext, which leaves it holding the cancel that halts the run). Program.WithSignals re-enables the trap on top of a supplied context; Program.WithoutSignalHandling suppresses it without one.

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) — supply a recording function to capture the code without terminating. A nil function is ignored.

If fn returns, Execute returns to its caller, and that is the ONLY way to observe the error Execute reports: under the default os.Exit the process ends first and the return never runs. So this is the seam that makes an end-to-end test of a real CLI ordinary Go —

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

— and equally the seam an embedder needs when a rotini CLI is one component of a larger process rather than the process itself.

func (*Program) WithFunnel

func (p *Program) WithFunnel(fn FunnelFunc) *Program

WithFunnel sets the program's outcome funnel — the one place a run's recorded channels are reported. It runs once per run, after the lifecycle settles, whenever any channel recorded something; a clean run never invokes it. See FunnelFunc.

The default prints info → warning → error → panic → success (infos and successes to stdout, the rest to stderr) and applies an exit floor: a recorded error or panic exits non-zero unless a handler already set a deliberate code. A custom funnel owns the exit entirely.

A nil fn RESTORES the default, which is why this one seam accepts nil rather than ignoring it: "report the way rotini does" is a thing a host may want back, and there is no other way to ask for it. The seams that replace a value rather than a behavior — Program.WithStdout, Program.WithResolver and the rest — ignore nil instead, so a conditional caller cannot erase a stream or a phase by passing one.

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, so a program built from a spec has its pages without setting anything; a nil help is ignored.

It is a seam on the Program rather than a page each handler holds because of composition: a command composed from another spec prints the page of the program it is RUNNING in — with the full command path and the flags its new ancestors pass down — and only the program knows it.

func (*Program) WithLifecycle

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

WithLifecycle overrides the run phase's plan — which declared hooks run, in what pairing and order (see Lifecycle and DefaultLifecycle). The semantics around the plan — halting, the balanced reverse unwind, teardown to completion, the panic funnel, exit codes — stay fixed. Wrap DefaultLifecycle rather than re-deriving it. A nil lifecycle is ignored.

Example

A lifecycle that reverses teardown order: wrapping DefaultLifecycle and swapping the cascading pairs makes CascadingPostRun unwind root→leaf. Only the plan changes — halting, balanced unwind, and the panic funnel stay rotini's.

NewProgram(exampleDef(), exHandlers{}).
	WithArgs([]string{"status"}).
	WithExit(func(int) {}).
	WithLifecycle(func(chain []ResolvedCommand, hs []Handlers) []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) 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 funnel, so users of the built CLI never see a raw stack dump. Pass false to re-raise it to the caller instead — for embedding rotini under your own recover, a crash reporter, or debugging.

It composes with Program.WithTeardownOnPanic, which is the separate question of whether teardown still runs:

  • recover=true (the default) → the panic never leaves rotini; it reaches the funnel as a *PanicError, and teardown runs or not according to WithTeardownOnPanic.
  • recover=false, teardown=true → teardown runs, THEN the panic is re-raised (its stack roots 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, skipping teardown.

Only panics on the hook goroutine can be recovered; one in a goroutine a handler spawned crashes regardless.

func (*Program) WithParser

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

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

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. Wrap DefaultResolver rather than re-deriving it: a resolver that rewrites tokens should rewrite argv, hand it to the default, and return the result, so routing and parsing agree. A resolver error is routed through the funnel as a fault and fails the run.

Completion candidates walk the Definition, so a resolver-only alias is dispatchable but not completable; declare real aliases in the spec for that. A nil resolver is ignored.

Example

A resolver that teaches the program a routing alias: "st" rewrites to "status" and delegates to DefaultResolver, so routing and (later) parsing agree on the rewritten argv. Declare real aliases in the spec when you also want completion; a resolver alias is routing-only.

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 forces rotini's graceful trap on for the given signals, whether or not the caller supplied a context. With a supplied context rotini derives a cancelable child to drive the trap, so the caller's context is never canceled. The action is fixed: the first signal cancels the run context for a graceful halt (teardown runs, exit 128+signum), a second forces exit. An empty signal list is ignored — use Program.WithoutSignalHandling to opt out.

func (*Program) WithStderr

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

WithStderr overrides the program's standard error (default os.Stderr), where the runtime writes its diagnostics and the default funnel reports. 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) — what a handler reads via Context.Stdin and what the Binder decodes a stdin channel from. 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 funnel 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 but still runs every begun setup hook's teardown, mirroring how defers run during a panic unwind. Pass false to skip the remaining teardown, as a hard Context.Exit would.

Where the panic GOES is the separate Program.WithPanicRecover knob; the two compose.

func (*Program) WithVersion

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

WithVersion sets what the program reports as its version — what a generated `version` command and a `--version` flag print, read back with Context.Version.

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 suppresses rotini's default trap without making the caller surrender the context: rotini still owns a cancelable run context but installs no signal.Notify, so the program's own handling is the only one. It matters because signal.Notify is additive — without this opt-out a caller's handler would stack with rotini's rather than replace it.

In this mode rotini exposes no cancel, so a handler installed here cannot halt the run gracefully. For that, prefer Program.WithContext with signal.NotifyContext.

type Provenance

type Provenance 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
}

Provenance 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 REPL

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

REPL runs a Program as an interactive read-eval-print loop: each line is tokenized like a shell command line and dispatched against the same Definition the binary uses, so every command, flag and handler behaves exactly as it does from the shell.

It rests on Program.Run being re-entrant: each line gets a fresh Context, so one command's outcomes and exit code never leak into the next, and services bound with Program.Bind are seeded into every line.

A REPL never exits the process — a non-zero command is reported and the loop continues. It ends at an exit command, at end of input, or when the context is done.

What rotini supplies, and what it does not

rotini owns the half nobody else can: turning a line into a dispatched invocation against YOUR command tree. Resolving, tokenizing, a fresh Context per line, services that persist, a failure that does not end the session, interrupt and exit semantics — and REPL.Complete, which no general-purpose line editor can offer because it does not know your commands.

rotini does NOT own reading the line. History, arrow keys, ^R, multi-line input and bracketed paste are a solved problem with better libraries behind it — chzyer/readline, peterh/liner, c-bata/go-prompt — and they need raw terminal mode, which rotini deliberately does not take. The built-in reader is a plain byte-at-a-time line read: exactly right for a pipe, a test or a CI job, and deliberately minimal at a terminal.

REPL.WithLineReader is the seam between the two. Everything else is configuration around it, so a program assembles the REPL it wants rather than accepting the one rotini imagined:

The zero value is not usable; start from NewREPL.

func NewREPL

func NewREPL(program *Program) *REPL

NewREPL returns a loop dispatching to program. Input and output default to the program's own streams, so a REPL inherits whatever Program.WithStdin and friends configured (including a test's buffers). A nil program yields a REPL whose REPL.Run returns an internal error and whose REPL.Complete returns nil.

func (*REPL) Complete

func (r *REPL) Complete(line string, pos int) []string

Complete returns what the command tree would complete for line, with the cursor at byte offset pos — sub-commands, flag names, enum values, and anything a FlagValueCompleter or ArgValueCompleter supplies dynamically.

This is the one thing a REPL gets from rotini that no line editor can give it: the same completion the generated shell scripts use, from the same Definition, against the same handlers. A general-purpose readline library cannot offer it because it does not know your commands.

rl, _ := readline.NewEx(&readline.Config{AutoComplete: completerFunc(repl.Complete)})

A pos outside the line is clamped to its end. Candidates are bare names, ready to insert; a trailing space means the NEXT word is being completed, matching how a shell reads the same line. An empty result means "nothing to offer", which a reader should treat as leaving the line alone rather than as an error.

func (*REPL) Run

func (r *REPL) Run(ctx context.Context) error

Run reads and dispatches lines until an exit command, end of input, or a done context. It returns nil on a clean exit; a command's own failure never ends the loop and is never returned.

Each line is dispatched on its OWN context, derived from ctx. That is what lets an interrupt from REPL.WithInterrupts cancel the command in flight and leave the session standing — while ctx finishing still ends everything, because that means the process is going down.

Run may be called again on the same REPL, and the per-session state it keeps lives on the stack; configure before running, since the With methods are not synchronized.

func (*REPL) WithErrorEcho

func (r *REPL) WithErrorEcho(enabled bool) *REPL

WithErrorEcho controls whether a failing command's error is written to the REPL's output.

It is OFF by default, because a rotini program already reports its own failures: the default funnel prints every recorded error, and a custom funnel almost always does too. With the echo on as well, every error in a session appeared twice — once from the funnel on stderr, once from the REPL on stdout — which in a terminal is the same destination:

syncd> Error: unknown command "nosuchcommand" for "syncd"
       unknown command "nosuchcommand" for "syncd"

Turn it on for a program whose funnel is deliberately silent, or one whose funnel writes somewhere the person at the prompt cannot see.

func (*REPL) WithExitCommands

func (r *REPL) WithExitCommands(words ...string) *REPL

WithExitCommands replaces the words that end the loop (default exit, quit). Passing none leaves end-of-input and context cancellation as the only exits.

func (*REPL) WithInput

func (r *REPL) WithInput(in io.Reader) *REPL

WithInput overrides the line source. A nil reader, with no REPL.WithLineReader installed, makes REPL.Run return an error.

func (*REPL) WithIntercept

func (r *REPL) WithIntercept(fn func(ctx context.Context, line string) (bool, error)) *REPL

WithIntercept installs a hook that sees each line BEFORE it is tokenized or dispatched, reporting whether it handled the line itself.

It is the seam for meta-commands — the things a real REPL has that are not commands of the program: psql's \d, sqlite's .schema, a pager toggle, a session variable. They cannot be spec commands, because they mean nothing to the binary outside a session, and they cannot be handled by a line reader, because they need the program's state.

repl.WithIntercept(func(_ context.Context, line string) (bool, error) {
	if !strings.HasPrefix(line, "\\") { return false, nil }
	fmt.Fprintln(out, help[strings.TrimPrefix(line, "\\")])
	return true, nil
})

Returning true consumes the line. Returning an error ENDS the session — an interceptor that fails has lost track of its own state, which is not something to keep prompting through; a meta-command that merely failed should report that itself and return (true, nil).

It sees every line, including blank ones and exit words, so a program can override either.

func (*REPL) WithInterrupts

func (r *REPL) WithInterrupts(ch <-chan struct{}) *REPL

WithInterrupts makes a ^C cancel the RUNNING COMMAND instead of the session.

Without it the REPL runs every line on the session's own context, so an interrupt that should abort one command tears down the shell — and in every interactive tool anyone has used (bash, python, psql, redis-cli) ^C returns you to the prompt. It is the most-pressed key in a REPL.

**Send on a buffered channel, without blocking.** A receive cancels the line in flight; anything already pending when a line is submitted arrived at the PROMPT rather than at a command, and is dropped — a stray ^C at an empty prompt must not kill the next thing typed. The REPL reads the channel only while a command is running and never closes it.

sigint := make(chan os.Signal, 1)
signal.Notify(sigint, os.Interrupt)
interrupts := make(chan struct{}, 1)
go func() { for range sigint { select { case interrupts <- struct{}{}: default: } } }()
repl.WithInterrupts(interrupts)

rotini wires no signal here on purpose: only the program knows whether its ^C means "abort this line" or "kill this process", and a framework guessing would be guessing about the user's most destructive key.

func (*REPL) WithLineReader

func (r *REPL) WithLineReader(fn func(ctx context.Context, prompt string) (string, error)) *REPL

WithLineReader replaces where a line comes from — the seam this whole type is built around.

The built-in reader takes no dependency and does no editing, which is correct for a pipe and minimal at a terminal. Supplying one is how a program gets history, arrow keys, ^R and tab completion, from a library built for it:

rl, err := readline.New("")
repl.WithLineReader(func(_ context.Context, prompt string) (string, error) {
	rl.SetPrompt(prompt)
	line, err := rl.Readline()
	switch {
	case errors.Is(err, readline.ErrInterrupt):
		return "", rotini.ErrInterrupted
	case errors.Is(err, io.EOF):
		return "", rotini.ErrNotInteractive
	}
	return line, err
})

The contract is a small set of errors:

  • ErrInterrupted — ^C at the prompt. The line is discarded and the loop prompts again.
  • ErrNotInteractive, io.EOF, context.Canceled or context.DeadlineExceeded — the input ended. The session closes cleanly, Run nil.
  • anything else — a real I/O failure, returned by REPL.Run.

A reader that writes its own prompt should ignore the one it is handed. The REPL writes no prompt of its own once a reader is installed, since the two would print twice.

A nil func restores the built-in reader.

func (*REPL) WithOutput

func (r *REPL) WithOutput(out io.Writer) *REPL

WithOutput overrides where the prompt and loop diagnostics are written. It does NOT redirect command output, which goes to the program's own streams. A nil writer writes neither the prompt nor the REPL.WithErrorEcho echo.

func (*REPL) WithPrompt

func (r *REPL) WithPrompt(prompt string) *REPL

WithPrompt sets the text written before each read (default "> "). An empty prompt writes nothing, which suits a piped session.

REPL.WithPromptFunc overrides it when both are set.

func (*REPL) WithPromptFunc

func (r *REPL) WithPromptFunc(fn func() string) *REPL

WithPromptFunc computes the prompt before every read, so it can show session state the way a real shell does — the current database, a pending transaction, how deep a queue is:

repl.WithPromptFunc(func() string { return fmt.Sprintf("syncd(queue:%d)> ", queue.Len()) })

It is called once per line, on the reading goroutine. A nil func restores REPL.WithPrompt.

type RemoteDef

type RemoteDef 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
}

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

type RemoteDiscoveryDef

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

RemoteDiscoveryDef 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 RemoteDispatch

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

RemoteDispatch is a resolved remote sub-command invocation: Def.Binary run with Args. Dir is the command's plugin path, searched after the host binary's own directory and before PATH, for declared remotes and discovered plugins alike; empty means no plugin path. The default resolver produces one for declared remotes and discovered plugins.

type RemoteError

type RemoteError struct {
	Name    string          // the remote command name (or discovery token)
	Binary  string          // the plugin binary that was sought or spawned
	Kind    RemoteErrorKind // what went wrong
	Timeout time.Duration   // the elapsed deadline, for Kind == RemoteTimeout (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
}

RemoteError reports a rotini-authored failure carrying out a remote dispatch — not the plugin's own non-zero exit, which passes through untouched. It is typed so a funnel can special-case a timeout or a missing plugin without matching the message:

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

A missing binary is CategoryUsage when discovered (the user's typo) and CategoryInternal when declared (an install problem); a spawn failure is CategoryInternal; a timeout is deliberately CategoryNone, operational and neither party's fault, but still As-able here.

func (*RemoteError) Error

func (e *RemoteError) Error() string

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

func (*RemoteError) Unwrap

func (e *RemoteError) 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 RemoteErrorKind

type RemoteErrorKind int

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

const (
	// RemoteBinaryNotFound: no binary was found next to the executable, in the
	// plugin path, or on PATH.
	RemoteBinaryNotFound RemoteErrorKind = iota
	// RemoteTimeout: the plugin ran past its declared timeout and was killed.
	RemoteTimeout
	// RemoteSpawnFailed: the binary was found but could not be started (a fork/
	// exec or pipe failure — NOT the plugin's own non-zero exit, which passes
	// through untouched).
	RemoteSpawnFailed
)

func (RemoteErrorKind) String

func (k RemoteErrorKind) String() string

String renders the kind as a short, stable label.

type Report

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

Report 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 Report is usable: it reports no fields, and its Validate returns nil.

func CollectP

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

CollectP is Collect with provenance: the same reconciled, validated inputs plus the Report answering Winner and History per field. It overlays the per-channel layers in the standard precedence, producing the same values Collect does at the cost of acquiring each channel separately. A validation failure returns the merged inputs and the report alongside the error, so a funnel can still say which layer supplied the offending value.

func OverlayInputsP

func OverlayInputsP[T any](layers ...Layer[T]) (T, Report)

OverlayInputsP is OverlayInputs plus the merged Report: 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 := OverlayInputsP(
	Layer[inputs]{Name: "defaults", Values: defaults, Set: Presence{"App.Flags.Color": {Layer: "defaults", Raw: "blue"}}},
	Layer[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 (Report) Fields

func (r Report) Fields() []FieldPath

Fields returns every field any layer set, sorted, for stable doctor-style output.

func (Report) History

func (r Report) History(path FieldPath) []Provenance

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

func (Report) Validate

func (r Report) 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, which is not the same as checking 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 value some layer supplied. A field nobody supplied is absent, and its zero value is not measured against its enum or bounds.

That distinction is invisible while the defaults layer is present, because a declared default supplies the field. Drop Defaults from a custom precedence and an enum-constrained flag can merge as "" — legally, because nothing claimed it — so build custom precedence from all five channels unless leaving one out is the point.

Hand-built layers contribute values but nothing to validate.

func (Report) Winner

func (r Report) Winner(path FieldPath) (Provenance, bool)

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

type Resolution

type Resolution struct {
	// Chain is the resolved command path the run phase dispatches (when
	// Remote is nil). It must be non-empty — the root frame is always there.
	Chain []ResolvedCommand
	// Remote, when non-nil, short-circuits local dispatch: the runtime execs this binary
	// instead, stdio passed through and context honored.
	Remote *RemoteDispatch
	// 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.
	//
	// Contrast [RemoteDispatch.Args], which stays Args: those are the arguments handed to a
	// CHILD process, not this invocation's own vector.
	Argv []string
}

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

func DefaultResolver

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

DefaultResolver is rotini's resolve phase, exported so a custom Resolver can wrap rather than re-derive it: descend sub-commands by name or alias, skip flags and their values, stop at the first positional, and divert to a remote dispatch for declared remotes and discovered plugins. It is deliberately lenient — bad input is the opt-in Parser's concern — and never errors.

type ResolvedCommand

type ResolvedCommand 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
	Remotes               []RemoteDef         // co-located plugin binaries dispatched as sub-commands
	Discovery             *RemoteDiscoveryDef // plugin auto-discovery (nil = off)
	// PluginPath is the extra directory this command's plugin binaries may live in, searched
	// for BOTH declared remotes and discovered plugins — they are the same binaries in the
	// same place. Empty means only the host binary's directory and PATH are searched. It is the
	// directory as searched: a leading ~ and $VAR references in the declared path are already
	// expanded.
	PluginPath  string
	Passthrough bool // every token after this command is a raw positional (no flag parsing)
}

ResolvedCommand is one node on the invoked command path, root → leaf: the flattened command-tree data the runtime resolved for this invocation. It is exposed via Context.Chain so opt-in tooling binds inputs against the exact command whose handler ran — including a statically composed child, whose 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.

type Resolver

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

Resolver is the resolve phase: argv against the Definition, deciding what this invocation targets. An error is routed through the funnel and fails the run. See DefaultResolver.

The Definition is READ-ONLY

It arrives by value, but a Definition is mostly slices — Commands, Flags, Arguments — and those are the program's own, not a copy. Writing through one (`def.Commands[0].Name = …`) edits the command tree itself, and the edit OUTLIVES the run: the next invocation of the same Program sees it, which for a REPL means every line after the first.

This is the same convention Context.Chain states for the frames it hands a handler, and it is what lets one Program serve many runs without rebuilding its tree. A resolver that wants a different tree should build its own rather than edit the one it was shown.

type Service

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

Service runs a set of long-lived workers until the context ends or one of them fails, then shuts them down in order. It is the daemon shape: the runtime's signal trap already cancels the run context, so a handler that builds a Service on its own ctx gets graceful shutdown for free.

svc := rotini.NewService().
    Go("http", serveHTTP).
    Go("reconciler", reconcile).
    WithShutdown(closeDB)
if err := svc.Run(ctx); err != nil { rtx.RecordError(err) }

Workers are plain funcs returning an error, so a failure travels back the normal Go way. A worker that PANICS does not take the process with it: the panic is recovered on its own goroutine, becomes the service's failure as a *PanicError, and teardown still runs. That is not a nicety — a panicking worker is exactly when the journal most needs flushing, and a process that dies on a goroutine rotini spawned would skip every hook, every outcome and every exit code on the way out. Program.WithPanicRecover cannot help here: it guards the dispatch goroutine, and a goroutine's panic is unrecoverable from anywhere but itself.

Configure before running. Service.Go, Service.WithShutdown and Service.WithShutdownTimeout are not synchronized, so calling one while Service.Run is in flight is a data race. A configured Service may be run more than once, and concurrently: Run keeps all of its mutable state on the stack.

The zero value is usable: a Service with no workers runs nothing and returns nil.

func NewService

func NewService() *Service

NewService returns an empty service with a 10-second shutdown budget.

func (*Service) Go

func (s *Service) Go(name string, fn func(context.Context) error) *Service

Go registers a worker to run under Service.Run. name identifies it in a failure message. Nothing starts until Run.

func (*Service) Run

func (s *Service) Run(ctx context.Context) error

Run starts every worker and blocks until the context is done, a worker fails, or all workers have returned.

The first worker error wins: it cancels the others and is what Run returns, wrapped with the worker's name. A worker returning nil has simply finished. Shutdown hooks run in every case, so cleanup is not conditional on success.

A context ENDED from outside is a graceful stop rather than a failure, so Run returns nil for it; only a worker's own error or ErrShutdownTimeout is an error. "Ended" covers both cancellation and a deadline, and the symmetry is deliberate: a worker that writes the idiomatic `<-ctx.Done(); return ctx.Err()` must not fail a bounded run merely because the bound was a timeout rather than a cancel. Both mean "the context you gave me is over".

func (*Service) WithShutdown

func (s *Service) WithShutdown(fn func(context.Context) error) *Service

WithShutdown registers a teardown func run after the workers stop. Hooks run in REVERSE registration order, like deferred calls, so a resource is released before whatever it depends on.

func (*Service) WithShutdownTimeout

func (s *Service) WithShutdownTimeout(d time.Duration) *Service

WithShutdownTimeout bounds how long Run waits for workers to stop and for the shutdown hooks to finish (default 10s). Zero or less means wait forever, which only suits a program with its own outer deadline.

type ServiceError

type ServiceError struct {
	Key string // the registry key that was requested
	// contains filtered or unexported fields
}

ServiceError reports a registry key that was requested but unbound, or bound to the wrong type. It unwraps to ErrServiceNotFound; recover the key with errors.As.

func (*ServiceError) Error

func (e *ServiceError) Error() string

Error renders the fault as a single line, saying which of the two it is: an unbound key sends the reader to the binding code, a wrong type to the declaration.

func (*ServiceError) Unwrap

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

Unwrap exposes both ErrServiceNotFound and ErrInternal, so CategoryOf classifies a missing service as CategoryInternal — a wiring bug, not the user's fault.

type Stream

type Stream int

Stream identifies which of a subprocess's output streams a line came from.

const (
	StreamStdout Stream = iota
	StreamStderr
)

The two output streams a subprocess line can come from.

func (Stream) String

func (s Stream) String() string

String renders the stream name.

type Subprocess

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

Subprocess runs an external command with streamed output, environment and working-directory control, and a timeout — the exec.Cmd wrapper a CLI reaches for when it shells out, whose failures quote the child's stderr instead of "exit status 1".

The zero value is not usable; start from NewSubprocess.

func NewSubprocess

func NewSubprocess(name string, args ...string) *Subprocess

NewSubprocess returns a subprocess that will run name with args. Nothing is executed until Subprocess.Run, Subprocess.Output or Subprocess.Lines.

func (*Subprocess) Lines

func (s *Subprocess) Lines(ctx context.Context) iter.Seq2[Line, error]

Lines runs the command and yields its output one line at a time, tagged with the stream it came from — an iterator rather than a pair of callbacks, so a caller can break out and errors arrive in the loop rather than in a closure that cannot return one.

for line, err := range proc.Lines(ctx) {
    if err != nil { return err }
    fmt.Fprintln(rtx.Stdout, line.Text)
}

The final iteration carries the run's error (nil on success). Stopping early kills the child.

func (*Subprocess) Output

func (s *Subprocess) Output(ctx context.Context) (string, error)

Output runs the command and returns its stdout, trimmed of the trailing newline, "\r\n" included, which is how Windows programs end their lines. Any Subprocess.WithStdout is ignored: Output IS the consumer.

func (*Subprocess) Run

func (s *Subprocess) Run(ctx context.Context) (int, error)

Run executes the command and returns its exit code. A non-zero exit is both a code and a *SubprocessError, so a caller can branch on the number or just check the error. Streams given no writer are captured, so a failure can quote the child's stderr.

func (*Subprocess) WithDir

func (s *Subprocess) WithDir(dir string) *Subprocess

WithDir sets the working directory (default: the parent's).

func (*Subprocess) WithEnv

func (s *Subprocess) WithEnv(entries ...string) *Subprocess

WithEnv adds "KEY=VALUE" entries on top of the inherited environment.

func (*Subprocess) WithStderr

func (s *Subprocess) WithStderr(w io.Writer) *Subprocess

WithStderr streams the child's stderr to w as it is produced. A nil w (the default) captures it instead, so a failure's *SubprocessError can quote it.

func (*Subprocess) WithStdin

func (s *Subprocess) WithStdin(r io.Reader) *Subprocess

WithStdin gives the child an input stream (default: no input, so a child that reads stdin sees EOF rather than blocking on the parent's terminal).

func (*Subprocess) WithStdout

func (s *Subprocess) WithStdout(w io.Writer) *Subprocess

WithStdout streams the child's stdout to w as it is produced. A nil w (the default) discards it for Subprocess.Run; Subprocess.Output captures it regardless.

func (*Subprocess) WithTimeout

func (s *Subprocess) WithTimeout(d time.Duration) *Subprocess

WithTimeout kills the child if it has not exited within d. Zero — the default — means no deadline beyond the context's, so WithTimeout(0) clears an earlier one; a negative d is treated as zero.

func (*Subprocess) WithoutParentEnv

func (s *Subprocess) WithoutParentEnv() *Subprocess

WithoutParentEnv drops the inherited environment, so the child sees only what Subprocess.WithEnv added.

type SubprocessError

type SubprocessError struct {
	Name     string
	Args     []string
	ExitCode int
	Stderr   string // captured stderr when the caller did not stream it, else ""
	Cause    error
}

SubprocessError reports that a subprocess could not be started, exited non-zero, or passed its deadline. ExitCode is the process's own status, -1 when it never ran or was killed. It is ErrInternal — a program that shells out owns the command it chose — and carries the underlying *exec.ExitError for errors.As.

func (*SubprocessError) Error

func (e *SubprocessError) Error() string

Error renders the failure with the command, its exit status, and the first line of whatever it wrote to stderr — rather than a bare "exit status 1".

func (*SubprocessError) Unwrap

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

Unwrap exposes the cause and the ErrInternal sentinel, so both errors.As on an *exec.ExitError and CategoryOf reach through.

type Suggestor

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

Suggestor ranks a possibly-mistyped token against a list of candidates. It is a pure ranking function: it discovers nothing, prints nothing, and holds no state beyond its configuration. Candidates come from whatever vocabulary the caller has — command names, enum members, map keys, any []string.

The common case is one call on a failed parse, which Suggestor.For does end to end:

var suggestor = rotini.NewSuggestor()

func funnel(_ 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 case-insensitive, always: a user typing `--VERBOSE` meant `--verbose`, and having to discover a setting to be told so is not a choice worth offering.

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 the defaults the measurements above chose: 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; start here.

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, nearest first, or nil.

This is the whole point of the type, and the one thing a CLI framework can offer that a string-distance library cannot: rotini owns the error, so it knows both what the user typed and what would have been valid there. Without it every program writes the same plumbing — an errors.As, a Token check, a Candidates check — before it can ask the question.

It returns nil for an error that is not a *ParseError, one carrying no token or no vocabulary, and one whose token is not near anything. **Offering nothing is a real answer**, and the reason this returns a slice rather than a string: a caller branches on emptiness rather than on a sentinel.

Nothing is printed. What to say, and whether to say it, stays with the program.

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 exactly matches a candidate was not mistyped, so it yields nil. "Exactly" means byte-for-byte: a case-only difference IS a typo — the parser rejected "--VERBOSE", and the useful thing to say about it is "--verbose".

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.

More than one is worth offering when two candidates are genuinely close — picking between `--verbose` and `--version` for `--vers` is the user's call, not the program's.

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.

Lower to suggest more freely, raise to suggest only on near-certainty. Both directions have a cost measured in the table above: below about 0.7 the ranker starts offering unrelated words, and above 0.75 it goes silent on four-character commands, which most CLIs have several of.

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 Handlers. It is a build-time bug surfaced at run time, always CategoryInternal, and names the offending command and method so a funnel need not match on the message.

Command and Handler are empty when the fault is not about one command — 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 is rotini's compile-time engine: it turns an end-user's CLI definition (a .rotini.spec + .rotini.conf) into a working Go program.
Package codegen is rotini's compile-time engine: it turns an end-user's CLI definition (a .rotini.spec + .rotini.conf) into a working Go program.

Jump to

Keyboard shortcuts

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