cli

package
v0.19.2 Latest Latest
Warning

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

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

Documentation

Overview

Package cli declares the command-line port of the SDK: the Action one command runs, the Binder that declares its flags, and the Executor that resolves an argument vector against a tree of CommandValue and dispatches it. A core sibling admitted by ADR 0065.

What this domain adds to package flag, and what it does not touch

The stdlib's flag package parses flags. It does that well and this domain does not reimplement one byte of it: the flag SYNTAX, the flag.Value interface, the -x=v / -x v / --x forms, the "stop at the first non-flag argument" rule and the rendering of the defaults table all stay flag's. A Binder receives the stdlib's own *flag.FlagSet, unchanged and unwrapped — the same shape internal/core/data/vfs takes for io/fs, and for the same reason: a wrapper around a stdlib contract is a second contract to learn, to keep in sync, and to get subtly wrong.

What flag does NOT have is exactly what this domain adds, and nothing else:

  • sub-commands, to arbitrary depth;
  • one help text GENERATED from the declarations, so it cannot lie;
  • a typed exit status, carried by the SDK's own errs codes;
  • a seam to the rest of the SDK — the flags an operator actually typed can become the top layer of a config load.

Nothing here can end the process

flag's own answer to a bad argument is flag.ExitOnError, which calls os.Exit from inside a library. That is refused by name: it makes the parse untestable, it skips every deferred function in the program, and it takes a decision — whether this process should still be alive — that belongs to main and to nobody else. Every failure in this domain is a returned *errs.Error carrying an exit status; main is the only place that may act on it. TestPackageNeverEndsTheProcess in internal/service/app/cli is the executable form of that claim.

The engine that walks the tree, the help renderer and the config adapter live in internal/service/app/cli; this package owns the contract, the two domain values, and the sentinels a malformed declaration is refused with.

Package cli — the declared command: a name, what it says about itself, its flags, and EITHER what it does OR what it contains.

Package cli — what a resolved command line looks like by the time an Action sees it.

Package cli — ranges 0.2.32.* (ADR 0065 core/app/cli block) and 0.3.62.* (ADR 0065 service/app/cli block, declared here since ADR 0160).

Package cli — declares the sentinel *errs.Error outcomes of the domain: the refused declarations, and the engine's verdicts on a command line. Each var's name equals its errs.Define Reason in SCREAMING_SNAKE form.

Index

Constants

View Source
const CodeAmbiguousCommand errs.Code = 0x00_02_20_02 // 0.2.32.2

CodeAmbiguousCommand identifies a declaration refused for carrying BOTH a Run and Commands, where the meaning of an existing command line would change the day a child is added.

View Source
const CodeCommandPanicked errs.Code = 0x00_03_3E_04 // 0.3.62.4

CodeCommandPanicked identifies an Action whose goroutine panicked; the engine recovered it, captured the originating stack, and turned it into this command's failure so main still gets a typed exit status instead of the runtime's 2.

View Source
const CodeDuplicateCommand errs.Code = 0x00_02_20_03 // 0.2.32.3

CodeDuplicateCommand identifies a declaration refused because two children of one group share a name, so the resolver could only ever reach one of them.

View Source
const CodeHelpWriteFailed errs.Code = 0x00_03_3E_05 // 0.3.62.5

CodeHelpWriteFailed identifies a help request (-h) whose answer the diagnostic stream did not take: a closed pipe, a full disk, a writer that accepted only part of the page. Asking a question is not a failure, but an answer nobody received is, so this is EX_IOERR and not the 0 a delivered help earns.

View Source
const CodeInvalidCommand errs.Code = 0x00_02_20_01 // 0.2.32.1

CodeInvalidCommand identifies a declaration refused because the command could never be reached or could never run: a blank name, a name carrying a space or leading "-", a child with no summary, or neither a Run nor any Commands.

View Source
const CodeInvalidFlags errs.Code = 0x00_03_3E_03 // 0.3.62.3

CodeInvalidFlags identifies a flag vector package flag refused: an unknown flag, a missing value, or a value its flag.Value could not parse. flag's own text is preserved in Private, never in Public, because it quotes the operator's value verbatim.

View Source
const CodeMissingCommand errs.Code = 0x00_03_3E_02 // 0.3.62.2

CodeMissingCommand identifies a group invoked with no sub-command at all. A group has no action of its own, so this is an incomplete command line and never a successful no-op.

