Claude Code CLIをNode.jsから叩く設計と罠

ブログ自動生成ツールのバックエンドでLLMを叩く際、仕上げのレビューだけはClaude Sonnetに通したいというニーズが出てきた。

選択肢は3つある。

  1. Anthropic APIを直叩き(ANTHROPIC_API_KEY経由)
  2. LiteLLM / LangChainなど既存のプロキシを挟む
  3. 手元のClaude Code CLI(Max subscription)をsubprocessで呼ぶ

1番はpay-per-token方式で、個人運用だと月末の請求が読みにくい。2番はプロキシを追加するだけでメリットが薄い。3番は既にMax planに払っている分を活用する方向で合理的だった。

ただし、いざ実装してみると罠が多かった。認証モードの切り替え、環境変数のリーク、CLI固有のバナー出力、JSON出力形式の変化——これらすべてを適切に処理する必要がある。本記事は、CliLlmGatewayの実装(300行弱)で踏んだ罠と設計判断の記録だ。

やりたかったこと

  • 同じLLMGatewayインターフェースにCodex / Gemini CLI / Claude Codeを並べ、差し替え可能にする
  • Claude Codeだけは認証モードを2択で切り替える:
    • subscription: ログイン済みのMaxサブスクで使う(追加課金なし)
    • api_key: ANTHROPIC_API_KEYを指定してpay-per-token
  • 失敗時にstdout / stderrから実エラーを抽出して上位に伝える
  • タイムアウト・stdin経由のプロンプト渡し・文字化け対策

最小限のsubprocess呼び出し

サーバープロセスの自動化フロー図

Node.jsから子プロセスでClaude CLIを叩く基本形は以下の通り。

import { spawn } from "node:child_process";

async function runClaude(
  command: string,
  args: string[],
  stdin: string,
  env: Record<string, string>,
): Promise<{ stdout: string; stderr: string }> {
  return new Promise((resolve, reject) => {
    const child = spawn(command, args, { env });
    let stdout = "";
    let stderr = "";
    child.stdout.on("data", (chunk) => (stdout += chunk.toString()));
    child.stderr.on("data", (chunk) => (stderr += chunk.toString()));
    child.on("close", (code) => {
      if (code !== 0) return reject(new Error(`exit ${code}: ${stderr}`));
      resolve({ stdout, stderr });
    });
    if (stdin) {
      child.stdin.write(stdin);
      child.stdin.end();
    }
  });
}

このベースに、Claude CLI固有の引数を組み立てていく。

Claude CLIの呼び出し引数

claude -p --output-format jsonで1ショット実行にできる。実装で使っている引数の一覧:

const args = [
  "-p",                          // print mode(interactive 無効)
  "--no-session-persistence",   // session をファイルに残さない
  "--disable-slash-commands",   // / コマンドを無効化(暴発防止)
  "--tools", "",                 // tool 使用を禁止(純粋な LLM 呼び出しに)
  "--model", cliModel,           // claude-sonnet-4-5 / claude-opus-4-5 等
  "--output-format", "json",     // 構造化出力(後述)
  "--system-prompt", systemPrompt,
];

--tools ""で明示的に空文字列を渡し、tool使用を封じている。指定しないと、ClaudeがBashやReadを勝手に呼び出して予期しないサイドエフェクトが発生する。Gatewayとして使うなら、toolは全部切るのが安全だ。

認証モードの切り替え

APIゲートウェイの複数認証モード(APIキー・subscription)を管理・切り替える設定機構

今回のGatewayで最もこだわった部分がここだ。

type ClaudeAuthConfig = {
  mode: "subscription" | "api_key";
  apiKey: string | null;
};

if (authConfig.mode === "api_key") {
  if (!authConfig.apiKey) {
    throw new ExternalServiceError("LLM", "Claude 認証モードが api_key ですが ANTHROPIC_API_KEY が未設定です");
  }
  args.splice(1, 0, "--bare");  // -p の直後に --bare を挿入
  env.ANTHROPIC_API_KEY = authConfig.apiKey;
}

