Node.js CLI development with Commander.js and @clack/prompts - command structure, interactive prompts, wizard state machines, config hierarchies, exit codes, cancellation handling
Scanned 9/5/2026
Install to Claude Code
npx -y skills add agents-inc/skills --skill cli-framework-cli-commander --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Cli Framework Cli Commander?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/agents-inc-cli-framework-cli-commander-skills)More formats (shields.io, HTML) on the badges page.
---
name: cli-framework-cli-commander
description: Node.js CLI development with Commander.js and @clack/prompts - command structure, interactive prompts, wizard state machines, config hierarchies, exit codes, cancellation handling
---
# CLI Application Development with Commander.js
> **Quick Guide:** Use Commander.js for command structure and option parsing. Use @clack/prompts for interactive UX (spinners, selects, confirms). Always handle Ctrl+C cancellation with `p.isCancel()`. Use named exit code constants. Use `parseAsync()` for async actions. Structure commands in separate files. Resolve config with precedence: flag > env > project > global > default.
---
<critical_requirements>
## CRITICAL: Before Building CLI Applications
> **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants)
**(You MUST handle SIGINT (Ctrl+C) gracefully and exit with appropriate codes)**
**(You MUST use `p.isCancel()` to detect cancellation in ALL @clack/prompts calls and handle gracefully)**
**(You MUST use named constants for ALL exit codes - NEVER use magic numbers like `process.exit(1)`)**
**(You MUST use `parseAsync()` for async actions to properly propagate errors)**
**(You MUST stop spinners before any console output or error display)**
</critical_requirements>
---
**Auto-detection:** Commander.js, commander, @clack/prompts, picocolors, p.spinner, p.select, p.confirm, p.text, p.isCancel, p.tasks, p.multiselect, p.group, process.exit, exit codes, SIGINT handling, interactive prompts, wizard state machine, config hierarchy, CLI error handling, parseAsync, subcommand
**When to use:**
- Building command-line tools with Node.js using Commander.js
- Creating interactive terminal prompts and wizards with @clack/prompts
- Implementing multi-step wizard flows with back navigation
- Managing hierarchical configuration (flag > env > project > global)
- Structuring CLI applications with subcommands and global options
**When NOT to use:**
- Simple scripts with no user interaction (just use process.argv directly)
- Web server frameworks (use your API framework skill)
- Single-prompt scripts (use readline or raw @clack/prompts without Commander)
**Key patterns covered:**
- CLI entry point with SIGINT handling and global options
- Standardized exit codes with named constants
- Command definition with typed options and subcommands
- @clack/prompts for interactive UX (spinners, selects, confirms, text)
- Cancellation handling (`p.isCancel()`) on every prompt
- Wizard state machines with back navigation
- Configuration hierarchy resolution
- Dry-run mode implementation
**Detailed Resources:**
- [examples/core.md](examples/core.md) - Entry point, exit codes, commands, prompts, cancellation
- [examples/wizard-patterns.md](examples/wizard-patterns.md) - State machines, config hierarchy, dry-run mode
---
<philosophy>
## Philosophy
**User experience first.** CLI tools should be intuitive, provide helpful feedback, and fail gracefully. Users should always know what's happening (spinners), what went wrong (clear errors), and how to fix it (actionable messages).
**Consistency across commands.** Every command follows the same patterns: options at top, spinner feedback, success/error messaging, and proper exit codes. This makes the CLI predictable and learnable.
**Graceful degradation.** Always handle cancellation (Ctrl+C), invalid input, and errors. Never leave users in an unknown state. Stop spinners before displaying errors.
**When to use Commander.js:**
- Multi-command CLI tools (git-like interfaces)
- Tools with complex option parsing and subcommands
- Applications needing auto-generated help text
- TypeScript-first development
**When to use @clack/prompts:**
- Interactive setup wizards and multi-step flows
- User confirmation before destructive actions
- Selection from lists of options
- Any user input beyond simple flags
</philosophy>
---
<patterns>
## Core Patterns
### Pattern 1: CLI Entry Point Structure
Register commands, handle SIGINT, use `parseAsync()` for async error propagation. See [examples/core.md](examples/core.md#pattern-1-cli-entry-point-structure) for full implementation.
```typescript
// Handle Ctrl+C gracefully
process.on("SIGINT", () => {
console.log(pc.yellow("\nCancelled"));
process.exit(EXIT_CODES.CANCELLED);
});
// Use parseAsync for proper async error handling
await program.parseAsync(process.argv);
```
---
### Pattern 2: Standardized Exit Codes
Define all exit codes as named constants. Never use magic numbers. See [examples/core.md](examples/core.md#pattern-2-standardized-exit-codes) for the full constant definition.
```typescript
export const EXIT_CODES = {
SUCCESS: 0,
ERROR: 1,
INVALID_ARGS: 2,
CANCELLED: 4,
VALIDATION_ERROR: 7,
} as const;
// GOOD: Named constant
process.exit(EXIT_CODES.VALIDATION_ERROR);
// BAD: Magic number
process.exit(1); // What does 1 mean?
```
#### This table is the authority for the whole CLI
Once a CLI defines this table, every exit in the process comes from it -- including the ones inside prompt handlers. A prompt library's own examples typically show a bare `process.exit(0)` after printing a cancellation message; those examples are making the point that you must exit at all, and they have no table to draw a value from. This CLI has one, so cancellation exits `EXIT_CODES.CANCELLED` wherever it happens.
The one value that is wrong everywhere is `0`. Cancellation is not success: `0` makes `mycli init && mycli deploy` run the deploy against a project the user abandoned halfway through, and makes a CI step pass on a run that did nothing. Any non-zero code is recoverable by a caller; `0` destroys the information.
**Choosing the number:** `2` for usage errors and `130` (128 + SIGINT) for interruption are the conventions callers are most likely to recognise. Beyond those, the specific integers matter far less than that they are named, distinct and documented in `--help` or the README, so scripts can branch on them.
---
### Pattern 3: Command Definition with Options
Structure commands with typed options, descriptions for help text, and global option access. See [examples/core.md](examples/core.md#pattern-3-command-definition-with-options) for full implementation.
```typescript
export const initCommand = new Command("init")
.description("Initialize the project")
.option("--source <url>", "Source URL")
.option("-f, --force", "Overwrite existing files", false)
.action(async (options, command) => {
const globalOpts = command.optsWithGlobals();
// ...
});
```
---
### Pattern 4: Interactive Prompts with Cancellation
Every @clack/prompts call must be followed by `p.isCancel()`. See [examples/core.md](examples/core.md#pattern-4-interactive-prompts-with-cancellation) for spinner, select, confirm, and text patterns.
```typescript
const result = await p.select({
message: "Select a framework:",
options: [
{ value: "react", label: "React", hint: "recommended" },
{ value: "vue", label: "Vue" },
],
});
// CRITICAL: Always check for cancellation
if (p.isCancel(result)) {
p.cancel("Setup cancelled");
process.exit(EXIT_CODES.CANCELLED);
}
```
---
### Pattern 5: Subcommand Organization
Group related commands under parent commands. See [examples/core.md](examples/core.md#pattern-5-subcommand-organization) for full implementation.
```typescript
export const configCommand = new Command("config").description(
"Manage configuration",
);
configCommand
.command("show")
.description("Show current effective configuration")
.action(async () => {
/* ... */
});
configCommand
.command("set")
.argument("<key>", "Configuration key")
.argument("<value>", "Configuration value")
.action(async (key, value) => {
/* ... */
});
```
---
### Pattern 6: Wizard State Machine
Complex multi-step flows with back navigation. See [examples/wizard-patterns.md](examples/wizard-patterns.md#pattern-6-wizard-state-machine) for full state machine implementation.
```typescript
const state = createInitialState();
while (true) {
switch (state.currentStep) {
case "approach": {
const result = await stepApproach(state);
if (p.isCancel(result)) return null;
pushHistory(state);
state.currentStep = "selection";
break;
}
case "selection": {
const result = await stepSelection(state);
if (result === BACK_VALUE) {
state.currentStep = popHistory(state) || "approach";
break;
}
// ...
}
}
}
```
---
### Pattern 7: Configuration Hierarchy
Resolve config values with clear precedence: flag > env > project > global > default. See [examples/wizard-patterns.md](examples/wizard-patterns.md#pattern-7-configuration-hierarchy) for full implementation.
```typescript
export async function resolveSource(
flagValue?: string,
projectDir?: string,
): Promise<ResolvedConfig> {
if (flagValue !== undefined)
return { source: flagValue, sourceOrigin: "flag" };
const envValue = process.env[SOURCE_ENV_VAR];
if (envValue) return { source: envValue, sourceOrigin: "env" };
// ... project config, global config, default
}
```
---
### Pattern 8: Dry-Run Mode
Preview operations without executing. See [examples/wizard-patterns.md](examples/wizard-patterns.md#pattern-8-dry-run-mode) for full implementation.
```typescript
export async function executeWithDryRun(
dryRun: boolean,
operations: Array<{ description: string; execute: () => Promise<void> }>,
): Promise<void> {
if (dryRun) {
for (const op of operations) {
console.log(pc.yellow(`[dry-run] Would: ${op.description}`));
}
return;
}
// Execute for real with spinner feedback
}
```
</patterns>
---
<decision_framework>
## Decision Framework
### Command Structure Decision
```
Is it a single operation?
├─ YES → Single command with options
└─ NO → Are operations related?
├─ YES → Subcommands under parent (config show, config set)
└─ NO → Separate top-level commands
```
### User Input Decision
```
Does user need to provide input?
├─ NO → Use options/flags only
└─ YES → Is it a simple yes/no?
├─ YES → p.confirm()
└─ NO → Is it choosing from options?
├─ YES → p.select() or p.multiselect()
└─ NO → Is it free-form text?
└─ YES → p.text() with validation
```
### Async Operation Feedback
```
Is operation quick (< 500ms)?
├─ YES → No spinner needed
└─ NO → Use p.spinner() with:
├─ start("Descriptive message...")
├─ stop("Success with result info")
└─ Error: stop first, then p.log.error()
```
### Config Value Resolution
```
Check in order, first defined wins:
1. --flag (CLI argument)
2. ENV_VAR (environment variable)
3. ./.myapp/config.yaml (project config)
4. ~/.myapp/config.yaml (global config)
5. DEFAULT_VALUE (hardcoded default)
```
</decision_framework>
---
<red_flags>
## RED FLAGS
**High Priority Issues:**
- Missing `p.isCancel()` checks after prompts — causes undefined behavior on Ctrl+C
- Using magic numbers for exit codes — makes debugging impossible
- Exiting `0` on cancellation — reports success for work that never happened, so `mycli init && mycli deploy` deploys an abandoned setup. Use `EXIT_CODES.CANCELLED`, never `0`, wherever a prompt is cancelled
- Not handling SIGINT — leaves processes in unknown state
- Using `.parse()` instead of `.parseAsync()` with async actions — swallows errors silently
**Medium Priority Issues:**
- Missing spinner feedback for operations > 500ms
- Inconsistent error message formatting across commands
- Missing `--help` descriptions for options
- Not stopping spinner before showing error output — corrupts terminal display
**Common Mistakes:**
- Forgetting to call `process.exit()` after `p.cancel()` — execution continues past cancellation
- Not validating inputs early — errors occur deep in flow where recovery is harder
- Not cleaning up on errors (spinners left running, terminal state corrupted)
- Using `program.parse()` then trying to catch errors — `parseAsync()` required for async error propagation
**Gotchas & Edge Cases:**
- Commander converts `--my-option` to `myOption` in camelCase automatically
- `optsWithGlobals()` needed to access parent command options (not just `opts()`)
- Spinner must be stopped before any `console.log` / `p.log` output
- `process.exit()` in async context may not wait for pending I/O — use `await` before exit-triggering operations
- Commander v13+ defaults `allowExcessArguments` to false — extra positional args are now errors
- @clack/prompts spinner has `.isCancelled` property and `.cancel()` / `.error()` methods for richer feedback
</red_flags>
---
<critical_reminders>
## CRITICAL REMINDERS
> **All code must follow project conventions in CLAUDE.md**
**(You MUST handle SIGINT (Ctrl+C) gracefully and exit with appropriate codes)**
**(You MUST use `p.isCancel()` to detect cancellation in ALL @clack/prompts calls and handle gracefully)**
**(You MUST use named constants for ALL exit codes - NEVER use magic numbers like `process.exit(1)`)**
**(You MUST use `parseAsync()` for async actions to properly propagate errors)**
**(You MUST stop spinners before any console output or error display)**
**Failure to follow these rules will result in poor UX, orphaned processes, and debugging nightmares.**
</critical_reminders>
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!