首页 › tutorials › 无人值守:-p 批量改造流水线

无人值守:用 -p 流式 JSON 驱动批量代码改造流水线

概述

本教程聚焦 Claude Code 的 -p(print / 无头)模式——把 Claude Code 当成一个可被脚本调用、输出可解析 JSON、可被 jq 接管的子进程。你会学到如何用 --bare、--output-format json、--json-schema、--allowedTools 与 --permission-mode 搭建完全无人值守的批量改造任务,再用 hooks 作为运行时护栏,最终在 GitHub Actions 里跑通一条"提交即迁移"的流水线。适合已写过几个 Skill / MCP、想把 Claude Code 嵌入 CI 与批处理脚本的工程师。


前置条件


核心心智模型:两个通道,职责分离

在交互式会话里,你既是"提问者"也是"监督者"。但在无人值守场景下,这两件事必须拆开:

通道 承载者 作用
结果通道 -p 的 --output-format json / --json-schema 让脚本拿到机器可读的结论(改了哪些文件、测试是否通过、花了多少钱)
护栏通道 hooks(PreToolUse 拦截、PostToolUse 校验) 在单次 -p 运行内部,阻止危险操作、自动格式化、记录变更

理解这一点,就不会把"用 hook 把结果写文件"和"用 JSON 输出拿结果"搞混——前者是运行期副作用,后者才是脚本与 Claude 之间的契约。


步骤 1:用 -p 跑通最简单的无头调用

加 -p(或 --print)即可让 Claude Code 非交互运行:执行一次 prompt、打印结果、退出。

# 最朴素的一次性调用
claude -p "What does the auth module do?"

# 管道喂入数据,把解释写入文件
cat build-error.txt | claude -p 'concisely explain the root cause of this build error' > output.txt

预期结果:终端直接打印文本回答,进程退出,没有 REPL。

关键坑:默认 -p 会加载和交互会话一样的上下文——你 ~/.claude 里的 hooks、项目 .mcp.json 里的 MCP、CLAUDE.md 全都会被读入。这在个人机器上方便,但在 CI 上是不确定性的来源:队友 ~/.claude 里一个 hook 就能让你的构建结果因人而异。

步骤 2:用 --bare 锁定可复现性

# 裸模式:跳过 hooks / skills / plugins / MCP / CLAUDE.md / auto memory 的自动发现
claude --bare -p "Summarize this file" --allowedTools "Read"

--bare 让一次调用只受你显式传入的 flag 影响。官方明确:--bare 是脚本与 SDK 调用的推荐模式,未来会成为 -p 的默认行为。裸模式下 Claude 仍有 Bash、文件读、文件编辑三类工具,其余上下文通过 flag 注入:

需要加载的内容 使用的 flag
追加系统提示 --append-system-prompt / --append-system-prompt-file
配置(含 hooks!) --settings <file-or-json>
MCP 服务器 --mcp-config <file-or-json>
自定义子代理 --agents <json>
插件 --plugin-dir <path> / --plugin-url <url>

常见问题:--bare 下鉴权失败怎么办? 解决:--bare 跳过 OAuth 与钥匙串。必须用 ANTHROPIC_API_KEY 环境变量,或在传给 --settings 的 JSON 里配置 apiKeyHelper。Bedrock/Vertex/Foundry 用各自的 provider 凭证。

步骤 3:拿结构化输出——--output-format json + --json-schema

这是无人值守场景最关键的一步。--output-format 有三档:

当你需要让 Claude 严格按 schema 返回时,组合 --output-format json 与 --json-schema:

claude -p "Extract the main function names from auth.py" \
  --output-format json \
  --json-schema '{"type":"object","properties":{"functions":{"type":"array","items":{"type":"string"}}},"required":["functions"]}'

返回的 JSON 里,结构化结果落在 structured_output 字段,元数据(session id、用量)在外层。用 jq 提取:

# 提取纯文本结果
claude -p "Summarize this project" --output-format json | jq -r '.result'

# 提取按 schema 生成的结构化结果
claude -p "Extract function names from auth.py" \
  --output-format json \
  --json-schema '{"type":"object","properties":{"functions":{"type":"array","items":{"type":"string"}}},"required":["functions"]}' \
  | jq '.structured_output'

常见问题:管道喂入的内容很大,Claude 直接报错退出? 解决:自 v2.1.128 起,管道 stdin 上限 10MB,超限会以非零状态码退出并报清晰错误。大输入请写入文件并在 prompt 里引用文件路径,而不是管道。

步骤 4:用 exit code + structured_output 做双重门禁

无人值守不能靠"看一眼输出"判断成功。建立两层门禁:

  1. 粗门禁(进程级):claude -p 的退出码。非零通常意味着调用级错误(超 10MB、鉴权失败、api_retry 耗尽等)。
  2. 细门禁(语义级):解析 structured_output 里的布尔字段,例如 tests_passed: true、files_changed: [...]。
set -o pipefail
RESULT=$(claude --bare -p "..." --output-format json --json-schema "$SCHEMA") || {
  echo "FAIL: claude exited non-zero" >&2; exit 1
}
TESTS_OK=$(echo "$RESULT" | jq -r '.structured_output.tests_passed')
[ "$TESTS_OK" = "true" ] || { echo "FAIL: tests did not pass"; exit 1; }

补充一个观测点:当 API 请求遇到可重试错误时,stream-json 会先吐出 system/api_retry 事件(含 attempt、max_retries、retry_delay_ms、error_status、error 类别如 rate_limit/overloaded/billing_error),system/init 事件则报告本次加载的 plugins 与 plugin_errors——后者可用于"插件没加载成功就让 CI 失败"。

步骤 5:用 --allowedTools / --permission-mode 圈定能力边界

无人值守必须提前授权工具,否则进程会卡在权限提示上。

# 显式列出允许的工具,逗号分隔;支持 permission rule 语法
claude -p "Look at my staged changes and create an appropriate commit" \
  --allowedTools "Bash(git diff *),Bash(git log *),Bash(git status *),Bash(git commit *)"

注意 Bash(git diff *) 末尾的 *:它做前缀匹配,且 * 前的空格很重要——写成 Bash(git diff*) 会连 git diff-index 也放行。

更彻底的做法是用 permission mode:

# acceptEdits:自动批准写文件 + mkdir/touch/mv/cp 等常见文件命令;其他 shell/网络命令仍需显式授权
claude -p "Apply the lint fixes" --permission-mode acceptEdits

# dontAsk:除了 permissions.allow 规则与只读命令集,一律拒绝——适合锁死的 CI
claude -p "..." --permission-mode dontAsk --allowedTools "Read,Bash(npm test *)"

常见问题:acceptEdits 模式下还是被某个 git push 卡住了? 解决:acceptEdits 只自动放行写文件和少数文件系统命令,git push 这类 shell 命令仍需 --allowedTools "Bash(git push *)" 或一条 permissions.allow 规则。

步骤 6:用 --resume / --continue 串联无头多轮

一次 -p 是一问一答。要在一个上下文里多轮追问,用会话续接:

# --continue:续接"最近一次"对话
claude -p "Now focus on the database queries" --continue

# --resume <id>:续接"指定"会话(同目录下查找)
session_id=$(claude -p "Start a review" --output-format json | jq -r '.session_id')
claude -p "Continue that review" --resume "$session_id"

会话 ID 查找限定在当前项目目录及其 git worktree 范围内,所以续接的两条命令必须在同一目录执行。批量场景里,给每个目标目录维护一个 session_id,可实现"改一版→审一版→再改一版"的无人值守迭代。

步骤 7:把 hooks 当成运行时护栏