各モードの挙動はシンプルだ。

  • subscriptionモード: 追加引数なし。~/.claude/config.jsonに保存されたMax planの資格情報をCLIが自動で使う
  • api_keyモード: --bareを引数に追加すると、Claude CodeはログインセッションをスキップしてAPIキーだけで動く

--bareオプションは公式ドキュメントでは目立たない。しかし「Maxサブスクは触らずpay-per-tokenでテストしたい」といった切り替えを1つのフラグで実現できる、実用的な機能だ。

最大の罠: ANTHROPIC_API_KEYの漏れ

これには本当にハマった。

Node.jsサーバの.envに古い(もう無効な)ANTHROPIC_API_KEYが残っていると、spawnで作った子プロセスがprocess.envを継承してその鍵を拾ってしまう。subscriptionモードのつもりなのに401 Unauthorizedが返ってきて、原因が分からなかった。

Claude CLI内部にはこんなロジックがある。

環境変数にANTHROPIC_API_KEYがあれば、ログインセッションより優先してAPIキーを使う。

結果として、次の連鎖が起きる。

  1. subscriptionモードで使いたい
  2. 環境変数からキーがリーク
  3. 強制的にapi_keyモードへ切り替わる
  4. キーが古い(無効)
  5. 401エラー

対処はscrubしてから子プロセスに渡すこと:

const { ANTHROPIC_API_KEY: _leaked, ...scrubbedEnv } = process.env;
const env: Record<string, string> = {
  ...scrubbedEnv,
  TERM: process.env.TERM ?? "xterm-256color",
  NO_COLOR: "1",
} as Record<string, string>;

if (authConfig.mode === "api_key") {
  env.ANTHROPIC_API_KEY = authConfig.apiKey;  // api_key モードのときだけ明示的に再注入
}

destructureでANTHROPIC_API_KEYだけを除外し、残りを渡す。_leakedというprefix-underscoreは「故意に捨てた」という意図の表明だ。

NO_COLOR=1も必ず指定する。ターミナルの色付けコードがstdoutに混入すると、JSONパースが壊れる。

JSON出力のパース

JSON形式のデータをパースして処理するワークフロー

--output-format jsonで返ってくる形式は以下の通り。

{
  "result": "生成されたテキスト本文...",
  "is_error": false,
  "stop_reason": "end_turn",
  "usage": {
    "input_tokens": 1234,
    "output_tokens": 567,
    "cache_creation_input_tokens": 100,
    "cache_read_input_tokens": 200
  }
}

パーサの実装:

const trimmed = stdout.trim();
const payload = JSON.parse(trimmed);

if (payload.is_error === true) {
  throw new ExternalServiceError("LLM", payload.result ?? "Claude CLI がエラー");
}

const content = (payload.result ?? "").trim();
const inputTokens =
  (payload.usage?.input_tokens ?? 0) +
  (payload.usage?.cache_creation_input_tokens ?? 0) +
  (payload.usage?.cache_read_input_tokens ?? 0);
const outputTokens = payload.usage?.output_tokens ?? 0;

cache_creation_input_tokenscache_read_input_tokensをinputに足しているのは、実際に消費されたトークン量を正確に集計するためだ。プロンプトキャッシュを使うとこれらの値が変動するため、合算しないと正確なコストを追えない。

失敗時のエラー抽出

CLIプロセスがexit code非ゼロで終了すると、stderrにエラーが出力される。ただし、Codex CLIなどはstderrの先頭にバナー(ASCII アート含む)を書く。素朴にstderr.slice(0, 500)すると、本当のエラーが埋もれてしまう。

そこでGateway共通のエラー抽出ヘルパを用意した。

