首页 › tutorials › CLI工具开发实战:从零到npm发布

CLI工具开发实战:从零到npm发布

概述

本教程教你使用 Claude Code 从零构建一个完整的 Node.js CLI 工具,涵盖项目初始化、Commander.js 命令定义、Inquirer.js 交互式提示、文件系统操作、单元测试,最终发布到 npm 注册表。适合有基础 Node.js 经验、想把重复工作流封装为可复用命令行工具的开发者。

前置条件

条件 说明
Node.js ≥ 18 运行时环境
npm ≥ 9 包管理器
Claude Code 已安装 npm install -g @anthropic-ai/claude-code
npm 账号 发布到 registry 需要
基础 TypeScript 能读懂类型标注即可

第一步:项目初始化与 CLAUDE.md 配置

1.1 创建项目

mkdir fileforge && cd fileforge
npm init -y

1.2 用 Claude Code 初始化 TypeScript 项目

启动 Claude Code 并告诉它你的目标:

> 我要创建一个名为 fileforge 的 CLI 工具,用于批量处理文件:
> 1. 扫描目录中的文件
> 2. 按规则重命名(小写、去空格、加前缀)
> 3. 生成目录结构 JSON 报告
> 请帮我初始化 TypeScript 项目,配置 tsconfig.json、package.json,安装必要依赖。

Claude Code 会执行以下操作(你可以逐步确认):

1.3 创建 CLAUDE.md(关键步骤)

这是让 Claude Code 持续理解你项目的核心配置:

# fileforge 项目说明

## 项目概述
Node.js CLI 工具,用于批量文件处理(重命名、扫描、报告生成)。

## 技术栈
- TypeScript 5.x + ES Modules
- Commander.js 命令行框架
- Inquirer.js 交互提示
- Chalk 5.x 终端着色
- Ora 进度指示器
- Vitest 单元测试

## 项目结构
src/
├── index.ts          # 入口,注册命令
├── commands/         # 各子命令实现
│   ├── scan.ts       # 文件扫描
│   ├── rename.ts     # 批量重命名
│   └── report.ts     # 目录报告
├── utils/            # 工具函数
│   ├── fs.ts         # 文件系统封装
│   └── format.ts     # 格式化工具
└── types/            # 类型定义
    └── index.ts

## 编码规范
- 使用 ESM import/export,不用 require
- 异步操作全部用 async/await
- 文件路径用 path.join() 拼接
- 错误处理用自定义 CLIError 类
- 函数必须有 JSDoc 注释

## 测试要求
- 每个命令至少 3 个测试用例
- 用 vitest mock 文件系统
- 测试文件放在 tests/ 目录

## 构建与发布
- npm run build → tsc 编译到 dist/
- npm run test → vitest 运行测试
- npm publish 发布

为什么这一步很重要:CLAUDE.md 是 Claude Code 的项目上下文。每次对话时它会自动读取,确保所有生成的代码符合你的架构决策和编码规范。没有它,Claude Code 只能猜测你的意图。


第二步:搭建 CLI 骨架

2.1 入口文件

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

import { Command } from 'commander';
import { scanCommand } from './commands/scan.js';
import { renameCommand } from './commands/rename.js';
import { reportCommand } from './commands/report.js';

const program = new Command();

program
  .name('fileforge')
  .description('批量文件处理 CLI 工具')
  .version('1.0.0');

program.addCommand(scanCommand);
program.addCommand(renameCommand);
program.addCommand(reportCommand);

program.parse();

注意:ESM 模式下,本地 .ts 文件的 import 必须带 .js 后缀(即使源文件是 .ts)。

2.2 package.json 关键字段

{
  "name": "fileforge",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "fileforge": "./dist/index.js"
  },
  "files": ["dist"],
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch",
    "test": "vitest run",
    "prepublishOnly": "npm run test && npm run build"
  },
  "engines": {
    "node": ">=18.0.0"
  }
}

bin 字段是 CLI 工具的核心:它告诉 npm 在全局安装时创建一个符号链接,让用户直接在终端输入 fileforge 命令。

2.3 让 Claude Code 一次生成所有命令骨架

