Skip to content

CLI Tool Template

The CLI Tool template provides a modern command-line tool development environment with TypeScript, perfect for building powerful CLI applications with excellent developer experience.

Technical Stack

  • Node.js - JavaScript runtime
  • TypeScript - Type-safe development
  • ESBuild - Fast JavaScript bundler
  • Commander.js - Command-line interface framework
  • Chalk - Terminal string styling
  • Prompts - Interactive prompts
  • fs-extra - Enhanced file system operations
  • simple-git - Git operations
  • i18next - Internationalization

Quick Start

1. Create Project

bash
# Initialize project
vup init my-cli-project
cd my-cli-project

# Add CLI template
vup add my-cli

2. Install Dependencies

bash
# Install dependencies
pnpm install

3. Build and Run

bash
# Build the CLI tool
cd apps/my-cli
pnpm build

# Run the CLI tool
node dist/index.js --help

Project Structure

apps/my-cli/
├── src/
│   ├── commands/         # Command implementations
│   │   └── language/     # Language command
│   │       └── index.ts  # Language command implementation
│   ├── utils/            # Utility functions
│   │   ├── file.ts       # File operations
│   │   ├── git.ts        # Git operations
│   │   └── logger.ts     # Logging utilities
│   ├── locales/          # Internationalization
│   │   ├── en_US/        # English translations
│   │   │   └── common.json
│   │   └── zh_CN/        # Chinese translations
│   │       └── common.json
│   ├── i18n.ts           # i18n configuration
│   └── index.ts          # Main entry point
├── esbuild.config.js     # ESBuild configuration
├── package.json          # Dependencies and scripts
└── tsconfig.json         # TypeScript configuration

Core Features

Command System

Main Entry Point

typescript
// src/index.ts
#!/usr/bin/env node

import { Command } from 'commander';
import { logger } from './utils/logger';
import { setupI18n } from './i18n';
import { languageCommand } from './commands/language';

const program = new Command();

// Set up internationalization
setupI18n();

// Configure the program
program
  .name('my-cli')
  .description('A modern CLI tool built with TypeScript')
  .version('1.0.0');

// Add commands
program.addCommand(languageCommand);

// Handle errors
program.on('command:*', () => {
  logger.error('Unknown command: %s', program.args.join(' '));
  process.exit(1);
});

// Parse arguments
program.parse();

Command Implementation

typescript
// src/commands/language/index.ts
import { Command } from 'commander';
import { logger } from '../../utils/logger';
import { i18n } from '../../i18n';

export const languageCommand = new Command('language')
  .description('Manage language settings')
  .option('-l, --list', 'List available languages')
  .option('-s, --set <lang>', 'Set language')
  .action(async (options) => {
    try {
      if (options.list) {
        await listLanguages();
      } else if (options.set) {
        await setLanguage(options.set);
      } else {
        logger.info('Current language: %s', i18n.language);
        logger.info('Use --help for more options');
      }
    } catch (error) {
      logger.error('Command failed: %s', error.message);
      process.exit(1);
    }
  });

async function listLanguages() {
  const languages = ['en', 'zh'];
  logger.info('Available languages:');
  languages.forEach((lang) => {
    logger.info('  - %s', lang);
  });
}

async function setLanguage(lang: string) {
  // Implementation for setting language
  logger.info('Language set to: %s', lang);
}

Interactive Prompts

typescript
// src/utils/prompts.ts
import prompts from 'prompts';
import { logger } from './logger';

export interface PromptOptions {
  type: 'text' | 'select' | 'confirm' | 'multiselect';
  name: string;
  message: string;
  choices?: Array<{ title: string; value: any }>;
  initial?: any;
  validate?: (value: any) => boolean | string;
}

export async function prompt(options: PromptOptions): Promise<any> {
  try {
    const response = await prompts({
      type: options.type,
      name: options.name,
      message: options.message,
      choices: options.choices,
      initial: options.initial,
      validate: options.validate,
    });

    if (response[options.name] === undefined) {
      logger.warn('Operation cancelled');
      process.exit(0);
    }

    return response[options.name];
  } catch (error) {
    logger.error('Prompt failed: %s', error.message);
    process.exit(1);
  }
}

export async function confirm(message: string): Promise<boolean> {
  const response = await prompt({
    type: 'confirm',
    name: 'value',
    message,
  });
  return response;
}

export async function select<T>(
  message: string,
  choices: Array<{ title: string; value: T }>
): Promise<T> {
  const response = await prompt({
    type: 'select',
    name: 'value',
    message,
    choices,
  });
  return response;
}

