首页 › tutorials › 项目健康检查Skill开发实战

概述

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

前置条件

核心概念: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/。本例用个人级:

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

得到结构:

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

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

步骤 3:编写 SKILL.md 的 frontmatter

创建 ~/.claude/skills/health-check/SKILL.md,开头是 YAML frontmatter:

---
description: 项目代码体检工具。扫描代码库,统计文件结构、挖掘技术债(TODO/FIXME/HACK)、识别超大文件与空目录,并生成可在浏览器打开的交互式 HTML 健康报告。当用户说"检查项目健康度""代码体检""有哪些 TODO""项目质量报告"时使用。
argument-hint: [目标目录,默认当前目录]
allowed-tools: Bash(python3 *)
---

几个关键点:

常见问题:装了很多 Skill 后,description 的总预算会拥挤,预算约为上下文窗口的 1%,溢出时"最少被调用的 Skill"描述会先被砍掉。用 /doctor 查看是否溢出;必要时把不重要的 Skill 在 skillOverrides 里设为 name-only 省额度。

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

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`

两处魔法:

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

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

这是真正的"士兵"。脚本只依赖 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>🐘 超大文件(&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,浏览器打开可见"文件卡片 + 类型分布条形图 + 技术债表格 + 大文件表格 + 空目录列表"。

常见问题:

步骤 6:测试两种触发方式

在目标仓库里启动 claude:

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

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

手动触发——直接点名,并可带目录参数:

/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。

完整工作流程图

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}"后,可快速派生:

模式都一样:SKILL.md 当指挥官,scripts/ 放士兵,allowed-tools 免审批,description 写好触发词。

训练营适用性评估

维度 评估
适合的训练营 Claude Code 进阶营、DevOps 自动化营、研发效能提升营
学员基础 需要会基本 Python 与命令行,能独立读懂 YAML
教学时长 讲授 30 分钟 + 动手 45 分钟 + 排障演练 15 分钟,合计约 90 分钟
验收标准 学员能独立产出一个可运行、带捆绑脚本、可自动触发并生成 HTML 的 Skill
常见踩坑 description 截断、${CLAUDE_SKILL_DIR} 漏写、! 紧跟字符不执行、项目级 Skill 未过信任对话框

延伸阅读

参考来源

  1. Extend Claude with skills — Claude Code Docs — 官方权威文档,本文 frontmatter 字段表、动态注入 !`cmd`、${CLAUDE_SKILL_DIR}、实时变更检测、codebase-visualizer 范例均出自此处
  2. Equipping agents for the real world with Agent Skills — Anthropic Engineering — 官方设计理念,解释渐进披露三层结构(描述/正文/捆绑文件)与"模型指挥官"模式
  3. Inside Claude Code Skills: Structure, prompts, invocation — Mikhail.io — 第三方对 SKILL.md 注入时机与上下文生命周期的拆解,佐证"描述常驻、正文按需加载"机制
  4. Claude Code Skills Documentation: The Complete Developer Reference — EvoMap — 补充启动时预加载 name/description 进系统提示、技能预算溢出处理等工程细节
  5. Agent Skills open standard — 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学通信知识星球海报-扫码加入