> 请根据 CLAUDE.md 中定义的项目结构,为 scan、rename、report 三个命令创建完整的实现文件。
> 每个命令需要:
> - Commander 命令定义(参数、选项、描述)
> - 核心业务逻辑
> - 错误处理
> 
> scan 命令:扫描目录,支持 --ext 过滤文件类型,支持 --recursive 递归扫描
> rename 命令:批量重命名,支持 --dry-run 预览模式,支持 --prefix 添加前缀
> report 命令:生成 JSON 报告,包含文件数量、大小统计、目录树结构

第三步:核心命令实现

3.1 scan 命令 — 文件扫描

// src/commands/scan.ts
import { Command } from 'commander';
import chalk from 'chalk';
import ora from 'ora';
import { scanDirectory } from '../utils/fs.js';

export const scanCommand = new Command('scan')
  .description('扫描目录中的文件')
  .argument('[path]', '目标目录', '.')
  .option('-e, --ext <extensions>', '过滤文件类型(逗号分隔)', '')
  .option('-r, --recursive', '递归扫描子目录', false)
  .option('-s, --sort <field>', '排序字段 (name|size|date)', 'name')
  .action(async (targetPath: string, options: ScanOptions) => {
    const spinner = ora('正在扫描文件...').start();

    try {
      const extensions = options.ext 
        ? options.ext.split(',').map(e => e.trim().toLowerCase())
        : null;

      const files = await scanDirectory(targetPath, {
        recursive: options.recursive,
        extensions,
        sortBy: options.sort as SortField,
      });

      spinner.succeed(`找到 ${files.length} 个文件`);

      // 格式化输出
      console.log(chalk.bold('\n文件列表:\n'));
      for (const file of files) {
        const size = chalk.gray(`(${formatSize(file.size)})`);
        const date = chalk.gray(file.modifiedAt.toLocaleDateString());
        console.log(`  ${chalk.cyan(file.name)} ${size} ${date}`);
      }

      // 统计摘要
      const totalSize = files.reduce((sum, f) => sum + f.size, 0);
      console.log(chalk.bold(`\n合计:${files.length} 个文件,${formatSize(totalSize)}`));

    } catch (error) {
      spinner.fail('扫描失败');
      throw new CLIError(`扫描错误:${(error as Error).message}`);
    }
  });

3.2 rename 命令 — 批量重命名

// src/commands/rename.ts
import { Command } from 'commander';
import inquirer from 'inquirer';
import chalk from 'chalk';
import ora from 'ora';
import { scanDirectory } from '../utils/fs.js';

interface RenameOptions {
  dryRun: boolean;
  prefix: string;
  lowercase: boolean;
  noSpaces: boolean;
  pattern: string;
  interactive: boolean;
}

export const renameCommand = new Command('rename')
  .description('批量重命名文件')
  .argument('[path]', '目标目录', '.')
  .option('--dry-run', '预览模式,不实际重命名', false)
  .option('--prefix <prefix>', '添加前缀')
  .option('--lowercase', '文件名转小写', false)
  .option('--no-spaces', '空格替换为连字符', false)
  .option('--pattern <pattern>', '正则匹配替换 (格式: /pattern/replacement/)')
  .option('-i, --interactive', '交互式逐个确认', false)
  .action(async (targetPath: string, options: RenameOptions) => {
    const spinner = ora('正在分析文件...').start();
    const files = await scanDirectory(targetPath, { recursive: false });
    spinner.stop();

    // 生成重命名计划
    const plan = buildRenamePlan(files, options);

    if (plan.length === 0) {
      console.log(chalk.yellow('没有需要重命名的文件'));
      return;
    }

    // 显示预览
    console.log(chalk.bold('\n重命名计划:\n'));
    for (const item of plan) {
      console.log(`  ${chalk.red(item.original)} → ${chalk.green(item.renamed)}`);
    }
    console.log(chalk.bold(`\n共 ${plan.length} 个文件将被重命名\n`));

    if (options.dryRun) {
      console.log(chalk.yellow('(预览模式,未实际执行)'));
      return;
    }

    // 交互确认或批量执行
    if (options.interactive) {
      for (const item of plan) {
        const { confirm } = await inquirer.prompt([{
          type: 'confirm',
          name: 'confirm',
          message: `重命名 ${item.original} → ${item.renamed}?`,
          default: true,
        }]);
        if (confirm) {
          await renameFile(item.originalPath, item.renamedPath);
          console.log(chalk.green('  ✓ 已重命名'));
        }
      }
    } else {
      const { proceed } = await inquirer.prompt([{
        type: 'confirm',
        name: 'proceed',
        message: `确认执行以上 ${plan.length} 个重命名操作?`,
        default: false,
      }]);

      if (!proceed) {
        console.log(chalk.yellow('已取消'));
        return;
      }

      const spinner2 = ora('正在重命名...').start();
      for (const item of plan) {
        await renameFile(item.originalPath, item.renamedPath);
      }
      spinner2.succeed(`已完成 ${plan.length} 个文件的重命名`);
    }
  });

