Part V: Commander.js as a Thin Shell
The CLI framework is the thinnest layer in the stack. Commander.js handles argument parsing and --help generation. Everything else — state, logic, output, testing — lives in the machines, pure functions, and ConsoleIO.
Why Commander.js?
It was already in node_modules — a transitive dependency of @axe-core/cli and @mermaid-js/mermaid-cli. One line to make it explicit:
npm install -D commander@12npm install -D commander@12What it gives us for free:
--helpauto-generated at every level (top, group, subcommand)- Subcommand tree (
work serve dev,work test e2e --target=dev) - Option parsing with types and defaults
- Version flag
What it doesn't do: manage state, handle I/O, run commands. That's our code.
The Composition Root
program.ts is 40 lines. It creates the dependencies and wires everything:
import { Command } from 'commander';
import { createConsoleIO } from './core/console';
import { loadConfig } from './core/config';
import { registerServeCommands } from './commands/serve';
import { registerBuildCommands } from './commands/build';
import { registerTestCommands } from './commands/test';
// ... 8 more imports
export function createProgram(): Command {
const io = createConsoleIO();
const config = loadConfig();
const program = new Command()
.name('work')
.description('serard.dev development workflow tool')
.version('1.0.0');
registerServeCommands(program, io, config);
registerBuildCommands(program, io, config);
registerTestCommands(program, io, config);
registerAuditCommands(program, io, config);
registerGitCommands(program, io);
registerCaptureCommand(program, io, config);
registerReviewCommand(program, io);
registerOpenCommand(program, io);
registerConfigCommand(program, io);
registerStatusCommand(program, io);
registerAssetsCommand(program, io);
return program;
}import { Command } from 'commander';
import { createConsoleIO } from './core/console';
import { loadConfig } from './core/config';
import { registerServeCommands } from './commands/serve';
import { registerBuildCommands } from './commands/build';
import { registerTestCommands } from './commands/test';
// ... 8 more imports
export function createProgram(): Command {
const io = createConsoleIO();
const config = loadConfig();
const program = new Command()
.name('work')
.description('serard.dev development workflow tool')
.version('1.0.0');
registerServeCommands(program, io, config);
registerBuildCommands(program, io, config);
registerTestCommands(program, io, config);
registerAuditCommands(program, io, config);
registerGitCommands(program, io);
registerCaptureCommand(program, io, config);
registerReviewCommand(program, io);
registerOpenCommand(program, io);
registerConfigCommand(program, io);
registerStatusCommand(program, io);
registerAssetsCommand(program, io);
return program;
}ConsoleIO and Config are created once and passed to every command. This is the only place in the codebase where real dependencies are instantiated. Everything else receives them.
Pure Functions in Commands
Each command file has two parts: pure logic (testable) and Commander wiring (thin).
resolveFlags() — Build Options
export function resolveFlags(opts: BuildStaticOptions): string[] {
const flags: string[] = [];
if (opts.noClean) flags.push('--no-clean');
if (opts.noImages) flags.push('--no-images');
if (opts.noStatics) flags.push('--no-statics');
if (opts.noMermaid) flags.push('--no-mermaid');
if (opts.noMinify) flags.push('--no-minify');
if (opts.noPages) flags.push('--no-pages');
return flags;
}export function resolveFlags(opts: BuildStaticOptions): string[] {
const flags: string[] = [];
if (opts.noClean) flags.push('--no-clean');
if (opts.noImages) flags.push('--no-images');
if (opts.noStatics) flags.push('--no-statics');
if (opts.noMermaid) flags.push('--no-mermaid');
if (opts.noMinify) flags.push('--no-minify');
if (opts.noPages) flags.push('--no-pages');
return flags;
}Five lines of logic. Zero dependencies. Testing is trivial:
expect(resolveFlags({})).toEqual([]);
expect(resolveFlags({ noMermaid: true })).toEqual(['--no-mermaid']);expect(resolveFlags({})).toEqual([]);
expect(resolveFlags({ noMermaid: true })).toEqual(['--no-mermaid']);resolveTestTarget() — Config Merge
export function resolveTestTarget(opts: TestOptions, config: WorkConfig): string {
return opts.target ?? config.test.target;
}export function resolveTestTarget(opts: TestOptions, config: WorkConfig): string {
return opts.target ?? config.test.target;
}One line. CLI flag overrides config default. This encodes the resolution order: CLI flag > .workrc.json > DEFAULTS.
escapeMessage() — Git Safety
export function escapeMessage(msg: string): string {
return msg.replace(/"/g, '\\"');
}export function escapeMessage(msg: string): string {
return msg.replace(/"/g, '\\"');
}Pure. Testable. Handles the edge case of quotes in commit messages.
.workrc.json — Persistent Configuration
All 12 configurable defaults live in one file:
{
"tui": { "mode": "panels" },
"serve": { "devPort": 4001, "staticPort": 4000, "startTimeout": 10000 },
"test": { "target": "static", "workers": 4, "retries": 2, "timeout": 15000 },
"audit": { "concurrency": 2, "lighthouseTimeout": 60000, "passThreshold": 90 },
"capture": { "themes": "all", "accents": "green" },
"visual": { "diffThreshold": 0.2 }
}{
"tui": { "mode": "panels" },
"serve": { "devPort": 4001, "staticPort": 4000, "startTimeout": 10000 },
"test": { "target": "static", "workers": 4, "retries": 2, "timeout": 15000 },
"audit": { "concurrency": 2, "lighthouseTimeout": 60000, "passThreshold": 90 },
"capture": { "themes": "all", "accents": "green" },
"visual": { "diffThreshold": 0.2 }
}All values are optional. Missing keys fall back to hardcoded DEFAULTS. The loadConfig function deep-merges:
export function loadConfig(fsIO: ConfigFS = realFS): WorkConfig {
if (!fsIO.exists(CONFIG_PATH)) return { ...DEFAULTS };
try {
const raw = JSON.parse(fsIO.readFile(CONFIG_PATH));
return deepMerge(DEFAULTS, raw);
} catch {
return { ...DEFAULTS }; // invalid JSON → defaults
}
}export function loadConfig(fsIO: ConfigFS = realFS): WorkConfig {
if (!fsIO.exists(CONFIG_PATH)) return { ...DEFAULTS };
try {
const raw = JSON.parse(fsIO.readFile(CONFIG_PATH));
return deepMerge(DEFAULTS, raw);
} catch {
return { ...DEFAULTS }; // invalid JSON → defaults
}
}saveConfig writes only changed values (sparse, no default bloat). So a .workrc.json with just { "tui": { "mode": "keys" } } works fine — everything else comes from DEFAULTS.
The work config command makes this accessible at runtime:
work config show # print merged config
work config set test.workers 8 # change a value
work config reset # back to defaults
work config path # print file pathwork config show # print merged config
work config set test.workers 8 # change a value
work config reset # back to defaults
work config path # print file pathThe Entry Point
workflow.ts is 30 lines:
import { createProgram } from './cli/program';
import { launchInteractive } from './cli/tui/menu';
const args = process.argv.slice(2);
if (args.length === 0 && (process.stdout.isTTY ?? false)) {
launchInteractive(); // no args + TTY → interactive TUI
} else if (args.length === 0) {
createProgram().outputHelp(); // no args + pipe → help text
} else {
createProgram().parseAsync(process.argv); // CLI mode
}import { createProgram } from './cli/program';
import { launchInteractive } from './cli/tui/menu';
const args = process.argv.slice(2);
if (args.length === 0 && (process.stdout.isTTY ?? false)) {
launchInteractive(); // no args + TTY → interactive TUI
} else if (args.length === 0) {
createProgram().outputHelp(); // no args + pipe → help text
} else {
createProgram().parseAsync(process.argv); // CLI mode
}Three paths. TTY detection determines which one. Commander handles the parsing. The machines handle the state. ConsoleIO handles the output. The entry point does nothing.
The --help Payoff
The old tool had a hand-written help block — 60 lines of console.log with manually aligned columns. Any new command meant updating the help text by hand.
Now:
$ work --help
Usage: work [options] [command]
Commands:
serve Manage development servers
build Build pipeline
test Run test suites
audit Run quality audits
git Version control operations
...
$ work test --help
Commands:
unit Unit tests + coverage (Vitest)
e2e [options] E2E tests (Playwright)
visual [options] Visual regression tests
...
$ work build static --help
Options:
--no-toc skip TOC rebuild
--no-clean keep output dir
--no-images skip content image copy
...$ work --help
Usage: work [options] [command]
Commands:
serve Manage development servers
build Build pipeline
test Run test suites
audit Run quality audits
git Version control operations
...
$ work test --help
Commands:
unit Unit tests + coverage (Vitest)
e2e [options] E2E tests (Playwright)
visual [options] Visual regression tests
...
$ work build static --help
Options:
--no-toc skip TOC rebuild
--no-clean keep output dir
--no-images skip content image copy
...Three levels deep. Auto-generated. Always in sync with the actual options.
Next: Part VI: Three Modes, One Machine — Panels, Keys, and Command mode all driven by a single TUI machine.