<!-- title: 造配置漂移检测Skill -->
# Claude Code Skills 开发实战：从零造一个配置漂移检测技能

## 概述

本教程带你从零构建一个名为 `config-drift-detector` 的自定义 Agent Skill，用来对比不同环境的配置文件（`.env` / `.env.example` / 各环境 YAML），自动报告"缺失键、多余键、取值不一致、占位符未替换"四类结构漂移。全程覆盖 SKILL.md 编写、渐进披露（progressive disclosure）、捆绑脚本、权限白名单与验证闭环。适合已经用过 Claude Code、想把自己的工作流沉淀成可复用技能的中级开发者。

## 前置条件

- Claude Code CLI 已装通并能正常对话（任意登录方式均可）。
- 本机装有 `python3`（3.8+），用于运行捆绑脚本。
- 一个带配置文件的真实或练习项目（有 `.env`、`.env.example` 或 `application.yml` 即可）。
- 了解 Markdown 与 YAML 基本语法。

---

## 第一步：先理解 Skill 到底是什么

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 门禁用非零退出码表达"有漂移" |

把这张表记在心里，下面三、四、五步都是围绕它落地。

## 第三步：写 SKILL.md 的头部与正文

在项目根目录创建 `.claude/skills/config-drift-detector/SKILL.md`，内容如下。注意 `description` 是**唯一始终在上下文里的字段**，必须同时包含"做什么 + 何时用 + 具体例子"三要素，否则 Claude 抓不准触发时机。

```markdown
---
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.
```

几个写头部的要点：

1. **`name` 用 kebab-case**，和目录名一致，这是 Claude 解析技能的键。
2. **`description` 里塞例子**——`Examples: "..."` 这种写法能显著提升触发命中率，因为它给了模型具体的自然语言锚点。
3. **`allowed-tools` 收紧到最小集合**。本技能只需读文件和跑 python3，所以是 `Read, Bash(python3:*)`——注意 `Bash(python3:*)` 这种带前缀的限定式白名单，比笼统的 `Bash` 安全得多，它只放行以 `python3` 开头的命令。
4. 常见的可选字段还有 `tags`（分类）、`model`（指定运行模型）、`disable-model-invocation`（禁止模型自主触发，只能手动 `/skill` 调）、`metadata`（自定义键值）。本例保持精简。

### 常见问题

- **技能不被触发？** 90% 是 `description` 写成了"它是什么"而不是"何时用它"。改成场景化句式。
- **`allowed-tools` 写了 `Bash` 却还弹权限？** 用 `Bash(命令前缀:*)` 精确限定，既减少弹窗又更安全。
- **`name` 和目录名不一致？** 必须一致，否则加载会失败或触发不稳定。

## 第四步：写捆绑脚本 drift_compare.py

正文里指向的脚本必须真实可跑，否则技能就是空架子。在 `scripts/drift_compare.py` 写入下面的实现（纯标准库，零依赖，保证任何机器都能跑）：

```python
#!/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
```

命令行直接跑：

```bash
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 调用了。

### 常见问题

- **退出码 1 把我的脚本打断？** 这是设计意图——把漂移当错误信号。若只想要报告，用 `--format json > report.json || true` 吞掉非零码。
- **多文件对比？** 当前脚本两两对比；要对比 N 个环境，在正文里写"两两调用并汇总"即可，不必把脚本写复杂。
- **嵌套 YAML 解析不准？** 本脚本是扁平解析器，只认 `key: value`。深嵌套请改用 `pyyaml`，并在 `allowed-tools` 保持 `python3` 即可。

## 第五步：写按需加载的 references

正文末尾埋了一个钩子："追问为什么时读 `references/drift-rules.md`"。这就是渐进披露的第二层。把判定细则放进去，平时不占上下文：

```markdown
# 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，用自然语言测试三种触发方式：

1. **隐式触发（靠 description）**：
   > "我们 staging 跑得好好的，prod 上这个功能就报错，帮我对比一下两个环境的配置。"
   
   预期：Claude 自动加载本技能，问你两个文件路径，调用脚本，把报告整理成可操作清单。