-p 默认不加载本地 hooks,但通过 --settings 显式注入,护栏就会在每一次无头调用内部生效。这是护栏通道的核心用法:拦截危险命令、自动格式化、记录变更——全部发生在 Claude 执行工具的瞬间,无需你盯着。

护栏配置 .claude/settings.ci.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(git push --force *)",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/deny-force-push.sh"
          }
        ]
      },
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "if": "Edit(*.lock)|Write(package-lock.json)|Write(*lock*)",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/deny-lockfile.sh"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format-on-save.sh" }
        ]
      }
    ]
  }
}

deny-force-push.sh 从 stdin 读 JSON、做判断、回写 permissionDecision:

#!/bin/bash
# .claude/hooks/deny-force-push.sh
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -q 'push --force\|push -f'; then
  jq -n '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: "Force push forbidden in unattended CI runs"
    }
  }'
else
  exit 0   # 静默 = 不表态,走正常权限流程(注意:静默≠批准)
fi

要点速记: - matcher 语法:* 匹配全部;纯字母数字下划线 | 视为精确串(Edit|Write);含其他字符视为正则(^Notebook、mcp__.*)。 - if 字段用 permission rule 语法,但只能写一条规则(没有 &&/||);多条件就定义多个 handler。 - PreToolUse 静默退出(exit 0 且无输出)只是"不表态",调用会继续走正常权限流程——要阻止,必须显式 permissionDecision: "deny"。 - --bare 跳过自动发现,但 --settings 显式传入的 hooks 照常生效——这正是 CI 可复现护栏的关键。


实战案例:批量把 console.log 迁移成结构化日志

目标:monorepo 下 packages/* 各包,把 JS/TS 源码里的 console.log(...) 替换为 logger.info(...),每个包改完跑测试,产出机器可读报告。完全无人值守。

目录结构:

my-monorepo/
├── packages/
│   ├── pkg-a/
│   ├── pkg-b/
│   └── pkg-c/
└── .claude/
    ├── settings.ci.json        # 护栏(上文)
    └── hooks/
        ├── deny-force-push.sh
        ├── deny-lockfile.sh
        └── format-on-save.sh

第一步:定义结构化报告 schema(每个包一条结论)。

# schema.json
cat > schema.json <<'JSON'
{
  "type": "object",
  "properties": {
    "package": { "type": "string" },
    "files_changed": { "type": "array", "items": { "type": "string" } },
    "log_calls_replaced": { "type": "integer" },
    "tests_passed": { "type": "boolean" },
    "summary": { "type": "string" }
  },
  "required": ["package", "files_changed", "tests_passed"]
}
JSON

第二步:批量驱动脚本 batch.sh。

#!/bin/bash
# batch.sh — 无人值守批量改造
set -uo pipefail

SCHEMA="$(cat schema.json)"
REPORT="report.ndjson"
: > "$REPORT"   # 清空报告

PROMPT_TEMPLATE='You are migrating logging in a Node package.
Replace every console.log(...) call in this package'"'"'s JS/TS source files
with logger.info(...). Do NOT touch *.lock files. Do NOT force push.
After editing, run `npm test --silent` in this package.
Report results strictly via the schema. Package name: PKG.'

for PKG_DIR in packages/*/; do
  PKG=$(basename "$PKG_DIR")
  echo "===> Processing $PKG"

  PROMPT="${PROMPT_TEMPLATE/PKG/$PKG}"

  # 粗门禁:进程退出码
  if ! OUT=$(cd "$PKG_DIR" && claude --bare -p "$PROMPT" \
        --output-format json \
        --json-schema "$SCHEMA" \
        --settings ../.claude/settings.ci.json \
        --allowedTools "Edit,Read,Grep,Bash(npm test *),Bash(npm run *)" \
        2>err.log); then
    echo "{\"package\":\"$PKG\",\"status\":\"error\",\"detail\":\"claude non-zero exit\"}" >> "$REPORT"
    cat err.log >&2
    continue
  fi

  # 细门禁:解析 structured_output
  STRUCT=$(echo "$OUT" | jq -c '.structured_output // empty')
  if [ -z "$STRUCT" ]; then
    echo "{\"package\":\"$PKG\",\"status\":\"no_structured_output\"}" >> "$REPORT"
    continue
  fi

  TESTS_OK=$(echo "$STRUCT" | jq -r '.tests_passed')
  if [ "$TESTS_OK" = "true" ]; then
    STATUS="ok"
  else
    STATUS="tests_failed"
  fi

  # 合并状态写回报告,并保留 session_id 以便 --resume 续接
  SID=$(echo "$OUT" | jq -r '.session_id')
  echo "$STRUCT" | jq -c --arg s "$STATUS" --arg sid "$SID" \
    '. + {status:$s, session_id:$sid}' >> "$REPORT"
