Use Cases (更新: 2026/6/2)

用 Claude Code 开发 CLI 工具:Node.js、TypeScript、测试与安全发布

用Claude Code构建Node.js/TypeScript CLI:参数、配置、stdin/stdout、退出码、测试、CI和密钥安全。

用 Claude Code 开发 CLI 工具:Node.js、TypeScript、测试与安全发布

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.jsonbinCommanderGitHub ActionsClaude 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 或同事的终端,就成了生产自动化的一部分。第一条提示词就应该按这个标准来写。

#Claude Code #CLI #Node.js #TypeScript #Commander #GitHub Actions
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。