AgentSkillsCN

cli-framework-cli-commander

Node.js CLI 开发:结合 Commander.js、@clack/prompts 与 picocolors。掌握命令结构、交互式提示、状态机、错误处理、退出码与配置层级。

SKILL.md
--- frontmatter
name: cli-framework-cli-commander
description: Node.js CLI development with Commander.js, @clack/prompts, and picocolors. Command structure, interactive prompts, state machines, error handling, exit codes, configuration hierarchies.

CLI Application Development with Commander.js

Quick Guide: Use Commander.js for command structure, @clack/prompts for interactive UX, and picocolors for terminal colors. Structure commands in separate files, use standardized exit codes, handle cancellation gracefully, and implement config hierarchies (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 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)

</critical_requirements>


Auto-detection: Commander.js, @clack/prompts, CLI command structure, p.spinner, p.select, p.confirm, p.text, picocolors, process.exit, exit codes, SIGINT handling, interactive prompts, wizard state machine, config hierarchy, CLI error handling

When to use:

  • Building command-line tools with Node.js
  • Creating interactive terminal prompts and wizards
  • Implementing multi-step workflows with state machines
  • Managing hierarchical configuration (flag > env > project > global)
  • Structuring CLI applications with subcommands
  • Handling errors and exit codes consistently

Key patterns covered:

  • Commander.js command structure and options
  • @clack/prompts for interactive UX (spinners, selects, confirms)
  • picocolors for terminal output styling
  • Standardized exit codes with named constants
  • SIGINT and cancellation handling
  • Config hierarchy resolution (precedence chain)
  • Wizard state machines for multi-step flows
  • Command organization and subcommands
  • Error handling patterns and user feedback
  • Dry-run mode implementation
  • File system operations with fs-extra and fast-glob

<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 should follow 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.

When to use Commander.js:

  • Multi-command CLI tools (git-like interfaces)
  • Tools with complex option parsing
  • Applications needing help generation
  • TypeScript-first development

When to use @clack/prompts:

  • Interactive setup wizards
  • User confirmation flows
  • Multi-step selections
  • Any user input beyond simple flags
</philosophy>
<patterns>

Core Patterns

Pattern 1: CLI Entry Point Structure

Organize the main entry point with global options, signal handling, and command registration.

File: src/cli/index.ts

typescript
import { Command } from "commander";
import pc from "picocolors";
import { initCommand } from "./commands/init";
import { configCommand } from "./commands/config";
import { validateCommand } from "./commands/validate";
import { EXIT_CODES } from "./lib/exit-codes";

// Handle Ctrl+C gracefully - REQUIRED for good UX
process.on("SIGINT", () => {
  console.log(pc.yellow("\nCancelled"));
  process.exit(EXIT_CODES.CANCELLED);
});

async function main() {
  const program = new Command();

  program
    .name("mycli")
    .description("My CLI tool description")
    .version("1.0.0")
    // Global options available to all commands
    .option("--dry-run", "Preview operations without executing")
    .option("-v, --verbose", "Enable verbose output")
    // Customize error output with colors
    .configureOutput({
      writeErr: (str) => console.error(pc.red(str)),
    })
    // Show help after errors for discoverability
    .showHelpAfterError(true);

  // Register commands - keep main file clean
  program.addCommand(initCommand);
  program.addCommand(configCommand);
  program.addCommand(validateCommand);

  // Use parseAsync for proper async error handling
  await program.parseAsync(process.argv);
}

// Centralized error handling
main().catch((err) => {
  console.error(pc.red("Error:"), err.message);
  process.exit(EXIT_CODES.ERROR);
});

Why good: SIGINT handler prevents orphaned processes, parseAsync properly propagates errors, configureOutput adds consistent styling, global options shared across commands


Pattern 2: Standardized Exit Codes

Define exit codes as named constants for consistency and maintainability.

File: src/cli/lib/exit-codes.ts

typescript
/**
 * CLI exit codes for standardized process termination
 * Following Unix conventions: 0 = success, non-zero = error
 */
