Skip to Content
AI 时代的 CLI 工具开发实战第 2 章:命令设计的艺术

第 2 章:命令设计的艺术

CLI 的 UX 设计原则

“CLI 不需要设计”——这大概是最常见的误解。事实上,CLI 的设计空间虽然比 GUI 小,但设计决策的密度更高。一个 GUI 按钮放错了位置,用户还能四处找找;一个 CLI 参数命名不当,用户根本猜不到它的存在。

核心观点:命令行是 API 的另一种形态,参数就是接口契约

这不是比喻。HTTP API 有 endpoint(路径)、method(动词)、query parameters(查询参数)、request body(请求体)、response format(响应格式)。CLI 一一对应:

HTTP APICLI
GET /repos/:idrepox scan <dir>
Query: ?format=jsonOption: --format json
Header: Authorization: Bearer xxx环境变量: REPOX_API_KEY=xxx
Response: JSONstdout: 文本或 JSON
Status Code: 200/404/500Exit Code: 0/1/2

既然是 API,就要遵循 API 设计的基本原则:

1. 一致性:相同的概念用相同的参数名。如果 scan--format 指定输出格式,review 也应该用 --format,不要一个叫 --format 一个叫 --output

2. 可预测性:用户基于已有经验能猜到参数名。--verbose 表示详细输出,--quiet 表示静默,--dry-run 表示试运行——这些是整个 CLI 生态的共识,不要发明新名字。

3. 渐进式复杂度:最常用的功能零配置可用,次常用的通过选项开启,高级功能通过配置文件控制。repox scan 开箱即用,repox scan --format json 满足进阶需求,repox scan --config custom.json 支持深度定制。

4. 容错性:用户会犯错,好的 CLI 会纠错。输入 repox scna,提示”你是不是要输入 scan?“。缺少必要参数,告诉用户缺什么、怎么补。

命令层级解析

一个 CLI 命令由以下元素组成:

repox scan ./src --format json --verbose │ │ │ │ │ │ │ │ │ └── 标志 (Flag): 布尔开关 │ │ │ └── 选项 (Option): 键值对参数 │ │ └── 参数 (Argument): 位置参数 │ └── 子命令 (Subcommand) └── 程序名 (Program)

参数 (Arguments)

位置参数,通过位置确定含义。适合表达命令的主要操作对象:

repox scan ./src # ./src 是要扫描的目录 repox explain ./src/app.ts # ./src/app.ts 是要解释的文件 git checkout main # main 是要切换的分支

参数的数量要克制——超过 2 个位置参数就很难记忆了。cp source dest 两个参数,直觉上能理解。如果 repox translate source dest lang format 有四个位置参数,没人记得住顺序。

选项 (Options)

键值对形式,通过名称确定含义:

repox scan --format json # 长选项 repox scan -f json # 短选项(单字母缩写) repox scan --format=json # 等号写法

命名约定:

  • 长选项用 --kebab-case--output-dir--max-depth
  • 短选项用单字母:-f-o-d
  • 不是每个长选项都需要短选项——只给最常用的命令配短选项

标志 (Flags)

布尔类型的选项,存在即为 true:

repox scan --verbose # verbose = true repox scan # verbose = false(默认) repox commit --dry-run # 只预览不执行

子命令 (Subcommands)

当工具功能较多时,用子命令组织:

repox scan # 一级子命令 repox config set key val # 二级子命令(config → set)

子命令的层级不要超过两级。git remote add origin url 看起来是两级子命令(remote → add),实际上 originurl 是参数。真正三级以上的嵌套(如 repox config auth token set xxx)会让用户迷失。

案例拆解:三个 CLI 的设计哲学

在选择 repox 的命令设计方案之前,值得研究几个标杆项目的做法。

git:动词优先

git clone <url> git add <file> git commit -m "message" git push origin main git log --oneline --graph

git 的设计哲学是动词优先——每个子命令都是一个动作。cloneaddcommitpushpull,全是动词。这让命令读起来像自然语言:“git add this file”、“git push to origin”。

