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. Add a default command
Mark a command default: true so it runs when no subcommand is given. Both invocation forms stay available:
A registered command name always wins over the default command's positionals -- my-app generate can never pass generate as a pattern. Use the explicit form when you need that value.
Mark only one command per level as default. Note that adding a default command means a bare my-app runs that handler instead of printing help, so help.header no longer appears.
7. Autoload commands from a directory
Dynamically discover commands at runtime:
Assign it to a key instead -- commands: { generate: autoload({ dir: './commands/generate' }) } -- to mount the directory under a parent command. You then invoke each named command as my-app generate <name>, and a nameless index.ts default as my-app generate.
An index file at the root of an autoloaded directory becomes that level's default command:
Give it a name to keep a named invocation form alongside the default; without one you can invoke it only as the default.
8. 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:
9. 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.
10. 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.