本教程手把手构建一个名为 health-check 的真实 Skill:它能扫描任意代码库、统计文件结构、挖出技术债(TODO/FIXME)、识别大文件与空目录,最后生成一份可在浏览器打开的交互式 HTML 报告。重点是掌握 Claude Code Skill 最强大却最少被讲透的两个能力——捆绑可执行脚本(bundled scripts)与动态上下文注入(!`cmd`)。适合已能跑通 claude 命令、想把自己的重复工作流固化成工具的开发者,预计 60–90 分钟完成。
${CLAUDE_SKILL_DIR}、allowed-tools 均依赖较新版本)python3 --version 与 git --version很多人把 Skill 等同于"一段 Markdown 提示词"。这只对了一半。Skill 的真正威力来自三层结构:
| 层 | 载体 | 何时进入上下文 | 典型用途 |
|---|---|---|---|
| 描述 | frontmatter 的 description |
会话全程常驻 | 让模型决定是否激活 |
| 正文 | SKILL.md 正文 |
被调用时一次性注入 | 给出步骤式指令 |
| 捆绑文件 | scripts/、reference.md 等 |
按需由模型读取或执行 | 重计算、长文档、可执行逻辑 |
当一个任务需要"实际跑代码、读真实数据、产出文件"时,把逻辑写进 Python/Bash 脚本,让 Skill 像指挥官一样调用它,远比让模型用自然语言硬算可靠得多。本次的 health-check 就是这个"指挥官+士兵"模式的完整范例。
动手前先回答三个问题,它们直接决定 frontmatter 怎么写:
/health-check 显式调用,也希望模型在用户说"这项目健康吗"时自动激活。→ 默认双通道,不加 disable-model-invocation。allowed-tools 预授权,避免每次弹权限框。argument-hint + $ARGUMENTS。常见问题:如果你把"自动触发"也关掉(disable-model-invocation: true),那么只有 /health-check 能启动它,自然语言不会激活——部署类有副作用的操作才该这样。
个人级 Skill 放在 ~/.claude/skills/ 下,对所有项目生效;项目级则放仓库的 .claude/skills/。本例用个人级:
mkdir -p ~/.claude/skills/health-check/scripts
得到结构:
health-check/
├── SKILL.md # 入口(必需)
└── scripts/
└── audit.py # 真正干活的脚本
预期结果:目录建好后无需重启 Claude Code——官方支持实时变更检测,会话内增删改 SKILL.md 即时生效。但若你新建的是一个"会话启动时不存在的顶层 skills 目录",则需要重启一次。
创建 ~/.claude/skills/health-check/SKILL.md,开头是 YAML frontmatter:
---
description: 项目代码体检工具。扫描代码库,统计文件结构、挖掘技术债(TODO/FIXME/HACK)、识别超大文件与空目录,并生成可在浏览器打开的交互式 HTML 健康报告。当用户说"检查项目健康度""代码体检""有哪些 TODO""项目质量报告"时使用。
argument-hint: [目标目录,默认当前目录]
allowed-tools: Bash(python3 *)
---
几个关键点:
description 第一句写"做什么",后面补"何时用"。系统会把它和 when_to_use 合并,超过 1536 字符会被截断,所以把最关键的触发词放最前面。allowed-tools: Bash(python3 *) 让模型跑 Python 时不再逐次询问;它不限制模型只能用这些工具,只是免审批。disable-model-invocation,于是模型可在相关对话中自动加载,用户也能 /health-check 手动调用。常见问题:装了很多 Skill 后,description 的总预算会拥挤,预算约为上下文窗口的 1%,溢出时"最少被调用的 Skill"描述会先被砍掉。用 /doctor 查看是否溢出;必要时把不重要的 Skill 在 skillOverrides 里设为 name-only 省额度。
frontmatter 之后是 Markdown 正文。这里有两处关键语法:
# 项目健康检查
对当前项目(或参数指定的目录)做一次代码体检,产出交互式 HTML 报告。
## 执行步骤
1. 运行体检脚本,目录优先取参数,否则取当前目录:
```bash
python3 ${CLAUDE_SKILL_DIR}/scripts/audit.py "${1:-.}"
```
2. 脚本在当前目录生成 `health-report.html`。
3. 用一句话告知报告路径,并按严重度口头总结最值得先处理的 3 项。
## 当前工作树(动态注入,仅供你参考)
- 分支与改动:!`git rev-parse --abbrev-ref HEAD 2>/dev/null; git status --short 2>/dev/null | head -15`
- 未提交文件数:!`git status --short 2>/dev/null | wc -l`
两处魔法:
${CLAUDE_SKILL_DIR}:无论 Skill 装在个人级、项目级还是插件级,都解析到本 Skill 目录,保证脚本路径正确。这是捆绑脚本能跨位置工作的核心。!`命令`:动态上下文注入。Claude Code 在把正文交给模型之前,会先执行该命令,把输出替换进正文。模型看到的是已经填好的真实数据,而不是命令本身。注意 ! 必须出现在行首或空格之后;像 KEY=!cmd 这种紧跟字符的写法不会被执行。常见问题:注入是"一次性预处理",命令输出不会被再次扫描占位符,所以一个命令不能"吐出"另一个占位符让第二轮展开。需要多步执行时,写成脚本里完成更稳。
这是真正的"士兵"。脚本只依赖 Python 标准库,扫描目录、收集指标、生成自包含 HTML:
#!/usr/bin/env python3
"""Project health-check: scan a codebase and emit an interactive HTML report."""
import sys, os, html
from pathlib import Path
from collections import Counter
IGNORE_DIRS = {'.git', 'node_modules', '__pycache__', '.venv', 'venv',
'dist', 'build', '.next', 'target', '.idea', '.vscode'}
IGNORE_EXT = {'.lock', '.map', '.min.js', '.min.css'}
DEBT_TOKENS = ('TODO', 'FIXME', 'HACK', 'XXX')
LARGE_BYTES = 100 * 1024 # 100KB 阈值
MAX_DEBT = 500 # 技术债条目上限,防止超大仓库爆内存
def walk(root: Path):
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in IGNORE_DIRS and not d.startswith('.')]
empty = not filenames and not dirnames
yield Path(dirpath), filenames, empty
def main():
root = Path(sys.argv[1] if len(sys.argv) > 1 else '.').resolve()
if not root.is_dir():
print(f'[error] not a directory: {root}', file=sys.stderr); sys.exit(1)
ext_count = Counter(); ext_size = Counter()
large_files = []; empty_dirs = []; debt = []; total = 0
for dpath, files, empty in walk(root):
if empty and dpath != root:
empty_dirs.append(dpath.relative_to(root))
for fn in files:
f = dpath / fn
try: size = f.stat().st_size
except OSError: continue
total += size
ext = f.suffix.lower() or '(无扩展名)'
ext_count[ext] += 1; ext_size[ext] += size
if size > LARGE_BYTES:
large_files.append((f.relative_to(root), size))
if ext in ('.py', '.js', '.ts', '.tsx', '.jsx', '.go', '.rs',
'.java', '.c', '.cpp', '.h', '.rb', '.php', '.vue', '.sh'):
try: scan_debt(f, root, debt)
except (OSError, UnicodeDecodeError): pass
if len(debt) > MAX_DEBT: break
write_html(root, ext_count, ext_size, large_files, empty_dirs, debt, total)
print(f'[ok] -> {Path("health-report.html").resolve()}')
def scan_debt(f, root, debt):
for i, line in enumerate(f.read_text(encoding='utf-8', errors='ignore').splitlines(), 1):
up = line.upper()
for tok in DEBT_TOKENS:
if tok in up:
debt.append((tok, f.relative_to(root), i, line.strip()[:120]))
return # 每行只记第一个命中,避免噪声
def write_html(root, ext_count, ext_size, large_files, empty_dirs, debt, total):
def kb(b): return f'{b/1024:.1f} KB' if b < 1048576 else f'{b/1048576:.2f} MB'
top = ext_size.most_common(8)
maxsz = top[0][1] if top else 1
palette = ['#3776ab','#3178c6','#f7df1e','#00add8','#dea584','#cc342d','#264de4','#6b7280']
bars = ''.join(
f'<div style="margin:6px 0"><span style="font-family:monospace">{html.escape(e)}</span>'
f'<div style="background:{palette[i%8]};width:{sz/maxsz*100:.1f}%;height:14px"></div>'
f'<small>{ext_count[e]} 个 · {kb(sz)}</small></div>'
for i,(e,sz) in enumerate(top))
debt_rows = ''.join(
f'<tr><td><b>{t}</b></td><td style="font-family:monospace">{html.escape(str(p))}</td>'
f'<td>{ln}</td><td>{html.escape(msg)}</td></tr>'
for t,p,ln,msg in sorted(debt, key=lambda x: ['TODO','FIXME','HACK','XXX'].index(x[0]))[:300]) or '<tr><td colspan=4>无</td></tr>'
large_rows = ''.join(
f'<tr><td style="font-family:monospace">{html.escape(str(p))}</td><td>{kb(sz)}</td></tr>'
for p,sz in sorted(large_files, key=lambda x:-x[1])[:50]) or '<tr><td colspan=2>无</td></tr>'
empty_list = ''.join(f'<li style="font-family:monospace">{html.escape(str(d))}/</li>'
for d in sorted(empty_dirs)[:50]) or '<li>无</li>'
doc = f'''<!doctype html><html lang="zh"><head><meta charset="utf-8">
<title>健康报告 · {html.escape(root.name)}</title>
<style>body{{font-family:-apple-system,Segoe UI,sans-serif;margin:24px;color:#1a1a1a}}
h1{{font-size:20px}} .cards{{display:flex;gap:12px;flex-wrap:wrap;margin:16px 0}}
.card{{background:#f5f7fa;border-radius:8px;padding:14px 18px;min-width:120px}}
.card b{{font-size:22px;display:block}} table{{border-collapse:collapse;width:100%;margin:8px 0}}
td,th{{border:1px solid #e3e8ef;padding:6px 10px;text-align:left;font-size:13px}}
th{{background:#f5f7fa}} code{{background:#eef;padding:1px 4px;border-radius:3px}}</style></head>
<body><h1>🏥 项目健康报告</h1><p>扫描根目录:<code>{html.escape(str(root))}</code></p>
<div class="cards">
<div class="card"><b>{sum(ext_count.values())}</b>文件总数</div>
<div class="card"><b>{kb(total)}</b>总体积</div>
<div class="card"><b>{len(debt)}</b>技术债标记</div>
<div class="card"><b>{len(large_files)}</b>超大文件</div>
<div class="card"><b>{len(empty_dirs)}</b>空目录</div>
</div>
<h2>📊 按类型体积分布(Top 8)</h2>{bars}
<h2>⚠️ 技术债(TODO/FIXME/HACK/XXX)</h2>
<table><tr><th>类型</th><th>文件</th><th>行</th><th>内容</th></tr>{debt_rows}</table>
<h2>🐘 超大文件(>100KB)</h2>
<table><tr><th>文件</th><th>体积</th></tr>{large_rows}</table>
<h2>🕳️ 空目录</h2><ul>{empty_list}</ul></body></html>'''
Path('health-report.html').write_text(doc, encoding='utf-8')
if __name__ == '__main__':
main()
预期结果:在任意仓库根目录执行 python3 ~/.claude/skills/health-check/scripts/audit.py .,会生成 health-report.html,浏览器打开可见"文件卡片 + 类型分布条形图 + 技术债表格 + 大文件表格 + 空目录列表"。
常见问题:
IGNORE_DIRS 与 MAX_DEBT=500 上限,对超大仓库仍可能偏慢,可把 walk 改为先用 os.scandir 限制深度。read_text(errors='ignore') 已兜底,HTML 也声明了 utf-8,正常情况不会乱。permissions 是否允许 Bash(python3 *);项目级 Skill 需先通过工作区信任对话框。在目标仓库里启动 claude:
自动触发——用自然语言贴近 description 的关键词:
帮我对这个项目做一次代码体检,重点看技术债。
手动触发——直接点名,并可带目录参数:
/health-check ./packages/backend
两种方式模型都会:执行注入命令拿到 git 概览 → 运行 audit.py → 读到脚本 [ok] -> .../health-report.html 输出 → 向你汇报路径并口头总结前三问题。
常见问题:自动触发没反应?多半是 description 关键词和你的说法不重合。把用户真实会说的短语("代码体检""有哪些 TODO")写进描述;也可临时用 /health-check 验证 Skill 本身没问题。
若希望生成后自动在浏览器打开,把 audit.py 末尾的 print 前加一行 import webbrowser; webbrowser.open(f'file://{out.absolute()}'),并在 frontmatter 的 allowed-tools 追加 Bash(open *)(macOS)或交给模型用 webbrowser。注意跨平台差异:Linux 用 xdg-open,Windows 用 start。
flowchart TD
A[用户: 帮我体检这个项目] --> B{description 是否匹配?}
B -- 是 --> C[Claude Code 决定激活 Skill]
B -- 否/手动 --> Z[/health-check dir/]
Z --> C
C --> D[注入 !git status 等真实数据]
D --> E[把 SKILL.md 正文交给模型]
E --> F[模型解析 ${CLAUDE_SKILL_DIR}]
F --> G[执行 audit.py 目标目录]
G --> H[脚本扫描并写 health-report.html]
H --> I[模型读取脚本输出]
I --> J[口头总结前三问题 + 报告路径]
style C fill:#e3f2fd
style G fill:#fff3e0
style H fill:#fff3e0
关键分工一目了然:模型负责理解意图与汇报,脚本负责重度计算与产出文件——这正是 Skill 区别于纯提示词的价值所在。
掌握"捆绑脚本 + 注入 + ${CLAUDE_SKILL_DIR}"后,可快速派生:
api-coverage:脚本解析 OpenAPI/Swagger JSON,比对实际代码调用点,生成接口覆盖率 HTML。log-triager:脚本扫描日志目录,按 ERROR/WARN 聚类,注入 !tail 摘要,输出故障画像。dep-audit:脚本读 package.json/requirements.txt,对照已知漏洞库本地清单,产出依赖风险表。模式都一样:SKILL.md 当指挥官,scripts/ 放士兵,allowed-tools 免审批,description 写好触发词。
| 维度 | 评估 |
|---|---|
| 适合的训练营 | Claude Code 进阶营、DevOps 自动化营、研发效能提升营 |
| 学员基础 | 需要会基本 Python 与命令行,能独立读懂 YAML |
| 教学时长 | 讲授 30 分钟 + 动手 45 分钟 + 排障演练 15 分钟,合计约 90 分钟 |
| 验收标准 | 学员能独立产出一个可运行、带捆绑脚本、可自动触发并生成 HTML 的 Skill |
| 常见踩坑 | description 截断、${CLAUDE_SKILL_DIR} 漏写、! 紧跟字符不执行、项目级 Skill 未过信任对话框 |
!`cmd`、${CLAUDE_SKILL_DIR}、实时变更检测、codebase-visualizer 范例均出自此处[!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 等场景全覆盖。
