本文是 Claude Code 教程系列的第 2 轮,聚焦 Skills 开发实战。我们将从零构建一个名为
env-reconcile的自定义 Skill,它扫描代码里所有环境变量引用,与.env/.env.example自动对账,找出"代码用了但没声明"的高危变量。
学完本教程,你将独立完成一个脚本型 Skill(Script Skill):理解 SKILL.md 的 frontmatter 字段、把核心逻辑下沉为 Python 脚本、用 ${CLAUDE_SKILL_DIR} 跨路径引用、用 ! 动态上下文注入把扫描报告直接喂给模型、用 allowed-tools 免审批运行脚本,并掌握渐进式披露(progressive disclosure)与触发准确率调试。适用人群:已跑通 Claude Code、想从"用现成 Skill"升级到"造自己的 Skill"的开发者,前端 / 后端 / 全栈 / DevOps 均可受益。
paths 字段;v2.1.202 以上可体验 Skill 内容去重),低版本核心功能也兼容;python3(脚本仅用标准库,无需 pip install);很多人造 Skill 的第一反应是"把一段 prompt 存成文件"。但 Skill 真正的价值在于可复用、可触发、可下沉。判断一个需求该不该做成 Skill,问自己三个问题:
process.env.X 找出来,跟 .env.example 对一下"——这就是典型的 Skill 候选。/ 触发,避免模型自作主张。配置对账这个需求三条全中:要遍历工程(重活)、是动作(生产前自检)、且人人都会忘。它正是 Script Skill 的绝佳样本。
为什么不用一次性 prompt 解决?因为环境变量对账是反复发生的——每次加新功能、每次新人入职、每次部署报错都要做。固化成 Skill 后,一句 /env-reconcile 或一句"帮我检查环境变量有没有漏",就拿到结构化报告。
Claude Code 的 Skill 是一个目录,入口文件必须叫 SKILL.md。官方推荐的渐进式披露结构如下:
env-reconcile/
├── SKILL.md # 入口:frontmatter + 精简指令(必需)
├── reference.md # 进阶说明:模式表、设计取舍(按需加载)
└── scripts/
└── env-reconcile.py # 真正干活的脚本(执行,而非读入上下文)
关键纪律:SKILL.md 控制在 500 行以内。一旦 Skill 被触发,它的正文会作为一条消息常驻整个会话上下文,每一行都是反复出现的 token 成本。所以"重活"放脚本、"详解"放 reference.md,SKILL.md 只放导航和最简指令。
创建目录(个人级,对所有项目生效):
mkdir -p ~/.claude/skills/env-reconcile/scripts
三种存放位置决定生效范围,按需选择:
| 位置 | 路径 | 生效范围 |
|---|---|---|
| 个人级 | ~/.claude/skills/<name>/SKILL.md |
你的所有项目 |
| 项目级 | .claude/skills/<name>/SKILL.md |
仅当前项目(可提交到 Git 共享给团队) |
| 插件级 | <plugin>/skills/<name>/SKILL.md |
启用该插件处 |
配置对账这种"团队共性"需求,建议最终落到项目级 .claude/skills/ 并提交版本库,让所有同事 git pull 后即用。
把以下内容存为 ~/.claude/skills/env-reconcile/SKILL.md 的开头部分(YAML 必须夹在两个 --- 之间):
---
name: env-reconcile
description: 对账代码中的环境变量引用与 .env 配置文件,扫出"代码引用了但 .env 缺失"和"声明了但代码没用"的变量。用于上线前自检、新人拉取项目核对配置、应用启动报错排查环境变量缺失。当用户提到环境变量对账、.env 缺失、配置漂移、启动报 undefined/KeyError、核对配置时使用。
allowed-tools: Bash(python3 *)
paths: "**/*.{js,jsx,ts,tsx,mjs,cjs,py,go,rb,vue,svelte}"
---
逐字段解释——这些都是官方 frontmatter 支持的字段:
name:展示名,也是 / 菜单里的命令名(个人级/项目级时,命令名取自目录名而非此字段,所以这里写不写不影响 /env-reconcile)。description(推荐)告诉 Claude 这个 Skill 干什么、何时该用。Claude 靠这段文本来判断要不要自动加载,所以把关键触发词放最前面。注意:description + when_to_use 合计在 Skill 列表里会被截断到 1536 字符,且全局还有一个按上下文窗口 1% 缩放的预算,技能太多时低频 Skill 的描述会被裁掉。把"环境变量对账、.env 缺失、配置漂移、启动报错"这些用户会自然说出口的词放前面。allowed-tools:列出该 Skill 激活期间免审批可用的工具。这里只放 Bash(python3 *),让 Claude 重跑脚本或加参数时不再每次问你要权限。注意它不限制可用工具集,只是"预批准";未列出的工具仍受你的全局权限规则约束。paths(v2.1.196+):只有当你在编辑匹配的文件时,Claude 才会自动加载该 Skill。配置对账只在有源码的项目里才有意义,加上这条可显著降低误触发。如果你想让它只接受手动 /env-reconcile 触发(避免 Claude 自作主张乱跑),再加一行:
disable-model-invocation: true
这是动作型 Skill 的标配——和 /deploy、/commit 同理。本教程为了演示"自动触发",先不加它;最后会讲什么时候该加。
常见坑:frontmatter YAML 写错(比如缩进、中文冒号)时,Claude Code 会用空元数据加载 Skill,
/env-reconcile仍能手动跑,但自动触发会失效(因为读不到 description)。排查用claude --debug启动,能看到解析错误。
SKILL.md 正文(接在 frontmatter 之后):
# 环境变量对账(env-reconcile)
## 当前项目的环境变量审计结果
```!
python3 ${CLAUDE_SKILL_DIR}/scripts/env-reconcile.py ${CLAUDE_PROJECT_DIR}
```
## 你的任务
基于上面已注入的对账报告,按下列要求处理:
1. **高危项优先**:列出所有"代码引用了但 `.env` 系列文件中缺失"的变量。每个标注:变量名、出现文件与行号、引用方式(如 `process.env` / `os.getenv`)。这是运行时崩溃的直接来源。
2. **给出建议**:对每个缺失变量,结合变量名与出现位置推断用途,给出推荐默认值;推断不出就写"需确认",**不要编造值**。
3. **遗留项提醒**:列出"声明了但代码没引用"的变量,提示可能是历史遗留或应在其他环境声明。
4. **可执行的修复**:询问用户是否要生成一份补齐缺失变量的 `.env.example` 片段;用户同意后,按 `KEY=默认值 # 用途说明` 格式输出。
## 输出格式
用三张 Markdown 表格:缺失变量(高危)、声明未用(警告)、已对账(仅计数)。表头示例:
| 变量 | 文件:行 | 引用方式 | 建议默认 |
## 进阶
- 各语言引用模式与设计取舍见 [reference.md](reference.md)。
这段正文有两个最值得学的设计:
(1) 动态上下文注入 !:反引号前缀 !`command` 的代码块会在 Skill 内容发给 Claude 之前先执行,命令输出原样替换占位符。也就是说,Claude 看到的不是"请运行脚本",而是"这是扫描报告:……"。这把模型从"猜测代码状态"升级为"基于真实数据作答",是 grounding 的关键。注意几个细节:
!`cmd` 只有在 ! 位于行首或紧跟空白时才被识别,KEY=!cmd`` 这种会被当字面量;disableSkillShellExecution: true 策略禁用,占位符会被替换成提示文字而不执行。(2) 两个内置变量:
${CLAUDE_SKILL_DIR}:当前 Skill 的目录,保证 python3 scripts/env-reconcile.py 不管 Skill 装在个人级还是项目级都能找到脚本;${CLAUDE_PROJECT_DIR}:项目根目录,与 hooks、MCP 收到的 CLAUDE_PROJECT_DIR 同义,保证扫描的是当前工程而非你启动 Claude 时的目录。(3) 渐进式披露:正文末尾把"各语言模式表"指向 reference.md,而不是塞进 SKILL.md。reference.md 只有在 Claude 觉得需要时才会读入,平时零成本。
把下面这段存为 scripts/env-reconcile.py。它覆盖 JS/TS、Python、Go、Ruby 四大生态的常见写法,仅用标准库:
#!/usr/bin/env python3
"""env-reconcile.py — 对账代码中的环境变量引用与 .env* 配置文件。"""
import re, sys
from pathlib import Path
# (标签, 正则) 每条正则只留一个真正用于取名的捕获组
PATTERNS = [
("process.env", re.compile(r"process\.env\.([A-Za-z_][A-Za-z0-9_]*)")),
("import.meta.env", re.compile(r"import\.meta\.env\.([A-Za-z_][A-Za-z0-9_]*)")),
("os.getenv", re.compile(r"os\.getenv\(\s*['\"]([A-Za-z_][A-Za-z0-9_]*)['\"]")),
("os.environ.get", re.compile(r"os\.environ\.get\(\s*['\"]([A-Za-z_][A-Za-z0-9_]*)['\"]")),
("os.environ[]", re.compile(r"os\.environ\[\s*['\"]([A-Za-z_][A-Za-z0-9_]*)['\"]")),
("ENV[...]", re.compile(r"ENV\[\s*['\"]([A-Za-z_][A-Za-z0-9_]*)['\"]|ENV\.fetch\(\s*['\"]([A-Za-z_][A-Za-z0-9_]*)['\"]")),
("os.Getenv", re.compile(r"os\.Getenv\(\s*['\"]([A-Za-z_][A-Za-z0-9_]*)['\"]")),
]
SCAN_EXT = {".js",".jsx",".ts",".tsx",".mjs",".cjs",".py",".go",".rb",".vue",".svelte"}
IGNORE = {".git","node_modules","__pycache__",".venv","venv","dist","build",".next",".turbo","coverage",".cache"}
ENV_FILES = [".env",".env.example",".env.sample",".env.schema",".env.template",".env.defaults"]
KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
def parse_env_files(root):
declared = {}
for name in ENV_FILES:
p = root / name
if not p.exists():
continue
for line in p.read_text(errors="ignore").splitlines():
s = line.strip()
if not s or s.startswith("#") or "=" not in s:
continue
key = s.split("=", 1)[0].strip()
if KEY_RE.match(key):
declared[key] = name
return declared
def scan_sources(root):
used = {}
for path in root.rglob("*"):
if not path.is_file() or path.suffix not in SCAN_EXT:
continue
if any(part in IGNORE for part in path.parts):
continue
try:
text = path.read_text(errors="ignore")
except Exception:
continue
rel = path.relative_to(root)
for i, line in enumerate(text.splitlines(), 1):
for label, pat in PATTERNS:
for m in pat.finditer(line):
key = next((g for g in m.groups() if g), None)
if key:
used.setdefault(key, []).append((str(rel), i, label))
return used
def main():
root = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path.cwd()
declared = parse_env_files(root)
used = scan_sources(root)
dk, uk = set(declared), set(used)
missing, unused, matched = sorted(uk - dk), sorted(dk - uk), sorted(uk & dk)
print("# 环境变量对账报告")
print(f"根目录: `{root}`")
print(f"声明 {len(dk)} 个 | 引用 {len(uk)} 个 | 已对账 {len(matched)} 个\n")
print("## 缺失变量(高危:代码引用了但 .env 中没有)")
if not missing:
print("(无)")
for k in missing:
locs = used[k][:3]
more = f" …另有{len(used[k])-3}处" if len(used[k]) > 3 else ""
print(f"- **{k}** @ " + ", ".join(f"{f}:{ln}({lab})" for f, ln, lab in locs) + more)
print("\n## 声明未用(警告:.env 里有但代码没引用)")
print("\n".join(f"- `{k}`(在 {declared[k]})" for k in unused) or "(无)")
print(f"\n## 已对账: {len(matched)} 个(" + ", ".join(matched[:10]) + ("…" if len(matched) > 10 else "") + ")")
if missing:
print("\n## 建议 .env.example 补齐片段")
for k in missing:
print(f"{k}= # 需确认:见 " + ", ".join(f"{f}:{ln}" for f, ln, _ in used[k][:2]))
if __name__ == "__main__":
main()
设计要点:脚本只做"对账"这一件事,输出纯 Markdown,不直接改文件。是否生成 .env.example 由 Claude 在正文授权后执行——把确定性计算交给脚本,把需要判断的决策交给模型,这是 Script Skill 的黄金分工。
在任一工程目录启动 claude,用两种方式验证:
方式 A:自动触发。直接说一句匹配 description 的话:
帮我对账一下这个项目的环境变量,看看 .env 有没有漏配的
Claude 应当自动加载 env-reconcile(因为 description 里有"环境变量对账"".env 缺失"),脚本执行后报告被注入,模型基于报告回答。
方式 B:手动触发:
/env-reconcile
两种方式都应得到三张表 + 修复建议。注意:
SKILL.md 或脚本即时生效(仅 SKILL.md 文本层面;若 Skill 同时是插件、改了 hooks/MCP,需 /reload-plugins)。/doctor 或看 /context 里的 Skills 行。Skill 不触发:① description 没包含用户会自然说的词——补关键词;② 问问 Claude "What skills are available?",看它在不在列表;③ 直接 /env-reconcile 排除是不是 frontmatter 解析挂了(--debug 看报错);④ 描述被预算裁剪——用 skillOverrides 把低优先级 Skill 设成 name-only 腾预算,或调大 skillListingBudgetFraction。
Skill 触发太频繁:① 把 description 写得更窄(加限定词,如"仅用于 Node/Python/Go/Ruby 工程");② 给动作型 Skill 加 disable-model-invocation: true,只留 / 手动触发。配置对账如果是"团队 CI 前必须跑一次"的流程,强烈建议加这条,免得 Claude 在你聊天时擅自扫一遍。
"Skill 能触发"不等于"该触发时触发、不该触发时不触发"。官方 skill-creator 插件把这个回归测试自动化了:
/plugin install skill-creator@claude-plugins-official
装完 /reload-plugins,然后:
用 skill-creator 评估我的 env-reconcile Skill
它会引导你写测试用例(该触发 / 不该触发的 prompt),每个用例起一个干净子代理独立跑,记录 token 与耗时,按断言判 pass/fail,最后输出 benchmark.json 做"有 Skill vs 无 Skill"的对比,还能盲测两个版本的 A/B。配置对账这种 Skill,典型该触发用例:"启动报 Cannot read undefined,怀疑环境变量没配";典型不该触发用例:"帮我看看这段排序算法对不对"。跑一轮就能发现 description 是不是把无关请求也招惹来了。
说明:上一轮教程已专门讲过"用 eval 基准打磨 Skill 触发准确率",这里只做衔接,不展开。
.claude/skills/env-reconcile/ 整个提交到 Git,同事拉代码后即用(首次需接受工作区信任对话框,因为项目级 Skill 的 allowed-tools 在信任后才生效);~/.claude/skills/;skills/ 目录做成 plugin,通过 marketplace 安装。准备一个最小混栈项目(Node + Python):
mkdir demo && cd demo
cat > app.js <<'EOF'
const db = process.env.DATABASE_URL;
const port = process.env.PORT;
const secret = process.env.JWT_SECRET; // 故意不在 .env
EOF
cat > worker.py <<'EOF'
import os
redis = os.getenv("REDIS_URL") # 故意不在 .env
EOF
cat > .env.example <<'EOF'
DATABASE_URL=postgres://localhost/app
PORT=3000
ORPHAN_KEY=leftover # 故意声明但没用
EOF
在这个目录里启动 claude,输入:
/env-reconcile
预期注入的报告(节选):
# 环境变量对账报告
根目录: `/path/to/demo`
声明 4 个 | 引用 4 个 | 已对账 2 个
## 缺失变量(高危:代码引用了但 .env 中没有)
- **JWT_SECRET** @ app.js:3(process.env)
- **REDIS_URL** @ worker.py:2(os.getenv)
## 声明未用(警告:.env 里有但代码没引用)
- `ORPHAN_KEY`(在 .env.example)
## 已对账: 2 个(DATABASE_URL, PORT)
## 建议 .env.example 补齐片段
JWT_SECRET= # 需确认:见 app.js:3
REDIS_URL= # 需确认:见 worker.py:2
Claude 据此给出高危清单、用途推断、修复片段。一次 /env-reconcile,把"上线时才发现 JWT_SECRET 是 undefined"这种事故拦在提交前。
flowchart TD
U[用户: 帮我对账环境变量] -->|自动匹配 description| S[env-reconcile Skill 激活]
U2[/手动输入 /env-reconcile/] --> S
S -->|预执行 ! 注入| P[scripts/env-reconcile.py]
P -->|扫描源码引用| P1[process.env / os.getenv / os.Getenv / ENV]
P -->|解析 .env*| P2[声明变量集合]
P1 --> R[(对账报告 Markdown)]
P2 --> R
R -->|输出替换占位符| C[Claude 收到已注入报告]
C --> A1[高危变量清单 + 行号]
C --> A2[声明未用提醒]
C --> A3[询问是否生成 .env.example 补齐片段]
A3 -->|用户确认| F[输出 KEY=默认 # 用途]
S -.按需加载.-> REF[reference.md 模式表]
code.claude.com/docs/en/skills——frontmatter 全字段、动态注入、子代理运行的最权威来源;agentskills.io,Claude Code 的 Skill 遵循的跨工具规范;code.claude.com/docs/en/best-practices——SKILL.md 与 CLAUDE.md 的取舍、上下文成本控制;context: fork 让 Skill 在隔离子代理里跑、hooks 字段把生命周期钩子挂到 Skill 上、skillOverrides 从 settings 控制可见性。env-reconcile;20 分钟跑实战案例与 eval;剩余时间留作"造你自己的对账型 Skill"练习。System.getenv、PHP getenv、Shell $VAR;② 增加 .env.schema 的类型/必填校验,把"建议默认值"升级为"类型不符告警";③ 给 Skill 加 disable-model-invocation: true,做成 CI 前的强制自检门禁。${CLAUDE_SKILL_DIR}、paths/allowed-tools/disable-model-invocation 用法、skill-creator eval 流程均出自此处。[!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 等场景全覆盖。
