用 Claude Code 开发 CLI 工具:Node.js、TypeScript、测试与安全发布
用Claude Code构建Node.js/TypeScript CLI:参数、配置、stdin/stdout、退出码、测试、CI和密钥安全。
CLI 工具常常从一个私人脚本开始。后来它需要参数、配置文件、CI 执行、npm 分发和团队协作,这时“能在我机器上跑”已经不够了。一个可靠的 CLI 必须有清晰的参数解析、干净的 stdout、写到 stderr 的警告、有意义的退出码、可重复的测试、dry-run 模式,以及安全处理密钥的方法。
Claude Code 很适合开发这类工具,因为 CLI 往往横跨多个文件:package.json、TypeScript 入口、测试、GitHub Actions、文档和发布检查都要一起改。但如果只说“帮我做一个 CLI”,很容易得到演示代码:帮助信息看起来不错,却没有明确的输出契约,也没有发布前的安全检查。
本文用 Node.js/TypeScript 构建一个名为 clipilot 的小工具。重点不是炫技,而是把 Claude Code 当作工程搭档:先写清楚输入输出约定,再生成可运行代码,最后用测试和 CI 锁住行为。
先给 Claude Code 明确边界
stdin 是标准输入,stdout 是标准输出,stderr 是标准错误输出,exit code 是 shell 和 CI 判断成功或失败的数字。CLI 的质量主要取决于这些约定是否稳定。
Create a Node.js 22 + TypeScript CLI named clipilot.
Requirements:
- Implement init, run, and check subcommands with commander
- Configure package.json bin so it can be installed as an npm CLI
- Load .clipilotrc.json or an explicit --config JSON file
- If stdin is present, use it as the run command input
- Write machine-readable results to stdout and warnings/errors to stderr
- Use exit code 0 for success, 2 for release configuration warnings, and 1 for exceptions
- Do not accept secrets through command-line flags; use environment variables or stdin
- Add Vitest coverage for help, stdin, config loading, and failure behavior
- Add a GitHub Actions workflow that runs test, build, and a dry-run command
审核生成结果时,建议对照官方资料:Node.js process、Node.js fs、npm package.json 的 bin、Commander、GitHub Actions 和 Claude Code 文档。提示词要告诉 Claude Code 哪些行为不能漂移,而不只是让它创建文件。
项目骨架与 package.json bin
先建立最小项目。bin 是 npm 分发 CLI 的关键字段,npm 会根据它创建可执行命令。被指向的文件需要带有 #!/usr/bin/env node。
mkdir clipilot
cd clipilot
npm init -y
npm i commander
npm i -D typescript tsx vitest @types/node
mkdir src tests
{
"name": "clipilot",
"version": "0.1.0",
"type": "module",
"private": true,
"bin": {
"clipilot": "./dist/cli.js"
},
"scripts": {
"dev": "tsx src/cli.ts",
"build": "tsc -p tsconfig.json",
"test": "vitest run"
},
"dependencies": {
"commander": "^12.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
这个骨架可以避免一个常见发布事故:TypeScript 编译到一个目录,而 bin 指向另一个目录。让 Claude Code 修改构建设置时,必须同时检查 outDir、shebang 和 package.json。
可复制运行的 TypeScript CLI
把下面内容保存为 src/cli.ts。它同时覆盖 Commander 用法、配置读取、stdin、stdout/stderr 分离和退出码。
#!/usr/bin/env node
import { access, readFile, writeFile } from "node:fs/promises";
import { constants } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
import { Command } from "commander";
type OutputMode = "text" | "json";
type AppConfig = {
defaultName: string;
output: OutputMode;
endpoint?: string;
};
type RawConfig = Partial<AppConfig> & Record<string, unknown>;
const defaultConfig: AppConfig = {
defaultName: "world",
output: "text"
};
async function exists(filePath: string) {
try {
await access(filePath, constants.F_OK);
return true;
} catch {
return false;
}
}
function normalizeConfig(raw: RawConfig, source: string): AppConfig {
const output = raw.output ?? defaultConfig.output;
if (output !== "text" && output !== "json") {
throw new Error(`Invalid output in ${source}: expected "text" or "json"`);
}
const defaultName =
typeof raw.defaultName === "string" && raw.defaultName.trim()
? raw.defaultName
: defaultConfig.defaultName;
const endpoint =
typeof raw.endpoint === "string" && raw.endpoint.trim()
? raw.endpoint
: undefined;
return {
defaultName,
output,
...(endpoint ? { endpoint } : {})
};
}
export async function loadConfig(configPath?: string): Promise<AppConfig> {
const candidates = configPath
? [path.resolve(configPath)]
: [
path.resolve(process.cwd(), ".clipilotrc.json"),
path.join(homedir(), ".clipilotrc.json")
];
for (const candidate of candidates) {
if (await exists(candidate)) {
const raw = JSON.parse(await readFile(candidate, "utf8")) as RawConfig;
return normalizeConfig(raw, candidate);
}
if (configPath) {
throw new Error(`Config file not found: ${candidate}`);
}
}
return defaultConfig;
}
async function readStdin() {
if (process.stdin.isTTY) return "";
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString("utf8").trim();
}
export function createProgram() {
const program = new Command();
program
.name("clipilot")
.description("A safe sample CLI generated with Claude Code")
.version(process.env.npm_package_version ?? "0.1.0")
.showHelpAfterError()
.option("-c, --config <path>", "Path to a JSON config file");
program
.command("init")
.description("Create .clipilotrc.json in the current directory")
.option("-f, --force", "Overwrite an existing config file")
.action(async (options: { force?: boolean }) => {
const filePath = path.resolve(process.cwd(), ".clipilotrc.json");
if ((await exists(filePath)) && !options.force) {
throw new Error(`${filePath} already exists. Use --force to overwrite it.`);
}
await writeFile(filePath, `${JSON.stringify(defaultConfig, null, 2)}\n`, "utf8");
process.stdout.write(`Created ${filePath}\n`);
});
program
.command("run")
.description("Read a message from an argument or stdin")
.argument("[message]", "Message to process")
.option("--json", "Print machine-readable JSON")
.option("--dry-run", "Show what would happen without side effects")
.action(async (message: string | undefined, options: { json?: boolean; dryRun?: boolean }) => {
const globalOptions = program.opts<{ config?: string }>();
const config = await loadConfig(globalOptions.config);
const pipedInput = await readStdin();
const text = (pipedInput || message || `hello ${config.defaultName}`).trim();
const payload = {
ok: true,
dryRun: Boolean(options.dryRun),
message: text,
endpoint: config.endpoint ?? null
};
if (options.json || config.output === "json") {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
return;
}
process.stdout.write(`[${payload.dryRun ? "dry-run" : "run"}] ${payload.message}\n`);
});
program
.command("check")
.description("Validate release-sensitive configuration")
.action(async () => {
const globalOptions = program.opts<{ config?: string }>();
const config = await loadConfig(globalOptions.config);
if (!config.endpoint) {
process.stderr.write("WARN endpoint is not set; release check failed.\n");
process.exitCode = 2;
return;
}
process.stdout.write("Configuration OK\n");
});
return program;
}
async function main(argv = process.argv) {
await createProgram().parseAsync(argv);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
});
}
运行方式:
npm run dev -- --help
npm run dev -- init
npm run dev -- run "hello cli"
printf "from stdin\n" | npm run dev -- run --json --dry-run
npm run dev -- check
check 在没有 endpoint 时返回退出码 2,表示命令执行成功,但发布条件不足。这个区分在 CI 中非常有用。
配置、stdin/stdout 与退出码
配置文件可以放在项目目录,也可以通过 --config 指定。建议顺序固定为:显式路径、当前项目、用户主目录、默认值。这样本地和 CI 使用同一套规则。
{
"defaultName": "Masa",
"output": "json",
"endpoint": "https://api.example.com"
}
stdout 应只输出下游命令要读取的结果。JSON 模式下,不要把进度条、问候语或调试日志混进去。警告和诊断写到 stderr。退出码也要固定:0 成功,1 异常,2 发布检查不通过。数字本身可以按团队习惯调整,但含义必须写进文档并用测试覆盖。
真实使用场景
这个模式至少适合以下场景:
| 场景 | 使用方式 | 容易踩坑 |
|---|---|---|
| 内容批处理 | 通过 stdin 传入文章 slug,stdout 返回 JSON | 人类可读日志混入 JSON |
| 仓库健康检查 | 在 CI 中执行 check | 警告仍然返回 0,导致继续发布 |
| API 运维工具 | 用 .clipilotrc.json 切换 endpoint | 把 token 写进配置文件 |
| npm 分发的开发工具 | 通过 bin 提供 clipilot 命令 | 构建输出路径或 shebang 错误 |
给 Claude Code 至少三个这样的例子,它会更容易生成贴近真实工作的命令。相关流程可以继续看 npm 包创建指南、Claude Code CI/CD 设置 和 Claude Code 入门。
用 Vitest 测试 CLI 行为
CLI 测试要检查进程行为,而不仅是函数返回值。下面的测试覆盖 help、stdin 优先级、配置读取和失败退出码。
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
const rootDir = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
const npxBin = process.platform === "win32" ? "npx.cmd" : "npx";
const tempDirs: string[] = [];
function makeTempDir() {
const dir = mkdtempSync(path.join(tmpdir(), "clipilot-"));
tempDirs.push(dir);
return dir;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function runCli(args: string[], options: { input?: string } = {}) {
return spawnSync(npxBin, ["tsx", path.join(rootDir, "src/cli.ts"), ...args], {
cwd: rootDir,
input: options.input,
encoding: "utf8"
});
}
describe("clipilot CLI", () => {
it("prints help", () => {
const result = runCli(["--help"]);
expect(result.status).toBe(0);
expect(result.stdout).toContain("clipilot");
expect(result.stdout).toContain("run");
});
it("uses stdin before the message argument", () => {
const result = runCli(["run", "from-argument", "--json", "--dry-run"], {
input: "from stdin\n"
});
const body = JSON.parse(result.stdout);
expect(result.status).toBe(0);
expect(body.message).toBe("from stdin");
expect(body.dryRun).toBe(true);
});
it("loads an explicit config file", () => {
const dir = makeTempDir();
const configPath = path.join(dir, ".clipilotrc.json");
writeFileSync(
configPath,
JSON.stringify({ defaultName: "Masa", output: "json", endpoint: "https://api.example.com" })
);
const result = runCli(["--config", configPath, "run"]);
const body = JSON.parse(result.stdout);
expect(result.status).toBe(0);
expect(body.message).toBe("hello Masa");
expect(body.endpoint).toBe("https://api.example.com");
});
it("returns exit code 1 when the config file is missing", () => {
const dir = makeTempDir();
const result = runCli(["--config", path.join(dir, "missing.json"), "run"]);
expect(result.status).toBe(1);
expect(result.stderr).toContain("Config file not found");
});
});
这些测试能防止 Claude Code 后续修改时破坏 CLI 合约。例如在 JSON 前打印 banner、改变 stdin 优先级、或把失败退出码改成 0,都会立刻暴露。
GitHub Actions 的 dry-run
CI 中要运行安全模式。dry-run 的意思是显示将要发生什么,但不写文件、不调用生产 API、不发送邮件、不部署。
name: clipilot-cli
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
- run: npm run build
- name: Dry-run CLI through stdin
run: |
printf 'hello from ci\n' | npm run dev -- run --json --dry-run
安全发布时还要关注密钥。不要把 token 设计成 --token xxx 这样的常规用法,因为参数可能出现在 shell 历史、CI 日志或进程列表里。使用环境变量、GitHub Actions Secrets 或 stdin 更稳妥。
常见坑与变现导线
常见错误包括:stdout 混入日志导致下游解析失败;发布检查失败却返回 0;管道执行时又弹出交互式问题导致 CI 卡住;把 token 写进配置文件;修改 tsconfig 后忘记同步 bin;--dry-run 仍然调用真实 API。让 Claude Code 做发布前审查时,要把这些点逐条列出来。
如果你的团队想把 Claude Code 用到内部工具、CI 和发布检查中,可以查看Claude Code 研修与导入咨询。想先拿日常参考资料,可以从免费速查表开始。需要提示词包和模板时,可以看产品页面。
Masa 用这个流程做过一个小型内容处理 CLI。最大的收益不是少写了多少代码,而是评审时争论变少了。stdin、stdout、stderr、退出码、dry-run 和密钥规则先定下来以后,Claude Code 的输出就能按契约检查,而不是凭感觉判断“好像能跑”。
总结
用 Claude Code 开发 CLI 时,先定义输入输出和失败行为,再让它写代码。Node.js/TypeScript、Commander、配置文件、stdin/stdout/stderr、退出码、npm bin、Vitest、GitHub Actions dry-run 和密钥处理应该属于同一个设计。
CLI 看起来很小,但一旦进入 CI 或同事的终端,就成了生产自动化的一部分。第一条提示词就应该按这个标准来写。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。