File Operations

typescript
// src/utils/file.ts
import fs from 'fs-extra';
import path from 'path';
import { logger } from './logger';

export class FileService {
  static async readFile(filePath: string): Promise<string> {
    try {
      return await fs.readFile(filePath, 'utf-8');
    } catch (error) {
      logger.error('Failed to read file %s: %s', filePath, error.message);
      throw error;
    }
  }

  static async writeFile(filePath: string, content: string): Promise<void> {
    try {
      await fs.ensureDir(path.dirname(filePath));
      await fs.writeFile(filePath, content, 'utf-8');
      logger.info('File written: %s', filePath);
    } catch (error) {
      logger.error('Failed to write file %s: %s', filePath, error.message);
      throw error;
    }
  }

  static async copyFile(src: string, dest: string): Promise<void> {
    try {
      await fs.ensureDir(path.dirname(dest));
      await fs.copy(src, dest);
      logger.info('File copied: %s -> %s', src, dest);
    } catch (error) {
      logger.error('Failed to copy file %s: %s', src, error.message);
      throw error;
    }
  }

  static async exists(filePath: string): Promise<boolean> {
    return await fs.pathExists(filePath);
  }

  static async mkdir(dirPath: string): Promise<void> {
    try {
      await fs.ensureDir(dirPath);
      logger.info('Directory created: %s', dirPath);
    } catch (error) {
      logger.error('Failed to create directory %s: %s', dirPath, error.message);
      throw error;
    }
  }
}

Git Operations

typescript
// src/utils/git.ts
import simpleGit, { SimpleGit } from 'simple-git';
import { logger } from './logger';

export class GitService {
  private git: SimpleGit;

  constructor(workingDir: string = process.cwd()) {
    this.git = simpleGit(workingDir);
  }

  async init(): Promise<void> {
    try {
      await this.git.init();
      logger.info('Git repository initialized');
    } catch (error) {
      logger.error('Failed to initialize git repository: %s', error.message);
      throw error;
    }
  }

  async add(files: string[]): Promise<void> {
    try {
      await this.git.add(files);
      logger.info('Files added to git: %s', files.join(', '));
    } catch (error) {
      logger.error('Failed to add files to git: %s', error.message);
      throw error;
    }
  }

  async commit(message: string): Promise<void> {
    try {
      await this.git.commit(message);
      logger.info('Changes committed: %s', message);
    } catch (error) {
      logger.error('Failed to commit changes: %s', error.message);
      throw error;
    }
  }

  async status(): Promise<any> {
    try {
      return await this.git.status();
    } catch (error) {
      logger.error('Failed to get git status: %s', error.message);
      throw error;
    }
  }

  async isRepo(): Promise<boolean> {
    try {
      await this.git.status();
      return true;
    } catch {
      return false;
    }
  }
}

Logging System

typescript
// src/utils/logger.ts
import chalk from 'chalk';

export class Logger {
  private static instance: Logger;
  private verbose: boolean = false;

  static getInstance(): Logger {
    if (!Logger.instance) {
      Logger.instance = new Logger();
    }
    return Logger.instance;
  }

  setVerbose(verbose: boolean): void {
    this.verbose = verbose;
  }

  info(message: string, ...args: any[]): void {
    console.log(chalk.blue('ℹ'), message, ...args);
  }

  success(message: string, ...args: any[]): void {
    console.log(chalk.green('✓'), message, ...args);
  }

  warn(message: string, ...args: any[]): void {
    console.log(chalk.yellow('⚠'), message, ...args);
  }

  error(message: string, ...args: any[]): void {
    console.error(chalk.red('✗'), message, ...args);
  }

  debug(message: string, ...args: any[]): void {
    if (this.verbose) {
      console.log(chalk.gray('🐛'), message, ...args);
    }
  }
}

export const logger = Logger.getInstance();

Internationalization

typescript
// src/i18n.ts
import i18next from 'i18next';
import { readFileSync } from 'fs';
import { join } from 'path';

let i18n: typeof i18next;

export function setupI18n(language: string = 'en'): typeof i18next {
  if (i18n) {
    return i18n;
  }

  i18n = i18next.init({
    lng: language,
    fallbackLng: 'en',
    resources: {
      en: {
        translation: JSON.parse(
          readFileSync(join(__dirname, 'locales/en_US/common.json'), 'utf-8')
        ),
      },
      zh: {
        translation: JSON.parse(
          readFileSync(join(__dirname, 'locales/zh_CN/common.json'), 'utf-8')
        ),
      },
    },
  });

  return i18n;
}