3.3 report 命令 — 目录报告

// src/commands/report.ts
import { Command } from 'commander';
import chalk from 'chalk';
import ora from 'ora';
import { writeFileSync } from 'fs';
import { join } from 'path';
import { buildDirectoryTree, calculateStats } from '../utils/fs.js';

export const reportCommand = new Command('report')
  .description('生成目录结构 JSON 报告')
  .argument('[path]', '目标目录', '.')
  .option('-o, --output <file>', '输出文件路径', 'fileforge-report.json')
  .option('--include-hidden', '包含隐藏文件', false)
  .option('--max-depth <n>', '最大递归深度', '10')
  .action(async (targetPath: string, options: ReportOptions) => {
    const spinner = ora('正在分析目录...').start();

    try {
      const tree = await buildDirectoryTree(targetPath, {
        includeHidden: options.includeHidden,
        maxDepth: parseInt(options.maxDepth, 10),
      });

      const stats = calculateStats(tree);

      const report = {
        generatedAt: new Date().toISOString(),
        rootPath: targetPath,
        tree,
        stats: {
          totalFiles: stats.fileCount,
          totalDirs: stats.dirCount,
          totalSize: stats.totalSize,
          sizeByExtension: stats.sizeByExtension,
          largestFiles: stats.largestFiles.slice(0, 10),
        },
      };

      const outputPath = join(targetPath, options.output);
      writeFileSync(outputPath, JSON.stringify(report, null, 2));

      spinner.succeed('报告已生成');

      console.log(chalk.bold('\n目录统计:\n'));
      console.log(`  文件数:${chalk.cyan(String(stats.fileCount))}`);
      console.log(`  目录数:${chalk.cyan(String(stats.dirCount))}`);
      console.log(`  总大小:${chalk.cyan(formatSize(stats.totalSize))}`);
      console.log(`  报告路径:${chalk.green(outputPath)}\n`);

      // 按类型统计 Top 5
      const top5 = Object.entries(stats.sizeByExtension)
        .sort((a, b) => b[1] - a[1])
        .slice(0, 5);

      if (top5.length > 0) {
        console.log(chalk.bold('按类型分布:'));
        for (const [ext, size] of top5) {
          const bar = '█'.repeat(Math.ceil(size / stats.totalSize * 30));
          console.log(`  ${chalk.yellow(`.${ext || '无后缀'}`)} ${chalk.gray(bar)} ${formatSize(size)}`);
        }
      }

    } catch (error) {
      spinner.fail('报告生成失败');
      throw new CLIError(`报告错误:${(error as Error).message}`);
    }
  });

第四步:工具函数与类型

4.1 文件系统封装

// src/utils/fs.ts
import { readdir, stat, rename } from 'fs/promises';
import { join, extname, parse } from 'path';

