🧱 第五课:从 YAML 到 TypeScript — 迈向 Sandcastle 架构

预计完成时间:35 分钟  |  难度:中级  |  类型:重构 + 实践

📌 前置要求:本课是对 第 4 课 的架构升级。你需要已理解 Agent-Runner 契约,并成功部署过 agent-analyze.yml(简化版)。如果你还没部署过,先去第 4 课跑通再回来。

本课目标

将第 4 课的 内联 claude -p 重构为 TypeScript 脚本编排模式:

这是从"第 4 课简化版"迈向 sandcastle 真实架构的第一步。功能不变,结构升级。


为什么要重构?第 4 课的问题

第 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 编排脚本,把关注点拆成三层:

.sandcastle/ └── analyze/ ├── analyze.ts ← 编排脚本:读 prompt → 调 claude → 写输出 → 验证 └── prompt.md ← AI prompt:纯 Markdown,可 review,可复用

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 文件
🔑 核心原则:YAML 不知道 prompt 长什么样;prompt 不知道自己在哪个工作流里运行;脚本是中间的胶水。任何一层可以独立修改、独立测试。

第一步:创建 prompt 文件

把第 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)路径不同。

第二步:写 TypeScript 编排脚本

创建 .sandcastle/analyze/analyze.ts。这个脚本做四件事:

  1. 读取环境变量(YAML 传入的 Issue 编号、标题等)
  2. 加载 prompt 文件,替换其中的模板变量
  3. 调用 claude CLI,传入 prompt 和权限参数
  4. 验证输出,写入结果文件
// .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 -pexecSync 通过 stdin 传入 prompt,不受命令行长度限制(Windows 下命令行最长 8191 字符)。而且 execSync 给代码带来了错误处理: 超时、非零退出码、stdout 过大——三种失败场景都能捕获并写入 failure_reason.txt

第三步:简化 YAML

重构后的 .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

完整对照:重构前 vs 重构后

维度第 4 课(重构前)第 5 课(重构后)
Prompt 位置 YAML heredoc 内联 .sandcastle/analyze/prompt.md
调用方式 claude -p "$(cat <<'PROMPT'...)" npx tsx .sandcastle/analyze/analyze.ts
Prompt 传入方式 命令行参数(-p stdin(execSyncinput 选项)
错误处理 || 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 调用的实际执行发生在哪一层?

A. 编排层(YAML)
B. 脚本层(TypeScript)
C. 知识层(Markdown)
D. GitHub Actions Runner 直接调用

2. 为什么 prompt 文件中用 {{CONTEXT_FILE}} 而不是硬编码路径?

A. 这是 YAML 语法,必须用双花括号
B. 不同运行环境路径不同,由脚本在运行时替换
C. Claude 要求所有文件路径用花括号包裹
D. 这是 TypeScript 的模板字符串语法

3. execSync 通过 stdin 传入 prompt 的主要优势是什么?

A. 比命令行参数执行更快
B. Claude 只能从 stdin 读取输入
C. 不受命令行长度限制,且便于错误处理
D. stdin 会自动加密传输内容

4. 重构后,YAML 中 AI 分析阶段的核心调用变成了什么?

A. claude -p "..." --output-format text
B. node analyze.js
C. npx tsx .sandcastle/analyze/analyze.ts
D. npm run analyze


🎯 总结:你学到了什么

新知识具体内容
三层架构 编排层(YAML,管 GitHub)→ 脚本层(TS,管 AI 调用)→ 知识层(MD,管 prompt)。各层独立修改、独立测试。
npx tsx 直接执行 .ts 文件,无需编译。sandcastle / course-video-manager 的标准做法。
execSync stdin 模式 通过 { input: prompt } 传入 prompt,不受命令行长度限制。配合 timeoutmaxBuffer 提供完整错误处理。
模板变量 prompt 文件用 {{VARIABLE}} 占位,脚本层在运行时替换。解耦 prompt 内容和运行环境。
失败输出规范 Agent-Runner 契约要求失败时输出 failure_reason.txt。用 try/catch 实现,比 shell || true 更精确。
📊 你现在的位置:
sandcastle 对齐度 ~60%。还缺两个关键组件:
  1. Schema 验证:AI 输出不只是"非空",而是要符合预期格式(sandcastle 用 runWithExtraction() 实现)
  2. @ai-hero/sandcastlemattpocock 的 npm 包,提供 sandcastle.claudeCode() / run() / runWithExtraction(),封装了 session 管理、重试、schema 验证

后面的课程会逐步补上这两块。


推荐进一步阅读


← 第 4 课:AI 分析 Issue 第 5 课

💬 有问题?重构过程中遇到任何 TypeScript 或 execSync 的报错——直接向你的 AI 导师提问。本地调试时建议先设好环境变量,然后直接运行 npx tsx .sandcastle/analyze/analyze.ts,比 push → 等 CI 快一百倍。