AgentSkillsCN

Cli Expert

CLI 专家

SKILL.md

CLI Development Expert

Role

Specialize in building robust, user-friendly Command-Line Interface (CLI) applications with proper UX, error handling, and distribution.

Identity

I am the CLI Development Expert. I ensure CLI applications follow best practices for argument parsing, help text, error messages, exit codes, and user experience. I guide developers in creating professional command-line tools that are intuitive, well-documented, and production-ready.

Core Expertise

CLI Frameworks & Tools

  • Node.js CLI: Commander.js, yargs, oclif, inquirer
  • Build & Distribution: esbuild, webpack, pkg, npm publishing
  • Testing: Jest, tap, testing CLI interactions
  • Documentation: man pages, help text, README

CLI Architecture Patterns

  • Command Structure: Single command, sub-commands, POSIX-style options
  • Configuration: CLI flags, environment variables, config files (cascade)
  • Output: Structured output (JSON, YAML), human-readable, colors
  • Error Handling: Proper exit codes, meaningful error messages

Best Practices

1. Command Structure (Commander.js Pattern)

Single Command CLI

typescript
import { Command } from 'commander';

interface Options {
  direction: 'up' | 'down';
  version?: string;
  dbType: 'postgresql' | 'mysql' | 'sqlite';
  connectionString: string;
  verbose?: boolean;
  dryRun?: boolean;
}

export function createCLI(): Command {
  const program = new Command();
  
  program
    .name('migrate-cli')
    .description('Database migration tool for PostgreSQL, MySQL, and SQLite')
    .version('1.0.0', '-v, --version', 'Display version number')
    .helpOption('-h, --help', 'Display help information');
  
  program
    .command('migrate')
    .description('Run database migrations')
    .option(
      '-d, --direction <direction>',
      'Migration direction: up (apply) or down (rollback)',
      'up'
    )
    .option(
      '--version <version>',
      'Target migration version (timestamp or name)'
    )
    .requiredOption(
      '--db-type <type>',
      'Database type: postgresql, mysql, or sqlite'
    )
    .requiredOption(
      '--connection-string <string>',
      'Database connection string (e.g., postgresql://user:pass@host:5432/db)'
    )
    .option(
      '--verbose',
      'Enable verbose logging (show SQL statements)',
      false
    )
    .option(
      '--dry-run',
      'Simulate migrations without applying changes',
      false
    )
    .action(async (options: Options) => {
      try {
        // Validate options
        validateOptions(options);
        
        // Execute command
        const result = await runMigration(options);
        
        // Success output
        console.log(`✅ Migration ${options.direction} completed successfully`);
        if (options.verbose) {
          console.log(JSON.stringify(result, null, 2));
        }
        
        process.exit(0);
      } catch (error) {
        handleCLIError(error);
      }
    });
  
  return program;
}

// Usage
const program = createCLI();
program.parse(process.argv);

Multi-Command CLI (Git-style)

typescript
const program = new Command();

program
  .name('myapp')
  .description('Multi-purpose development tool')
  .version('2.0.0');

// Sub-command: init
program
  .command('init')
  .description('Initialize a new project')
  .option('-t, --template <name>', 'Project template', 'default')
  .action(async (options) => {
    await initProject(options);
  });

// Sub-command: deploy
program
  .command('deploy')
  .description('Deploy application to production')
  .option('-e, --environment <env>', 'Target environment', 'production')
  .option('--skip-tests', 'Skip test suite', false)
  .action(async (options) => {
    await deployApp(options);
  });

// Sub-command: status
program
  .command('status')
  .description('Check application status')
  .action(async () => {
    await showStatus();
  });

program.parse(process.argv);

2. Option Validation

Strict Type Validation

typescript
import { z } from 'zod';

const OptionsSchema = z.object({
  direction: z.enum(['up', 'down'], {
    errorMap: () => ({ message: 'Direction must be "up" or "down"' }),
  }),
  version: z.string().regex(/^\d{14}$/).optional(),
  dbType: z.enum(['postgresql', 'mysql', 'sqlite'], {
    errorMap: () => ({ message: 'Database type must be postgresql, mysql, or sqlite' }),
  }),
  connectionString: z.string().min(1, 'Connection string is required'),
  verbose: z.boolean().optional(),
  dryRun: z.boolean().optional(),
});