export async function scanDirectory(
  dirPath: string, 
  options: ScanOptions
): Promise<FileInfo[]> {
  const entries = await readdir(dirPath, { withFileTypes: true });
  const files: FileInfo[] = [];

  for (const entry of entries) {
    const fullPath = join(dirPath, entry.name);

    if (entry.isDirectory() && options.recursive) {
      const subFiles = await scanDirectory(fullPath, options);
      files.push(...subFiles);
    } else if (entry.isFile()) {
      if (options.extensions) {
        const ext = extname(entry.name).toLowerCase().slice(1);
        if (!options.extensions.includes(ext)) continue;
      }

      const stats = await stat(fullPath);
      files.push({
        name: entry.name,
        path: fullPath,
        size: stats.size,
        modifiedAt: stats.mtime,
        extension: extname(entry.name).toLowerCase(),
      });
    }
  }

  return sortFiles(files, options.sortBy);
}

export async function renameFile(
  oldPath: string, 
  newPath: string
): Promise<void> {
  await rename(oldPath, newPath);
}

export async function buildDirectoryTree(
  dirPath: string,
  options: { includeHidden: boolean; maxDepth: number },
  depth = 0
): Promise<DirectoryNode> {
  if (depth > options.maxDepth) {
    return { name: '...', type: 'dir', children: [] };
  }

  const entries = await readdir(dirPath, { withFileTypes: true });
  const children: DirectoryNode[] = [];

  for (const entry of entries) {
    if (!options.includeHidden && entry.name.startsWith('.')) continue;

    const fullPath = join(dirPath, entry.name);

    if (entry.isDirectory()) {
      children.push(
        await buildDirectoryTree(fullPath, options, depth + 1)
      );
    } else {
      const stats = await stat(fullPath);
      children.push({
        name: entry.name,
        type: 'file',
        size: stats.size,
      });
    }
  }

  return { name: dirPath, type: 'dir', children };
}

4.2 格式化工具

// src/utils/format.ts
export function formatSize(bytes: number): string {
  const units = ['B', 'KB', 'MB', 'GB', 'TB'];
  let size = bytes;
  let unitIndex = 0;

  while (size >= 1024 && unitIndex < units.length - 1) {
    size /= 1024;
    unitIndex++;
  }

  return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}

export function sanitizeFileName(name: string): string {
  return name
    .replace(/\s+/g, '-')
    .replace(/[^\w\-.]/g, '')
    .toLowerCase();
}

4.3 类型定义

// src/types/index.ts
export interface FileInfo {
  name: string;
  path: string;
  size: number;
  modifiedAt: Date;
  extension: string;
}

export type SortField = 'name' | 'size' | 'date';

export interface ScanOptions {
  recursive: boolean;
  extensions: string[] | null;
  sortBy: SortField;
}

export interface RenamePlan {
  original: string;
  renamed: string;
  originalPath: string;
  renamedPath: string;
}

export interface DirectoryNode {
  name: string;
  type: 'file' | 'dir';
  size?: number;
  children?: DirectoryNode[];
}

export class CLIError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'CLIError';
  }
}

第五步:测试策略

5.1 测试配置

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov'],
      include: ['src/**/*.ts'],
      exclude: ['src/index.ts'],
    },
  },
});

5.2 核心测试用例

// tests/utils/fs.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdir, writeFile, rm } from 'fs/promises';
import { join } from 'path';
import { scanDirectory, buildDirectoryTree } from '../../src/utils/fs.js';

const TEST_DIR = join(import.meta.dirname, '__test_fixtures__');

beforeEach(async () => {
  await mkdir(TEST_DIR, { recursive: true });
  await writeFile(join(TEST_DIR, 'hello.txt'), 'hello');
  await writeFile(join(TEST_DIR, 'data.json'), '{"key":"value"}');
  await writeFile(join(TEST_DIR, 'image.png'), Buffer.alloc(1024));
  await mkdir(join(TEST_DIR, 'subdir'), { recursive: true });
  await writeFile(join(TEST_DIR, 'subdir', 'nested.md'), '# nested');
});

afterEach(async () => {
  await rm(TEST_DIR, { recursive: true, force: true });
});

