预计完成时间:35 分钟 | 难度:中级 | 类型:重构 + 实践
agent-analyze.yml(简化版)。如果你还没部署过,先去第 4 课跑通再回来。
将第 4 课的 内联 claude -p 重构为 TypeScript 脚本编排模式:
.md 文件npx tsx 执行)这是从"第 4 课简化版"迈向 sandcastle 真实架构的第一步。功能不变,结构升级。
第 4 课的 agent-analyze.yml 把所有逻辑塞进了一个 YAML 文件:
# 第 4 课做法:prompt 内联在 YAML 的 heredoc 里
- name: Run AI analysis
run: |
claude -p "$(cat <<'PROMPT'
你是一个软件架构分析助手...
1. **需求理解**:用 2-3 句话...
2. **技术方案**:提出 1-2 个...
...
PROMPT
)" --output-format text --allowedTools Read,Grep,Glob \
> "$RUNNER_TEMP/analysis.md"
能跑,但有三个问题会随着扩展而放大:
| 问题 | 为什么是问题 |
|---|---|
| Prompt 不可 review | 嵌在 YAML 的 shell heredoc 里,没有语法高亮,PR diff 一坨,改一个词要翻几十行 YAML。 |
| YAML 职责混乱 | YAML 既要管 GitHub 事件(标签、评论、token),又要管 AI 调用逻辑(prompt、参数、输出重定向)——两个不同层次的关注点搅在一起。 |
| 不可复用、不可测试 | Analyze 的逻辑没法脱离 GitHub Actions 单独运行。想在本地调试 prompt?只能 push → 贴标签 → 等 CI 跑完。 |
sandcastle 和 course-video-manager 的做法完全不同。它们引入了一层TypeScript 编排脚本,把关注点拆成三层:
YAML 只剩下一行调用:
npx tsx .sandcastle/analyze/analyze.ts
sandcastle 不直接调用 claude -p,而是严格遵循三层分离:
| 层级 | 载体 | 职责 | 第 4 课(❌) | 本课目标(✅) |
|---|---|---|---|---|
| 编排层 | .github/workflows/*.yml |
响应事件、准备环境、消费输出、操作 GitHub | 混入了 AI 调用 | 只做编排 |
| 脚本层 | .sandcastle/*.ts |
组装上下文、加载 prompt、调用 AI、验证输出 | 不存在 | TypeScript 脚本 |
| 知识层 | .sandcastle/*.md |
AI prompt 内容——"AI 应该怎么做" | 嵌在 YAML heredoc | 独立 Markdown 文件 |
把第 4 课的 prompt 从 YAML heredoc 中提取出来,存为独立文件。
在仓库根目录创建 .sandcastle/analyze/prompt.md:
你是一个软件架构分析助手。下面是一个 GitHub Issue 的 JSON 数据,
存储在 {{CONTEXT_FILE}} 中。
请阅读这个 Issue 并用中文做以下分析:
1. **需求理解**:用 2-3 句话总结 Issue 想解决的问题
2. **技术方案**:提出 1-2 个可行的技术实现思路
3. **潜在风险**:指出实现中需要注意的 1-3 个风险点
4. **建议的下一步**:给出具体的行动建议
直接输出分析结果,不要做其他操作。
{{CONTEXT_FILE}}:这是一个模板变量,由脚本层在运行时替换为实际文件路径。prompt 文件不硬编码路径——不同环境(本地 vs CI)路径不同。
创建 .sandcastle/analyze/analyze.ts。这个脚本做四件事:
claude CLI,传入 prompt 和权限参数// .sandcastle/analyze/analyze.ts
import { execSync } from "node:child_process";
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { join } from "node:path";
// ── 1. 读取 YAML 传来的环境变量 ──
const issueNumber = process.env.ISSUE_NUMBER;
const issueTitle = process.env.ISSUE_TITLE;
const contextFile = process.env.CONTEXT_FILE;
const outputDir = process.env.OUTPUT_DIR || process.env.RUNNER_TEMP || "/tmp";
if (!issueNumber || !contextFile) {
console.error("❌ 缺少必要的环境变量: ISSUE_NUMBER 或 CONTEXT_FILE");
process.exit(1);
}
console.log(`🔍 正在分析 Issue #${issueNumber}: ${issueTitle}`);
// ── 2. 加载 prompt 模板,替换变量 ──
const promptTemplate = readFileSync(
join(import.meta.dirname, "prompt.md"),
"utf-8"
);
const prompt = promptTemplate.replace("{{CONTEXT_FILE}}", contextFile);
// ── 3. 调用 claude CLI ──
const outputFile = join(outputDir, "analysis.md");
try {
const result = execSync("claude", {
input: prompt,
env: {
...process.env,
CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN!,
},
timeout: 5 * 60 * 1000, // 5 分钟超时
maxBuffer: 1024 * 1024, // 1MB 输出上限
});
// ── 4. 写入输出文件 ──
writeFileSync(outputFile, result.stdout, "utf-8");
console.log(`✅ 分析完成,输出已写入 ${outputFile}`);
console.log(`📊 分析长度: ${result.stdout.length} 字符`);
} catch (error) {
// ── 失败处理:写 failure_reason.txt ──
const reason = error instanceof Error ? error.message : String(error);
writeFileSync(join(outputDir, "failure_reason.txt"), reason, "utf-8");
console.error(`❌ AI 分析失败: ${reason}`);
process.exit(1);
}
| 代码 | 解释 |
|---|---|
import { execSync } from "node:child_process" |
Node.js 内置模块。同步调用外部命令(这里就是 claude CLI)。同步 = 阻塞等待完成,简单直接,适合 CI 场景。 |
process.env.ISSUE_NUMBER |
读取 YAML 通过 env: 传入的环境变量。这是 YAML 和脚本之间的"合同"。 |
import.meta.dirname |
当前脚本所在目录的绝对路径。用它拼接 prompt.md 路径,确保无论从哪里执行都能找到 prompt 文件。 |
promptTemplate.replace("{{CONTEXT_FILE}}", ...) |
运行时模板替换。比 YAML heredoc 灵活——不同环境替换不同值。 |
execSync("claude", { input: prompt, ... }) |
通过 stdin 传入 prompt(而不是命令行参数 -p)。这样 prompt 可以任意长,不受命令行长度限制。 |
timeout: 5 * 60 * 1000 |
5 分钟超时。如果 AI 卡住,脚本不会永远挂起。 |
maxBuffer: 1024 * 1024 |
stdout 最大 1MB。防止异常情况下内存溢出。 |
failure_reason.txt |
Agent-Runner 契约规定的失败输出文件。工作流可以读取它来发布错误信息。 |
execSync 而不是 claude -p?execSync 通过 stdin 传入 prompt,不受命令行长度限制(Windows 下命令行最长 8191 字符)。而且 execSync 给代码带来了错误处理:
超时、非零退出码、stdout 过大——三种失败场景都能捕获并写入 failure_reason.txt。
重构后的 .github/workflows/agent-analyze.yml 第 4 阶段大幅简化:
# ── 阶段 3: 获取上下文(不变)──
- name: Fetch issue body
run: |
gh issue view "$ISSUE_NUMBER" --json title,body,labels \
> "$RUNNER_TEMP/issue-context.json"
# ── 阶段 4: AI 分析(重构后)──
- name: Run AI analysis
id: analyze
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
CONTEXT_FILE: ${{ runner.temp }}/issue-context.json
OUTPUT_DIR: ${{ runner.temp }}
run: |
npx tsx .sandcastle/analyze/analyze.ts \
> "$RUNNER_TEMP/analysis.md"
原来 ~20 行的 heredoc + claude 参数 + 重定向,现在变成了 1 行 npx tsx 调用。prompt 内容、claude 参数、错误处理全部搬到了 TypeScript 脚本里。
npx tsx 是什么?tsx 是一个 npm 包,能直接执行 .ts 文件而无需先编译成 .js。它内部用 esbuild 做即时编译,速度极快。sandcastle 和 course-video-manager 都用它来跑 TypeScript 编排脚本。
现在工作流需要 tsx 来执行 TypeScript 脚本。在环境准备阶段加一行安装:
# ── 阶段 2: 环境准备 ──
- uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install tsx
run: npm install -g tsx
- name: Install Claude Code
run: npm install -g @anthropic-ai/claude-code
| 维度 | 第 4 课(重构前) | 第 5 课(重构后) |
|---|---|---|
| Prompt 位置 | YAML heredoc 内联 | .sandcastle/analyze/prompt.md |
| 调用方式 | claude -p "$(cat <<'PROMPT'...)" |
npx tsx .sandcastle/analyze/analyze.ts |
| Prompt 传入方式 | 命令行参数(-p) |
stdin(execSync 的 input 选项) |
| 错误处理 | || true(shell 级别) |
try/catch + failure_reason.txt(TS 级别) |
| 本地可调试? | ❌ 脱离 YAML 没法跑 | ✅ npx tsx analyze.ts 本地直接跑 |
| Prompt 可 review? | ❌ PR diff 里一坨 | ✅ 纯 Markdown,diff 清晰 |
| 向 sandcastle 对齐 | 0% | ~60%(缺 @ai-hero/sandcastle 和 schema 验证) |
1. 重构后,AI 调用的实际执行发生在哪一层?
2. 为什么 prompt 文件中用 {{CONTEXT_FILE}} 而不是硬编码路径?
3. execSync 通过 stdin 传入 prompt 的主要优势是什么?
4. 重构后,YAML 中 AI 分析阶段的核心调用变成了什么?
claude -p "..." --output-format textnode analyze.jsnpx tsx .sandcastle/analyze/analyze.tsnpm run analyze| 新知识 | 具体内容 |
|---|---|
| 三层架构 | 编排层(YAML,管 GitHub)→ 脚本层(TS,管 AI 调用)→ 知识层(MD,管 prompt)。各层独立修改、独立测试。 |
npx tsx |
直接执行 .ts 文件,无需编译。sandcastle / course-video-manager 的标准做法。 |
execSync stdin 模式 |
通过 { input: prompt } 传入 prompt,不受命令行长度限制。配合 timeout 和 maxBuffer 提供完整错误处理。 |
| 模板变量 | prompt 文件用 {{VARIABLE}} 占位,脚本层在运行时替换。解耦 prompt 内容和运行环境。 |
| 失败输出规范 | Agent-Runner 契约要求失败时输出 failure_reason.txt。用 try/catch 实现,比 shell || true 更精确。 |
runWithExtraction() 实现)@ai-hero/sandcastle:mattpocock 的 npm 包,提供 sandcastle.claudeCode() / run() / runWithExtraction(),封装了 session 管理、重试、schema 验证后面的课程会逐步补上这两块。
review.ts、implement.ts 是怎么写的。对比你刚写的 analyze.ts,找出相同模式和扩展之处@ai-hero/sandcastle npm 包 — 了解 sandcastle.claudeCode() 和 runWithExtraction() 的 APIinput、timeout、maxBuffer 等选项的官方说明execSync 的报错——直接向你的 AI 导师提问。本地调试时建议先设好环境变量,然后直接运行 npx tsx .sandcastle/analyze/analyze.ts,比 push → 等 CI 快一百倍。