Search
Search documentation
Programs
Updated Oct 21, 2026
f.program(config?) opens a builder for a CLI tree. Pass commander-level
config, then call .router({ ... }) with a map of commands and nested
programs. The terminal .router(...) returns the fully-wired Program.
import { f } from "fireargs"
import { z } from "zod"
const greet = f
.command({ name: "greet" })
.input(z.object({ name: z.string() }))
.output(z.object({ greeting: z.string() }))
.handler(input => ({ greeting: `hello ${input.name}` }))
const deploy = f
.command({ name: "deploy" })
.input(z.object({ env: z.enum(["staging", "prod"]) }))
.output(z.object({ ok: z.boolean() }))
.handler(() => ({ ok: true }))
const cli = f
.program({ name: "myapp", description: "My CLI", version: "1.0.0" })
.router({ greet, deploy })
cli.parseAsync(process.argv)myapp greet world, myapp deploy --env prod, myapp --help,
myapp --llms all work.
Object key wins as the subcommand name
.router({ greet: someCmd }) attaches someCmd under the name "greet"
even if the leaf was built with name: "something-else". Decoupling the
program-level routing from each leaf's own naming makes it easy to mount
the same command twice or rename at attach time.
Nesting
.router(...) takes a Route, which is either a Command (from .handler(...))
or a Program (from .router(...)), so a program can be a subcommand of
another program:
const apiCli = f.program({ name: "api" }).router({ greet, deploy })
const dbCli = f.program({ name: "db" }).router({ migrate, seed })
const cli = f.program({ name: "myapp" }).router({
api: apiCli,
db: dbCli,
status: statusLeaf,
})
// myapp api greet world
// myapp db migrate
// myapp status--llms flattens the tree to space-separated tool names — the inner
greet becomes "api greet" in the manifest. See LLMs mode.
Command, Program and Route
Both are subclasses of commander's Command, so parseAsync, --help
and the rest of commander work unchanged. Each carries a type field —
"command" or "program" — that keeps them apart in the type system, and
Route is their union. A raw commander Command isn't a Route: without
fireargs' schemas it would be missing from --llms, so .router(...)
rejects it at compile time.
import type { Command, Program, Route } from "fireargs"
import { Command as CommanderCommand } from "commander" // alias on clashGrouping commands in help
Set helpGroup on a command or a nested program to list it under its own
heading in the parent's --help, the same way f.option({ helpGroup })
groups options. Routes without one stay under Commands:, and headings
print in the order their first route appears.
const deploy = f.command({ description: "Deploy", helpGroup: "Ops:" }) // ...
const users = f.program({ description: "Manage users", helpGroup: "Ops:" }).router({ list })
f.program({ name: "myapp" }).router({ deploy, users, version })
// Ops:
// deploy [options] Deploy
// users [options] Manage users
//
// Commands:
// version [options] Print versionProgramConfig
Same shape as CommandConfig minus the input-side concerns: arguments
(no input fields at the program level) and jsonOption (programs dispatch —
they don't read JSON input). Otherwise everything in
CommandConfig applies — identity (name,
description, summary, aliases, usage, version, helpGroup), help
slots (helpOption, helpCommand, configureHelp, addHelpText,
addHelpCommand, addHelpOption), behavior toggles, hooks, exit/output
config, on listeners, executableDir, and llmsOption.
Two of them reach past the program itself:
versionwithversionOption: { flags: "-v, --version" }keeps a short version flag.renderErrorset on a program covers every leaf below it that has no hook of its own, so one hook formats errors for the whole tree (see JSON mode).
enablePositionalOptions defaults to true on programs
Without it, a program-level option like --llms would shadow a subcommand's
--llms (commander would parse --llms against the program no matter where
it appears in argv). With positional options on, options before the
subcommand belong to the program and options after belong to the subcommand
— exactly what you want for tree CLIs. Override by passing
enablePositionalOptions: false if you specifically need the legacy
behavior.
--help and --llms per level
Each level keeps its own:
myapp --help # top-level help, lists subcommands
myapp greet --help # leaf-level help, lists args/options
myapp --llms # entire tree as a flat MCP tools/list
myapp greet --llms # just the greet leafSuppress program-level --llms with llmsOption: false in the program
config; subcommands keep theirs.
Created with ❤ and Livemark