export const EXIT_CODES = {
  /** Successful execution */
  SUCCESS: 0,
  /** General error */
  ERROR: 1,
  /** Invalid arguments or options */
  INVALID_ARGS: 2,
  /** Network or connectivity error */
  NETWORK_ERROR: 3,
  /** User cancelled operation (Ctrl+C) */
  CANCELLED: 4,
  /** Resource not found */
  NOT_FOUND: 5,
  /** Permission denied */
  PERMISSION_DENIED: 6,
  /** Validation failed */
  VALIDATION_ERROR: 7,
} as const;

export type ExitCode = (typeof EXIT_CODES)[keyof typeof EXIT_CODES];

Why good: Named constants make code readable, as const enables type inference, JSDoc explains each code's meaning, follows Unix conventions

typescript
// BAD Example - Magic numbers
process.exit(1); // What does 1 mean?
process.exit(0);

// GOOD Example - Named constants
import { EXIT_CODES } from "./lib/exit-codes";
process.exit(EXIT_CODES.VALIDATION_ERROR);

Why bad: Magic numbers are unmaintainable and unclear to readers


Pattern 3: Command Definition with Options

Structure individual commands with proper typing and option handling.

File: src/cli/commands/init.ts

typescript
import { Command } from "commander";
import * as p from "@clack/prompts";
import pc from "picocolors";
import { EXIT_CODES } from "../lib/exit-codes";

export const initCommand = new Command("init")
  .description("Initialize the project")
  // Options with descriptions for auto-generated help
  .option("--source <url>", "Source URL (e.g., github:org/repo)")
  .option("--refresh", "Force refresh from remote", false)
  .option("-f, --force", "Overwrite existing files", false)
  // Consistent error styling
  .configureOutput({
    writeErr: (str) => console.error(pc.red(str)),
  })
  .showHelpAfterError(true)
  // Action handler receives options and command context
  .action(async (options, command) => {
    // Access global options from parent
    const globalOpts = command.optsWithGlobals();
    const dryRun = globalOpts.dryRun ?? false;
    const verbose = globalOpts.verbose ?? false;

    // Start interactive UI
    p.intro(pc.cyan("Project Setup"));

    if (dryRun) {
      p.log.info(
        pc.yellow("[dry-run] Preview mode - no files will be created"),
      );
    }

    // ... implementation
  });

Why good: Named export follows convention, options have descriptions, access global options via optsWithGlobals(), intro sets context for user


Pattern 4: Interactive Prompts with @clack/prompts

Use @clack/prompts for beautiful, consistent interactive UX.

Spinner Pattern

typescript
import * as p from "@clack/prompts";
import pc from "picocolors";

async function processFiles() {
  const s = p.spinner();

  // Start with descriptive message
  s.start("Processing files...");

  try {
    const result = await doAsyncWork();
    // Stop with success message including result info
    s.stop(`Processed ${result.count} files`);
    return result;
  } catch (error) {
    // Stop spinner before showing error
    s.stop("Failed to process files");
    p.log.error(error instanceof Error ? error.message : "Unknown error");
    process.exit(EXIT_CODES.ERROR);
  }
}

Select Pattern with Cancellation Handling

typescript
import * as p from "@clack/prompts";
import pc from "picocolors";

async function selectFramework(): Promise<string | null> {
  const result = await p.select({
    message: "Select a framework:",
    options: [
      { value: "react", label: "React", hint: "recommended" },
      { value: "vue", label: "Vue" },
      { value: "angular", label: "Angular" },
    ],
    initialValue: "react",
  });

  // CRITICAL: Always check for cancellation
  if (p.isCancel(result)) {
    p.cancel("Setup cancelled");
    process.exit(EXIT_CODES.CANCELLED);
  }

  return result;
}

Why good: isCancel check prevents undefined behavior, hint guides users, initialValue improves UX

Confirm Pattern

typescript
async function confirmDestructiveAction(): Promise<boolean> {
  const proceed = await p.confirm({
    message: "This will delete existing files. Continue?",
    initialValue: false, // Default to safe option
  });

  if (p.isCancel(proceed)) {
    p.cancel("Operation cancelled");
    process.exit(EXIT_CODES.CANCELLED);
  }

  return proceed;
}

Text Input with Validation