git 的问题在于历史包袱太重。git checkout 既能切换分支又能恢复文件,职责不清。后来 git 引入了 git switch(切换分支)和 git restore(恢复文件)来解耦,但老的 checkout 仍然保留。这就是向后兼容的代价——即使设计有缺陷,一旦发布就很难移除。

git 的选项风格也不统一。-mcommit 中是 message,在 branch 中是 move(重命名)。这种不一致性源于各个子命令由不同的人在不同时期开发。repox 要避免这个问题。

gh(GitHub CLI):名词-动词结构

gh repo clone owner/repo gh pr create --title "feat: add scan" gh pr list --state open gh issue view 42 gh release create v1.0.0

gh 的设计更系统化,采用名词-动词两级结构:先指定操作的资源(repo、pr、issue、release),再指定动作(create、list、view、delete)。

这个模式的优点是高度可预测。用户知道 gh pr creategh pr list,就能猜到 gh issue creategh issue list。资源和动作的组合是正交的。

gh 还有一个值得学习的设计:交互式降级gh pr create 不带参数时会启动交互式向导,引导用户填写标题、描述、reviewer。带了参数就直接执行。这样新手通过交互学习,熟手通过参数提效,同一个命令覆盖两种使用场景。第 3 章会详细实现这个模式。

Vercel CLI:隐式上下文

vercel # 部署当前目录 vercel dev # 本地开发 vercel env pull .env.local # 拉取环境变量 vercel domains add example.com

Vercel CLI 最大的特点是隐式上下文。直接执行 vercel(不带任何子命令)就能部署当前项目。它会自动检测框架(Next.js/Vite/Remix)、自动配置构建命令、自动分配域名。

这种设计适合工作流明确的场景——Vercel 的核心功能就是部署,让最常用的操作最简单。但它不适合 repox 这样的多功能工具,因为”最常用的操作”不明确。

repox 的设计选择

综合以上分析,repox 采用 git 风格的动词优先设计,原因如下:

  1. repox 的命令都是动作:scan(扫描)、explain(解释)、review(审查)、commit(提交)。动词比名词-动词结构更简洁。
  2. 命令数量有限(不超过 10 个),不需要名词-动词的正交组合来管理复杂度。
  3. 开发者对 git 的动词风格最熟悉,学习成本最低。

全局选项保持一致:

  • --verbose / -V:详细输出(所有命令通用)
  • --format <format> / -f:输出格式(所有有输出的命令通用)
  • --debug:调试信息(开发者用)
  • --config <path>:配置文件路径

Commander.js 深度使用

为什么选 Commander

Node.js 生态有多个命令行解析库:Commander、yargs、CAC、clipanion、oclif。repox 选择 Commander,原因如下:

市场验证:Commander 是 npm 上下载量最大的 CLI 框架,周下载量超过 1 亿。Claude Code 选择了它,Express 的 CLI 用它,Vue CLI 用它,create-react-app 用它。

API 简洁:Commander 的 API 是链式调用风格,代码读起来就是命令的声明:

program .command('scan') .description('扫描仓库') .option('-f, --format <format>', '输出格式', 'table') .action(handler)

对比 yargs 的配置对象风格:

yargs.command('scan', '扫描仓库', { format: { alias: 'f', describe: '输出格式', default: 'table' } }, handler)

两者功能等价,但 Commander 的声明式写法更直观,IDE 自动补全也更好。

类型安全:Commander 的 TypeScript 支持完善。虽然不如 clipanion(TypeScript 优先设计)那么极致,但覆盖了日常使用的全部场景。

轻量:Commander 零依赖,打包后体积极小。CLI 工具对启动速度敏感,依赖越少启动越快。

基础用法

安装:

npm install commander

创建程序实例并定义命令:

// src/cli.ts import { Command } from 'commander' export function createProgram(): Command { const program = new Command() program .name('repox') .description('AI 驱动的仓库助手') .version('0.1.0', '-v, --version', '显示版本号') return program }