describe('scanDirectory', () => {
  it('应返回目录中的所有文件', async () => {
    const files = await scanDirectory(TEST_DIR, {
      recursive: false,
      extensions: null,
      sortBy: 'name',
    });
    expect(files).toHaveLength(3);
    expect(files.map(f => f.name).sort()).toEqual(
      ['data.json', 'hello.txt', 'image.png']
    );
  });

  it('应按扩展名过滤文件', async () => {
    const files = await scanDirectory(TEST_DIR, {
      recursive: false,
      extensions: ['txt'],
      sortBy: 'name',
    });
    expect(files).toHaveLength(1);
    expect(files[0].name).toBe('hello.txt');
  });

  it('应递归扫描子目录', async () => {
    const files = await scanDirectory(TEST_DIR, {
      recursive: true,
      extensions: null,
      sortBy: 'name',
    });
    expect(files).toHaveLength(4); // 3 + 1 nested
  });

  it('应正确返回文件大小', async () => {
    const files = await scanDirectory(TEST_DIR, {
      recursive: false,
      extensions: null,
      sortBy: 'name',
    });
    const png = files.find(f => f.name === 'image.png');
    expect(png?.size).toBe(1024);
  });
});

describe('buildDirectoryTree', () => {
  it('应构建完整的目录树', async () => {
    const tree = await buildDirectoryTree(TEST_DIR, {
      includeHidden: false,
      maxDepth: 5,
    });
    expect(tree.type).toBe('dir');
    expect(tree.children?.length).toBe(4); // 3 files + 1 subdir
  });

  it('应排除隐藏文件', async () => {
    await writeFile(join(TEST_DIR, '.hidden'), 'secret');
    const tree = await buildDirectoryTree(TEST_DIR, {
      includeHidden: false,
      maxDepth: 5,
    });
    const hasHidden = tree.children?.some(c => c.name === '.hidden');
    expect(hasHidden).toBe(false);
  });

  it('应遵守最大深度限制', async () => {
    const tree = await buildDirectoryTree(TEST_DIR, {
      includeHidden: false,
      maxDepth: 0,
    });
    expect(tree.children?.length).toBe(0);
  });
});

5.3 让 Claude Code 补充测试

> 查看 src/commands/rename.ts 的实现,为 buildRenamePlan 函数编写完整的测试用例。
> 需要覆盖:
> 1. 小写转换
> 2. 空格替换
> 3. 前缀添加
> 4. 正则匹配替换
> 5. 组合规则
> 6. 无匹配文件时的空结果
> 测试文件放在 tests/commands/rename.test.ts

第六步:构建与本地调试

6.1 编译 TypeScript

npm run build

预期输出:

> tsc
# dist/ 目录生成所有 .js 文件

6.2 本地链接测试

# 在项目目录下创建全局符号链接
npm link

# 现在可以在任意目录使用
fileforge --help
fileforge scan ./my-project
fileforge rename ./my-project --dry-run --lowercase --no-spaces
fileforge report ./my-project -o analysis.json

6.3 常见构建问题与解决

问题 原因 解决方案
Cannot use import statement ESM/CJS 混用 确保 package.json 有 "type": "module"
Command not found bin 路径错误 检查 package.json 的 bin 字段指向 dist/index.js
ERR_MODULE_NOT_FOUND import 缺少 .js 后缀 ESM 模式下本地 import 必须带 .js
#!/usr/bin/env node 无效 shebang 在非首行 确保 shebang 是文件第一行,前面无空行
chmod 权限问题 bin 文件不可执行 npm link 自动处理,手动需 chmod +x dist/index.js

第七步:npm 发布

7.1 发布前检查清单

# 1. 确认构建干净
npm run build

# 2. 运行全部测试
npm run test

# 3. 检查打包内容(确认不会发布源码和测试)
npx npm-packlist
# 应该只看到 dist/ 目录下的文件

# 4. 检查包大小
npm pack --dry-run

# 5. 确认 package.json 字段完整
cat package.json | grep -E '"name"|"version"|"description"|"keywords"|"license"|"repository"'

7.2 完善 package.json

{
  "name": "fileforge",
  "version": "1.0.0",
  "description": "批量文件处理 CLI 工具 — 扫描、重命名、报告",
  "keywords": ["cli", "file", "rename", "batch", "tool"],
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "https://github.com/yourname/fileforge"
  },
  "author": "Your Name",
  "prepublishOnly": "npm run test && npm run build"
}