typescript
async function getProjectName(): Promise<string> {
  const name = await p.text({
    message: "Project name:",
    placeholder: "my-project",
    validate: (value) => {
      if (!value) return "Name is required";
      if (!/^[a-z0-9-]+$/.test(value)) {
        return "Use lowercase letters, numbers, and hyphens only";
      }
    },
  });

  if (p.isCancel(name)) {
    p.cancel("Setup cancelled");
    process.exit(EXIT_CODES.CANCELLED);
  }

  return name;
}

Pattern 5: Wizard State Machine

Implement complex multi-step flows with a state machine pattern.

Constants

typescript
// Navigation sentinel values
const BACK_VALUE = "__back__";
const CONTINUE_VALUE = "__continue__";

// State machine step types
type WizardStep = "approach" | "selection" | "review" | "confirm";

State Interface

typescript
interface WizardState {
  currentStep: WizardStep;
  selectedItems: string[];
  history: WizardStep[]; // For back navigation
  lastSelectedItem: string | null; // For cursor restoration
}

function createInitialState(): WizardState {
  return {
    currentStep: "approach",
    selectedItems: [],
    history: [],
    lastSelectedItem: null,
  };
}

State Machine Implementation

typescript
import * as p from "@clack/prompts";
import pc from "picocolors";

interface WizardResult {
  selectedItems: string[];
  confirmed: boolean;
}

export async function runWizard(): Promise<WizardResult | null> {
  const state = createInitialState();

  // Main wizard loop
  while (true) {
    switch (state.currentStep) {
      case "approach": {
        const result = await stepApproach(state);

        if (p.isCancel(result)) {
          return null; // User cancelled
        }

        if (result === "scratch") {
          pushHistory(state);
          state.currentStep = "selection";
        } else if (result === "template") {
          // Handle template selection...
        }
        break;
      }

      case "selection": {
        const result = await stepSelection(state);

        if (p.isCancel(result)) {
          return null;
        }

        if (result === BACK_VALUE) {
          state.currentStep = popHistory(state) || "approach";
          break;
        }

        if (result === CONTINUE_VALUE) {
          pushHistory(state);
          state.currentStep = "confirm";
          break;
        }

        // Toggle selection
        toggleSelection(state, result as string);
        break;
      }

      case "confirm": {
        const result = await stepConfirm(state);

        if (p.isCancel(result)) {
          return null;
        }

        if (result === BACK_VALUE) {
          state.currentStep = popHistory(state) || "selection";
          break;
        }

        if (result === "confirm") {
          return {
            selectedItems: state.selectedItems,
            confirmed: true,
          };
        }
        break;
      }
    }
  }
}

// History management for back navigation
function pushHistory(state: WizardState): void {
  state.history.push(state.currentStep);
}

function popHistory(state: WizardState): WizardStep | null {
  return state.history.pop() || null;
}

function toggleSelection(state: WizardState, item: string): void {
  const index = state.selectedItems.indexOf(item);
  if (index > -1) {
    state.selectedItems.splice(index, 1);
  } else {
    state.selectedItems.push(item);
  }
  state.lastSelectedItem = item;
}

Why good: State machine makes complex flows manageable, history enables natural back navigation, separation of step functions keeps code organized


Pattern 6: Configuration Hierarchy

Implement config resolution with clear precedence chain.

File: src/cli/lib/config.ts

typescript
import path from "path";
import os from "os";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import { readFile, writeFile, fileExists, ensureDir } from "../utils/fs";

// Config locations
export const GLOBAL_CONFIG_DIR = path.join(os.homedir(), ".myapp");
export const GLOBAL_CONFIG_FILE = "config.yaml";
const PROJECT_CONFIG_DIR = ".myapp";
const PROJECT_CONFIG_FILE = "config.yaml";

// Environment variable name
export const SOURCE_ENV_VAR = "MYAPP_SOURCE";

// Default value
export const DEFAULT_SOURCE = "github:myorg/default-repo";

export interface GlobalConfig {
  source?: string;
  author?: string;
}

export interface ProjectConfig {
  source?: string;
}

export interface ResolvedConfig {
  source: string;
  sourceOrigin: "flag" | "env" | "project" | "global" | "default";
}

/**
 * Resolve configuration with precedence:
 * 1. CLI flag (--source)
 * 2. Environment variable (MYAPP_SOURCE)
 * 3. Project config (.myapp/config.yaml)
 * 4. Global config (~/.myapp/config.yaml)
 * 5. Default value
 */