version() 方法自动注册 -v--version 选项。执行 repox --version 输出 0.1.0 并退出。

选项定义

Commander 支持多种选项类型:

program // 布尔标志 .option('--verbose', '详细输出') .option('--no-color', '禁用颜色输出') // 自动生成 color=false // 带值的选项 .option('-f, --format <format>', '输出格式', 'table') // 必填值+默认值 .option('-o, --output [path]', '输出到文件') // 可选值 // 可重复选项 .option('-e, --exclude <pattern...>', '排除的文件模式') // repox scan --exclude node_modules --exclude dist // → options.exclude = ['node_modules', 'dist'] // 类型转换 .option('-d, --depth <number>', '最大深度', parseInt, 10) // parseInt 作为处理函数,10 是默认值

<format>[path] 的区别:尖括号表示必填(不提供会报错),方括号表示可选(不提供值时选项值为 true)。

--no- 前缀是 Commander 的特殊语法。定义 --no-color 后,Commander 自动将 options.color 的默认值设为 true,使用 --no-color 时设为 false。这比 --color false 更符合 Unix 惯例。

子命令定义

// 方式一:action handler program .command('scan') .description('扫描仓库,生成项目画像') .argument('[dir]', '目标目录', '.') .option('-f, --format <format>', '输出格式', 'table') .option('--max-depth <n>', '最大扫描深度', parseInt) .action(async (dir: string, options: { format: string; maxDepth?: number }) => { // dir 是位置参数,options 是选项集合 await performScan(dir, options) }) // 方式二:独立命令文件(适合复杂命令) // Commander 会 fork 一个子进程执行 repox-scan program .command('scan', '扫描仓库', { executableFile: 'repox-scan' })

方式一适合 repox 这样的中等规模工具——所有命令在同一个进程内,共享配置和工具函数。方式二适合 git 这样的巨型工具,每个子命令是独立的可执行文件,可以用不同的语言编写。

钩子

Commander 提供 hook 方法,在命令执行前后插入逻辑:

