Help us shape the future of Go! Take the 2026 Go Developer Survey

oden

package module
v0.1.1 Latest Latest
Warning

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

Go to latest
Published: Oct 26, 2026 License: MIT Imports: 18 Imported by: 5

README

oden

The lowest layer for extensible, subcommand-driven CLI tools in Go: a bin's inline commands merged with third-party executables on PATH, git- and hiiro-style. No dependencies outside the standard library, no application knowledge.

go get github.com/unixsuperhero/oden@latest

Module github.com/unixsuperhero/oden; requires Go 1.26 or newer.

app := oden.New("hello", os.Args[1:], oden.WithBuiltins())
app.Cmd("greet", "say hello", func(c *oden.Ctx) error {
	return c.Printf("hello, %s\n", c.Arg(0, "world"))
}, "hi")
os.Exit(oden.Report(os.Stderr, "hello", app.Run(context.Background())))

Handlers and input

Cmd registers a Handler with arguments, context, and the app's streams. Command{Run: ...} remains supported. Command.Handle takes precedence when both fields are set. Registration through Cmd uses the same catalog, ownership rules, aliases, and caller-source tracking as Add.

app.Cmd("cat", "concatenate files or stdin", func(c *oden.Ctx) error {
	in := c.Input()
	defer in.Close()
	_, err := io.Copy(c.Stdout, in)
	return err
})
Input Example Handler access
Positional arguments hello greet Ada c.Args, c.Arg(0, "world")
Pipe printf 'hello\n' | hello cat c.Stdin or c.Input()
Redirection hello cat < notes.txt Same stdin stream
File paths hello cat a.txt b.txt c.Input()
Files mixed with stdin printf 'middle\n' | hello cat a.txt - b.txt - reads stdin in order

Open(paths...) opens OS files lazily, one at a time, and reports failures through Read. No paths means stdin. Close the reader when done; closing never closes stdin. WithFS affects executable discovery, not input files. Text() reads all input into memory. Lines() streams newline-stripped lines and an optional final error, closing files even on an early break. It uses bufio.Scanner's default 64 KiB token limit. For larger lines, use Open with your own scanner or buffered reader.

Input helpers concatenate bytes without inserting separators between files. They consume stdin from its current position; repeated - does not rewind it. Arguments are paths only when you call Input, Text, or Lines. Parse flags yourself and pass the remaining paths to Open when needed. No Piped() heuristic is exposed: pipes and redirects both work by reading stdin, and a generic injected io.Reader has no portable terminal status. Context cancellation is available through c.Context; it does not interrupt a blocking file or stdin read automatically.

For an in-process command group, Sub inherits the app's discovery settings, environment, root, and streams, but not extra prefixes:

app.Cmd("admin", "administration", func(c *oden.Ctx) error {
	return c.Sub(oden.WithBuiltins()).
		Cmd("status", "show status", func(c *oden.Ctx) error {
			return c.Println("ready")
		}).Run(c)
})
Declarative command groups

Register child rows with Command.Children when a command has subcommands. Oden owns their resolution, dispatch, and contextual help; no adapter-specific help renderer is needed.

app.Add(oden.Command{Name: "project", Children: []oden.Command{
	{Name: "list", Summary: "list projects", Handle: listProjects},
	{Name: "new", Summary: "create a project", Handle: newProject,
		Usage: "[--name NAME]",
		Parameters: []oden.Parameter{
			{Name: "name", Type: "string", Description: "Project name", Required: true},
		}},
}})

BIN project, BIN project -h, and BIN project --help list that group's commands, descriptions, and parameter metadata without running a handler. BIN project new --help and builtin BIN help project new describe one leaf. Unknown child commands list the current group's candidates, not unrelated root commands. Aliases, unique prefixes, ownership, and <bin>-project-<sub> external discovery use the ordinary child catalog.

Children: nil is a leaf; a non-nil empty slice declares an empty group. Groups dispatch their children; Run / Handle belong on leaves. An omitted group summary is derived from its child names. Optional Usage, Details, and Parameters describe leaf help, while App.HelpText supplies shared application notes inherited by child groups. Parameter order is the order registered; Choices, Source, and Free describe accepted values. These fields are display metadata: oden does not parse or validate application flags. Existing leaf handlers without help metadata retain ownership of their arguments.

The contract

Naming. Any executable named <bin>-<sub> on PATH is the command sub of bin: put mtm-web on PATH and mtm web ARGS... runs it, as git-web makes git web. Nothing in the bin changes.

Discovery. PATH is read in order (an empty entry is ., a repeated directory is read once at its first place). A candidate is a regular file, or a link to one, with an executable bit; directories and non-executables are skipped. The first <bin>-<sub> on PATH wins; later ones are recorded as shadowed. Discovery runs once, on first use, and is cached until the next Add or Cmd. The PATH list and the file system are injectable (WithPath, WithFS).