done

# 汇总
echo "----- SUMMARY -----"
jq -s 'group_by(.status) | map({status:.[0].status, count:length})' "$REPORT"

第三步:护栏脚本 format-on-save.sh(PostToolUse,每次编辑后自动格式化被改文件)。

#!/bin/bash
# .claude/hooks/format-on-save.sh
FILE=$(jq -r '.tool_input.file_path // .tool_input.path // empty')
[ -z "$FILE" ] && exit 0
case "$FILE" in
  *.ts|*.js|*.tsx|*.jsx)
    npx --no-install prettier --write "$FILE" >/dev/null 2>&1 || true
    ;;
esac
exit 0

记得 chmod +x .claude/hooks/*.sh。

第四步:运行与解读。

export ANTHROPIC_API_KEY=sk-ant-...
./batch.sh

report.ndjson 每行一个包的结论,例如:

{"package":"pkg-a","files_changed":["src/index.ts","src/util.ts"],"log_calls_replaced":12,"tests_passed":true,"summary":"...","status":"ok","session_id":"abc123"}
{"package":"pkg-b","files_changed":[],"tests_passed":true,"status":"ok","session_id":"def456"}
{"package":"pkg-c","files_changed":["src/api.js"],"log_calls_replaced":3,"tests_passed":false,"summary":"...","status":"tests_failed","session_id":"ghi789"}

第五步:对失败的包用 --resume 续接修复(仍在无人值守循环里)。

SID=$(jq -r 'select(.package=="pkg-c").session_id' report.ndjson)
cd packages/pkg-c && claude --bare -p "The previous migration left tests failing. Read the test output, fix the breakage, re-run npm test." \
  --resume "$SID" \
  --settings ../.claude/settings.ci.json \
  --allowedTools "Edit,Read,Bash(npm test *)" \
  --output-format json | jq '.result'

第六步:接入 GitHub Actions。

# .github/workflows/migrate.yml
name: Batch Logging Migration
on: { workflow_dispatch: {}, schedule: [{cron: "0 2 * * *"}] }
jobs:
  migrate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: sudo apt-get update && sudo apt-get install -y jq
      - run: npm ci
      - name: Run batch migration
        env: { ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} }
        run: ./batch.sh
      - uses: actions/upload-artifact@v4
        with: { name: migration-report, path: report.ndjson }

整体工作流程

flowchart TD
    A[batch.sh 读取 packages/*] --> B{遍历每个包}
    B --> C["claude --bare -p ...<br/>--output-format json --json-schema<br/>--settings settings.ci.json<br/>--allowedTools ..."]
    C --> H{hooks 护栏<br/>运行期内生效}
    H -->|PreToolUse deny force-push/lockfile| X[阻止危险操作]
    H -->|PostToolUse 自动 prettier| Y[格式化改动]
    C --> D{exit code 粗门禁}
    D -->|非零| E[标记 error]
    D -->|零| F[jq 解析 structured_output]
    F --> G{tests_passed?}
    G -->|false| I[标记 tests_failed]
    G -->|true| J[标记 ok + session_id]
    E --> K[写入 report.ndjson]
    I --> K
    J --> K
    K --> L{还有包?}
    L -->|是| B
    L -->|否| M[jq 汇总]
    M --> N[失败的包用 --resume 续接修复]

单次 -p 调用内部的事件时序:

sequenceDiagram
    participant S as batch.sh
    participant C as claude -p --bare
    participant HK as hooks (settings.ci.json)
    participant API as Anthropic API
    S->>C: 启动 + prompt + schema + allowedTools
    C->>API: system/init(plugins/plugin_errors 上报)
    C->>HK: PreToolUse(Edit src/index.ts)
    HK-->>C: 静默放行(非锁文件)
    C->>API: 执行编辑/测试
    C->>HK: PostToolUse(Edit) → prettier 格式化
    C->>HK: PreToolUse(Bash git push --force) 
    HK-->>C: permissionDecision: deny
    C->>API: 收到拒绝原因,改走正常流程
    C-->>S: stdout JSON(result/structured_output/session_id/cost)
    S->>S: jq 双重门禁 → 写 report.ndjson

进阶要点与排错

1. 后台任务的生命周期。 如果 Claude 在 -p 运行中起了一个后台 Bash(如 dev server),它会在 Claude 返回最终结果、stdin 关闭后约 5 秒被终止(v2.1.163+)。后台子代理/工作流不受此宽限影响,但等待时长有上限:默认 10 分钟(v2.1.182+),可用 CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS 调整,设为 0 表示无限等待。批处理里若依赖长跑后台进程,务必设置这个上限,否则一个卡住的子代理会让整个批量任务挂死。

2. --bare 下 skill 仍可用。 用户可调用的 skill 和自定义命令在 -p 下生效:在 prompt 字符串里写 /skill-name,Claude Code 会在运行前展开它。但 /login 这类会弹出交互对话框的内置命令在 -p 下不可用。改设置可走 /config thinking=false(传 key=value)。

3. --bare 与 --settings 的关系。 这是新手最易混的点:--bare 跳过自动发现(不读 ~/.claude、项目 .mcp.json、CLAUDE.md),但它不拒绝你用 flag 显式注入的内容。所以"用 --bare 保可复现 + 用 --settings 注入受版本控制的护栏"是无冲突的组合,也是 CI 的最佳实践。

4. cost 可观测。 --output-format json 的载荷含 total_cost_usd 与每模型成本分解。批量任务务必在循环里累加并打印,避免一次无人值守跑飞烧掉预算:

COST=$(echo "$OUT" | jq -r '.total_cost_usd // 0')
TOTAL=$(echo "$TOTAL + $COST" | bc)
echo "  cost so far: \$$TOTAL"

5. 不要用 hook 强制写结果文件。 看到上面 hooks 能在运行期执行脚本,很容易想"让 Stop hook 把改动清单写到 report"。可行但反模式——结果应走 --output-format json 这条契约通道,hooks 只做护栏与格式化。混用会让调试变成"到底是谁写错了文件"的玄学。


延伸阅读


训练营适用性评估


参考来源

  1. Run Claude Code programmatically(官方 Headless 文档) — -p、--bare、--output-format json、--json-schema、--allowedTools、--resume/--continue 的权威说明
  2. Hooks reference(官方) — hook 事件、matcher/if 语法、permissionDecision 的 deny/allow 输出格式与 settings 配置
  3. Claude Code Headless Mode for Batch Tasks and CI/CD — 无头模式用于批处理与 CI/CD 的工程实践
  4. Claude Code Headless Mode: The Complete Self-Hosting Guide (2026) — CI/CD、隔夜批量、API 驱动工作流的自托管完整指南
  5. How to Automate Claude Code with Hooks and MCP Servers — 用 hooks 把 Claude Code 从被动助手变成自动化基础设施

[!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学通信知识星球海报-扫码加入