export async function resolveSource(
  flagValue?: string,
  projectDir?: string,
): Promise<ResolvedConfig> {
  // 1. CLI flag takes highest priority
  if (flagValue !== undefined) {
    if (flagValue.trim() === "") {
      throw new Error("--source flag cannot be empty");
    }
    return { source: flagValue, sourceOrigin: "flag" };
  }

  // 2. Environment variable
  const envValue = process.env[SOURCE_ENV_VAR];
  if (envValue) {
    return { source: envValue, sourceOrigin: "env" };
  }

  // 3. Project config
  if (projectDir) {
    const projectConfig = await loadProjectConfig(projectDir);
    if (projectConfig?.source) {
      return { source: projectConfig.source, sourceOrigin: "project" };
    }
  }

  // 4. Global config
  const globalConfig = await loadGlobalConfig();
  if (globalConfig?.source) {
    return { source: globalConfig.source, sourceOrigin: "global" };
  }

  // 5. Default
  return { source: DEFAULT_SOURCE, sourceOrigin: "default" };
}

export function formatSourceOrigin(
  origin: ResolvedConfig["sourceOrigin"],
): string {
  switch (origin) {
    case "flag":
      return "--source flag";
    case "env":
      return `${SOURCE_ENV_VAR} environment variable`;
    case "project":
      return "project config (.myapp/config.yaml)";
    case "global":
      return "global config (~/.myapp/config.yaml)";
    case "default":
      return "default";
  }
}

Why good: Clear precedence chain documented, each source explicitly named, format function for user-friendly display


Pattern 7: Subcommand Organization

Structure complex commands with subcommands for related operations.

File: src/cli/commands/config.ts

typescript
import { Command } from "commander";
import * as p from "@clack/prompts";
import pc from "picocolors";
import {
  resolveSource,
  loadGlobalConfig,
  saveGlobalConfig,
  getGlobalConfigPath,
  formatSourceOrigin,
} from "../lib/config";
import { EXIT_CODES } from "../lib/exit-codes";

export const configCommand = new Command("config")
  .description("Manage configuration")
  .configureOutput({
    writeErr: (str) => console.error(pc.red(str)),
  })
  .showHelpAfterError(true);

// Subcommand: config show
configCommand
  .command("show")
  .description("Show current effective configuration")
  .action(async () => {
    const projectDir = process.cwd();
    const resolved = await resolveSource(undefined, projectDir);

    console.log(pc.cyan("\nConfiguration\n"));
    console.log(pc.bold("Source:"));
    console.log(`  ${pc.green(resolved.source)}`);
    console.log(
      `  ${pc.dim(`(from ${formatSourceOrigin(resolved.sourceOrigin)})`)}`,
    );
    console.log("");
  });

// Subcommand: config set
configCommand
  .command("set")
  .description("Set a global configuration value")
  .argument("<key>", "Configuration key (source, author)")
  .argument("<value>", "Configuration value")
  .action(async (key: string, value: string) => {
    const validKeys = ["source", "author"];

    if (!validKeys.includes(key)) {
      p.log.error(`Unknown configuration key: ${key}`);
      p.log.info(`Valid keys: ${validKeys.join(", ")}`);
      process.exit(EXIT_CODES.INVALID_ARGS);
    }

    const existingConfig = (await loadGlobalConfig()) || {};
    const newConfig = { ...existingConfig, [key]: value };

    await saveGlobalConfig(newConfig);

    p.log.success(`Set ${key} = ${value}`);
    p.log.info(`Saved to ${getGlobalConfigPath()}`);
  });

// Subcommand: config get
configCommand
  .command("get")
  .description("Get a configuration value")
  .argument("<key>", "Configuration key")
  .action(async (key: string) => {
    if (key === "source") {
      const resolved = await resolveSource(undefined, process.cwd());
      console.log(resolved.source);
    } else {
      p.log.error(`Unknown key: ${key}`);
      process.exit(EXIT_CODES.INVALID_ARGS);
    }
  });

Why good: Logical grouping of related commands, arguments validated early, consistent error handling across subcommands


Pattern 8: Output Formatting with picocolors

Use consistent styling for different message types.

typescript
import pc from "picocolors";

// Success messages
console.log(pc.green("Operation completed successfully!"));