Extra prefixes (opt-in, WithPrefixes("o")): more bin names whose <name>-<sub> executables are also this bin's commands, discovered after the bin's own <bin>-<sub> (every PATH directory for the bin's prefix, then every directory per extra prefix, in the given order). So mtm built with WithPrefixes("o") runs o-proj for mtm proj, and an mtm-proj anywhere on PATH wins and lists o-proj as shadowed. Entries carry their prefix (JSON) and help shows the path. The descendant rule below is unchanged across prefixes; a Child does not inherit extra prefixes.

Ownership of a token, highest first:

  1. an inline command's name (the first registration wins)
  2. an external's name (the first on PATH wins)
  3. an inline alias (the first registration wins)

Every losing claim stays on the winner as a shadows ... line in help and in commands --json, so nothing is hidden silently.

Resolution of the first word: an exact name or alias, else the one command with a name or alias starting with the word (mtm ta is task when nothing else starts with ta). Descendants never make a prefix ambiguous: a candidate named <other>-... where <other> is also a candidate is that candidate's child and is dropped, so when every candidate equals the shortest one or starts with <shortest>-, the shortest wins (horror bio is biographies even though horror-biographies-freddie-kruger is also on PATH and listed as biographies-freddie-kruger, which, as with git, can be run directly). Any other set of several candidates is ambiguous (horror b with biographies and books). The rule is the same for inline and external commands, and is hiiro's (~/proj/hiiro/lib/hiiro/catalog.rb:56-57: prefix matches are collected by token.start_with?(word), then a match whose name starts with "<other match>-" is rejected).

Merge and shadow (command groups). An inline command group NAME (Children) and an external <bin>-NAME (or <prefix>-NAME through WithPrefixes) on PATH are one group, not a winner and a loser: BIN NAME WORD ... resolves WORD over the group's children (inline, and <bin>-NAME-<sub> on PATH as for any child) together with the external's subcommands. The order is unchanged: an exact name or alias first, inline before the external (a name on both sides runs the inline one), then a unique prefix over the union with the descendant rule. A prefix matching several candidates across both sides is ambiguous and lists them with their source. An external subcommand runs as <bin>-NAME SUB ARGS... with the usual ODEN_PARENT=<bin> and ODEN_ROOT. The group's help lists the union, each row with its source (noun or file:line inline, the external's file name otherwise); an external subcommand that loses to an inline name stays on the inline row as shadows external <bin>-NAME SUB and is still reachable directly as <bin>-NAME SUB. The parent's help shows the group row merges external PATH, and commands marks that claim "merged": true. subcommand not found suggestions search both sides.

Oden learns the external's subcommands by asking it, without any declared dependency: <bin>-NAME catalog (an ochazuke catalog's nouns; for a plain oden bin's command noun, its command list rows), else <bin>-NAME commands --json, each with no stdin, the external's environment and a 2-second timeout. The answer, an empty one too, is cached in $XDG_CACHE_HOME/oden (else ~/.cache/oden), keyed by the executable's path, size and modification time, so a rebuilt or touched external is asked again and an unchanged one never is. An external that answers nothing usable (a shell script) is pass-through: every word that does not resolve to an inline child runs <bin>-NAME WORD ... unchanged, and the group's help says Other words run <bin>-NAME (PATH). The external is never run while a word resolves exactly (inline or a PATH child), nor for a group that has no same-named external, nor for anything outside a group. Only the first external on PATH merges; later ones stay shadowed. A non-group inline command and a same-named external keep the plain rule below: the inline command owns the word and the external is shadowed. Merging is one level deep: a nested inline group merges its own <bin>-NAME-<sub> external, not the parent external's verbs, and Ctx.Sub groups (handler-built) do not merge.

Not found. A word with no exact and no prefix match names nothing. Run prints to stderr BIN: command "WORD" not found (below the root, such as in a command group, BIN GROUP: subcommand "WORD" not found), then, when there are any, a short did you mean: A, B line of at most three commands (first those whose name or alias is a prefix of the word, tasks suggests task, then those within a small edit distance: one insertion, deletion, substitution or adjacent swap for a word of up to five letters, else two) and a (BIN --help lists every command) pointer; with no suggestion, the level's help (the same listing as BIN help or a bare group). It exits 1. Suggestions are never run: tasks does not resolve to task. App.Suggest(word) returns the suggestions and App.NotFound(word) prints the report and returns the exit error.

Running. An inline command gets the remaining words. An external is executed with the remaining words, the App's stdio, and the environment plus ODEN_PARENT=<bin> and ODEN_ROOT=<outermost bin>. No word, or a word that names nothing, runs the Default handler with every argument; without one, no word prints help to stderr and exits 1, and an unknown word is reported as above. A Default that only wants the no-word case (a TUI) returns oden.ErrNotFound for words, and Run reports the unknown word as if there were no Default. An ambiguous word prints the candidates to stderr and exits 1, even with a Default.