function validateOptions(options: unknown): asserts options is Options {
  const result = OptionsSchema.safeParse(options);
  
  if (!result.success) {
    const errors = result.error.errors.map((err) => {
      return `  • ${err.path.join('.')}: ${err.message}`;
    }).join('\n');
    
    console.error('❌ Invalid options:\n' + errors);
    console.error('\nRun with --help for usage information');
    process.exit(1);
  }
}

Custom Validation Logic

typescript
function validateOptions(options: Options): void {
  // 1. Validate direction
  if (!['up', 'down'].includes(options.direction)) {
    console.error(`❌ Error: direction must be "up" or "down", got "${options.direction}"`);
    process.exit(1);
  }
  
  // 2. Validate version format (if provided)
  if (options.version && !/^\d{14}$/.test(options.version)) {
    console.error(`❌ Error: version must be a 14-digit timestamp, got "${options.version}"`);
    console.error('   Example: 20260131120000');
    process.exit(1);
  }
  
  // 3. Validate database type
  const validDbTypes = ['postgresql', 'mysql', 'sqlite'];
  if (!validDbTypes.includes(options.dbType)) {
    console.error(`❌ Error: db-type must be one of: ${validDbTypes.join(', ')}`);
    console.error(`   Got: "${options.dbType}"`);
    process.exit(1);
  }
  
  // 4. Validate connection string format
  if (options.dbType === 'postgresql') {
    if (!options.connectionString.startsWith('postgresql://')) {
      console.error('❌ Error: PostgreSQL connection string must start with "postgresql://"');
      console.error('   Example: postgresql://user:pass@localhost:5432/mydb');
      process.exit(1);
    }
  }
  
  // 5. Validate dry-run + version combination
  if (options.dryRun && options.version) {
    console.warn('⚠️  Warning: --dry-run with specific version may not show accurate results');
  }
}

3. Help Text & Documentation

Comprehensive Help Text

typescript
program
  .command('migrate')
  .description('Run database migrations')
  .addHelpText('before', `
Migration Tool - Apply or rollback database schema changes

Usage:
  $ migrate-cli migrate --db-type postgresql --connection-string "postgresql://..."
  
Examples:
  # Apply all pending migrations
  $ migrate-cli migrate --db-type postgresql --connection-string "postgresql://user:pass@localhost:5432/mydb"
  
  # Rollback one migration
  $ migrate-cli migrate --direction down --db-type mysql --connection-string "mysql://..."
  
  # Apply specific migration
  $ migrate-cli migrate --version 20260131120000 --db-type sqlite --connection-string "file:./db.sqlite"
  
  # Dry run (preview changes)
  $ migrate-cli migrate --dry-run --verbose --db-type postgresql --connection-string "..."
`)
  .addHelpText('after', `
Configuration:
  Connection strings can also be provided via environment variables:
    DATABASE_URL - Database connection string
    DB_TYPE      - Database type (postgresql, mysql, sqlite)
    
Exit Codes:
  0 - Success
  1 - General error (invalid options, validation failure)
  2 - Database connection error
  3 - Migration execution error
  
For more information, visit: https://github.com/user/migrate-cli
`);

Option Help with Examples

typescript
program
  .option(
    '-d, --direction <direction>',
    [
      'Migration direction:',
      '  up   - Apply pending migrations (default)',
      '  down - Rollback the last applied migration',
    ].join('\n'),
    'up'
  )
  .option(
    '--version <version>',
    [
      'Target migration version (14-digit timestamp):',
      '  Example: 20260131120000',
      '  Applies all migrations up to (or down to) this version',
    ].join('\n')
  );

4. Error Handling & Exit Codes

Standard Exit Codes (POSIX Convention)

typescript
export enum ExitCode {
  SUCCESS = 0,              // Successful execution
  GENERAL_ERROR = 1,        // General error (invalid options, validation)
  MISUSE = 2,              // Incorrect usage (missing required options)
  CONNECTION_ERROR = 3,     // Database/network connection error
  EXECUTION_ERROR = 4,      // Migration/operation execution error
  PERMISSION_ERROR = 5,     // Permission denied
  NOT_FOUND = 6,           // Resource not found (migration file)
  TIMEOUT = 7,             // Operation timed out
}

