Ai-setup adding-a-command
Creates a new CLI command following the Commander.js pattern in src/commands/. Handles command registration in src/cli.ts, telemetry tracking via tracked() wrapper, and option parsing. Use when user says add command, new CLI command, create subcommand, or adds files to src/commands/. Do NOT use for modifying existing commands or fixing bugs in existing commands.
git clone https://github.com/caliber-ai-org/ai-setup
T=$(mktemp -d) && git clone --depth=1 https://github.com/caliber-ai-org/ai-setup "$T" && mkdir -p ~/.claude/skills && cp -r "$T/.claude/skills/adding-a-command" ~/.claude/skills/caliber-ai-org-ai-setup-adding-a-command-4ee106 && rm -rf "$T"
.claude/skills/adding-a-command/SKILL.mdAdding a Command
Critical
- Export pattern: Command must export a named async function:
. Never use default exports.export async function myCommand(options?: OptionType) - Registration in cli.ts: Every command must be imported and registered with
chain in.command()
, wrapped withsrc/cli.ts
for telemetry.tracked() - Error signaling: Use
to exit gracefully without printing the error message. Use chalk for user-facing messages.throw new Error('__exit__') - Options typing: Commands receiving options must define a TypeScript interface for those options. Pass options as a destructured object parameter.
Instructions
-
Create the command file at
with named async export.src/commands/{commandName}.ts- Signature:
export async function {commandName}Command(options?: { optionName?: optionType }) { ... } - Import only what you need (avoid kitchen-sink imports).
- Return void (handle all output via console.log or chalk).
- Verify the file follows the naming convention: camelCase function + "Command" suffix.
- Signature:
-
Handle errors consistently: Wrap error-prone operations in try/catch. Distinguish between user errors and system errors:
- User error (bad input):
console.error(chalk.red('message')); throw new Error('__exit__'); - System error (missing dependency):
— this will print and exit with code 1.throw new Error('Detailed error message'); - Parse-like errors: Use ora spinner with
before throwing..fail() - This step prevents double error printing in bin.ts.
- User error (bad input):
-
Import and register in src/cli.ts in the correct location:
- Add import at the top:
import { {commandName}Command } from './commands/{commandName}.js'; - Register the command in the appropriate section (main commands, or nested under a group like
).sources - For main commands:
.command('{kebab-name}').description('...').option(...).action(tracked('{kebab-name}', {commandName}Command)) - For subcommands (like
):sources addsources.command('add').description(...).action(tracked('sources:add', sourcesAddCommand)) - Key: Wrap handler with
for automatic telemetry.tracked('{command-name}', handler) - Verify the command name in tracked() uses kebab-case for main commands and colon-separated for subcommands.
- Add import at the top:
-
Define options (if needed):
- Add
chains before.option()
:.action()
or.option('--flag', 'Description').option('--opt <value>', 'Description') - For parsed options (like comma-separated agents), add a parse function:
.option('--opt <value>', 'Description', parseFunction) - Pass options to handler:
.action(tracked('name', (opts) => command(opts))) - Define TypeScript interface for the options object.
- Verify option names use camelCase (Commander converts kebab-case flags to camelCase).
- Add
-
Verify before proceeding:
- Function exports correctly and is imported in cli.ts.
- Command is registered with tracked() wrapper.
- Output uses chalk for colors, not plain console.log.
- Error paths throw
for user errors.new Error('__exit__')
Examples
Example 1: Simple command (status)
User says: "Add a command to show config status"
Actions taken:
- Create src/commands/status.ts with statusCommand() export
- Import and register in src/cli.ts with tracked() wrapper
Result:
caliber status displays config status; caliber status --json outputs JSON.
Code example:
import chalk from 'chalk'; import { loadConfig } from '../llm/config.js'; export async function statusCommand(options?: { json?: boolean }) { const config = loadConfig(); if (options?.json) { console.log(JSON.stringify({ configured: !!config }, null, 2)); return; } console.log(chalk.bold('Status')); console.log(` LLM: ${chalk.green(config?.provider || 'Not configured')}`); }
Registration in src/cli.ts:
import { statusCommand } from './commands/status.js'; program .command('status') .description('Show config status') .option('--json', 'Output as JSON') .action(tracked('status', statusCommand));
Example 2: Subcommand with arguments
User says: "Add a sources add subcommand"
Actions taken:
- Create src/commands/sources.ts with sourcesAddCommand() export
- Register under sources group with tracked('sources:add', ...)
Result:
caliber sources add ../lib adds a source.
Code example:
export async function sourcesAddCommand(sourcePath: string) { if (!fs.existsSync(sourcePath)) { console.log(chalk.red(`Path not found: ${sourcePath}`)); throw new Error('__exit__'); } const existing = loadSourcesConfig(process.cwd()); existing.push({ type: 'repo', path: sourcePath }); writeSourcesConfig(process.cwd(), existing); console.log(chalk.green(`Added ${sourcePath}`)); }
Registration:
const sources = program.command('sources'); sources .command('add') .argument('<path>', 'Path to add') .action(tracked('sources:add', sourcesAddCommand));
Example 3: Command with option parsing
User says: "Add init with --agent flag supporting comma-separated values"
Actions taken:
- Create parseAgentOption() parser in src/cli.ts
- Create src/commands/init.ts with initCommand(options)
- Register with custom parser
Result:
caliber init --agent claude,cursor passes parsed array to handler.
Parser code:
function parseAgentOption(value: string) { const agents = value.split(',').map(s => s.trim().toLowerCase()); if (agents.length === 0) { console.error('Invalid agent'); process.exit(1); } return agents; } program.command('init') .option('--agent <type>', 'Agents (comma-separated)', parseAgentOption) .action(tracked('init', initCommand));
Common Issues
Issue: "SyntaxError: The requested module does not provide an export named 'myCommand'"
- Cause: Function not exported or exported as default instead of named.
- Fix: Use
(notexport async function myCommand(...)
).export default
Issue: Command appears in help but crashes when run
- Cause: Handler not wrapped with
or function import mismatch.tracked() - Fix: Verify import name matches function export. Wrap with
.tracked('command-name', handler)
Issue: "Error: exit" appears in output for user errors
- Cause: Throwing generic error instead of using error exit pattern.
- Fix: Use
for user-facing errors.console.error(chalk.red('message')); throw new Error('__exit__');
Issue: --dry-run flag not recognized
- Cause: Option not declared with
or wrong camelCase in interface..option() - Fix: Add
and ensure options interface has.option('--dry-run', 'Description')
.dryRun?: boolean
Issue: Subcommand crashes but parent command works
- Cause: Using
instead ofprogram.command()
for subcommands.groupVar.command() - Fix: Register on group:
const sources = program.command('sources'); sources.command('add')...
Issue: Telemetry not appearing
- Cause: Handler not wrapped with
or wrong command name.tracked() - Fix: Ensure
wraps handler. Use colon for subcommands like 'sources:add'..action(tracked('{kebab-case}', handler))
Issue: "Cannot find module" with relative imports
- Cause: Using
extension in imports..ts - Fix: Always use
extension:.js
(required for ESM).import { x } from '../lib/file.js'