CLI
Overview of the CLI system -- commands, middleware, the context object, autoloading, and how errors flow from handlers to the terminal.
Overview
maltty uses yargs for command routing and @clack/prompts for styled terminal output. The CLI entry point is in packages/cli/src/, which registers all commands and attaches middleware. Each command is implemented using the command() factory from maltty.
Commands
Commands are created with the command() factory. Each command defines a description, optional args schema, optional subcommands, and a handler function.
Resolvable<T> means the field accepts either a static value or a zero-argument function that returns the value. Functions are resolved once at registration time.
With Zod args
Without args
Hidden and deprecated
With subcommands
Context
Every handler and middleware receives a Context object with the following properties:
All data properties (args, config, meta) are deeply readonly. The store is the only mutable property -- middleware uses it to pass typed data to handlers.
Store
The store is an in-memory Map<string, unknown> with typed accessors:
Consumers register typed keys via module augmentation:
Prompts
Interactive prompts suspend execution until user input:
Cancellation (Ctrl-C) produces a ContextError with code PROMPT_CANCELLED.
Format
Pure string formatters for data serialization (no I/O):
Styled Output
Structured output methods on the logger for test results, lint findings, and tallies:
Errors
User-facing error utility:
ctx.fail() throws a ContextError that is caught at the CLI boundary for clean exit handling.
Middleware
Middleware wraps command execution with pre/post logic. Created with the middleware() factory. maltty supports middleware at two levels:
Root middleware
Declared on cli({ middleware: [...] }). Runs for every command invocation:
Command middleware
Declared on command({ middleware: [...] }). Runs only for that command:
Execution model
Middleware follows an onion model. Root middleware wraps command middleware, which wraps the handler:
- Each middleware calls
next()to pass control inward - Code before
next()runs on the way in; code after runs on the way out - A middleware can short-circuit by not calling
next() - Data flows between middleware and handlers via
ctx.store
See Lifecycle for the full execution model.
Autoloading
Commands are auto-discovered from a directory via autoload():
Rules:
- Files must have a default export created via
command() - Extensions:
.tsor.js(not.d.ts) - Ignored: files starting with
_or., files namedindex(used as parent handlers) - Subdirectories become parent commands with nested subcommands
Error Flow
Errors propagate from handlers to the terminal through a single path:
Unexpected errors that escape the handler pattern (parse failures, missing commands) are caught by the global error handler, logged, and exit with code 1.
This design means:
- No handler ever calls
process.exitdirectly - All user-facing error formatting is centralized at the CLI boundary
- Exit codes are explicit and testable
- Lib functions return
Resulttuples instead of throwing
CLI Entry Point
The cli() function wires everything together:
Adding a Command
See the Adding a CLI Command guide for a step-by-step walkthrough.