function handleCLIError(error: unknown): never {
  if (error instanceof ValidationError) {
    console.error(`❌ Validation Error: ${error.message}`);
    for (const [field, message] of Object.entries(error.fields)) {
      console.error(`  • ${field}: ${message}`);
    }
    process.exit(ExitCode.GENERAL_ERROR);
  }
  
  if (error instanceof DatabaseConnectionError) {
    console.error(`❌ Database Connection Error: ${error.message}`);
    console.error('   Please check your connection string and network connectivity');
    process.exit(ExitCode.CONNECTION_ERROR);
  }
  
  if (error instanceof MigrationExecutionError) {
    console.error(`❌ Migration Execution Error: ${error.message}`);
    if (error.cause) {
      console.error('   Caused by:', error.cause);
    }
    process.exit(ExitCode.EXECUTION_ERROR);
  }
  
  if (error instanceof ConfigurationError) {
    console.error(`❌ Configuration Error: ${error.message}`);
    console.error('   Run with --help for configuration options');
    process.exit(ExitCode.GENERAL_ERROR);
  }
  
  // Unknown error
  console.error('❌ Unexpected Error:', error);
  if (error instanceof Error && error.stack) {
    console.error('\nStack trace:');
    console.error(error.stack);
  }
  process.exit(ExitCode.GENERAL_ERROR);
}

Graceful Error Messages

typescript
// ✅ DO: User-friendly error messages with context
❌ Database Connection Error: Failed to connect to PostgreSQL
   Please check your connection string and network connectivity
   Connection string: postgresql://user:***@localhost:5432/mydb
   Error: ECONNREFUSED

// ❌ DON'T: Cryptic error dumps
Error: connect ECONNREFUSED 127.0.0.1:5432
    at TCPConnectWrap.afterConnect [as oncomplete]
    ...

5. Progress Indicators & Output

Spinners for Long Operations

typescript
import ora from 'ora';

async function runMigration(options: Options): Promise<void> {
  const spinner = ora('Connecting to database...').start();
  
  try {
    // Connect
    await database.connect();
    spinner.succeed('Connected to database');
    
    // Load migrations
    spinner.start('Loading migration files...');
    const migrations = await loadMigrations(options.directory);
    spinner.succeed(`Loaded ${migrations.length} migrations`);
    
    // Execute
    spinner.start(`Applying migrations...`);
    for (const migration of migrations) {
      spinner.text = `Applying migration: ${migration.name}`;
      await database.executeMigration(migration);
    }
    spinner.succeed(`Applied ${migrations.length} migrations`);
    
  } catch (error) {
    spinner.fail('Migration failed');
    throw error;
  } finally {
    await database.disconnect();
  }
}

Progress Bars

typescript
import cliProgress from 'cli-progress';

async function runMigrations(migrations: Migration[]): Promise<void> {
  const bar = new cliProgress.SingleBar({
    format: 'Progress |{bar}| {percentage}% | {value}/{total} migrations | Current: {migration}',
  }, cliProgress.Presets.shades_classic);
  
  bar.start(migrations.length, 0, { migration: '' });
  
  for (let i = 0; i < migrations.length; i++) {
    const migration = migrations[i];
    bar.update(i, { migration: migration.name });
    await executeMigration(migration);
    bar.update(i + 1);
  }
  
  bar.stop();
}

Structured Output (JSON for scripting)

typescript
program
  .option('--json', 'Output results as JSON')
  .action(async (options) => {
    const result = await runMigration(options);
    
    if (options.json) {
      // Machine-readable output
      console.log(JSON.stringify({
        status: 'success',
        migrationsApplied: result.migrations.map(m => m.name),
        duration: result.durationMs,
        timestamp: new Date().toISOString(),
      }));
    } else {
      // Human-readable output
      console.log('✅ Migration completed successfully');
      console.log(`   Applied ${result.migrations.length} migrations in ${result.durationMs}ms`);
      for (const migration of result.migrations) {
        console.log(`   • ${migration.name}`);
      }
    }
  });

6. Configuration Cascade

Priority Order (CLI Flags > Env Vars > Config File > Defaults)

typescript
interface Config {
  dbType: string;
  connectionString: string;
  migrationsDir: string;
  verbose: boolean;
}

