首页 › tutorials › 造配置漂移检测Skill

Claude Code Skills 开发实战:从零造一个配置漂移检测技能

概述

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

前置条件


第一步:先理解 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 抓不准触发时机。

---
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(自定义键值)。本例保持精简。

常见问题

第四步:写捆绑脚本 drift_compare.py

正文里指向的脚本必须真实可跑,否则技能就是空架子。在 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 调用了。

常见问题

第五步:写按需加载的 references

正文末尾埋了一个钩子:"追问为什么时读 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:*) 这一行是技能的"安全边界"。它的含义:

这种"前缀限定"白名单比写裸 Bash 安全得多——它不会放行 rm、curl 之类危险命令,因此即便技能描述被模型误解、试图执行别的命令,也会被拦下。要进一步收紧,可以把脚本路径也写死进正文命令模板,让模型几乎不可能跑偏。

常见问题

第七步:触发与验证闭环

文件就位后,在项目根目录启动 Claude Code,用自然语言测试三种触发方式:

  1. 隐式触发(靠 description):

    "我们 staging 跑得好好的,prod 上这个功能就报错,帮我对比一下两个环境的配置。"

预期:Claude 自动加载本技能,问你两个文件路径,调用脚本,把报告整理成可操作清单。

  1. 显式触发(斜杠命令):

    /config-drift-detector .env.example .env

若你给技能配了同名斜杠命令,可这样直接调用并传参。

  1. 追问触发第二层(验证渐进披露): 报告出来后追问:"为什么 SECRET_KEY 被标成占位符?"

预期:Claude 这时才去读 references/drift-rules.md,引用其中的正则规则回答——而不是一开始就把它塞进上下文。你可以通过观察会话中"是否多读了一个文件"来确认第二层生效。

验证清单


实战案例:发布前配置门禁

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

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 分层)、权限收敛(脚本走前缀白名单)、可作门禁(非零退出码表达漂移)。

延伸阅读

训练营适用性评估

参考来源

  1. Extend Claude with skills — Claude Code Docs — 官方权威文档,定义 SKILL.md 头部字段、目录结构与渐进披露机制。
  2. How to Create Custom Claude Skills: Complete Tutorial (2026) — 从零创建技能的完整步骤,覆盖 frontmatter 与正文写法。
  3. Claude Code Skill Frontmatter: Every YAML Option Explained — 逐字段解释 name/description/allowed-tools/disable-model-invocation 等 YAML 选项。
  4. dependency-auditor SKILL.md 示例 — 真实生产级技能范本,演示头部+脚本+按需参考资料的分层组织。
  5. Inside Claude Code Skills: Structure, prompts, invocation — 深度剖析技能的 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学通信知识星球海报-扫码加入