Nesting. oden.Child(app, "ai", args) is the App named mtm-ai, so it resolves mtm-ai-skills; an external mtm-ai that is itself an oden app gets its root from ODEN_ROOT. mtm ai skills → mtm-ai skills → mtm-ai-skills; horror bio fre runs horror-biographies fre, which (an oden app named horror-biographies) runs horror-biographies-freddie-kruger.

Exit codes. Run returns an error; ExitCode(err) is 0 for nil, the code of an *ExitError (an external's own exit status, 1 for help and ambiguity), else 1. Report(w, prefix, err) prints the error unless it is an ExitError with no cause, and returns the code.

Builtins (opt-in, WithBuiltins()): usage separates commands by audience. Human-facing commands print readable text; machine-facing commands retain their established JSON formats without requiring an output flag.

Audience Invocation Output
Human help, -h, --help Commands grouped by audience and optional domain category, with kind, summary, source and shadowed claims
Human info [COMMAND] Bin information, or a command's summary, audience, category, kind, source and aliases
Human version Embedded module version, full commit SHA, commit time, dirty-build flag, Go version and module path
Human which WORD name kind source for the resolved command
Machine commands Entry array: name, kind, aliases, summary, category, audience, source, prefix, shadowed
Machine catalog ochazuke/cli schema 1: command noun with list, show and state; no bindings or pages
Machine command list Command row array
Machine command show NAME One command row
Machine state, command state One command row per JSON Line

info COMMAND, which WORD and command show NAME resolve names, aliases and unique prefixes. A row has noun, id, label and data; data includes kind, path, source, aliases, shadows, prefix and optional summary, category and audience. Build info supplies the catalog version. WriteCatalog and WriteState remain explicit JSON APIs.

Machine commands preserve their original argument handling: commands, catalog, state, command list and command state ignore trailing arguments, including the host convention --json '{}' --yes. command show still takes exactly one name, and which exactly one word. No -j is required, no -H / --human mode exists, and output never changes based on whether stdout is a terminal. Use help rather than changing a machine command's output format.

info -h / info --help prints its usage; info -- -h describes the help alias. Invalid info arguments exit 2; unresolved names exit 1. Existing builtins retain their prior usage-error exit behavior.

Version provenance. version is included by WithBuiltins(), so o and applications using the builtins acquire it when rebuilt against this oden version. App registrations can override it like any other builtin. The command reads Go's embedded runtime/debug.ReadBuildInfo, not the current directory, repository, or network. Modified describes the main module's source repository at build time, not its current status. It does not report dirty source in locally replaced dependency repositories.

Use a normal package build with VCS stamping enabled to include the commit, for example go build -buildvcs=true -o ~/bin/o ./cmd/o. The version is the embedded main-module version, including (devel) or a pseudo-version when that is how the binary was built. Builds with no VCS metadata, such as -buildvcs=false or some go run builds, print unknown for the commit, time and modified flag rather than claiming a clean or released build. version -h / version --help prints usage; unexpected arguments exit 2. No output-format flag is needed.

Audience. Set Command.Audience to AudienceMachine for a structured machine endpoint or AudienceHuman for readable output. Unspecified commands, including discovered externals, are presented as human-facing. Audience is presentation metadata, not an output serializer or dispatch rule. An app command overriding a builtin inherits that builtin's audience unless it explicitly sets one. Thus an overridden runtime catalog stays in the machine group without changing its handler. The app still owns the name whenever added, and the builtin remains a shadowed claim.

Family flag convention. Give long flags single-letter aliases from a-zA-Z unless every available letter in that command's scope is already assigned. Reserve -h for help. Human commands default to readable output; documented machine endpoints retain stable structured output. Oden does not parse arbitrary handlers' or external processes' flags. Ochazuke's existing --json OBJECT input protocol is unchanged.

Categories. Audience and domain category are independent. Set Command.Category for inline commands, or use WithCategories(map[string]string{"task": "Work", "web": "Network"}) to assign or override groups by canonical name, including discovered externals. Cmd users can supply the same option. Within each audience, help sorts category headings alphabetically, keeps commands sorted within each group, and retains uncategorized commands under Other. Without categories a group stays flat. Apps with no machine commands retain the plain commands: listing. JSON entries and command rows retain optional category/audience metadata. Neither affects resolution.

Oden does not execute discovered tools to collect metadata, except to learn a merged external's subcommands (see Merge and shadow). External categories must be assigned by the owning app. Child does not inherit category assignments because its command names belong to a different scope.

usage: mtm COMMAND [ARGS...]

commands:
  task, t  (inline)    tasks     app.go:10
           shadows external /bin/mtm-task
  web      (external)            /bin/mtm-web
           shadows external /opt/mtm-web

The o root

cmd/o is the family root bin (oden, ochazuke): an oden app named o with the builtins and nothing else built in. Every word follows the normal rule, so a tool joins the family by installing as o-<tool>; by convention the TUI host, nabe, is installed as both o-nabe and o-tui:

Word Resolves to, highest first
o help, o info [W], o version, o which W; o commands, o catalog, o state, o command list|show|state inline builtins; first group human-facing, second group JSON-first
o tui ARGS, o nabe ARGS external o-tui / o-nabe (nabe)
o proj new x external o-proj new x (ODEN_PARENT=o)
o pr new x the same, by unique prefix

Install: go build -o ~/bin/o ./cmd/o. Another app shares the family's tools with WithPrefixes("o"), as mtm does (mtm proj runs o-proj).

One oden bin delegates to another

examples/delegation.go contains runnable apps selected by executable name. Real projects can keep each app in its own package. Build the example executables into a temporary directory:

bins=$(mktemp -d)
for name in toolbox toolbox-files toolbox-files-text desk; do
	go build -o "$bins/$name" ./examples/delegation.go
done
export PATH="$bins:$PATH"
A parent delegates to a child bin

toolbox has no inline files handler. Discovery finds toolbox-files, which is another oden app with an inline info handler:

toolbox files info Ada
# bin=toolbox-files parent=toolbox root=toolbox arg=Ada
toolbox fi info Ada
# Same result through a unique prefix.
Three bins form a command chain

toolbox-files discovers toolbox-files-text. Each bin consumes its own command word and forwards the rest, together with stdin, stdout, and stderr:

printf 'hello\n' | toolbox files text show
# hello
toolbox files text show README.md
# Prints README.md.
toolbox files text show < README.md
# Prints the same input through stdin.
An unrelated bin shares another family's commands

desk opts into WithPrefixes("toolbox"). It can dispatch files to the same toolbox-files executable without a wrapper or duplicate handler:

desk files info Ada
# bin=toolbox-files parent=desk root=toolbox-files arg=Ada
printf 'shared\n' | desk files text show
# shared

The environment contains ODEN_ROOT=desk on the first shared-prefix hop. New adopts that root only when the executable name starts with desk-. Here it does not, so toolbox-files starts its own root, as the output shows. An installed desk-files wins over toolbox-files; the catalog records the losing executable as shadowed.

API

The signatures below list the consumer methods. go doc -all . also shows every exported data field and its documentation.

type Handler func(*Ctx) error
type Ctx struct {
	context.Context
	App *App
	Name string
	Args []string
	Stdin io.Reader
	Stdout, Stderr io.Writer
}
type Option func(*App)
func New(name string, args []string, opts ...Option) *App
func WithBuiltins() Option
func WithDefault(run func(context.Context, []string) error) Option
func WithPrefixes(names ...string) Option
func WithCategories(categories map[string]string) Option
func WithPath(path string) Option
func WithFS(fsys FS) Option
func WithEnv(env []string) Option
func WithStdio(in io.Reader, out, errOut io.Writer) Option
func Child(parent *App, sub string, args []string) *App
func (a *App) Cmd(name, summary string, run Handler, aliases ...string) *App
func (a *App) Add(cmds ...Command) *App
func (a *App) Catalog() *Catalog
func (a *App) RootName() string
func (a *App) Run(ctx context.Context) error
func (a *App) RunEntry(ctx context.Context, e *Entry, args []string) error
func (a *App) Help(w io.Writer)
func (a *App) List(w io.Writer, entries []*Entry)
func (a *App) NotFound(word string) error // reports an unknown word; exit 1
func (a *App) Suggest(word string) []string
var ErrNotFound error // returned by Default to defer an unknown word to Run
func (c *Ctx) Arg(i int, def string) string
func (c *Ctx) Open(paths ...string) io.ReadCloser
func (c *Ctx) Input() io.ReadCloser
func (c *Ctx) Text() (string, error)
func (c *Ctx) Lines() iter.Seq2[string, error]
func (c *Ctx) Printf(f string, a ...any) error
func (c *Ctx) Println(a ...any) error
func (c *Ctx) Usage(synopsis string) error // prints usage; exit code 2
func (c *Ctx) Sub(opts ...Option) *App
func NewCatalog(bin string, inline []*Command, externals, shadowed []External) *Catalog
func (c *Catalog) Resolve(word string) Resolution
func (c *Catalog) Lookup(token string) *Entry
func (r Resolution) Resolved() bool
func Discover(bin, path string, fsys FS) (found, shadowed []External)
func DiscoverAll(bins []string, path string, fsys FS) (found, shadowed []External)
func PathDirs(path string) []string
func Executable(fsys FS, path string) bool
func ExitCode(err error) int
func Report(w io.Writer, prefix string, err error) int
func (e *ExitError) Error() string
func (e *ExitError) Unwrap() error

Exported data types and values:

Name Consumer-facing data
Command Name, Aliases, Summary, Category, Audience, Children, Usage, Details, Parameters, legacy Run, Handle, Source
Parameter Name, Type, Description, Required, Choices, Source, Free
App Name, Root, Args, Default, Path, FS, Env, Prefixes, Stdin, Stdout, Stderr, HelpText
Catalog Bin, sorted Entries
Entry Name, Kind, Aliases, Summary, Source, Prefix, Shadowed, Sub (a merged external's subcommand), Command, Path
Claim Name, Kind, Source, AliasOf, Merged
External Name, Path, Prefix
Resolution Query, Entry, Exact, Ambiguous, Candidates
Kind KindInline, KindExternal
FS ReadDir(string) ([]fs.DirEntry, error), Stat(string) (fs.FileInfo, error)
OS OS-backed discovery filesystem
ExitError Code int, Err error
EnvParent, EnvRoot Environment keys ODEN_PARENT, ODEN_ROOT

TUI mode and ochazuke

Not built yet; this is the intended seam. oden stays the outer shell and knows no TUI: an oden app hands its Catalog (plain data: entries with name, kind, aliases, summary, source and shadowed claims) to an ochazuke-based TUI that defines a noun command whose list rows are the catalog entries, with verbs such as run (calls RunEntry, an external under a terminal instance) and which, so the TUI is one more consumer of the same table that help and commands read. In the other direction an ochazuke app mounts oden as its outer shell, as matomeru's mtm does: oden resolves the first word, ochazuke's nouns are registered as inline commands that delegate to the app's NOUN VERB shell, and Default runs the TUI when no word is given.

Checks

gofmt -l . && go vet ./... && go test -race ./...
go run ./examples/hello help
go run ./cmd/o help

Documentation

Overview

Package oden is a small framework for extensible subcommand-driven CLI tools: a bin's inline commands merged with the executables named <bin>-<sub> on PATH (git- and hiiro-style third-party commands), resolved by exact name, alias or unique prefix. It knows no application.

Index

Constants

View Source
const (
	EnvParent = "ODEN_PARENT" // the bin that ran it
	EnvRoot   = "ODEN_ROOT"   // the outermost bin of the chain
)

Environment variables an external command is started with.

View Source
const CatalogSchema = 1

CatalogSchema is the catalog format version CatalogDoc writes: the schema ochazuke/cli.Catalog writes, so a host (nabe, o-inventory) reads a plain oden bin and an ochazuke-backed one alike.

View Source
const CommandNoun = "command"

CommandNoun is the one noun a plain oden bin's catalog has: its commands, the catalog entries.

Variables

View Source
var ErrNotFound = errors.New("command not found")

ErrNotFound is what a Default returns to defer an unknown command word to Run: Run then reports it as NotFound does. A Default that returns it with no words gets it back unchanged.

Functions

func Executable

func Executable(fsys FS, path string) bool

Executable reports whether path is a regular file (or a link to one) with an executable bit.

func ExitCode

func ExitCode(err error) int

ExitCode is the process exit code for err: 0 for nil, an ExitError's code, else 1.

func PathDirs

func PathDirs(path string) []string

PathDirs splits a PATH list in order: an empty entry is the current directory, and a repeated directory is kept once, at its first place.

func Report

func Report(w io.Writer, prefix string, err error) int

Report prints err as "prefix: err" to w, unless it is an ExitError with no cause (its message was already printed), and returns the exit code.

Types

type App

type App struct {
	Name string
	Root string // the outermost bin; "" is Name
	Args []string

	// Default runs when no command word is given or the word names no
	// command; it receives every argument. Without it, help is printed to
	// Stderr and Run exits 1. Default returns ErrNotFound to hand an
	// unknown word back to Run, which then reports it as NotFound does.
	Default func(ctx context.Context, args []string) error

	Path func() string // the PATH list; nil reads $PATH
	FS   FS            // nil is OS
	Env  []string      // an external's environment; nil is os.Environ()

	// Prefixes (WithPrefixes) are extra bin names whose <name>-<sub>
	// executables are discovered after the bin's own.
	Prefixes   []string
	Categories map[string]string // optional category overrides by canonical command name

	Stdin          io.Reader
	Stdout, Stderr io.Writer
	HelpText       string // optional application notes appended to help
	// contains filtered or unexported fields
}

App is one bin: its name, its arguments (without the bin name), its inline commands and where it looks for external ones.

func Child

func Child(parent *App, sub string, args []string) *App

Child is the App for parent's command sub: named <parent>-<sub>, so its own externals are <parent>-<sub>-<word>, sharing the parent's root, PATH, file system, environment and streams.

func New

func New(name string, args []string, opts ...Option) *App

New builds an App named name over args. Root is taken from ODEN_ROOT when name is nested under it (an external run by an oden parent).

func (*App) Add

func (a *App) Add(cmds ...Command) *App

Add registers inline commands, recording the caller as their Source when none is set.

func (*App) Catalog

func (a *App) Catalog() *Catalog

Catalog is the merged inline and external commands, discovered on first use and cached until the next Add. A command group whose name an external also has adds that external's subcommands (merge.go).

func (*App) CatalogDoc

func (a *App) CatalogDoc() CatalogDoc

CatalogDoc is the bin's catalog answer: the command noun with list (rows command), show (positional name) and state (the catalog's state). Version is the main module's build version.

func (*App) Cmd

func (a *App) Cmd(name, summary string, run Handler, aliases ...string) *App

Cmd registers a handler and records the caller as its source.

func (*App) CommandRows

func (a *App) CommandRows() []Row

CommandRows are the command rows, one per catalog entry, by name: id is the name, label "NAME KIND PATH-OR-SOURCE".

func (*App) Help

func (a *App) Help(w io.Writer)

Help writes commands separated by audience, then by optional category, including their kind, summary, source and shadowed claims.

func (*App) List

func (a *App) List(w io.Writer, entries []*Entry)

List writes entries grouped by optional category, with uncategorized entries under Other. Without categories it keeps a flat listing.

func (*App) NotFound

func (a *App) NotFound(word string) error

NotFound reports word as naming no command of this level on Stderr: a "BIN: command "WORD" not found" line ("BIN GROUP: subcommand ..." below the root), then a "did you mean:" line and a pointer to --help when Suggest finds any, else the level's help. It returns an ExitError with code 1 and no cause.

func (*App) RootName

func (a *App) RootName() string

RootName is the outermost bin of the chain.

func (*App) Run

func (a *App) Run(ctx context.Context) error

Run resolves the first argument and runs what it names with the rest: an inline command in process, an external with inherited streams. No word or an unknown one runs Default (or prints help and exits 1); an ambiguous prefix prints the candidates and exits 1.

func (*App) RunEntry

func (a *App) RunEntry(ctx context.Context, e *Entry, args []string) error

RunEntry runs one catalog entry with args.

func (*App) Suggest

func (a *App) Suggest(word string) []string

Suggest lists the commands word may have meant, at most three, for a word that names none: first those whose name or alias is a prefix of word (tasks → task), then those within a small edit distance of word (one edit for a word of up to five letters, else two). It only suggests; Resolve never takes these.

func (*App) WriteCatalog

func (a *App) WriteCatalog(w io.Writer) error

WriteCatalog writes CatalogDoc as indented JSON.

func (*App) WriteState

func (a *App) WriteState(w io.Writer) error

WriteState writes the command rows as JSON Lines.

type Audience

type Audience string

Audience identifies a command's intended consumer, independently of its domain category. Unspecified commands are presented as human-facing.

const (
	AudienceHuman   Audience = "human"
	AudienceMachine Audience = "machine"
)

type Catalog

type Catalog struct {
	Bin     string
	Entries []*Entry // sorted by name
	// contains filtered or unexported fields
}

Catalog is the commands one bin can run: its inline commands merged with the externals discovered on PATH. Ownership of a token, highest first:

  1. an inline command's name (the first registration wins)
  2. an external's name (the first on PATH wins)
  3. an inline alias (the first registration wins)

Every losing claim is kept in the winner's Shadowed list.

func NewCatalog

func NewCatalog(bin string, inline []*Command, externals, shadowedExternals []External) *Catalog

NewCatalog merges inline commands (in registration order), externals (in PATH order) and the externals that lost to an earlier PATH entry.

func (*Catalog) Lookup

func (c *Catalog) Lookup(token string) *Entry

Lookup is the entry owning an exact name or alias, or nil.

func (*Catalog) Resolve

func (c *Catalog) Resolve(word string) Resolution

Resolve finds what word names: an exact name or alias, else the one entry with a token starting with word. Descendants never make a prefix ambiguous: a match named <other>-... where <other> also matches is dropped, so bio resolves to biographies over biographies-freddie-kruger, while biographies and books stay ambiguous. These are hiiro's semantics (~/proj/hiiro/lib/hiiro/catalog.rb:56-57).

type CatalogDoc

type CatalogDoc struct {
	Schema   int           `json:"schema"`
	Name     string        `json:"name"`
	Version  string        `json:"version"`
	State    string        `json:"state,omitempty"`
	Nouns    []CatalogNoun `json:"nouns"`
	Bindings []any         `json:"bindings"`
	Pages    []any         `json:"pages"`
}

CatalogDoc is a bin's catalog answer in the ochazuke/cli schema. A plain oden bin has one noun, command, whose rows are its catalog entries; it has no bindings and no pages. State is "command.state".

type CatalogNoun

type CatalogNoun struct {
	Name  string        `json:"name"`
	Pin   string        `json:"pin,omitempty"`
	Verbs []CatalogVerb `json:"verbs"`
}

CatalogNoun is a noun and its verbs, sorted by name.

type CatalogParam

type CatalogParam struct {
	Type        string `json:"type"`
	Required    bool   `json:"required,omitempty"`
	Description string `json:"description,omitempty"`
}

CatalogParam is a verb parameter, in ochazuke.Parameter's field names.

type CatalogVerb

type CatalogVerb struct {
	Name        string                  `json:"name"`
	Description string                  `json:"description,omitempty"`
	Kind        string                  `json:"kind,omitempty"`
	Params      map[string]CatalogParam `json:"params,omitempty"`
	Rows        string                  `json:"rows,omitempty"`
	Quiet       bool                    `json:"quiet,omitempty"`
	Positional  []string                `json:"positional,omitempty"`
}

CatalogVerb is one verb, in ochazuke/cli.CatalogVerb's field names.

type Claim

type Claim struct {
	Name    string `json:"name"`
	Kind    Kind   `json:"kind"`
	Source  string `json:"source"`
	AliasOf string `json:"alias_of,omitempty"`
	// Merged marks the external a command group merges: its subcommands
	// join the group's children (see App.RunEntry).
	Merged bool `json:"merged,omitempty"`
}

Claim is a losing claim on a token: a later inline registration, an external that lost to an inline command or to an earlier PATH entry, or an alias that names a command already owned.

type Command

type Command struct {
	Name     string
	Aliases  []string
	Summary  string
	Category string   // optional listing group
	Audience Audience // empty defaults to human; overrides inherit a builtin's audience
	// Children makes this a command group. A non-nil empty slice is an
	// empty group; nil leaves Run/Handle in control of the arguments.
	Children   []Command
	Usage      string // optional leaf argument synopsis, for contextual help
	Details    string // additional descriptive help text
	Parameters []Parameter
	Run        func(ctx context.Context, args []string) error
	Handle     Handler // takes precedence over Run when set
	Source     string
	// contains filtered or unexported fields
}

Command is an inline command. Run receives the words after the command's own. Source is where it was registered (file:line), filled by Add.

type CommandData

type CommandData struct {
	Kind     Kind     `json:"kind"`
	Category string   `json:"category,omitempty"`
	Summary  string   `json:"summary,omitempty"`
	Audience Audience `json:"audience,omitempty"`
	Path     string   `json:"path,omitempty"` // an external's executable
	Source   string   `json:"source"`
	Aliases  []string `json:"aliases"`
	Shadows  []Claim  `json:"shadows"`
	Prefix   string   `json:"prefix,omitempty"` // an external's <prefix>-
	Sub      string   `json:"sub,omitempty"`    // a merged external's subcommand
}

CommandData is a command row's record: how the entry runs, where from, its aliases and every claim it shadows.

type Ctx

type Ctx struct {
	context.Context
	App            *App
	Name           string
	Args           []string
	Stdin          io.Reader
	Stdout, Stderr io.Writer
}

Ctx is one invocation. Name is the canonical command name, not its alias. Input helpers open OS files; WithFS configures discovery only.

func (*Ctx) Arg

func (c *Ctx) Arg(i int, def string) string

Arg returns a positional argument or def when i is out of bounds.

func (*Ctx) Input

func (c *Ctx) Input() io.ReadCloser

Input treats Args as file paths, like cat. Parse flags before calling Open explicitly if the command accepts anything other than paths.

func (*Ctx) Lines

func (c *Ctx) Lines() iter.Seq2[string, error]

Lines yields lines without newlines, then any read error once. It closes files even when iteration stops early. Scanner's default 64 KiB limit applies.

func (*Ctx) Open

func (c *Ctx) Open(paths ...string) io.ReadCloser

Open reads paths in order, opening one file at a time. "-" means stdin; no paths means stdin. Open and read errors are returned by Read. Close releases the current file but never closes stdin. Callers must close it.

func (*Ctx) Printf

func (c *Ctx) Printf(f string, a ...any) error

Printf writes formatted output to the command's stdout.

func (*Ctx) Println

func (c *Ctx) Println(a ...any) error

Println writes a line to the command's stdout.

func (*Ctx) Sub

func (c *Ctx) Sub(opts ...Option) *App

Sub creates a child app over the remaining arguments and inherited streams. Options override inherited settings; extra prefixes are not inherited.

func (*Ctx) Text

func (c *Ctx) Text() (string, error)

Text reads all input and closes any opened file.

func (*Ctx) Usage

func (c *Ctx) Usage(synopsis string) error

Usage prints usage to stderr and returns exit code 2.

type Entry

type Entry struct {
	Name     string   `json:"name"`
	Kind     Kind     `json:"kind"`
	Aliases  []string `json:"aliases"`
	Summary  string   `json:"summary,omitempty"`
	Category string   `json:"category,omitempty"`
	Audience Audience `json:"audience,omitempty"`
	Source   string   `json:"source"`
	Prefix   string   `json:"prefix,omitempty"` // an external's <prefix>-
	Shadowed []Claim  `json:"shadowed"`

	// Sub is set for a subcommand of a merged external: it runs as
	// Path Sub ARGS... (BIN-NAME SUB ARGS...).
	Sub string `json:"sub,omitempty"`

	Command *Command `json:"-"` // set for KindInline
	Path    string   `json:"-"` // set for KindExternal
	// contains filtered or unexported fields
}

Entry is one command of a catalog: the winning claim on its name, its aliases and every claim it shadows.

type ExitError

type ExitError struct {
	Code int
	Err  error
}

ExitError carries a process exit code; Err (may be nil) is the cause. A nil Err means the message, if any, was already printed.

func (*ExitError) Error

func (e *ExitError) Error() string

Error returns the cause's message, or "exit status N" without one.

func (*ExitError) Unwrap

func (e *ExitError) Unwrap() error

Unwrap returns the cause, so errors.Is and errors.As see through it.

type External

type External struct {
	Name   string `json:"name"`
	Path   string `json:"path"`
	Prefix string `json:"prefix,omitempty"`
}

External is an executable named <Prefix>-<Name> found on PATH. Prefix is the bin itself or one of its extra prefixes (WithPrefixes).

func Discover

func Discover(bin, path string, fsys FS) (found, shadowed []External)

Discover scans the PATH directories for executables named <bin>-<sub>. The first one on PATH for each sub is found; later ones are shadowed. Unreadable directories, directories and non-executables are skipped. Both lists are in PATH order, then by name within a directory.

func DiscoverAll

func DiscoverAll(bins []string, path string, fsys FS) (found, shadowed []External)

DiscoverAll discovers each prefix in turn: every <bin>-<sub> on PATH first, then every <extra>-<sub> per extra prefix, in the given order. A sub already found under an earlier prefix shadows the later ones.

type FS

type FS interface {
	ReadDir(dir string) ([]fs.DirEntry, error)
	Stat(path string) (fs.FileInfo, error)
}

FS is the file system discovery reads: directory listings and Stat (which follows symlinks). OS is the real one; tests pass a fake.

var OS FS = osFS{}

OS is the operating system's file system.

type Handler

type Handler func(*Ctx) error

Handler runs an inline command with its arguments and streams.

type Kind

type Kind string

Kind says how an entry runs: an inline Command in this process, or an external executable found on PATH.

const (
	KindInline   Kind = "inline"
	KindExternal Kind = "external"
)

type Option

type Option func(*App)

Option configures an App in New.

func WithBuiltins

func WithBuiltins() Option

WithBuiltins adds human-facing help, info, version and which, and machine-facing commands, catalog, state and command list|show|state. Machine commands retain their JSON formats and argument handling. An app's own command always owns its name over a builtin.

func WithCategories

func WithCategories(categories map[string]string) Option

WithCategories assigns listing groups by canonical command name, including discovered externals. Unassigned commands remain visible under Other.

func WithDefault

func WithDefault(run func(ctx context.Context, args []string) error) Option

WithDefault sets the handler for no command word or an unknown one.

func WithEnv

func WithEnv(env []string) Option

WithEnv sets the environment externals start with.

func WithFS

func WithFS(fsys FS) Option

WithFS sets the file system discovery reads.

func WithPath

func WithPath(path string) Option

WithPath fixes the PATH list discovery reads.

func WithPrefixes

func WithPrefixes(names ...string) Option

WithPrefixes adds bin names whose <name>-<sub> executables on PATH are also this bin's commands, discovered after its own <bin>-<sub> and in the given order: with WithPrefixes("o"), mtm proj runs o-proj unless an mtm-proj is on PATH, which then shadows it. A Child does not inherit them.

func WithStdio

func WithStdio(in io.Reader, out, errOut io.Writer) Option

WithStdio sets the streams commands read and write.

type Parameter

type Parameter struct {
	Name        string
	Type        string
	Description string
	Required    bool
	Choices     []string
	Source      string
	Free        bool
}

Parameter describes a command argument for help. Oden does not parse it.

type Resolution

type Resolution struct {
	Query      string
	Entry      *Entry
	Exact      bool
	Ambiguous  bool
	Candidates []*Entry
}

Resolution is what a word names: the entry when it names exactly one (Exact for a name or alias, else a unique prefix), or the candidates when it is a prefix of several.

func (Resolution) Resolved

func (r Resolution) Resolved() bool

Resolved reports whether the word names one entry.

type Row

type Row struct {
	Noun  string `json:"noun"`
	ID    string `json:"id"`
	Label string `json:"label,omitempty"`
	Data  any    `json:"data,omitempty"`
}

Row is one line of a state answer, ochazuke/cli.Row's shape:

{"noun":"command","id":"proj","label":"proj  external  /home/u/bin/o-proj","data":{...}}

Directories

Path Synopsis
cmd
o command
Command o is the family root bin (oden, ochazuke): every o-<tool> on PATH is o's command <tool> by oden's normal rule (o proj new x runs o-proj new x with ODEN_PARENT=o), plus oden's builtins.
Command o is the family root bin (oden, ochazuke): every o-<tool> on PATH is o's command <tool> by oden's normal rule (o proj new x runs o-proj new x with ODEN_PARENT=o), plus oden's builtins.
Build this source under the names listed in README's delegation examples.
Build this source under the names listed in README's delegation examples.
hello command
Command hello demonstrates context handlers (greet, echo, cat, lines) and any hello-<sub> executable on PATH.
Command hello demonstrates context handlers (greet, echo, cat, lines) and any hello-<sub> executable on PATH.

Jump to

Keyboard shortcuts

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