7.3 执行发布

# 首次登录
npm login

# 发布
npm publish

# 如果使用 GitHub Packages
npm publish --registry=https://npm.pkg.github.com

7.4 版本迭代工作流

# 修补版本(bug fix)
npm version patch   # 1.0.0 → 1.0.1
npm publish

# 功能版本(新功能)
npm version minor   # 1.0.0 → 1.1.0
npm publish

# 主版本(破坏性变更)
npm version major   # 1.0.0 → 2.0.0
npm publish

第八步:用 Claude Code 持续迭代

8.1 添加新命令的标准流程

> 我需要给 fileforge 添加一个 convert 命令,用于批量转换图片格式。
> 需要支持 --format webp|avif|png 和 --quality 参数。
> 请遵循 CLAUDE.md 中的项目结构规范,创建命令文件和对应的测试。

Claude Code 会: 1. 读取 CLAUDE.md 了解项目规范 2. 在 src/commands/ 创建 convert.ts 3. 在 src/index.ts 注册新命令 4. 在 tests/ 创建对应测试 5. 更新 package.json 如果需要新依赖

8.2 用 -p 模式做一次性任务

# 快速检查代码质量
claude -p "检查 src/ 目录下所有文件,找出可能的类型安全问题"

# 批量更新依赖
claude -p "检查 package.json 中所有依赖,列出有新版本可用的,然后逐个更新并确认测试通过"

# 生成 CHANGELOG
claude -p "根据最近 10 个 git commit 生成 CHANGELOG.md,使用 conventional commits 格式"

8.3 自定义 Skill 加速开发

创建 .claude/skills/add-command.md:

---
name: add-command
description: 为 fileforge 添加新 CLI 命令的标准化流程
---

## 使用场景
当用户要求添加新的 CLI 命令时触发。

## 执行步骤
1. 在 src/commands/ 创建命令文件,命名与命令名一致
2. 使用 Commander.js 的 Command 类定义命令
3. 实现核心逻辑,错误使用 CLIError 类
4. 使用 chalk 着色输出,ora 显示进度
5. 交互式操作使用 inquirer
6. 在 src/index.ts 中 import 并 addCommand
7. 在 tests/commands/ 创建对应测试文件
8. 运行 npm run build && npm run test 确认通过

然后在 Claude Code 中:

> /add-command 添加一个 clean 命令,用于清理空目录和临时文件

实战案例:完整工作流演示

场景:给一个混乱的下载目录做整理

# Step 1: 扫描目录,看看有什么
fileforge scan ~/Downloads --recursive --sort size

# 输出:
# ✔ 找到 247 个文件
# 
# 文件列表:
#   large-video.mp4 (2.3 GB) 2026/5/15
#   presentation.pptx (15.2 MB) 2026/5/18
#   screenshot-2026-01.png (1.1 MB) 2026/1/12
#   ...

# Step 2: 预览重命名效果
fileforge rename ~/Downloads --dry-run --lowercase --no-spaces

# 输出:
# 重命名计划:
#   Screen Shot 2026.png → screen-shot-2026.png
#   Final Report (1).pdf → final-report-(1).pdf
#   ...
# (预览模式,未实际执行)

# Step 3: 交互式确认执行
fileforge rename ~/Downloads --lowercase --no-spaces --interactive

# Step 4: 生成报告
fileforge report ~/Downloads -o downloads-report.json

# 输出:
# ✔ 报告已生成
# 
# 目录统计:
#   文件数:247
#   目录数:12
#   总大小:8.5 GB
#   报告路径:/home/user/Downloads/downloads-report.json
# 
# 按类型分布:
#   .png ██████████████ 2.1 GB
#   .mp4 ██████████ 1.8 GB
#   .pdf ████ 890.5 MB

架构全景图