View Source
const CodeReservedFlag errs.Code = 0x00_02_20_04 // 0.2.32.4

CodeReservedFlag identifies a declaration refused because its Binder bound "h" or "help", the two names the domain answers itself.

View Source
const CodeUnknownCommand errs.Code = 0x00_03_3E_01 // 0.3.62.1

CodeUnknownCommand identifies an argument vector naming a sub-command the group does not declare. The group's help — which lists every name it DOES declare — has been written to the diagnostic stream by the time this is returned.

Variables

View Source
var (
	// InvalidCommand is returned by the constructor for a command that could
	// never be reached or could never run. The fields name the path and the
	// clause that failed.
	//
	// "Neither Run nor Commands" is the case worth naming: it is the inert
	// declaration ADR 0031 refuses. A name an operator can type that does
	// nothing looks configured, reports success, and is discovered only by
	// somebody wondering why the tool did not do the thing.
	InvalidCommand = errs.Define(CodeInvalidCommand, "INVALID_COMMAND",
		"The command declaration is not runnable and was refused",
		"core/app/cli: blank or unreachable name, missing summary, or neither Run nor Commands; the fields name the path and the clause",
		errs.WithExitCode(exitConfig))

	// AmbiguousCommand is returned by the constructor for a command carrying
	// both a Run and Commands.
	//
	// The refusal is not fastidiousness. With both, `tool db migrate` means
	// "run db with the positional argument migrate" — until somebody adds a
	// child named migrate, at which point the identical command line means
	// something else, in a release that changed no line of db's own code. A
	// declaration whose meaning depends on which siblings exist today is the
	// same class of failure as prefix matching, and it is refused for the same
	// reason.
	AmbiguousCommand = errs.Define(CodeAmbiguousCommand, "AMBIGUOUS_COMMAND",
		"A command may declare an action or sub-commands, not both",
		"core/app/cli: Run and Commands are both set; adding a child would silently change what an existing command line means",
		errs.WithExitCode(exitConfig))

	// DuplicateCommand is returned by the constructor when two children of one
	// group share a name. The resolver stops at the first match, so the second
	// declaration would be unreachable code that looks reachable.
	DuplicateCommand = errs.Define(CodeDuplicateCommand, "DUPLICATE_COMMAND",
		"Two sub-commands are declared under the same name",
		"core/app/cli: sibling names are unique per group; the fields carry the parent path and the repeated name",
		errs.WithExitCode(exitConfig))

	// ReservedFlag is returned by the constructor when a Binder bound "h" or
	// "help".
	//
	// The domain answers both names itself, and it must: package flag reports
	// an undefined -h as flag.ErrHelp rather than as a parse failure, and that
	// is the ONLY signal distinguishing "the operator asked for help" (exit 0)
	// from "the operator got it wrong" (exit 64). A command that binds -h
	// takes that signal away, and the engine would report a successful parse
	// where an operator expected a help page.
	ReservedFlag = errs.Define(CodeReservedFlag, "RESERVED_FLAG",
		"The flag names -h and -help are answered by the SDK",
		"core/app/cli: a Binder bound h or help; the engine needs flag.ErrHelp to tell a help request from a usage error",
		errs.WithExitCode(exitConfig))

	// UnknownCommand is returned when a token does not name any child of the
	// group that was reached.
	//
	// There is deliberately no "did you mean …?". The group's help has already
	// been written, and it lists every name that WOULD have worked — which is
	// the complete answer, cannot be wrong, and costs no edit-distance table
	// to keep correct. A suggestion is a second answer that can differ from
	// the first.
	UnknownCommand = errs.Define(CodeUnknownCommand, "UNKNOWN_COMMAND",
		"That sub-command does not exist",
		"service/app/cli: the token names no child of this group; the fields carry the group path and the token",
		errs.WithExitCode(exitUsage))

	// MissingCommand is returned when a group is invoked with nothing after
	// its flags.
	//
	// It is a failure and not a quiet success on purpose: a group declares no
	// action, so `tool db` did nothing, and a script that read exit status 0
	// there would treat "I forgot the verb" as "the migration ran".
	MissingCommand = errs.Define(CodeMissingCommand, "MISSING_COMMAND",
		"This command needs a sub-command",
		"service/app/cli: a group has no action of its own; the field carries the group path and its help was written",
		errs.WithExitCode(exitUsage))

	// InvalidFlags is returned when package flag refuses the vector.
	//
	// flag's own message quotes the operator's value ("invalid value \"abc\"
	// for flag -n"), so it lives in a field and in Private and never in
	// Public, which is the wire-safe half.
	InvalidFlags = errs.Define(CodeInvalidFlags, "INVALID_FLAGS",
		"The flags for this command could not be parsed",
		"service/app/cli: flag.FlagSet.Parse refused the vector; the fields carry the command path and flag's own text",
		errs.WithExitCode(exitUsage))

	// CommandPanicked is the failure of an Action that panicked.
	//
	// It is recovered rather than left to the runtime for one reason that is
	// not "hiding the bug": a panicking Go program exits with status 2, which
	// sysexits gives no meaning and which several supervisors read as a usage
	// error. Nothing is lost — the recovered value AND the stack of the
	// goroutine that actually failed both travel as fields — and what changes
	// is that main, rather than the runtime, decides what the process does
	// next. It keeps the default EX_SOFTWARE (70): the command line was fine.
	CommandPanicked = errs.Define(CodeCommandPanicked, "COMMAND_PANICKED",
		"The command panicked and was recovered",
		"service/app/cli: the Action panicked; the fields carry the command path, the panic value and the originating stack")

	// HelpWriteFailed is returned when -h asked for the help and the
	// diagnostic stream did not take it.
	//
	// Only the -h path returns it. After a bad command line the usage error is
	// the verdict and the help is the actionable half of it, so a failed write
	// there does not replace the status the operator's mistake earned.
	HelpWriteFailed = errs.Define(CodeHelpWriteFailed, "HELP_WRITE_FAILED",
		"The help could not be written",
		"service/app/cli: the diagnostic stream refused the help -h asked for, or took part of it; the fields carry the command path",
		errs.WithExitCode(exitIOErr))
)

