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:
- Standalone — an own spec node with its own generated stub. The default.
- 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.
- 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.
- 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.
- 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:
Collect is the typical handler's whole input story: every declared channel reconciled and validated in one line, into the command's generated inputs type.
inputs, err := rotini.Collect[DeployInputs](rtx)
CollectP adds the provenance Report. Both ride the BindMeta the generated NewProgram supplies via Program.WithBindMeta.
Parser parses and validates the argv channel alone — GNU/POSIX grammar, typed coercion, enum and constraint checks — failing with a *ParseError. Binder is Collect's engine, for callers who want to hold the meta explicitly. Neither needs supplying to be used: Collect builds its own. Program.WithParser replaces only the parser that Context.Parser returns.
Deprecations reports the deprecated aliases and identifiers this invocation actually used. It is a plain function over the Context and needs no service bound.
The per-channel surface (ParseArgv, ParseEnv, ParseFiles, ParseStdin, Defaults, composed by OverlayInputs or OverlayInputsP) acquires channels one at a time, for programs that want custom precedence.
Suggestor turns a *ParseError's rejected token and candidate vocabulary into "did you mean" suggestions — Suggestor.For does it in one call. Constructing one is the whole of the opt-in: rotini emits nothing of its own, and what to say stays with the program.
Program.WithResolver and Program.WithLifecycle replace the resolve and orchestration phases wholesale; FlagValueCompleter and ArgValueCompleter feed dynamic completion.
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 ¶
- Variables
- func AtFrame(i int, hook func(context.Context, *Context)) func(context.Context, *Context)
- func Collect[T any](rtx *Context) (T, error)
- func DiscoveryDiagnostics(cmd ResolvedCommand) []error
- func EnvNoColor() bool
- func ExitCode(code int) error
- func InternalError(err error) error
- func IsTerminal(stream any) bool
- func OverlayInputs[T any](layers ...Layer[T]) T
- func Ptr[T any](v T) *T
- func ReadSecret(r io.Reader) ([]byte, error)
- func RemoteBinaryPath(cmd ResolvedCommand, name string) (string, bool)
- func Strip(text string) string
- func TerminalSize(stream any) (cols, rows int, ok bool)
- func UsageError(err error) error
- type ArgDef
- type ArgValueCompleter
- type Base64Bytes
- type BindError
- type BindMeta
- type Binder
- type ByteSize
- type Category
- type CommandDef
- type Completion
- type ConfigFile
- type Constraints
- type Context
- func (rtx *Context) Bind(key string, value any) *Context
- func (rtx *Context) BindIfAbsent(key string, value any) *Context
- func (rtx *Context) Chain() []ResolvedCommand
- func (rtx *Context) Command() ResolvedCommand
- func (rtx *Context) CommandPath() string
- func (rtx *Context) Exit(code int)
- func (rtx *Context) Failed() bool
- func (rtx *Context) Frame() ResolvedCommand
- func (rtx *Context) Get[T any](key string) (T, bool)
- func (rtx *Context) Halt()
- func (rtx *Context) HaltWith(err error)
- func (rtx *Context) HaltWithCode(code int)
- func (rtx *Context) Help() string
- func (rtx *Context) IsLeaf() bool
- func (rtx *Context) MustGet[T any](key string) T
- func (rtx *Context) Parser() *Parser
- func (rtx *Context) RecordError(err error)
- func (rtx *Context) RecordInfo(msg string)
- func (rtx *Context) RecordSuccess(msg string)
- func (rtx *Context) RecordWarning(warn error)
- func (rtx *Context) Value(key string) any
- func (rtx *Context) Version() string
- func (rtx *Context) WithBindMeta(meta BindMeta) *Context
- func (rtx *Context) WithBinder(fn func(BindMeta) *Binder) *Context
- func (rtx *Context) WithHelp(help HelpFunc) *Context
- func (rtx *Context) WithParser(parser *Parser) *Context
- func (rtx *Context) WithVersion(version string) *Context
- type DefaultCascadingPostRun
- type DefaultCascadingPreRun
- type DefaultHooks
- type DefaultPostRun
- type DefaultPreRun
- type Definition
- type Deprecation
- type DiscoverDef
- type DiscoveredPlugin
- type FieldPath
- type FlagDef
- type FlagDependency
- type FlagGroup
- type FlagGroupKind
- type FlagValueCompleter
- type FunnelFunc
- type Handlers
- type HelpFunc
- type HexBytes
- type Key
- type Layer
- type Lifecycle
- type LifecycleStep
- type Line
- type Option
- type Outcome
- type PanicError
- type ParseError
- type ParseKind
- type Parser
- type PathFromDef
- type Presence
- type Program
- func (p *Program) Bind(key string, value any) *Program
- func (p *Program) Execute() error
- func (p *Program) Run(argv []string) (int, error)
- func (p *Program) RunContext(ctx context.Context, argv []string) (int, error)
- func (p *Program) With(opts ...Option) *Program
- func (p *Program) WithArgs(args []string) *Program
- func (p *Program) WithBindMeta(meta BindMeta) *Program
- func (p *Program) WithBinder(fn func(BindMeta) *Binder) *Program
- func (p *Program) WithContext(ctx context.Context) *Program
- func (p *Program) WithExit(fn func(int)) *Program
- func (p *Program) WithFunnel(fn FunnelFunc) *Program
- func (p *Program) WithHelp(help HelpFunc) *Program
- func (p *Program) WithLifecycle(fn Lifecycle) *Program
- func (p *Program) WithPanicRecover(enabled bool) *Program
- func (p *Program) WithParser(parser *Parser) *Program
- func (p *Program) WithResolver(fn Resolver) *Program
- func (p *Program) WithSignals(sigs ...os.Signal) *Program
- func (p *Program) WithStderr(w io.Writer) *Program
- func (p *Program) WithStdin(r io.Reader) *Program
- func (p *Program) WithStdout(w io.Writer) *Program
- func (p *Program) WithTeardownOnPanic(enabled bool) *Program
- func (p *Program) WithVersion(version string) *Program
- func (p *Program) WithoutSignalHandling() *Program
- type Provenance
- type REPL
- func (r *REPL) Complete(line string, pos int) []string
- func (r *REPL) Run(ctx context.Context) error
- func (r *REPL) WithErrorEcho(enabled bool) *REPL
- func (r *REPL) WithExitCommands(words ...string) *REPL
- func (r *REPL) WithInput(in io.Reader) *REPL
- func (r *REPL) WithIntercept(fn func(ctx context.Context, line string) (bool, error)) *REPL
- func (r *REPL) WithInterrupts(ch <-chan struct{}) *REPL
- func (r *REPL) WithLineReader(fn func(ctx context.Context, prompt string) (string, error)) *REPL
- func (r *REPL) WithOutput(out io.Writer) *REPL
- func (r *REPL) WithPrompt(prompt string) *REPL
- func (r *REPL) WithPromptFunc(fn func() string) *REPL
- type RemoteDef
- type RemoteDiscoveryDef
- type RemoteDispatch
- type RemoteError
- type RemoteErrorKind
- type Report
- type Resolution
- type ResolvedCommand
- type Resolver
- type Service
- type ServiceError
- type Stream
- type Subprocess
- func (s *Subprocess) Lines(ctx context.Context) iter.Seq2[Line, error]
- func (s *Subprocess) Output(ctx context.Context) (string, error)
- func (s *Subprocess) Run(ctx context.Context) (int, error)
- func (s *Subprocess) WithDir(dir string) *Subprocess
- func (s *Subprocess) WithEnv(entries ...string) *Subprocess
- func (s *Subprocess) WithStderr(w io.Writer) *Subprocess
- func (s *Subprocess) WithStdin(r io.Reader) *Subprocess
- func (s *Subprocess) WithStdout(w io.Writer) *Subprocess
- func (s *Subprocess) WithTimeout(d time.Duration) *Subprocess
- func (s *Subprocess) WithoutParentEnv() *Subprocess
- type SubprocessError
- type Suggestor
- func (s *Suggestor) Closest(input string, candidates []string) (suggestion string, ok bool)
- func (s *Suggestor) For(err error) []string
- func (s *Suggestor) Suggest(input string, candidates []string) []string
- func (s *Suggestor) WithMaxResults(n int) *Suggestor
- func (s *Suggestor) WithMinScore(score float64) *Suggestor
- type WiringError
Examples ¶
Constants ¶
This section is empty.
Variables ¶
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.
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.
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.
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.
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 ¶
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 ¶
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 ¶
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 ¶
InternalError tags err as a CategoryInternal error — a bug or misconfiguration — without altering its message. It returns nil when err is nil.
func IsTerminal ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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())
}
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 ¶
NewBinder returns the default binder, configured from the generated BindMeta descriptor.
func (*Binder) Bind ¶
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 ¶
MarshalText implements encoding.TextMarshaler, rendering the size as ByteSize.String does.
func (ByteSize) 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 ¶
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 ¶
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
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:
- what was typed — the Context.Argv, Context.Stdin, Context.Stdout and Context.Stderr fields above
- which command — Context.Frame is whose hook is running, Context.Command is the one the user invoked, Context.IsLeaf says whether they are the same, Context.CommandPath names it, Context.Chain is the whole path
- YOUR dependencies — Context.Bind and Context.BindIfAbsent for a key you name, Context.Get and Context.MustGet to read one back, Context.Value for the raw entry
- report what happened — Context.RecordInfo, Context.RecordSuccess, Context.RecordWarning, Context.RecordError, and Context.Failed to ask
- stop — Context.HaltWith to fail, Context.Halt to stop, Context.HaltWithCode when the code is the point, Context.Exit to skip pending teardown
- rotini's own seams — Context.Version, Context.Help and Context.Parser to read, and for a Context you built yourself rather than one the runtime handed you, Context.WithVersion, Context.WithHelp, Context.WithParser, Context.WithBindMeta and Context.WithBinder to set
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
type DefaultHooks struct {
DefaultCascadingPreRun
DefaultPreRun
DefaultPostRun
DefaultCascadingPostRun
}
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.
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.
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 ¶
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 ¶
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 ¶
MarshalText implements encoding.TextMarshaler as lowercase hex without a prefix.
func (*HexBytes) UnmarshalText ¶
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 ¶
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 ¶
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]) MustGet ¶
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 ¶
Name returns the underlying registry key, for interoperating with the untyped Context.Bind / Context.Get surface.
func (Key[T]) Provide ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.
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 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 ¶
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.
type PanicError ¶
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.
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 (*Parser) Parse ¶
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:
- run it — Program.Execute exits, Program.Run returns the code, Program.RunContext scopes one invocation
- streams — Program.WithStdin, Program.WithStdout, Program.WithStderr
- process — Program.WithExit, Program.WithArgs, Program.WithContext, Program.WithSignals, Program.WithoutSignalHandling
- failure — Program.WithTeardownOnPanic, Program.WithPanicRecover, Program.WithFunnel
- YOUR dependencies — Program.Bind for a key you name, Program.With with Provide for a type-checked one
- rotini's own seams — Program.WithVersion, Program.WithParser, Program.WithBinder, and the two the generated code handles for you, Program.WithBindMeta and Program.WithHelp
- replace a phase — Program.WithResolver, Program.WithLifecycle
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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:
- REPL.WithLineReader — where a line comes from (readline, a socket, a test)
- REPL.WithPromptFunc — what the prompt says, per line, from live state
- REPL.Complete — what the command tree would complete, for that reader to render
- REPL.WithInterrupts — what ^C cancels
- REPL.WithIntercept — lines that never reach dispatch at all (\d, .schema, :q)
The zero value is not usable; start from NewREPL.
func NewREPL ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
WithInput overrides the line source. A nil reader, with no REPL.WithLineReader installed, makes REPL.Run return an error.
func (*REPL) WithIntercept ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.
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 ¶
Go registers a worker to run under Service.Run. name identifies it in a failure message. Nothing starts until Run.
func (*Service) Run ¶
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 ¶
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 ¶
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.
The two output streams a subprocess line can come from.
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.
Source Files
¶
- binder.go
- category.go
- completion.go
- context.go
- definition.go
- detect.go
- doc.go
- handlers.go
- interactive.go
- key.go
- lifecycle.go
- object.go
- overlay.go
- parser.go
- program.go
- remote.go
- repl.go
- resolve.go
- service.go
- subprocess.go
- suggestor.go
- terminal_echo.go
- terminal_echo_linux.go
- terminal_echo_termios.go
- terminal_size.go
- terminal_size_unix.go
- text.go
- types.go
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. |