// Warnings
console.log(pc.yellow("Warning: This may take a while"));

// Errors
console.error(pc.red("Error:"), error.message);

// Informational with dimmed context
console.log(`Source: ${pc.cyan(source)}`);
console.log(pc.dim(`(from ${origin})`));

// Section headers
console.log(pc.bold("\nConfiguration:\n"));

// Lists with icons
console.log(`  ${pc.green("+")} Added: ${item}`);
console.log(`  ${pc.red("-")} Removed: ${item}`);
console.log(`  ${pc.yellow("!")} Warning: ${message}`);

// Status indicators
const status = isValid ? pc.green("valid") : pc.red("invalid");
console.log(`Status: ${status}`);

// Styled options in prompts
const options = [
  { value: "yes", label: pc.green("Yes, proceed") },
  { value: "no", label: pc.dim("No, cancel") },
];

Pattern 9: Dry-Run Mode

Implement preview functionality for destructive operations.

typescript
import * as p from "@clack/prompts";
import pc from "picocolors";

export async function executeWithDryRun(
  dryRun: boolean,
  operations: Array<{ description: string; execute: () => Promise<void> }>,
): Promise<void> {
  if (dryRun) {
    p.log.info(pc.yellow("[dry-run] Preview mode - no changes will be made"));
    console.log("");

    for (const op of operations) {
      console.log(pc.yellow(`[dry-run] Would: ${op.description}`));
    }

    console.log("");
    p.outro(pc.green("[dry-run] Preview complete"));
    return;
  }

  // Execute for real
  const s = p.spinner();
  for (const op of operations) {
    s.start(op.description);
    await op.execute();
    s.stop(`Done: ${op.description}`);
  }
}

// Usage in command
.action(async (options, command) => {
  const dryRun = command.optsWithGlobals().dryRun ?? false;

  await executeWithDryRun(dryRun, [
    {
      description: "Create config file",
      execute: async () => {
        await writeFile(configPath, content);
      },
    },
    {
      description: "Install dependencies",
      execute: async () => {
        await installDeps();
      },
    },
  ]);
});

Pattern 10: File System Utilities

Wrap fs-extra and fast-glob for consistent async patterns.

File: src/cli/utils/fs.ts

typescript
import fs from "fs-extra";
import fg from "fast-glob";
import path from "path";

export async function readFile(filePath: string): Promise<string> {
  return fs.readFile(filePath, "utf-8");
}

export async function readFileOptional(
  filePath: string,
  fallback = "",
): Promise<string> {
  try {
    return await fs.readFile(filePath, "utf-8");
  } catch {
    return fallback;
  }
}

export async function fileExists(filePath: string): Promise<boolean> {
  return fs.pathExists(filePath);
}

export async function directoryExists(dirPath: string): Promise<boolean> {
  try {
    const stat = await fs.stat(dirPath);
    return stat.isDirectory();
  } catch {
    return false;
  }
}

export async function glob(pattern: string, cwd: string): Promise<string[]> {
  return fg(pattern, { cwd, onlyFiles: true });
}

export async function writeFile(
  filePath: string,
  content: string,
): Promise<void> {
  // Ensure parent directory exists
  await fs.ensureDir(path.dirname(filePath));
  await fs.writeFile(filePath, content, "utf-8");
}

export async function ensureDir(dirPath: string): Promise<void> {
  await fs.ensureDir(dirPath);
}

export async function remove(filePath: string): Promise<void> {
  await fs.remove(filePath);
}

export async function copy(src: string, dest: string): Promise<void> {
  await fs.copy(src, dest);
}

export async function listDirectories(dirPath: string): Promise<string[]> {
  try {
    const entries = await fs.readdir(dirPath, { withFileTypes: true });
    return entries.filter((e) => e.isDirectory()).map((e) => e.name);
  } catch {
    return [];
  }
}

Why good: Consistent async API, ensureDir before writes prevents errors, optional file read with fallback


Pattern 11: Verbose Logging Utility

Implement conditional verbose output.

File: src/cli/utils/logger.ts

typescript
import pc from "picocolors";

let verboseMode = false;

export function setVerbose(enabled: boolean): void {
  verboseMode = enabled;
}

