背景
最近在 mbp 项目里要做一个 /blog skill:把工程会话里散落的 git diff、notepad、session transcript 自动整合成一篇中文复盘博客,预览确认后同步发布到两个站点 —— 一个是 Astro 静态博客(blog.lhq.homes),一个是自研的 Cloudflare Workers 博客 CFBlog-Plus(blog.nsu.dpdns.org)。
整个任务通过 OMC team 流水线启动,规划阶段产出了一份 4 Lane 的执行计划:
| Lane | 内容 |
|---|---|
| 1 | SKILL.md 骨架 + 公共函数 common.sh + 复盘模板 |
| 2 | collect-context.sh 采集 git/notepad/memory |
| 3a | publish-astro.sh 推送到 blog_static 仓库 |
| 3b | cfblog_post.py + publish-cfblog.sh 调用 CFBlog API |
| 4 | verify-publish.sh + 集成测试 + run-all.sh |
3 个 worker(worker-1/2/3)并行启动,按 Lane 拆分。worker-1 顺利完成 Lane 1(17 个 bats 用例通过)。
目标
- 让
/blog在任意项目下可触发 - 端到端可发布两站(Astro + CFBlog),任何一边失败不阻塞另一边
- 必须有测试(用户硬性要求),脚本可独立单测
- 不污染
~/.claude/,按现有惯例把实际目录放~/.agents/skills/,再符号链接到~/.claude/skills/
非目标:
- 不做 V2 的草稿/定时发布、版本管理、Codex/Gemini 适配
- 不做封面图自动生成(
img字段留空)
遇到的问题
1. worker 失联与误报
我作为 team lead 在 worker-1 完成 Lane 1 后,看到 mission state 显示 worker-2/3 仍 pending,于是 SendMessage 推送任务。但 8 小时后再核查发现:自 worker-1 完成的时间起,目录没有任何新文件出现,三个 worker 全部 unreachable。
No agent named 'worker-2' is currently addressable. Spawn a new one or use the agent ID.
我之前给用户的”已推送 worker-2/3”是误报。实际上推送时它们已经断了,我没及时去 find 核查产物文件,光看 task state 误判。
2. set -e 下 [ -f ] && cat 提前退出
接管后第一个写 collect-context.sh,函数里这样写:
collect_notepad() {
local notepad="$PROJECT_DIR/.omc/notepad.md"
[ -f "$notepad" ] && cat "$notepad"
}
跑 smoke test 时脚本走到 collect_notepad 之后直接退出,根本没走到后面的 collect_memory_files 和 JSON 组装。
3. publish-astro.sh title 解析不兼容
第一版用 awk 抽 title:
title="$(awk -F'"' '/^title:/ {print $2; exit}' "$md_file" 2>/dev/null || true)"
集成测试 fixture 写的是不带引号的 title: 集成测试文章,于是 commit message 退化成了 post: e2e-post(fallback 到 slug),不是预期的 post: 集成测试文章。
4. CFBlog API 的 Content-Type 选择坑
CFBlog-Plus 后台对不同 Content-Type 的请求体走不同的解析路径,两条路径返回的结构并不一致 —— 用错 Content-Type 时,所有字段都会被读成 undefined,校验直接失败。这种实现细节文档里不会写,调通后封装到脚本里即可,不必每次都重新踩。
5. python3 -m pip install pytest 被 PEP 668 拦截
Homebrew Python 3.14 是 externally-managed-environment:
error: externally-managed-environment
× This environment is externally managed
hint: ... pass --break-system-packages.
不想破坏系统 Python,也不想给 skill 加 venv 步骤增加复杂度。
6. bats 不支持纯中文测试名
测试名里写了 @test "[E2E] CFBlog 发布仍可执行 even if astro push 提前失败",bats 解析出错:
bats: unknown test name `$'test_-5bE2E-5d_CFBlog_\xe5\x8f\x91\xe5\xb8\x83...
排查过程与踩坑
踩坑 1:worker 死活,要看文件不要看状态
发现状态显示 in_progress 不可信后,改成定期 find ~/.agents/skills/dev-blog-publisher -newer SKILL.md 看真实产物。一旦 8 小时没新文件就当 worker 已死,主动询问用户是否接管。
之后单人串行实现剩余 4 个 Lane,每个 Lane 写完立刻跑 bats 验证,发现错误立刻修,绝不堆积。
踩坑 2:函数尾返回非零会触发 set -e
[ -f ... ] && cat ... 当文件不存在时整条表达式返回 1,函数也就返回 1,外层 set -euo pipefail 就把脚本毙了。修复用显式 if 块,并给函数末尾加 return 0:
collect_notepad() {
local notepad="$PROJECT_DIR/.omc/notepad.md"
if [ -f "$notepad" ]; then
cat "$notepad"
fi
return 0
}
教训:任何在 set -e 下被 source 的 lib 函数,末尾都该显式 return 0 防御,特别是带条件分支的。
踩坑 3:YAML 解析交给 Python,不要 hack awk
把 awk 那一行换成 Python heredoc:
title="$(python3 - "$md_file" <<'PY' || true
import sys, re
try:
text = open(sys.argv[1], encoding="utf-8").read()
m = re.match(r"^---\s*\n(.*?)\n---", text, re.DOTALL)
if m:
for line in m.group(1).splitlines():
mm = re.match(r'^\s*title\s*:\s*(.*?)\s*$', line)
if mm:
t = mm.group(1).strip()
if (t.startswith('"') and t.endswith('"')) or (t.startswith("'") and t.endswith("'")):
t = t[1:-1]
print(t)
break
except Exception:
pass
PY
)"
这样带引号、不带引号、单引号、双引号都兼容。
踩坑 4:CFBlog API 字段映射要按后台预期组装
最终把 frontmatter → CFBlog 请求体的映射做成了 cfblog_post.py 里的纯函数(pandoc 转 HTML、字段重命名、日期规范化),具体字段名和形状由调试时 attach 后台拿到的结构决定,封装好后上层一行调用就行。请求体格式和具体字段不在博客里展开,免得变成一份非官方 API 手册。
另一个隐藏的坑:响应的 rst 是 JavaScript boolean true,不是数字 1:
if data.get("rst") is True or data.get("rst") == 1:
...
还有 createDate 字段对 ISO 8601 的 T 分隔不友好,前置在 Python 端做一次 "YYYY-MM-DDTHH:MM" → "YYYY-MM-DD HH:MM" 的规范化,避免下游字段长度校验漏过空值。
踩坑 5:pytest 用 uv run 隔离
不污染系统 Python,又不想给 skill 加 venv 包袱。用 uv 的 uv run --with:
uv run --with pytest --with pyyaml --python python3 python -m pytest tests/
每次跑测试 uv 临时拉环境,速度可接受(首次几秒,后续秒级)。run-all.sh 里做了 fallback:优先 uv,没 uv 就走系统 pytest,都没有就报清晰错误。
踩坑 6:bats 测试名只用 ASCII
bats 把测试名转成 shell 函数名时不支持非 ASCII 字符。改回全英文:
@test "[E2E] CFBlog publish still works when astro push fails" {
...
}
中文要留就放注释里,不放在 @test 引号里。
踩坑 7:验证状态优先级 failed > pending
集成测试发现 astro_status=pending(GH Actions 在跑)+ cfblog_status=failed(mock 返回 404)时,期望整体退出码 = pending (2),但实际 = failed (1)。
读代码确认 verify-publish.sh 的设计是 failed 优先于 pending(任何一站 failed 立刻判定失败),这是对的。改测试用例:不传 article_id 让 CFBlog 跳过,只看 Astro 的 pending 状态。
解决方案
最终架构:
~/.agents/skills/dev-blog-publisher/ ← 实际目录
├── SKILL.md ← 工作流编排(Bash/Read/Write/AskUserQuestion + session_search)
├── scripts/
│ ├── common.sh ← 日志 + env/dep 检查
│ ├── collect-context.sh ← git/notepad/memory 采集 → JSON
│ ├── publish-astro.sh ← git add/commit/push
│ ├── cfblog_post.py ← YAML 解析 + pandoc + JSON 数组 POST
│ ├── publish-cfblog.sh ← Shell 入口(调用 Python)
│ └── verify-publish.sh ← GH Actions + HTTP 200 双站验证
├── templates/retrospective.md ← 6 章节复盘模板
└── tests/
├── test_common.bats ← 17 用例
├── test_collect_context.bats ← 12 用例
├── test_publish_astro.bats ← 11 用例
├── test_publish_cfblog.bats ← 9 用例
├── test_cfblog_post.py ← 27 pytest 用例
├── test_verify_publish.bats ← 9 用例
├── test_integration.bats ← 6 用例
└── run-all.sh ← 跑 bats + uv-pytest
~/.claude/skills/dev-blog-publisher → 符号链接到上面
为什么把实际目录放 ~/.agents/skills/:现有的 OMC skill 已经全是这种符号链接结构(opencli-adapter-author 等),保持一致。直接在 ~/.claude/skills/ 实体写会破坏惯例。
发布两站独立运行:
# Step 5a
bash scripts/publish-astro.sh "$BLOG_STATIC_REPO_PATH/src/content/blog/$SLUG.md"
# Step 5b
bash scripts/publish-cfblog.sh "$BLOG_STATIC_REPO_PATH/src/content/blog/$SLUG.md"
任何一站失败重试一次(间隔 5 秒),仍失败就只报告这一站,不阻塞另一站。
验证阶段:
bash scripts/verify-publish.sh "$SLUG" "$CFBLOG_ARTICLE_ID"
退出码:0 全成功 / 1 任何 failed / 2 任何 pending。
经验总结与工具推荐
关于多 agent 协作:
- 状态信号不如产物可信。worker 显示 in_progress 不代表它还活着。第一手验证永远是
find -newer+ 实际看产物文件。 - 失联超过 30 分钟主动切单人接管,不要无止境等待。
- 给用户的进度报告一定要核查文件系统,宁可少说也不要误报。
关于 Shell + Python 混合:
- 关键数据转换交给 Python(YAML / JSON / HTTP),Shell 只做编排和子进程调用。
set -euo pipefail是默认开。代价是所有函数尾要return 0防御,所有&&短路要审视会不会让函数失败。- 用
--var环境变量(如PUBLISH_ASTRO_GIT)暴露关键二进制路径,让 bats 测试可以 mock。
关于测试:
- bats-core 是 Shell 测试事实标准,
brew install bats-core一行装好。 - 不用真实远端 git push 也能测:本地
git init --bare当 push 目标。 uv run --with pkg比 venv 简洁得多,CI 友好。
关于第三方 API:
- 看源码比看文档可靠。文档里通常不会写”换个 Content-Type 行为就完全不同”这类隐含约束,调通的最短路径常常是直接读 service code 的解析层。
JSON.stringify(true) === "true",但 Pythondata["rst"] == 1不会匹配True。跨语言比较要警惕类型。
推荐工具:
| 工具 | 用途 | 安装 |
|---|---|---|
bats-core | Shell 单元测试 | brew install bats-core |
pandoc | Markdown → HTML | brew install pandoc |
uv | Python 临时依赖隔离 | brew install uv |
gh | GitHub Actions 状态查询 | brew install gh |
jq | JSON 处理(备用) | brew install jq |
最终成绩:64 个 bats + 27 个 pytest,91 个用例 100% 通过,覆盖 6 个脚本和端到端集成。skill 注册后 Claude Code 自动检测到,输入 /blog 即可触发。