<!-- title: 项目健康检查Skill开发实战 -->

## 概述

本教程手把手构建一个名为 `health-check` 的真实 Skill：它能扫描任意代码库、统计文件结构、挖出技术债（TODO/FIXME）、识别大文件与空目录，最后生成一份可在浏览器打开的交互式 HTML 报告。重点是掌握 Claude Code Skill 最强大却最少被讲透的两个能力——**捆绑可执行脚本（bundled scripts）**与**动态上下文注入（`` !`cmd` ``）**。适合已能跑通 `claude` 命令、想把自己的重复工作流固化成工具的开发者，预计 60–90 分钟完成。

## 前置条件

- Claude Code v2.1.145 及以上（动态注入、`${CLAUDE_SKILL_DIR}`、`allowed-tools` 均依赖较新版本）
- Python 3.8+（体检脚本只用标准库，无需 pip 安装任何包）
- 一个任意的本地代码仓库，用于验收效果
- 终端能正常执行 `python3 --version` 与 `git --version`

## 核心概念：Skill 为什么需要"脚本"

很多人把 Skill 等同于"一段 Markdown 提示词"。这只对了一半。Skill 的真正威力来自三层结构：

| 层 | 载体 | 何时进入上下文 | 典型用途 |
|---|---|---|---|
| 描述 | frontmatter 的 `description` | 会话全程常驻 | 让模型决定是否激活 |
| 正文 | `SKILL.md` 正文 | 被调用时一次性注入 | 给出步骤式指令 |
| 捆绑文件 | `scripts/`、`reference.md` 等 | 按需由模型读取或执行 | 重计算、长文档、可执行逻辑 |

当一个任务需要"实际跑代码、读真实数据、产出文件"时，把逻辑写进 Python/Bash 脚本，让 Skill 像指挥官一样调用它，远比让模型用自然语言硬算可靠得多。本次的 `health-check` 就是这个"指挥官+士兵"模式的完整范例。

## 步骤 1：确定 Skill 的调用方式

动手前先回答三个问题，它们直接决定 frontmatter 怎么写：

1. **谁来触发？** 健康检查属于"主动体检"，既希望用户敲 `/health-check` 显式调用，也希望模型在用户说"这项目健康吗"时自动激活。→ 默认双通道，不加 `disable-model-invocation`。
2. **需要什么权限？** 要跑 Python 脚本、要读 git 状态。→ 用 `allowed-tools` 预授权，避免每次弹权限框。
3. **需要参数吗？** 希望能指定目录。→ 用 `argument-hint` + `$ARGUMENTS`。

**常见问题**：如果你把"自动触发"也关掉（`disable-model-invocation: true`），那么只有 `/health-check` 能启动它，自然语言不会激活——部署类有副作用的操作才该这样。

## 步骤 2：建立目录结构

个人级 Skill 放在 `~/.claude/skills/` 下，对所有项目生效；项目级则放仓库的 `.claude/skills/`。本例用个人级：

```bash
mkdir -p ~/.claude/skills/health-check/scripts
```

得到结构：

```text
health-check/
├── SKILL.md          # 入口（必需）
└── scripts/
    └── audit.py      # 真正干活的脚本
```

**预期结果**：目录建好后无需重启 Claude Code——官方支持**实时变更检测**，会话内增删改 `SKILL.md` 即时生效。但若你新建的是一个"会话启动时不存在的顶层 skills 目录"，则需要重启一次。

## 步骤 3：编写 SKILL.md 的 frontmatter

创建 `~/.claude/skills/health-check/SKILL.md`，开头是 YAML frontmatter：

```yaml
---
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` 省额度。

## 步骤 4：编写正文与动态上下文注入

frontmatter 之后是 Markdown 正文。这里有两处关键语法：

```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` 这种紧跟字符的写法不会被执行。

**常见问题**：注入是"一次性预处理"，命令输出不会被再次扫描占位符，所以一个命令不能"吐出"另一个占位符让第二轮展开。需要多步执行时，写成脚本里完成更稳。

## 步骤 5：编写捆绑脚本 audit.py

这是真正的"士兵"。脚本只依赖 Python 标准库，扫描目录、收集指标、生成自包含 HTML：

```python
#!/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>🐘 超大文件（&gt;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`，正常情况不会乱。
- 权限被拒：检查 Claude Code 的 `permissions` 是否允许 `Bash(python3 *)`；项目级 Skill 需先通过工作区信任对话框。

## 步骤 6：测试两种触发方式

在目标仓库里启动 `claude`：

**自动触发**——用自然语言贴近 `description` 的关键词：

```text
帮我对这个项目做一次代码体检，重点看技术债。
```

**手动触发**——直接点名，并可带目录参数：

```text
/health-check ./packages/backend
```

两种方式模型都会：执行注入命令拿到 git 概览 → 运行 `audit.py` → 读到脚本 `[ok] -> .../health-report.html` 输出 → 向你汇报路径并口头总结前三问题。

**常见问题**：自动触发没反应？多半是 `description` 关键词和你的说法不重合。把用户真实会说的短语（"代码体检""有哪些 TODO"）写进描述；也可临时用 `/health-check` 验证 Skill 本身没问题。

## 步骤 7：让报告自动打开（进阶）

若希望生成后自动在浏览器打开，把 `audit.py` 末尾的 `print` 前加一行 `import webbrowser; webbrowser.open(f'file://{out.absolute()}')`，并在 frontmatter 的 `allowed-tools` 追加 `Bash(open *)`（macOS）或交给模型用 `webbrowser`。注意跨平台差异：Linux 用 `xdg-open`，Windows 用 `start`。

## 完整工作流程图

```text
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 未过信任对话框 |

## 延伸阅读

- Claude Code 官方 Skills 文档（frontmatter 全字段、子代理、动态注入）：https://code.claude.com/docs/en/skills
- Anthropic 工程博客《Equipping agents for the real world with Agent Skills》：https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills
- Agent Skills 开放标准（跨工具可移植）：https://agentskills.io
- 第三方深度解析《Inside Claude Code Skills: Structure, prompts, invocation》：https://mikhail.io/2025/10/claude-code-skills/
- Skills 开发者完整参考（含目录结构与 API）：https://evomap.ai/blog/claude-code-skills-developer-reference

## 参考来源

1. [Extend Claude with skills — Claude Code Docs](https://code.claude.com/docs/en/skills) — 官方权威文档，本文 frontmatter 字段表、动态注入 `` !`cmd` ``、`${CLAUDE_SKILL_DIR}`、实时变更检测、codebase-visualizer 范例均出自此处
2. [Equipping agents for the real world with Agent Skills — Anthropic Engineering](https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills) — 官方设计理念，解释渐进披露三层结构（描述/正文/捆绑文件）与"模型指挥官"模式
3. [Inside Claude Code Skills: Structure, prompts, invocation — Mikhail.io](https://mikhail.io/2025/10/claude-code-skills/) — 第三方对 SKILL.md 注入时机与上下文生命周期的拆解，佐证"描述常驻、正文按需加载"机制
4. [Claude Code Skills Documentation: The Complete Developer Reference — EvoMap](https://evomap.ai/blog/claude-code-skills-developer-reference) — 补充启动时预加载 name/description 进系统提示、技能预算溢出处理等工程细节
5. [Agent Skills open standard — agentskills.io](https://agentskills.io) — Skill 跨平台开放标准，说明 2025-12-18 后 SKILL.md 的可移植性约束

> [!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)