export function verbose(msg: string): void {
  if (verboseMode) {
    console.log(pc.dim(`  ${msg}`));
  }
}

// Usage in commands
import { setVerbose, verbose } from "../utils/logger";

.action(async (options) => {
  setVerbose(options.verbose);

  verbose("Starting operation...");
  verbose(`Reading from ${path}`);

  // ... regular output
  console.log("Operation complete");
});
</patterns>

<decision_framework>

Decision Framework

Command Structure Decision

code
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

code
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

code
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

code
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>


<testing>

Testing CLI Commands

Testing Commander Commands

typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Command } from "commander";

describe("init command", () => {
  let program: Command;
  let exitSpy: ReturnType<typeof vi.spyOn>;
  let consoleSpy: ReturnType<typeof vi.spyOn>;

  beforeEach(() => {
    program = new Command();
    program.exitOverride(); // Prevent actual process.exit
    exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
      throw new Error("process.exit called");
    });
    consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
  });

  it("shows help with --help", async () => {
    const helpSpy = vi.spyOn(program, "outputHelp");

    try {
      await program.parseAsync(["node", "test", "--help"]);
    } catch {
      // exitOverride throws
    }

    expect(helpSpy).toHaveBeenCalled();
  });

  it("validates required options", async () => {
    // Add your command setup here

    await expect(
      program.parseAsync(["node", "test", "init", "--invalid"]),
    ).rejects.toThrow();
  });
});

Testing with Mock File System

typescript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { vol } from "memfs";

vi.mock("fs-extra", async () => {
  const memfs = await import("memfs");
  return memfs.fs.promises;
});

describe("config loading", () => {
  beforeEach(() => {
    vol.reset();
  });

  afterEach(() => {
    vol.reset();
  });

  it("loads config from file", async () => {
    vol.fromJSON({
      "/home/user/.myapp/config.yaml": "source: github:org/repo",
    });

    const config = await loadGlobalConfig();
    expect(config?.source).toBe("github:org/repo");
  });

  it("returns null for missing config", async () => {
    vol.fromJSON({});

    const config = await loadGlobalConfig();
    expect(config).toBeNull();
  });
});

Testing Interactive Prompts

typescript
import { describe, it, expect, vi } from "vitest";
import * as p from "@clack/prompts";

vi.mock("@clack/prompts", () => ({
  select: vi.fn(),
  confirm: vi.fn(),
  spinner: vi.fn(() => ({
    start: vi.fn(),
    stop: vi.fn(),
  })),
  isCancel: vi.fn((value) => value === Symbol.for("cancel")),
  intro: vi.fn(),
  outro: vi.fn(),
  log: {
    info: vi.fn(),
    error: vi.fn(),
    success: vi.fn(),
  },
}));

describe("wizard flow", () => {
  it("handles user cancellation", async () => {
    vi.mocked(p.select).mockResolvedValueOnce(Symbol.for("cancel"));

    const result = await runWizard();

    expect(result).toBeNull();
  });

  it("completes selection flow", async () => {
    vi.mocked(p.select)
      .mockResolvedValueOnce("scratch")
      .mockResolvedValueOnce("item1")
      .mockResolvedValueOnce("__continue__")
      .mockResolvedValueOnce("confirm");

    const result = await runWizard();

    expect(result?.selectedItems).toContain("item1");
  });
});
</testing>

<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
  • Not handling SIGINT - leaves processes in unknown state
  • Synchronous file operations blocking the event loop

Medium Priority Issues:

  • Missing spinner feedback for operations > 500ms
  • Not using parseAsync() for async actions - swallows errors
  • Inconsistent error message formatting
  • Missing --help descriptions for options

Common Mistakes:

  • Forgetting to call process.exit() after p.cancel() - execution continues
  • Not validating early - errors occur deep in flow
  • Mixing sync and async patterns - leads to race conditions
  • Not cleaning up on errors (spinners left running)

Gotchas & Edge Cases:

  • Commander converts --my-option to myOption in camelCase
  • optsWithGlobals() needed to access parent command options
  • Spinner must be stopped before any console output
  • YAML parsing can throw - always wrap in try/catch
  • process.exit() in async context may not wait for cleanup

</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 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)

Failure to follow these rules will result in poor UX, orphaned processes, and debugging nightmares.

</critical_reminders>