Build a CLI
Define commands, middleware, configuration, and use maltty's sub-exports to build a complete CLI tool.
Prerequisites
- Node.js 24+
- Bun 1.3+ (required if compiling to standalone binaries)
malttyinstalled (pnpm add maltty)
Declare runtime constraints in your CLI's package.json so consumers get clear errors on incompatible runtimes:
Steps
1. Define a command
Commands accept a description, typed arguments via Zod, and a handler function.
2. Bootstrap the CLI
cli() registers commands, parses arguments, runs middleware, and invokes the matched handler.
3. Add middleware
Middleware wraps command execution with pre/post logic. It receives the context and a next function.
Root middleware runs for every command:
Command middleware runs only for a specific command:
Root middleware wraps command middleware, which wraps the handler. See Lifecycle for the full execution model.
4. Hide or deprecate commands
Commands support hidden and deprecated fields for controlling help output visibility. Both accept a static value or a function (Resolvable<T>), resolved once at registration time.
Individual flags also support hidden, deprecated, and group for organizing help output:
5. Add subcommands
Commands can contain nested subcommands:
6. Autoload commands from a directory
Dynamically discover commands at runtime:
7. Add typed config
Scaffold config setup with the CLI, or create the files manually.
Scaffold with the CLI:
This creates src/config.ts with a Zod schema and ConfigType module augmentation. You can also include config setup when creating a new project:
Manual setup:
Create a config schema file with ConfigType to derive ConfigRegistry from your Zod schema:
Register the config middleware in cli():
Commands load config lazily via the ctx.config handle:
To load config eagerly (during middleware pass), use eager: true:
Standalone config client:
For loading config outside the cli() bootstrap, use createConfigClient:
8. Use sub-exports
maltty exposes focused utilities through sub-exports.
Log -- structured terminal output is available on every context:
Store -- file-backed JSON store for persistent data (separate from the in-memory ctx.store used for middleware-to-handler data flow):
Project -- git root resolution, submodule detection, path utilities:
findProjectRoot returns ProjectRoot | null (with path and isSubmodule properties). resolvePath accepts { dirName, source?, startDir? } and resolves to a local or global directory path.
9. Define a screen command
For interactive terminal UIs, use screen() instead of command(). Screen commands render a React component using Ink.
Screen commands use .tsx files and access runtime context via hooks (useConfig(), useMeta(), useStore()) instead of the ctx object. See Screens for the full guide.
Verification
Troubleshooting
Autoload finds no commands
Issue: autoload() returns an empty command tree.
Fix: Ensure the dir path is relative to the compiled entrypoint, not the source file. Check that each file in the directory exports a command() as its default export.
Config file not found
Issue: config.load() returns a parse error.
Fix: Confirm the config file is named maltty.config.ts (or .js, .json, .yaml) and is in the project root.