2. **显式触发（斜杠命令）**：
   > `/config-drift-detector .env.example .env`
   
   若你给技能配了同名斜杠命令，可这样直接调用并传参。

3. **追问触发第二层（验证渐进披露）**：
   报告出来后追问："为什么 `SECRET_KEY` 被标成占位符？"
   
   预期：Claude 这时才去读 `references/drift-rules.md`，引用其中的正则规则回答——而不是一开始就把它塞进上下文。你可以通过观察会话中"是否多读了一个文件"来确认第二层生效。

### 验证清单

- [ ] 头部 `name` 与目录名一致。
- [ ] `description` 含触发场景 + 例子。
- [ ] 脚本离线自测通过（第四步的预期输出）。
- [ ] `allowed-tools` 收紧到最小前缀集合。
- [ ] 追问"为什么"时才加载 `references/`，未追问时不加载。
- [ ] CI 场景下退出码符合预期（有漂移返回 1）。

---

## 实战案例：发布前配置门禁

把上面的技能串进一条真实的发布流水线。假设你有三个环境配置：

```
config/
├── base.env       # 公共默认值
├── staging.env    # 预发覆盖
└── prod.env       # 生产覆盖
```

目标是：**任何环境相对 base 出现缺失键或占位符残留，就阻断发布**。做法是把技能调用包进 CI 步骤（GitHub Actions 为例）：

```yaml
# .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 把重复工作流沉淀成能力的价值。

## 工作流程图

```mermaid
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` 等字段的权威定义。
- **Frontmatter 全字段表**：搜索 "Claude Code skill frontmatter every YAML option"，掌握 `tags`/`model`/`disable-model-invocation`/`metadata` 等可选字段。
- **渐进披露深度实践**：可参考本系列已发布的"Skill 渐进披露：三层按需加载实战"。
- **描述调优**：触发不命中时，参考"调优 Skill 描述：让技能精准触发"。
- **技能评估**：要量化技能质量，参考"给 Skill 造量化基准测试体系"，用 evals 跑触发率与准确率。

## 训练营适用性评估

- **适用类型**：企业内部工具链训练营、DevOps/SRE 效能提升营、AI 辅助开发入门营（中级班）。把"配置漂移"换成团队自己的痛点（日志、密钥、慢查询），即可复用同一套骨架。
- **教学时长**：约 90–120 分钟。拆分为：原理与契约 20 分钟、写 SKILL.md 20 分钟、写脚本 30 分钟、权限与触发验证 20 分钟、实战串讲 20 分钟。
- **难度**：中级。需要能读懂基础 Python 与 YAML，但不要求高级框架知识。
- **可交付物**：学员离场时带走一个可跑、有白名单、有自测样例、能接 CI 的完整技能目录，可直接放进自己的真实仓库。

## 参考来源
1. [Extend Claude with skills — Claude Code Docs](https://code.claude.com/docs/en/skills) — 官方权威文档，定义 SKILL.md 头部字段、目录结构与渐进披露机制。
2. [How to Create Custom Claude Skills: Complete Tutorial (2026)](https://skillsmd.pro/blog/create-custom-claude-skills) — 从零创建技能的完整步骤，覆盖 frontmatter 与正文写法。
3. [Claude Code Skill Frontmatter: Every YAML Option Explained](https://allahabadi.dev/blogs/ai/claude-code-skills-frontmatter-complete-guide/) — 逐字段解释 `name`/`description`/`allowed-tools`/`disable-model-invocation` 等 YAML 选项。
4. [dependency-auditor SKILL.md 示例](https://github.com/alirezarezvani/claude-skills/blob/main/engineering/skills/dependency-auditor/SKILL.md) — 真实生产级技能范本，演示头部+脚本+按需参考资料的分层组织。
5. [Inside Claude Code Skills: Structure, prompts, invocation](https://mikhail.io/2025/10/claude-code-skills/) — 深度剖析技能的 description 触发机制与加载时序。

> [!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学通信知识星球海报-扫码加入](https://wiki.500nian.com/images/zhixingqiu_qrcode.png)
