<!-- title: 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 创建项目

```bash
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 会执行以下操作（你可以逐步确认）：

- 生成 `tsconfig.json`（target: ES2022, module: NodeNext）
- 更新 `package.json`（添加 `bin` 字段、`type: "module"`）
- 安装依赖：`commander`、`chalk`、`ora`、`inquirer`
- 安装开发依赖：`typescript`、`@types/node`、`vitest`

### 1.3 创建 CLAUDE.md（关键步骤）

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

```markdown
# 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 入口文件

```typescript
// 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 关键字段

```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 命令 — 文件扫描

```typescript
// 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 命令 — 批量重命名

```typescript
// 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 命令 — 目录报告

```typescript
// 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 文件系统封装

```typescript
// 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 格式化工具

```typescript
// 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 类型定义

```typescript
// 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 测试配置

```typescript
// 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 核心测试用例

```typescript
// 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

```bash
npm run build
```

预期输出：
```
> tsc
# dist/ 目录生成所有 .js 文件
```

### 6.2 本地链接测试

```bash
# 在项目目录下创建全局符号链接
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 发布前检查清单

```bash
# 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

```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 执行发布

```bash
# 首次登录
npm login

# 发布
npm publish

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

### 7.4 版本迭代工作流

```bash
# 修补版本（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` 模式做一次性任务

```bash
# 快速检查代码质量
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`：

```markdown
---
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 命令，用于清理空目录和临时文件
```

---

## 实战案例：完整工作流演示

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

```bash
# 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
```

---

## 架构全景图

```mermaid
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
```

## 开发工作流

```mermaid
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 中定义命令模板

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

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

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

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

使用方式：`/check-exports`

### 技巧 3：用 Hooks 自动运行测试

在 `.claude/settings.json` 中配置：

```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](https://claudecode-lab.com/en/blog/claude-code-cli-tool-development/) — Claude Code 开发 CLI 工具的完整流程指南
2. [Building a CLI Tool From Scratch With Claude Code in 20 Minutes](https://www.visimade.com/p/analystdev-blog?article=build-cli-tool-with-claude-code) — 从零到 npm 发布的实战教程
3. [Building CLI Tools with AI: From Idea to npm Package](https://www.fundesk.io/building-cli-tools-ai-npm-package-guide) — AI 辅助 CLI 开发的最佳实践
4. [Commander.js - The Complete Solution for Node.js CLI Programs](https://www.npmjs.com/package/commander) — Commander.js 官方文档与 API 参考
5. [The Complete CLAUDE.md Guide: How to Configure Claude Code](https://ralphable.com/blog/claude-code-claude-md-setup-guide-2026) — 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学通信知识星球海报-扫码加入](https://wiki.500nian.com/images/zhixingqiu_qrcode.png)