Functions

This section is empty.

Types

type Action

type Action func(ctx context.Context, invocation InvocationValue) error

Action is what one command DOES. It receives the context Executor.Execute was called with — a cancelled context is how a caller aborts a running command — and the resolved InvocationValue.

Returning a non-nil error is how a command fails. If it is an *errs.Error the SDK's origin-wins rule (CLAUDE.md rule 6) preserves its Code and its exit status all the way out of Execute, so a command's own exit status is never overwritten by the framework that called it.

type Binder

type Binder func(flags *flag.FlagSet)

Binder declares one command's flags onto the set that will parse them.

It is handed the stdlib's *flag.FlagSet deliberately: every flag type the stdlib ships, and every flag.Value a caller has already written, works here with no adapter. The set is fresh, its name is the command path, and its error handling is always flag.ContinueOnError.

A Binder MUST only touch the set it is given, and MUST be safe to call more than once: the engine runs it once at construction to validate the declaration and to render help, and once per invocation to parse.

What concurrency the SDK cannot give you

The engine keeps no per-invocation state, so two concurrent Executor.Execute calls do not interfere. A Binder, however, usually writes into a variable the CALLER closed over — fs.IntVar(&port, …) — and that variable is the caller's, invisible to the SDK and written by every concurrent invocation. Concurrent execution is therefore safe only when the Binder allocates its destination per call (fs.Int, fs.String, …) and the Action reads it back through InvocationValue.Flags. That is what those sets are for. The alternative would be the SDK taking a lock around memory it does not own, for a duration it cannot know.

type CommandValue

type CommandValue struct {
	// Name is the single token an operator types to reach this command. It is
	// compared by exact byte equality — there is deliberately no abbreviation
	// matching and no case folding, because both make the meaning of an
	// existing command line depend on which siblings exist today.
	//
	// It must be non-empty, must contain no space, and must not begin with
	// "-": a name beginning with "-" could never be typed, since the parent's
	// flag parse would consume it as a flag and stop before it.
	Name string
	// Summary is the one-line description listed beside this command in its
	// parent's help. It is required for a child, because a command listed with
	// no summary is a name an operator has to guess at.
	Summary string
	// Description is the optional long form shown in this command's OWN help,
	// under the usage line. It may span several lines; it is written verbatim.
	Description string
	// Flags declares this command's flags. A nil Binder is a working
	// declaration and means "this command takes no flags" — unlike a nil Run,
	// which is a real absence, a command with no flags is an ordinary command.
	Flags Binder
	// Run is what a LEAF does. Exactly one of Run and Commands is set.
	Run Action
	// Commands are a GROUP's children, in the order the help lists them. Two
	// children may not share a Name.
	Commands []CommandValue
}

