本教程带你从零构建一个名为 config-drift-detector 的自定义 Agent Skill,用来对比不同环境的配置文件(.env / .env.example / 各环境 YAML),自动报告"缺失键、多余键、取值不一致、占位符未替换"四类结构漂移。全程覆盖 SKILL.md 编写、渐进披露(progressive disclosure)、捆绑脚本、权限白名单与验证闭环。适合已经用过 Claude Code、想把自己的工作流沉淀成可复用技能的中级开发者。
python3(3.8+),用于运行捆绑脚本。.env、.env.example 或 application.yml 即可)。Agent Skill 本质是一个带 YAML 头部的 Markdown 文件加上它目录里的"附件"(脚本、参考资料、样例)。它和 CLAUDE.md、斜杠命令、子代理(subagent)的区别可以用一句话概括:
CLAUDE.md是"项目规则",永远加载;Skill 是"专项能力",按需加载;斜杠命令是"你主动喊一声才跑"的快捷入口;子代理是"换一个独立上下文去干活"。
Skill 最关键的设计哲学叫 progressive disclosure(渐进披露):头部里的 description 始终在 Claude 的上下文里(所以必须写准),而正文和附件只有当 Claude 判断需要时才会被读进来。这样做的好处是把上下文窗口留给真正相关的技能细节,几十个技能叠加也不会撑爆。
一个 Skill 目录的最小结构:
.claude/skills/config-drift-detector/
├── SKILL.md # 头部(必需)+ 正文(按需加载)
├── scripts/
│ └── drift_compare.py # 捆绑脚本:干活的实际代码
├── references/
│ └── drift-rules.md # 深度参考资料:仅在追问"为什么"时加载
└── examples/
└── sample-configs/ # 样例配置,便于自测
存放位置有三档优先级,从高到低:项目级 .claude/skills/(随仓库共享)、用户级 ~/.claude/skills/(个人工具箱,跨项目可用)、插件级(通过 plugin 分发)。本教程用项目级。
写代码之前,先用一句话说清这个技能被什么触发、产出什么、副作用是什么。这是后续写 description 和验证脚本行为的依据,跳过这一步技能就会变成"看起来对、其实没人喊得动"的死代码。
| 维度 | 本技能的答案 |
|---|---|
| 触发场景 | 排查"在我电脑上没问题"、上手新项目对配置、发布前门禁 |
| 输入 | 两个或多个配置文件路径 |
| 产出 | 漂移报告(缺失键 / 多余键 / 取值不一致 / 占位符残留) |
| 副作用 | 只读,不改任何文件;可作为 CI 门禁用非零退出码表达"有漂移" |
把这张表记在心里,下面三、四、五步都是围绕它落地。
在项目根目录创建 .claude/skills/config-drift-detector/SKILL.md,内容如下。注意 description 是唯一始终在上下文里的字段,必须同时包含"做什么 + 何时用 + 具体例子"三要素,否则 Claude 抓不准触发时机。
---
name: config-drift-detector
description: Compare config files across environments and report structural drift — missing keys, extra keys, value mismatches, and leftover placeholders. Use when onboarding to a project, debugging "works on my machine" issues caused by env differences, or gating a release before promotion. Examples: "compare .env and .env.example", "check config drift between staging and prod", "find missing env vars in production".
allowed-tools: Read, Bash(python3:*)
---
# Config Drift Detector
Compare two config files and surface structural drift. Works with `.env`
style (`KEY=VALUE`) and flat YAML. Output is a drift report you act on:
missing keys go to the user as a checklist; placeholder leftovers must be
filled before deploy.
## When to use
- A feature works in staging but fails in prod → compare their configs.
- Onboarding → diff `.env.example` against the team's real `.env`.
- Release gate → assert no drift between the target environments.
## How to run
\`\`\`bash
python3 scripts/drift_compare.py .env.example .env \
--name-a example --name-b local --format json
\`\`\`
**Acting on output:** `only_in_a`/`only_in_b` → hand the missing-key list to
the user as a checklist; `placeholder_leftover` → block deploy until filled;
`value_differs` → confirm the intended value per environment.
Read `references/drift-rules.md` **only** when the user asks *why* a key is
flagged (placeholder regex, type-hint heuristics). Don't load it by default.
几个写头部的要点:
name 用 kebab-case,和目录名一致,这是 Claude 解析技能的键。description 里塞例子——Examples: "..." 这种写法能显著提升触发命中率,因为它给了模型具体的自然语言锚点。allowed-tools 收紧到最小集合。本技能只需读文件和跑 python3,所以是 Read, Bash(python3:*)——注意 Bash(python3:*) 这种带前缀的限定式白名单,比笼统的 Bash 安全得多,它只放行以 python3 开头的命令。tags(分类)、model(指定运行模型)、disable-model-invocation(禁止模型自主触发,只能手动 /skill 调)、metadata(自定义键值)。本例保持精简。description 写成了"它是什么"而不是"何时用它"。改成场景化句式。allowed-tools 写了 Bash 却还弹权限? 用 Bash(命令前缀:*) 精确限定,既减少弹窗又更安全。name 和目录名不一致? 必须一致,否则加载会失败或触发不稳定。正文里指向的脚本必须真实可跑,否则技能就是空架子。在 scripts/drift_compare.py 写入下面的实现(纯标准库,零依赖,保证任何机器都能跑):
#!/usr/bin/env python3
"""drift_compare.py — 比较两个配置文件并报告结构漂移。
支持 .env 风格 (KEY=VALUE) 与扁平 YAML (key: value)。报告四类漂移:
- 仅在 A / 仅在 B (键只出现在一侧)
- 取值不一致 (同名键,值不同)
- 占位符未替换 (值仍是 <...> / changeme / example.com 等)
退出码:发现任意漂移返回 1,便于用作 CI 门禁;完全一致返回 0。
"""
import argparse, json, re, sys
from pathlib import Path
# 占位符特征:尖括号、未解析的 ${VAR、常见占位字面量
PLACEHOLDER_RE = re.compile(
r"^(<[^>]+>|\$\{|changeme|todo|xxx|replace_me|example\.com|your_)",
re.I,
)
def _strip_quotes(v: str) -> str:
if len(v) >= 2 and v[0] == v[-1] and v[0] in ("'", '"'):
return v[1:-1]
return v
def parse_env(text: str) -> dict:
cfg = {}
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
cfg[key.strip()] = _strip_quotes(val.strip())
return cfg
def parse_yaml_flat(text: str) -> dict:
cfg = {}
for raw in text.splitlines():
line = raw.split("#", 1)[0].rstrip()
if ":" not in line:
continue
key, _, val = line.partition(":")
key, val = key.strip(), val.strip()
if key and val:
cfg[key] = _strip_quotes(val)
return cfg
def load(path: str) -> dict:
text = Path(path).read_text(encoding="utf-8", errors="replace")
if path.endswith((".yml", ".yaml")):
return parse_yaml_flat(text)
return parse_env(text)
def drift(a: dict, b: dict, na: str, nb: str) -> dict:
ka, kb = set(a), set(b)
differs = [
{"key": k, na: a[k], nb: b[k]}
for k in sorted(ka & kb) if a[k] != b[k]
]
placeholders = [
k for k in sorted(ka & kb)
if PLACEHOLDER_RE.match(a[k]) or PLACEHOLDER_RE.match(b[k])
]
return {
"only_in_a": sorted(ka - kb),
"only_in_b": sorted(kb - ka),
"value_differs": differs,
"placeholder_leftover": placeholders,
}
def render_text(report: dict, na: str, nb: str) -> str:
out = []
if report["only_in_a"]:
out.append(f"## 仅在 {na}({nb} 缺失)")
out += [f" - {k}" for k in report["only_in_a"]]
if report["only_in_b"]:
out.append(f"## 仅在 {nb}({na} 缺失)")
out += [f" - {k}" for k in report["only_in_b"]]
if report["value_differs"]:
out.append("## 取值不一致")
for d in report["value_differs"]:
out.append(f" - {d['key']}: {na}={d[na]!r} vs {nb}={d[nb]!r}")
if report["placeholder_leftover"]:
out.append("## 占位符未替换(疑似忘记配置)")
out += [f" - {k}" for k in report["placeholder_leftover"]]
if not out:
out.append("✓ 无漂移:两个文件结构与取值完全一致。")
return "\n".join(out)
def main():
p = argparse.ArgumentParser(description="比较配置文件并报告结构漂移")
p.add_argument("file_a")
p.add_argument("file_b")
p.add_argument("--name-a", default="A")
p.add_argument("--name-b", default="B")
p.add_argument("--format", choices=["text", "json"], default="text")
args = p.parse_args()
a, b = load(args.file_a), load(args.file_b)
report = drift(a, b, args.name_a, args.name_b)
if args.format == "json":
print(json.dumps(report, ensure_ascii=False, indent=2))
else:
print(render_text(report, args.name_a, args.name_b))
has_drift = any([report["only_in_a"], report["only_in_b"],
report["value_differs"], report["placeholder_leftover"]])
sys.exit(1 if has_drift else 0)
if __name__ == "__main__":
main()
在 examples/sample-configs/ 下放两个小文件:
.env.example:
DATABASE_URL=postgres://localhost/app
REDIS_URL=redis://localhost:6379
PORT=3000
SECRET_KEY=<replace-me>
FEATURE_FLAG_NEW_UI=false
.env:
DATABASE_URL=postgres://localhost/app
PORT=8080
SECRET_KEY=changeme
API_TIMEOUT=30
命令行直接跑:
python3 .claude/skills/config-drift-detector/scripts/drift_compare.py \
.claude/skills/config-drift-detector/examples/sample-configs/.env.example \
.claude/skills/config-drift-detector/examples/sample-configs/.env \
--name-a example --name-b local
预期输出(文本模式):
## 仅在 example(local 缺失)
- FEATURE_FLAG_NEW_UI
- REDIS_URL
## 仅在 local(example 缺失)
- API_TIMEOUT
## 取值不一致
- PORT: example='3000' vs local='8080'
## 占位符未替换(疑似忘记配置)
- SECRET_KEY
看到这四段,说明脚本逻辑正确,可以交给 Skill 调用了。
--format json > report.json || true 吞掉非零码。key: value。深嵌套请改用 pyyaml,并在 allowed-tools 保持 python3 即可。正文末尾埋了一个钩子:"追问为什么时读 references/drift-rules.md"。这就是渐进披露的第二层。把判定细则放进去,平时不占上下文:
# Drift Detection Rules(按需加载)
## 什么算漂移
1. **缺失键** — 只出现在一侧。
2. **取值不一致** — 同名键、非占位符值不同。
3. **占位符残留** — 值命中占位符正则。
## 占位符正则
命中以 `<...>` 开头、未解析的 `${VAR`、或字面量
changeme / todo / xxx / replace_me / example.com / your_(不区分大小写)。
## 类型提示(建议性)
两文件共享某键,但一侧是数字另一侧是字符串时,
即便值还没"不一致"也优先标红——端口/超时类型错配是
"在我电脑上没问题"的头号元凶。
## 故意忽略的差异
- 仅空白差异
- 键的顺序
- 注释行
把规则塞进正文,等于每次触发技能都全量加载;放进 references/ 并在正文里"指名道姓地说何时读",模型只会在用户真正追问判定逻辑时才把它读进来。这是把技能做"厚"而不"胖"的关键手法——参考 Anthropic 官方与社区的通用实践:头部精简、正文讲怎么用、深度内容下沉到附件。
allowed-tools: Read, Bash(python3:*) 这一行是技能的"安全边界"。它的含义:
Read:允许读任意文件(对比配置必须能读)。Bash(python3:*):只允许以 python3 开头的 Bash 命令,且自动放行、不弹权限确认。这种"前缀限定"白名单比写裸 Bash 安全得多——它不会放行 rm、curl 之类危险命令,因此即便技能描述被模型误解、试图执行别的命令,也会被拦下。要进一步收紧,可以把脚本路径也写死进正文命令模板,让模型几乎不可能跑偏。
node 又允许 python3? 写成 Bash(python3:*), Bash(node:*),逗号分隔。python3 能干很多事? 的确,它图灵完备。若要更硬的隔离,可改用只读解析器或把脚本编译后用更窄的包装器调用。本教程权衡易用性与安全性,选了零依赖标准库 + 前缀白名单。文件就位后,在项目根目录启动 Claude Code,用自然语言测试三种触发方式:
"我们 staging 跑得好好的,prod 上这个功能就报错,帮我对比一下两个环境的配置。"
预期:Claude 自动加载本技能,问你两个文件路径,调用脚本,把报告整理成可操作清单。
/config-drift-detector .env.example .env
若你给技能配了同名斜杠命令,可这样直接调用并传参。
SECRET_KEY 被标成占位符?"预期:Claude 这时才去读 references/drift-rules.md,引用其中的正则规则回答——而不是一开始就把它塞进上下文。你可以通过观察会话中"是否多读了一个文件"来确认第二层生效。
name 与目录名一致。description 含触发场景 + 例子。allowed-tools 收紧到最小前缀集合。references/,未追问时不加载。把上面的技能串进一条真实的发布流水线。假设你有三个环境配置:
config/
├── base.env # 公共默认值
├── staging.env # 预发覆盖
└── prod.env # 生产覆盖
目标是:任何环境相对 base 出现缺失键或占位符残留,就阻断发布。做法是把技能调用包进 CI 步骤(GitHub Actions 为例):
# .github/workflows/config-gate.yml
name: Config Drift Gate
on: [pull_request]
jobs:
drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check staging vs base
run: |
python3 .claude/skills/config-drift-detector/scripts/drift_compare.py \
config/base.env config/staging.env \
--name-a base --name-b staging --format json > staging-drift.json || true
- name: Check prod vs base
run: |
python3 .claude/skills/config-drift-detector/scripts/drift_compare.py \
config/base.env config/prod.env \
--name-a base --name-b prod --format text
注意 prod 那一步不带 || true——一旦有漂移,脚本返回 1,CI 直接红,PR 无法合并。staging 那一步用 --format json 落盘成制品,方便在 PR 评论里由 Claude Code 总结(你可以再配一个 hook 把 staging-drift.json 贴回 PR)。
回到交互侧,发布前你在 Claude Code 里说一句"跑一下配置门禁",技能就会:加载头部 → 调脚本 → 把四类漂移整理成"必须处理 / 建议处理"两档清单 → 你确认后它把缺失键补进对应文件。整条链路从"人眼逐行 diff"变成"一句话出报告",这正是 Skill 把重复工作流沉淀成能力的价值。
flowchart TD
U["用户:检查配置漂移"] --> CC["Claude Code"]
CC -->|"匹配 description 锚点"| LOAD["加载 SKILL.md 头部+正文"]
LOAD --> ASK["确认两个配置文件路径"]
ASK --> RUN["运行 drift_compare.py<br/>allowed-tools: Bash(python3:*)"]
RUN --> OUT{"有漂移?"}
OUT -->|"有"| RPT["输出四类漂移报告"]
OUT -->|"无"| OK["✓ 一致,放行"]
RPT --> DECIDE{"用户追问为什么?"}
DECIDE -->|"是"| REF["按需读 references/drift-rules.md"]
DECIDE -->|"否"| ACT["缺失补全 / 占位符替换 / 门禁拦截"]
REF --> ACT
这张图同时体现了三条核心:按需加载(头部常驻、正文与 references 分层)、权限收敛(脚本走前缀白名单)、可作门禁(非零退出码表达漂移)。
code.claude.com/docs/en/skills——name/description/allowed-tools 等字段的权威定义。tags/model/disable-model-invocation/metadata 等可选字段。name/description/allowed-tools/disable-model-invocation 等 YAML 选项。[!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 等场景全覆盖。