export function extractCliErrorDetail(stderr: string, stdout: string): string {
  const raw = (stderr || stdout).trim();
  if (!raw) return "不明なエラー";

  // ERROR: / Error: で始まる行を優先抽出
  const errorLines = raw
    .split(/\r?\n/)
    .map((line) => line.trim())
    .filter((line) => /^(ERROR|Error|error)[:\s]/.test(line));

  if (errorLines.length > 0) {
    const joined = [...new Set(errorLines)].join("\n");
    return joined.length <= 1000 ? joined : `${joined.slice(0, 1000)}...`;
  }

  // なければ末尾 1000 文字(最後にエラーが出るパターン向け)
  if (raw.length <= 1000) return raw;
  return `...${raw.slice(-1000)}`;
}

3つのポイントがある。

  • ERROR行優先: 既知のエラーパターンに一致する行があれば、それだけを返す
  • 先頭ではなく末尾をslice: バナーを避け、真のエラー部分を取得する
  • Setでdedup: stdout/stderr重複など、同一エラーが複数回現れるケースを除去する

使う側のインターフェース

複数のシステムが相互に連携するAPIインターフェースのアーキテクチャ図

最終的に、呼び出し側は以下のように書ける。

const response = await llmService.generateText({
  messages: [
    { role: "system", content: "あなたは記事レビューの専門家です" },
    { role: "user", content: articleBody },
  ],
  taskType: "final_review",
  model: "claude-sonnet-4-5",  // auto-selected by stage-based routing
});

console.log(response.content);      // Claude の出力
console.log(response.inputTokens);  // キャッシュ込みの実消費

内部の処理フローはこうなっている。

  1. ModelSelectortaskTypeからモデルIDを決定(別記事で解説予定)
  2. LLMServiceがモデルIDを見てproviderを判定
  3. CliLlmGateway.runClaudeに到達
  4. 設定DBから認証モードを解決
  5. scrubbedEnvでspawn
  6. stdoutをJSONパースして返す

Codex CLIもGemini CLIも同じLLMGatewayインターフェースを実装している。呼び出し側はproviderを意識しなくてよい。

得られたもの / 得られなかったもの

得られたもの:

  • コストの上限が読みやすい: Max相当の定額サブスク前提なら、記事レビューを何本叩いても追加課金は発生しない(rate limit範囲内)
  • 鍵管理が減る: .envANTHROPIC_API_KEYを書かなくてよいケースが増えた
  • Claude Codeの設定を活用できる: subscriptionモード時はSkills / Settings / hooksも有効になる

得られなかったもの / 注意点:

  • subscriptionモードは遅い: CLI起動とシステムプロンプト読み込みで1〜2秒のオーバーヘッドが毎回発生する
  • 並列度が低い: Max planの内部rate limitにより、体感で1〜2並列あたりが上限になる
  • CLIバージョン依存: --output-format jsonのフィールド名が将来変わるリスクがある
  • クラウド環境では使えない: CI/クラウドで動かす場合、subscriptionモードは使えずapi_keyモードへの切り替えが必要

要するに、開発機と自宅サーバで動かす個人プロジェクト向けの設計だ。複数人が使う本番システムには向かない。

まとめ

複数のプロバイダを統合したシステムアーキテクチャの図

  • Claude Codeは-p --output-format json --bareでLLMのsubprocessとして使える
  • 認証モードはsubscription / api_keyの2択。--bareフラグで切り替える
  • ANTHROPIC_API_KEYのリークは最大の罠。scrubbedEnvで明示的に除外する
  • stdoutはJSON形式(result + usage)。cacheトークンもinputに合算して集計
  • エラーは先頭ではなく末尾またはERROR行を優先して抽出(CLIバナー対策)

このGatewayを実装したことで、「手元のMax planを使い切る」方針を機械的に実現できるようになった。Codex CLIも同じパターンで実装済みで、CliLlmGatewayの裏側でsubscriptionベースの3プロバイダを同じインターフェースで扱える。pay-per-tokenへの切り替えも設定1つで完了する。

Claude Code / Codex / Gemini CLIをすでにログイン済みの個人開発者なら、APIキー運用よりCLIラッパーのほうがコスト予測のしやすさと運用のシンプルさで有利な場面が多い。構築コストは300行程度。同じ構成を試したい方の参考になれば幸いだ。

コメント