CommandValue declares one command. The tree it forms is the whole declaration: the engine walks it to resolve an argument vector, and the help renderer walks the SAME tree to render the text, so a command that exists and a command that is documented are one fact rather than two.

A command is a LEAF or a GROUP, never both:

Both other combinations are refused at construction. Neither Run nor Commands is an inert command — a name an operator can type that does nothing, which is exactly the zero value ADR 0031 refuses. Both is worse: it makes `tool db migrate` mean "run db with the argument migrate" until somebody adds a child named migrate, at which point the same command line silently means something else. A declaration whose meaning changes when a SIBLING is added is not a declaration.

CommandValue is a published concrete shape (pkg/v1/app/cli.Command aliases it), so ADR 0040 applies: it may still change while the module is v0, said out loud, and not after v1.

func (CommandValue) IsGroup

func (c CommandValue) IsGroup() bool

IsGroup reports whether this command dispatches to children rather than running an Action. It reads the declaration rather than a flag set at construction, so it answers the same on a CommandValue the caller has just written and on one the engine has already validated.

type Executor

type Executor interface {
	// Execute resolves args against the tree and runs the command it names.
	// args is the vector WITHOUT the program name — os.Args[1:] at a call
	// site — because a library that reads os.Args itself cannot be tested and
	// cannot be embedded.
	//
	// It returns nil when the command succeeded AND when help was requested,
	// because asking for help is not a failure. Every other outcome is an
	// *errs.Error whose exit status the caller reads with errs.ExitCodeOf.
	Execute(ctx context.Context, args []string) error
}

Executor resolves an argument vector against a command tree and runs the command it names.

IFACE-PLUGIN: the concrete engine stays unexported behind its constructor in internal/service/app/cli.

type InvocationValue

type InvocationValue struct {
	// Path is the resolved command path, root first — {"tool", "db",
	// "migrate"}. It is what the help and every error message name the command
	// by, so a message and a shell prompt spell the same thing.
	Path []string
	// Args are the positional arguments left after the LEAF's flags were
	// parsed: flag.FlagSet.Args of the last set in Flags.
	//
	// It is nil when there were none, which is Go's ordinary spelling — len
	// and range answer identically for a nil and an empty slice, and manufacturing
	// an empty one would only hide that distinction from a caller who does not
	// need it.
	Args []string
	// Flags are the flag sets that parsed this invocation, root first, one per
	// element of Path; the leaf's own set is the last. A group's flags are
	// parsed by the group's set before its child's name is even read, which is
	// why they are separate sets and not one merged one.
	//
	// The stdlib's set is handed over unchanged rather than wrapped, so
	// Lookup, Visit, VisitAll, NFlag and Arg all work exactly as their
	// documentation says. Calling Parse on one again is not defended against:
	// the engine has already parsed it, and re-parsing it is a caller's bug
	// the SDK cannot distinguish from a caller's intent.
	Flags []*flag.FlagSet
	// Output is the writer this command may print to. It is never nil.
	//
	// It is NOT os.Stdout unless the caller said so: ADR 0030 makes stdout a
	// protocol channel that no SDK default may claim, so the zero
	// configuration resolves this to os.Stderr and a tool whose output IS a
	// protocol sets it explicitly, in main, on one visible line. An Action is
	// handed the writer precisely so it never has to reach for a stream
	// itself.
	Output io.Writer
}

InvocationValue is one resolved command line: which command was reached, what was left over after its flags were parsed, the sets that parsed them, and the writer the command may print to.

InvocationValue is a published concrete shape (pkg/v1/app/cli.Invocation aliases it), so ADR 0040 applies: it may still change while the module is v0, said out loud, and not after v1.

func (InvocationValue) Leaf

func (i InvocationValue) Leaf() *flag.FlagSet

Leaf returns the flag set of the command that is actually running — the last element of InvocationValue.Flags — or nil when the command declared no flags anywhere on its path.

It exists so a caller reading its own flags does not index a slice whose length is a property of how deep the command happens to sit in the tree.

Jump to

Keyboard shortcuts

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