program.hook('preAction', (thisCommand, actionCommand) => { const options = thisCommand.opts() if (options.verbose) { console.error(`[verbose] 执行命令: ${actionCommand.name()}`) } if (options.debug) { console.error(`[debug] 参数:`, actionCommand.args) console.error(`[debug] 选项:`, actionCommand.opts()) } }) program.hook('postAction', () => { // 清理资源、上报耗时等 })

preAction 钩子是实现 --verbose--debug 全局选项的理想位置。不需要在每个命令的 action 里重复判断。

自定义帮助

默认的帮助输出已经不错,但可以定制:

program.addHelpText('after', ` 示例: $ repox scan 扫描当前目录 $ repox scan ./src --format json 扫描 src 目录并输出 JSON $ repox explain ./src/app.ts 解释指定文件 $ repox review 审查当前变更 $ repox commit 生成并执行提交 文档: https://github.com/user/repox `)

帮助信息中的示例(Examples)极其重要。很多用户不看参数说明,只看示例然后修改。确保每个命令的帮助信息都包含 2-3 个典型用法示例。

错误处理

Commander 默认在参数错误时输出错误信息并退出。可以自定义这个行为:

program.exitOverride() // 抛异常而不是直接 process.exit program.configureOutput({ writeOut: (str) => process.stdout.write(str), writeErr: (str) => process.stderr.write(str), outputError: (str, write) => { write(`\x1b[31m${str}\x1b[0m`) // 红色显示错误 }, })

参数校验:Zod 驱动的类型安全

Commander 的参数解析是弱类型的——--depth 拿到的是字符串,需要手动 parseInt。对于简单工具这够用了,但当参数变复杂时(嵌套对象、枚举值、正则约束),手写校验代码会很痛苦。

Zod 是一个 TypeScript 优先的 schema 校验库,可以同时完成类型定义运行时校验

// src/schemas/scan-options.ts import { z } from 'zod' export const ScanOptionsSchema = z.object({ format: z.enum(['table', 'json', 'markdown']).default('table'), maxDepth: z.number().int().positive().optional(), exclude: z.array(z.string()).default([]), verbose: z.boolean().default(false), }) // 自动推导 TypeScript 类型 export type ScanOptions = z.infer<typeof ScanOptionsSchema> // 等价于: // type ScanOptions = { // format: 'table' | 'json' | 'markdown' // maxDepth?: number // exclude: string[] // verbose: boolean // }

在命令的 action 中使用:

import { ScanOptionsSchema } from '../schemas/scan-options.js' program .command('scan') .argument('[dir]', '目标目录', '.') .option('-f, --format <format>', '输出格式', 'table') .option('--max-depth <n>', '最大扫描深度') .option('-e, --exclude <pattern...>', '排除模式') .action((dir: string, rawOptions: Record<string, unknown>) => { // Zod 校验 + 类型转换 const result = ScanOptionsSchema.safeParse({ ...rawOptions, maxDepth: rawOptions.maxDepth ? Number(rawOptions.maxDepth) : undefined, }) if (!result.success) { const errors = result.error.issues .map(issue => ` ${issue.path.join('.')}: ${issue.message}`) .join('\n') console.error(`参数校验失败:\n${errors}`) process.exit(2) // 退出码 2 表示参数错误 } const options = result.data // 类型安全的 ScanOptions performScan(dir, options) })

封装一个通用的校验函数,避免在每个命令里重复这段逻辑:

// src/utils/validate.ts import { z, ZodSchema } from 'zod' export function validateOptions<T>( schema: ZodSchema<T>, raw: Record<string, unknown>, ): T { const result = schema.safeParse(raw) if (result.success) return result.data const errors = result.error.issues .map(issue => ` ${issue.path.join('.')}: ${issue.message}`) .join('\n') console.error(`参数错误:\n${errors}`) process.exit(2) }

使用时一行搞定:

.action((dir, rawOptions) => { const options = validateOptions(ScanOptionsSchema, rawOptions) // options 自动获得正确的类型 })

这个模式的价值在于:schema 就是文档。新加入项目的开发者看 ScanOptionsSchema 就知道 scan 命令接受哪些参数、什么类型、什么约束,不需要翻阅其他代码。

命令组织模式

当子命令数量增长到 5 个以上,如何组织代码就变得重要了。常见的三种模式:

模式一:单文件

所有命令定义在一个文件里:

// src/cli.ts program.command('scan').action(scanHandler) program.command('explain').action(explainHandler) program.command('review').action(reviewHandler) program.command('commit').action(commitHandler) program.command('init').action(initHandler)

适合命令数量 ≤ 3 的小工具。超过这个数量,文件会膨胀到难以维护。

模式二:目录约定式

按文件系统结构自动发现命令:

src/commands/ ├── scan.ts → repox scan ├── explain.ts → repox explain ├── review.ts → repox review └── config/ ├── set.ts → repox config set └── get.ts → repox config get

框架扫描 commands/ 目录,根据文件名自动注册命令。oclif 和 clipanion 用这种模式。

优点是零配置——加文件就加命令。缺点是隐式约定多,调试困难(“为什么我的命令没注册?“——可能是文件名拼错了、导出格式不对、目录层级不对),且测试时需要文件系统支持。

模式三:注册式

每个命令导出一个注册函数,在入口文件显式注册:

// src/commands/scan.ts export function registerScanCommand(program: Command): void { program .command('scan') .description('扫描仓库,生成项目画像') .action(scanHandler) }
// src/cli.ts import { registerScanCommand } from './commands/scan.js' import { registerExplainCommand } from './commands/explain.js' import { registerReviewCommand } from './commands/review.js' import { registerCommitCommand } from './commands/commit.js' import { registerInitCommand } from './commands/init.js' export function createProgram(): Command { const program = new Command() program.name('repox').description('AI 驱动的仓库助手').version('0.1.0') registerScanCommand(program) registerExplainCommand(program) registerReviewCommand(program) registerCommitCommand(program) registerInitCommand(program) return program }

repox 采用这种模式。原因:

  1. 显式优于隐式cli.ts 一目了然地列出所有命令,新成员看这一个文件就知道工具有哪些功能。
  2. 类型安全。导入路径错误时 TypeScript 编译器会报错,不会出现运行时才发现命令缺失的情况。
  3. 灵活。可以根据条件注册命令(比如某些命令只在 debug 模式下可用),也可以在测试中只注册需要测试的命令。
  4. 启动速度可控。命令的 action handler 可以用动态 import 延迟加载,减少启动时的模块解析开销。

延迟加载的技巧:

export function registerScanCommand(program: Command): void { program .command('scan') .description('扫描仓库,生成项目画像') .action(async (...args) => { // 只在命令实际执行时才加载 handler 模块 const { handleScan } = await import('./scan-handler.js') await handleScan(...args) }) }

当 repox 有 10+ 个命令时,每个命令的 handler 可能依赖不同的重量级模块(AI SDK、Git 操作库等)。延迟加载确保 repox scan 不会加载 review 命令的依赖,保持启动速度。

实战:搭建 repox 完整命令体系

基于前面的设计决策,开始实现 repox 的完整命令结构。

命令清单

命令描述典型用法
scan扫描仓库生成项目画像repox scan --format json
explain解释代码文件或目录repox explain ./src/cli.ts
review审查代码变更repox review --diff HEAD~1
commitAI 生成提交信息并提交repox commit --dry-run
init初始化 repox 配置repox init
config管理配置repox config set model gpt-4o

项目结构

src/ ├── index.ts # 入口:信号处理 + 启动 ├── cli.ts # CLI 定义 + 命令注册 ├── commands/ │ ├── scan.ts # scan 命令 │ ├── explain.ts # explain 命令 │ ├── review.ts # review 命令 │ ├── commit.ts # commit 命令 │ ├── init.ts # init 命令 │ └── config.ts # config 命令(含 set/get 子命令) ├── schemas/ │ ├── scan-options.ts # scan 参数 schema │ ├── commit-options.ts # commit 参数 schema │ └── common.ts # 公共 schema(format、verbosity 等) └── utils/ ├── validate.ts # Zod 校验工具 ├── output.ts # 输出格式化(table/json/markdown) └── logger.ts # 日志工具(verbose/debug 支持)

cli.ts 完整实现

// src/cli.ts import { Command } from 'commander' import { registerScanCommand } from './commands/scan.js' import { registerExplainCommand } from './commands/explain.js' import { registerReviewCommand } from './commands/review.js' import { registerCommitCommand } from './commands/commit.js' import { registerInitCommand } from './commands/init.js' import { registerConfigCommand } from './commands/config.js' export function run(argv: string[]): void { const program = createProgram() if (argv.length <= 2) { program.help() } program.parse(argv) } function createProgram(): Command { const program = new Command() program .name('repox') .description('AI 驱动的仓库助手') .version('0.1.0', '-v, --version', '显示版本号') .option('--verbose', '输出详细日志') .option('--debug', '输出调试信息') .option('--no-color', '禁用颜色输出') // preAction 钩子:全局选项处理 program.hook('preAction', (thisCommand) => { const opts = thisCommand.opts() if (opts.debug) { process.env.REPOX_DEBUG = '1' } if (opts.verbose || opts.debug) { process.env.REPOX_VERBOSE = '1' } // 通过环境变量传递全局选项,避免在每个命令中显式传递 if (opts.color === false) { process.env.NO_COLOR = '1' } }) // 注册命令 registerScanCommand(program) registerExplainCommand(program) registerReviewCommand(program) registerCommitCommand(program) registerInitCommand(program) registerConfigCommand(program) // 帮助信息增强 program.addHelpText('after', ` 示例: $ repox scan 扫描当前目录 $ repox explain src/cli.ts 解释指定文件 $ repox review 审查未提交的变更 $ repox commit AI 生成提交信息 $ repox init 初始化项目配置 环境变量: REPOX_API_KEY AI 服务的 API Key REPOX_MODEL 默认使用的模型 REPOX_DEBUG 启用调试输出 (等同 --debug) `) // 未知命令处理 program.on('command:*', (operands) => { console.error(`错误: 未知命令 '${operands[0]}'`) const availableCommands = program.commands.map(cmd => cmd.name()) const suggestion = findClosestCommand(operands[0], availableCommands) if (suggestion) { console.error(`你是不是要输入: repox ${suggestion}`) } console.error(`执行 'repox --help' 查看所有可用命令`) process.exit(1) }) return program } // 简单的编辑距离匹配,用于命令纠错 function findClosestCommand(input: string, commands: string[]): string | null { let minDistance = Infinity let closest: string | null = null for (const cmd of commands) { const distance = levenshteinDistance(input, cmd) if (distance < minDistance && distance <= 2) { minDistance = distance closest = cmd } } return closest } function levenshteinDistance(a: string, b: string): number { const matrix: number[][] = [] for (let i = 0; i <= b.length; i++) matrix[i] = [i] for (let j = 0; j <= a.length; j++) matrix[0][j] = j for (let i = 1; i <= b.length; i++) { for (let j = 1; j <= a.length; j++) { const cost = a[j - 1] === b[i - 1] ? 0 : 1 matrix[i][j] = Math.min( matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost, ) } } return matrix[b.length][a.length] }

几个设计要点:

全局选项通过环境变量传递--verbose 设置 process.env.REPOX_VERBOSE,这样深层的工具函数不需要逐层传递 verbose 参数,直接读环境变量即可。这是很多 CLI 工具的通用做法——环境变量充当了全局状态,但因为 CLI 进程是短暂的,不存在 GUI 应用中全局状态难以管理的问题。

未知命令纠错。用编辑距离(Levenshtein Distance)计算用户输入和已有命令的相似度。repox scna 会提示”你是不是要输入 scan”。git 和 gh 都有这个功能,是 CLI UX 的加分项。

NO_COLOR 约定NO_COLOR 是一个跨工具的标准环境变量(参见 https://no-color.org/)。设置后,所有支持该标准的工具都会禁用颜色输出。repox 通过 --no-color 标志设置它。

explain 命令

// src/commands/explain.ts import { Command } from 'commander' import { readFileSync, statSync } from 'node:fs' export function registerExplainCommand(program: Command): void { program .command('explain') .description('解释代码文件或函数') .argument('<target>', '目标文件路径') .option('-d, --detail <level>', '详细程度 (brief|normal|deep)', 'normal') .option('-f, --format <format>', '输出格式 (text|json|markdown)', 'text') .option('--no-context', '不包含项目上下文') .action(async (target: string, options) => { // 检查文件是否存在 try { const stat = statSync(target) if (stat.isDirectory()) { console.error(`"${target}" 是目录。explain 命令目前只支持单个文件。`) console.error('提示: 用 repox scan 查看整个项目的概况') process.exit(1) } } catch { console.error(`文件不存在: ${target}`) process.exit(1) } const content = readFileSync(target, 'utf-8') // 这里后续会接入 AI 服务 // 目前输出文件基本信息作为占位 const lines = content.split('\n') console.log(`文件: ${target}`) console.log(`行数: ${lines.length}`) console.log(`大小: ${Buffer.byteLength(content)} bytes`) console.log('') console.log('(AI 解释功能将在后续章节实现)') }) }

review 命令

// src/commands/review.ts import { Command } from 'commander' import { execSync } from 'node:child_process' export function registerReviewCommand(program: Command): void { program .command('review') .description('审查代码变更') .option('--diff <ref>', '对比的 Git 引用', 'HEAD') .option('--staged', '只审查暂存区的变更') .option('-f, --format <format>', '输出格式', 'text') .action(async (options) => { // 检查是否在 Git 仓库中 try { execSync('git rev-parse --git-dir', { stdio: 'pipe' }) } catch { console.error('错误: 当前目录不是 Git 仓库') process.exit(1) } // 获取 diff const diffCmd = options.staged ? 'git diff --staged' : `git diff ${options.diff}` let diff: string try { diff = execSync(diffCmd, { encoding: 'utf-8' }) } catch (error) { console.error(`获取 diff 失败: ${(error as Error).message}`) process.exit(1) } if (!diff.trim()) { console.log('没有检测到变更') process.exit(0) } const stats = parseDiffStats(diff) console.log(`变更统计:`) console.log(` 文件数: ${stats.files}`) console.log(` 新增行: +${stats.additions}`) console.log(` 删除行: -${stats.deletions}`) console.log('') console.log('(AI 审查功能将在后续章节实现)') }) } function parseDiffStats(diff: string): { files: number; additions: number; deletions: number } { const files = new Set<string>() let additions = 0 let deletions = 0 for (const line of diff.split('\n')) { if (line.startsWith('diff --git')) { const match = line.match(/b\/(.+)$/) if (match) files.add(match[1]) } else if (line.startsWith('+') && !line.startsWith('+++')) { additions++ } else if (line.startsWith('-') && !line.startsWith('---')) { deletions++ } } return { files: files.size, additions, deletions } }

commit 命令

// src/commands/commit.ts import { Command } from 'commander' import { execSync } from 'node:child_process' export function registerCommitCommand(program: Command): void { program .command('commit') .description('AI 生成提交信息并提交') .option('--dry-run', '只生成提交信息,不执行提交') .option('-f, --format <format>', '提交信息风格 (conventional|simple)', 'conventional') .option('--no-verify', '跳过 Git hooks') .action(async (options) => { // 检查是否在 Git 仓库中 try { execSync('git rev-parse --git-dir', { stdio: 'pipe' }) } catch { console.error('错误: 当前目录不是 Git 仓库') process.exit(1) } // 检查是否有暂存的变更 const staged = execSync('git diff --staged --stat', { encoding: 'utf-8' }) if (!staged.trim()) { console.error('没有暂存的变更。请先使用 git add 暂存要提交的文件。') process.exit(1) } console.log('暂存的变更:') console.log(staged) if (options.dryRun) { console.log('(dry-run 模式,AI 提交信息生成将在后续章节实现)') process.exit(0) } console.log('(AI 提交功能将在后续章节实现)') }) }

config 命令(二级子命令示例)

// src/commands/config.ts import { Command } from 'commander' import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs' import { join } from 'node:path' import { homedir } from 'node:os' const CONFIG_DIR = join(homedir(), '.repox') const CONFIG_FILE = join(CONFIG_DIR, 'config.json') function loadConfig(): Record<string, string> { try { return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')) } catch { return {} } } function saveConfig(config: Record<string, string>): void { if (!existsSync(CONFIG_DIR)) { mkdirSync(CONFIG_DIR, { recursive: true }) } writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n') } export function registerConfigCommand(program: Command): void { const configCmd = program .command('config') .description('管理 repox 配置') // repox config set <key> <value> configCmd .command('set') .description('设置配置项') .argument('<key>', '配置键') .argument('<value>', '配置值') .action((key: string, value: string) => { const config = loadConfig() config[key] = value saveConfig(config) console.log(`已设置 ${key} = ${value}`) }) // repox config get <key> configCmd .command('get') .description('获取配置项') .argument('<key>', '配置键') .action((key: string) => { const config = loadConfig() if (key in config) { console.log(config[key]) } else { console.error(`配置项 "${key}" 未设置`) process.exit(1) } }) // repox config list configCmd .command('list') .description('列出所有配置') .action(() => { const config = loadConfig() const entries = Object.entries(config) if (entries.length === 0) { console.log('(无配置)') return } for (const [key, value] of entries) { console.log(`${key} = ${value}`) } }) // repox config delete <key> configCmd .command('delete') .description('删除配置项') .argument('<key>', '配置键') .action((key: string) => { const config = loadConfig() if (key in config) { delete config[key] saveConfig(config) console.log(`已删除 ${key}`) } else { console.error(`配置项 "${key}" 不存在`) process.exit(1) } }) }

config 命令展示了二级子命令的实现方式。program.command('config') 返回一个 Command 实例,可以继续在上面注册子命令。最终用户通过 repox config set model gpt-4o 使用。

配置文件路径 ~/.repox/config.json 遵循 Unix 惯例——用户级配置放在 home 目录下的隐藏文件夹中。gh~/.config/gh/npm~/.npmrc,都是类似的模式。

init 命令

// src/commands/init.ts import { Command } from 'commander' import { existsSync, writeFileSync } from 'node:fs' const DEFAULT_CONFIG = { model: 'doubao-1-5-pro-32k-250115', format: 'conventional', exclude: ['node_modules', 'dist', '.git'], language: 'zh-CN', } export function registerInitCommand(program: Command): void { program .command('init') .description('初始化 repox 项目配置') .option('--force', '覆盖已有配置') .action(async (options) => { const configPath = '.repox.json' if (existsSync(configPath) && !options.force) { console.error(`配置文件 ${configPath} 已存在。使用 --force 覆盖。`) process.exit(1) } // 交互式配置(第 3 章实现),这里先用默认值 const config = { ...DEFAULT_CONFIG } writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n') console.log(`已创建 ${configPath}`) console.log('') console.log('配置内容:') for (const [key, value] of Object.entries(config)) { console.log(` ${key}: ${Array.isArray(value) ? value.join(', ') : value}`) } }) }

init 命令目前是非交互式的,直接使用默认配置。第 3 章会改造成交互式向导,让用户选择模型、语言等偏好。

日志工具

// src/utils/logger.ts export const logger = { debug(...args: unknown[]): void { if (process.env.REPOX_DEBUG) { console.error('[debug]', ...args) } }, verbose(...args: unknown[]): void { if (process.env.REPOX_VERBOSE) { console.error('[verbose]', ...args) } }, info(...args: unknown[]): void { console.error(...args) }, error(...args: unknown[]): void { console.error(...args) }, }

注意 logger 的所有方法都写到 stderr。这是一个重要的设计决策:日志是给人看的,不应该混入 stdout 的数据流。这样 repox scan --format json 2>/dev/null 可以得到纯净的 JSON 输出,repox scan --format json --verbose 的日志信息显示在终端但不影响管道。

小结

这一章完成了 repox 命令体系的设计和实现,核心内容:

  • CLI 是 API。参数命名、选项设计、输出格式都是接口契约,要像设计 REST API 一样认真对待。一致性、可预测性、渐进式复杂度是三个关键原则。
  • 命令解剖学。程序名、子命令、参数、选项、标志——每个元素有其适用场景。位置参数不超过 2 个,长选项用 kebab-case,标志用 --no- 前缀表示否定。
  • Commander.js。链式声明、钩子机制、自定义帮助、错误处理——覆盖了 CLI 开发的核心需求,且零依赖、打包体积小。
  • Zod 校验。schema 同时充当类型定义和运行时校验,消除了 Commander 弱类型参数的痛点。schema 本身就是最好的文档。
  • 注册式命令组织。每个命令一个文件、一个注册函数,在 cli.ts 显式注册。比目录约定式更透明,比单文件更可维护,支持延迟加载优化启动速度。
  • 标杆对比。git 的动词优先、gh 的名词-动词结构、Vercel 的隐式上下文——理解不同设计哲学才能做出适合自己工具的选择。

下一章将为这些命令加上交互式体验——当用户没提供必要参数时,通过交互式提示引导完成,同时保持管道兼容性。

动手试一试

  1. repox scan 命令添加一个 --ignore <dirs> 选项,接受逗号分隔的目录名,在扫描时跳过这些目录
  2. 实现一个 repox hello <name> 命令,接受一个必选参数 name,输出 你好, <name>!。如果不传参数,Commander 应该自动报错
  3. 运行 repox --help,观察所有子命令是否按字母序排列。如果不是,研究一下 Commander 是否支持排序
Last updated on