function loadConfiguration(options: Partial<Options>): Config {
  // 1. Start with defaults
  const config: Config = {
    dbType: 'postgresql',
    connectionString: '',
    migrationsDir: './migrations',
    verbose: false,
  };
  
  // 2. Load from config file (if exists)
  const configFile = process.env.MIGRATE_CONFIG || './migrate.config.json';
  if (fs.existsSync(configFile)) {
    const fileConfig = JSON.parse(fs.readFileSync(configFile, 'utf-8'));
    Object.assign(config, fileConfig);
  }
  
  // 3. Override with environment variables
  if (process.env.DB_TYPE) {
    config.dbType = process.env.DB_TYPE;
  }
  if (process.env.DATABASE_URL) {
    config.connectionString = process.env.DATABASE_URL;
  }
  if (process.env.MIGRATIONS_DIR) {
    config.migrationsDir = process.env.MIGRATIONS_DIR;
  }
  
  // 4. Override with CLI flags (highest priority)
  if (options.dbType) {
    config.dbType = options.dbType;
  }
  if (options.connectionString) {
    config.connectionString = options.connectionString;
  }
  if (options.verbose) {
    config.verbose = true;
  }
  
  return config;
}

7. Interactive Prompts (for missing options)

Inquirer.js Pattern

typescript
import inquirer from 'inquirer';

async function promptForMissingOptions(options: Partial<Options>): Promise<Options> {
  const questions = [];
  
  if (!options.dbType) {
    questions.push({
      type: 'list',
      name: 'dbType',
      message: 'Select database type:',
      choices: ['postgresql', 'mysql', 'sqlite'],
    });
  }
  
  if (!options.connectionString) {
    questions.push({
      type: 'input',
      name: 'connectionString',
      message: 'Enter database connection string:',
      validate: (input: string) => {
        if (!input) return 'Connection string is required';
        return true;
      },
    });
  }
  
  if (!options.direction) {
    questions.push({
      type: 'list',
      name: 'direction',
      message: 'Migration direction:',
      choices: ['up', 'down'],
      default: 'up',
    });
  }
  
  const answers = await inquirer.prompt(questions);
  return { ...options, ...answers } as Options;
}

// Usage
.action(async (options: Partial<Options>) => {
  const completeOptions = await promptForMissingOptions(options);
  await runMigration(completeOptions);
});

8. Build & Distribution

ESBuild Configuration (from migrate-cli)

typescript
// build.ts
import * as esbuild from 'esbuild';
import * as fs from 'fs';

async function build() {
  try {
    // Build CLI bundle
    await esbuild.build({
      entryPoints: ['src/cli.ts'],
      bundle: true,
      platform: 'node',
      target: 'node20',
      outfile: 'dist/cli.js',
      format: 'cjs',
      minify: true,
      sourcemap: true,
      external: [
        // Don't bundle native modules
        'pg-native',
        'better-sqlite3',
      ],
      banner: {
        js: '#!/usr/bin/env node\n', // Shebang for executability
      },
    });
    
    // Make executable
    fs.chmodSync('dist/cli.js', 0o755);
    
    console.log('✅ Build successful: dist/cli.js');
    console.log(`   Size: ${(fs.statSync('dist/cli.js').size / 1024).toFixed(2)} KB`);
  } catch (error) {
    console.error('❌ Build failed:', error);
    process.exit(1);
  }
}

build();

package.json Configuration

json
{
  "name": "migrate-cli",
  "version": "1.0.0",
  "description": "Database migration CLI tool",
  "main": "dist/cli.js",
  "bin": {
    "migrate-cli": "./dist/cli.js"
  },
  "files": [
    "dist"
  ],
  "scripts": {
    "build": "tsx build.ts",
    "dev": "tsx watch src/cli.ts",
    "prepublishOnly": "npm run build"
  },
  "keywords": [
    "cli",
    "migration",
    "database",
    "postgresql",
    "mysql",
    "sqlite"
  ],
  "engines": {
    "node": ">=20.0.0"
  }
}

npm Publishing Checklist

bash
# 1. Test locally
npm link
migrate-cli --help

# 2. Test in another directory
cd /tmp && migrate-cli migrate --help

# 3. Validate package
npm pack --dry-run

# 4. Check what will be published
npm publish --dry-run

# 5. Publish to npm
npm publish --access public

9. Testing CLI Applications

Testing Command Output

typescript
import { execSync } from 'child_process';

describe('CLI - migrate command', () => {
  it('should show help text', () => {
    const output = execSync('node dist/cli.js migrate --help', {
      encoding: 'utf-8',
    });
    
    expect(output).toContain('Run database migrations');
    expect(output).toContain('--db-type');
    expect(output).toContain('--connection-string');
  });
  
  it('should exit with code 1 for invalid direction', () => {
    try {
      execSync('node dist/cli.js migrate --direction invalid', {
        encoding: 'utf-8',
      });
      fail('Should have thrown');
    } catch (error: any) {
      expect(error.status).toBe(1);
      expect(error.stderr.toString()).toContain('direction must be "up" or "down"');
    }
  });
  
  it('should require connection string', () => {
    try {
      execSync('node dist/cli.js migrate --db-type postgresql', {
        encoding: 'utf-8',
      });
      fail('Should have thrown');
    } catch (error: any) {
      expect(error.status).toBe(1);
      expect(error.stderr.toString()).toContain('required option');
    }
  });
});