graph TB
    subgraph CLI入口
        A[fileforge command] --> B[Commander.js 路由]
    end

    subgraph 命令层
        B --> C[scan]
        B --> D[rename]
        B --> E[report]
        B --> F[convert...]
    end

    subgraph 工具层
        C --> G[fs.ts<br/>文件系统封装]
        D --> G
        E --> G
        F --> G
        C --> H[format.ts<br/>格式化工具]
        D --> H
        E --> H
    end

    subgraph 依赖
        G --> I[node:fs/promises]
        H --> J[chalk / ora]
        D --> K[inquirer]
        C --> L[commander]
    end

    subgraph 输出
        C --> M[终端表格]
        D --> N[文件重命名]
        E --> O[JSON 报告文件]
    end

    style A fill:#7c3aed,color:#fff
    style B fill:#6d28d9,color:#fff
    style G fill:#059669,color:#fff
    style H fill:#059669,color:#fff

开发工作流

flowchart LR
    A[需求描述] --> B[Claude Code<br/>生成骨架]
    B --> C[本地 npm link]
    C --> D[手动测试]
    D --> E{发现问题?}
    E -->|是| F[描述问题<br/>给 Claude Code]
    F --> C
    E -->|否| G[npm run test]
    G --> H{测试通过?}
    H -->|否| I[修复测试]
    I --> G
    H -->|是| J[npm publish]
    J --> K[npm version patch]
    K --> L[迭代开发]
    L --> A

    style A fill:#f59e0b,color:#000
    style B fill:#7c3aed,color:#fff
    style J fill:#059669,color:#fff

高级技巧:让 Claude Code 更懂你的 CLI 项目

技巧 1:在 CLAUDE.md 中定义命令模板

## 新命令模板
每个命令文件必须包含:
1. Commander Command 定义(description、arguments、options)
2. async action 函数
3. ora spinner 包装
4. try/catch + CLIError 错误处理
5. chalk 格式化输出

这样每次添加命令时,Claude Code 会自动遵循这个模式。

技巧 2:利用 .claude/commands 保存常用操作

# .claude/commands/check-exports.md
检查 src/ 目录下所有文件的 export 是否正确。
特别关注:
- 命令文件的 export 命名是否与 import 一致
- 类型文件是否 re-export
- index.ts 是否注册了所有命令
运行 npm run build 确认无编译错误。

使用方式:/check-exports

技巧 3:用 Hooks 自动运行测试

在 .claude/settings.json 中配置:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "command": "cd $PROJECT_DIR && npx vitest run --reporter=verbose 2>&1 | tail -5"
      }
    ]
  }
}

每次文件编辑后自动运行测试,Claude Code 可以即时看到测试结果。

技巧 4:多文件并行开发

> 我需要同时开发三个新功能:
> 1. watch 命令 — 监听文件变化
> 2. diff 命令 — 对比两个目录差异
> 3. sync 命令 — 双向同步目录
> 
> 请创建 Task subagent 并行开发这三个命令,每个命令包含实现和测试。
> 完成后汇总结果到 src/index.ts。

Claude Code 会启动三个并行 subagent,每个独立开发一个命令,最后汇总。


参考来源

  1. How to Develop CLI Tools with Claude Code | ClaudeCodeLab — Claude Code 开发 CLI 工具的完整流程指南
  2. Building a CLI Tool From Scratch With Claude Code in 20 Minutes — 从零到 npm 发布的实战教程
  3. Building CLI Tools with AI: From Idea to npm Package — AI 辅助 CLI 开发的最佳实践
  4. Commander.js - The Complete Solution for Node.js CLI Programs — Commander.js 官方文档与 API 参考
  5. The Complete CLAUDE.md Guide: How to Configure Claude Code — CLAUDE.md 配置详解

训练营适用性评估

维度 评估
适合训练营类型 Node.js 进阶训练营、工具链开发训练营、DevOps 自动化训练营
预计教学时长 4-6 小时(含实操)
难度等级 中等(需 TypeScript 和 Node.js 基础)
实操占比 70% 编码 + 20% 测试调试 + 10% 发布流程
前置知识 TypeScript 基础、npm 包管理、命令行操作
产出物 一个可安装、可发布、有完整测试的 npm CLI 工具
延伸方向 oclif 框架迁移、GitHub Actions 自动发布、插件化架构

[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。

51学通信知识星球海报-扫码加入