export { i18n };

Development Tools

ESBuild Configuration

javascript
// esbuild.config.js
import { build } from 'esbuild';
import { resolve } from 'path';

const buildConfig = {
  entryPoints: ['src/index.ts'],
  bundle: true,
  platform: 'node',
  target: 'node18',
  outfile: 'dist/index.js',
  format: 'cjs',
  sourcemap: true,
  minify: true,
  external: [
    // Mark dependencies as external
  ],
  banner: {
    js: '#!/usr/bin/env node',
  },
  define: {
    'process.env.NODE_ENV': '"production"',
  },
};

build(buildConfig).catch(() => process.exit(1));

TypeScript Configuration

json
// tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir": "./dist",
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"],
      "@shared/*": ["../../_shared/*"]
    }
  },
  "include": ["src/**/*", "src/**/*.ts"]
}

Available Scripts

json
{
  "scripts": {
    "dev": "tsc --watch",
    "build": "tsc && esbuild src/index.ts --bundle --platform=node --outfile=dist/index.js --banner:js='#!/usr/bin/env node'",
    "start": "node dist/index.js",
    "lint": "eslint src/ --ext .ts",
    "lint:fix": "eslint src/ --ext .ts --fix",
    "format": "prettier --write \"src/**/*.{ts,json}\"",
    "format:check": "prettier --check \"src/**/*.{ts,json}\""
  }
}

Best Practices

Error Handling

typescript
// src/utils/error.ts
export class CLIError extends Error {
  constructor(
    message: string,
    public code: string = 'CLI_ERROR'
  ) {
    super(message);
    this.name = 'CLIError';
  }
}

export function handleError(error: unknown): void {
  if (error instanceof CLIError) {
    logger.error('CLI Error [%s]: %s', error.code, error.message);
  } else if (error instanceof Error) {
    logger.error('Unexpected Error: %s', error.message);
  } else {
    logger.error('Unknown Error: %s', String(error));
  }
  process.exit(1);
}

Configuration Management

typescript
// src/utils/config.ts
import { readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';

export interface Config {
  language: string;
  verbose: boolean;
  [key: string]: any;
}

export class ConfigService {
  private static configPath = join(homedir(), '.my-cli', 'config.json');
  private static config: Config;

  static load(): Config {
    try {
      const configData = readFileSync(this.configPath, 'utf-8');
      this.config = JSON.parse(configData);
    } catch {
      this.config = {
        language: 'en',
        verbose: false,
      };
    }
    return this.config;
  }

  static save(config: Config): void {
    try {
      writeFileSync(this.configPath, JSON.stringify(config, null, 2));
      this.config = config;
    } catch (error) {
      logger.error('Failed to save config: %s', error.message);
    }
  }

  static get(key: string): any {
    return this.config[key];
  }

  static set(key: string, value: any): void {
    this.config[key] = value;
    this.save(this.config);
  }
}

Command Validation

typescript
// src/utils/validation.ts
export function validateRequired(value: any, name: string): void {
  if (value === undefined || value === null || value === '') {
    throw new CLIError(`${name} is required`, 'VALIDATION_ERROR');
  }
}

export function validateFileExists(filePath: string): void {
  if (!fs.existsSync(filePath)) {
    throw new CLIError(`File not found: ${filePath}`, 'FILE_NOT_FOUND');
  }
}

export function validateDirectory(dirPath: string): void {
  if (!fs.existsSync(dirPath)) {
    throw new CLIError(
      `Directory not found: ${dirPath}`,
      'DIRECTORY_NOT_FOUND'
    );
  }
  if (!fs.statSync(dirPath).isDirectory()) {
    throw new CLIError(
      `Path is not a directory: ${dirPath}`,
      'NOT_A_DIRECTORY'
    );
  }
}

Build and Publishing

Build for Production

bash
# Build the CLI tool
pnpm build

# The built files will be in the dist directory

Publish to NPM

bash
# Login to NPM
npm login

# Publish the package
npm publish

Package Configuration

json
// package.json
{
  "name": "@your-org/my-cli",
  "version": "1.0.0",
  "description": "A modern CLI tool built with TypeScript",
  "main": "dist/index.js",
  "bin": {
    "my-cli": "dist/index.js"
  },
  "files": ["dist"],
  "engines": {
    "node": ">=18.0.0"
  }
}