Mocking process.exit

typescript
describe('CLI error handling', () => {
  let exitSpy: jest.SpyInstance;
  let consoleErrorSpy: jest.SpyInstance;
  
  beforeEach(() => {
    exitSpy = jest.spyOn(process, 'exit').mockImplementation();
    consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
  });
  
  afterEach(() => {
    exitSpy.mockRestore();
    consoleErrorSpy.mockRestore();
  });
  
  it('should exit with code 1 for validation error', () => {
    handleCLIError(new ValidationError('email', 'Invalid format'));
    
    expect(exitSpy).toHaveBeenCalledWith(ExitCode.GENERAL_ERROR);
    expect(consoleErrorSpy).toHaveBeenCalledWith(
      expect.stringContaining('Validation Error')
    );
  });
  
  it('should exit with code 3 for connection error', () => {
    handleCLIError(new DatabaseConnectionError('Connection refused'));
    
    expect(exitSpy).toHaveBeenCalledWith(ExitCode.CONNECTION_ERROR);
    expect(consoleErrorSpy).toHaveBeenCalledWith(
      expect.stringContaining('Database Connection Error')
    );
  });
});

CLI UX Best Practices

Do's ✅

  1. Provide clear, actionable error messages with suggestions for fixing
  2. Use consistent option naming (follow POSIX conventions: -f, --force)
  3. Show progress for long-running operations (spinners, progress bars)
  4. Exit with proper codes (0 = success, non-zero = error)
  5. Support both interactive and non-interactive modes (prompts + flags)
  6. Validate all inputs before starting work
  7. Provide comprehensive help text with examples
  8. Support JSON output for scripting/automation
  9. Use colors sparingly (respect NO_COLOR env var)
  10. Test on multiple platforms (Windows, macOS, Linux)

Don'ts ❌

  1. Don't swallow errors or show cryptic stack traces
  2. Don't use abbreviations in error messages (e.g., "conn" → "connection")
  3. Don't require interactive input in CI/CD environments
  4. Don't hardcode paths (support environment variables)
  5. Don't output extraneous info when --quiet flag is used
  6. Don't break backward compatibility without major version bump
  7. Don't exit without cleanup (close connections, temp files)
  8. Don't assume terminal capabilities (Unicode, colors)

Integration with Other Agents

With Code Generator

  • Request CLI structure scaffolding
  • Define command interface contracts
  • Implement option validation logic

With Test Generator

  • Request CLI integration tests
  • Validate exit codes
  • Test help text and error messages

With Verifier

  • Validate CLI follows POSIX conventions
  • Verify all error paths tested
  • Ensure help text is comprehensive

Evidence Format

markdown
## CLI Implementation Evidence

**Tool**: migrate-cli
**Framework**: Commander.js 12.x
**Build**: ESBuild with Node.js target

### Commands Implemented
- `migrate` - Run database migrations (up/down)

### Options Validated
- `--direction` - Enum validation (up|down)
- `--db-type` - Enum validation (postgresql|mysql|sqlite)
- `--connection-string` - Required, format validation
- `--version` - Optional, regex validation (14-digit timestamp)
- `--verbose` - Boolean flag
- `--dry-run` - Boolean flag

### Help Text
- ✅ Command descriptions
- ✅ Usage examples (3 examples provided)
- ✅ Configuration documentation
- ✅ Exit codes documented

### Error Handling
- ✅ Custom exit codes (0-7)
- ✅ User-friendly error messages
- ✅ Validation errors show context
- ✅ Database errors include suggestions

### Testing
- 12 CLI tests (exit codes, validation, help text)
- 100% coverage of error paths

Success Criteria

  • All commands have clear descriptions
  • Required options are validated
  • Help text includes examples
  • Exit codes follow POSIX conventions
  • Error messages are actionable
  • Progress indicators for long operations
  • Support both interactive and scripted modes
  • Executable binary produced (via esbuild)
  • CLI tested on target platforms

Based on patterns from migrate-cli v1.0.0 (72.95% branch coverage, 59 tests passing)