Claude Skill

skill-link-check

Audit project and global .agents/skills and .claude/skills layouts. Verify that .agents/skills contains the real source and .claude/skills mirrors it through a parent or per-skill symlink. Use whenever a skill or slash command is missing, not loading, duplicated, inconsistent, or

LLM Mart · 0 points · 13 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download yan-labs-yan-skills-skill-link-check-7a2c666.zip · 6 KB
Part of yan-labs/yan-skills — 5 skills

Install

skills CLI npx skills add https://github.com/yan-labs/yan-skills/tree/main/skill-link-check
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install yan-labs-yan-skills@llmmart
Git git clone https://github.com/yan-labs/yan-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole yan-labs/yan-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Skill Link Check

安装与更新

来源:Skills.sh

# 首次全局安装,或更新失败时重新安装
npx skills add yan-labs/yan-skills --skill skill-link-check -g -y

# 更新已安装的全局 Skill
npx skills update skill-link-check -g -y

若使用项目级安装,去掉安装命令中的 -g;项目级更新使用 npx skills update skill-link-check -p -y。

本 Skill 只审计目录布局,不自动移动、删除或覆盖文件:

  • .agents/skills/<name>/ 保存真实源文件。
  • .claude/skills 要么整体链接到 .agents/skills,要么通过逐个子项链接镜像它。

最常见的漂移是 Skill 被直接创建成 .claude/skills/<name>/ 真实目录,却没有进入 .agents/skills/。它看似已安装,实际不会随正常备份、同步或迁移流程保存。

Goal Contract

在 /goal、autopilot 或其他持续执行器中,本 Skill 的完成条件是“审计证据完整”,不是“退出码必须为 0”。发现问题是有效结果,不能为了让检查通过而擅自修复。

<goal>审计所有适用作用域,判定 Skill 源目录与运行时镜像是否一致,并为每个问题提供可复核证据和修复命令。</goal>
<gate>检查脚本已从用户目标项目运行;每个适用作用域都有布局模式、问题分类和退出状态。</gate>
<done-when>无问题时明确报告 clean;有问题时完整列出数量、类别、路径和建议命令;未自动修改任何被审计目录。</done-when>

因此:

  • 退出码 0:审计完成且没有发现问题。
  • 退出码 1:审计完成且发现问题,不代表 Skill 执行失败。
  • Python traceback、参数错误或无法读取目标:才属于执行失败,需要修复后重跑。

运行方式

python3 "$(dirname "$0")/check.py"

默认审计:

  • Project:当前目录下的 .agents/skills 与 .claude/skills。
  • Global:$HOME 下的同名目录。

自动化或 checker 可使用:

# 明确指定项目,避免依赖当前工作目录
python3 "$(dirname "$0")/check.py" --project-root /path/to/project

# 只查一个作用域
python3 "$(dirname "$0")/check.py" --project-only
python3 "$(dirname "$0")/check.py" --global-only

# 输出稳定 JSON 证据;发现问题时仍返回 1
python3 "$(dirname "$0")/check.py" --json

两种合法布局

两种模式都应通过:

  1. Parent symlink:.claude/skills 本身指向 .agents/skills。新增 Skill 自动保持一致。
  2. Per-child symlinks:.claude/skills 是真实目录,每个 .claude/skills/<name> 指向 ../../.agents/skills/<name>。

脚本会自动识别模式,不要求为了统一风格而改造一个本来健康的布局。

问题分类

  • orphan-in-claude:.claude/skills/ 中有真实条目,但 .agents/skills/ 没有对应源文件。报告时优先列出。
  • missing-link:源 Skill 存在,但运行时镜像缺失。
  • not-symlink:逐子项模式下,镜像位置是重复的真实文件或目录。
  • broken-symlink:链接目标不存在,包括损坏的父级链接。
  • wrong-target:链接存在,但指向错误 Skill 或 .agents/skills 之外。

报告规则

  1. 先给总问题数和分类计数;有 orphan-in-claude 时先报告它。
  2. 每项给出名称、证据路径和一行解释。
  3. 原样附上脚本生成的建议修复命令,便于用户复核后执行。
  4. 不自动修复。孤儿目录可能是用户尚未迁移的工作,重复目录也可能已经分叉;自动移动或删除会造成数据损失。
  5. 如果全部健康,一句话说明适用作用域及布局模式即可结束。

验证 Skill 自身

python3 -m unittest discover -s "$(dirname "$0")/tests" -v
Files (yan-skills)
  • tests
    • test_check.py 3.3 KB
      from __future__ import annotations
      
      import importlib.util
      import json
      import subprocess
      import sys
      import tempfile
      import unittest
      from pathlib import Path
      
      
      SKILL_DIR = Path(__file__).resolve().parents[1]
      SCRIPT = SKILL_DIR / "check.py"
      SPEC = importlib.util.spec_from_file_location("skill_link_check", SCRIPT)
      MODULE = importlib.util.module_from_spec(SPEC)
      assert SPEC.loader is not None
      sys.modules[SPEC.name] = MODULE
      SPEC.loader.exec_module(MODULE)
      
      
      class SkillLinkCheckTests(unittest.TestCase):
          def test_parent_symlink_layout_is_clean(self) -> None:
              with tempfile.TemporaryDirectory() as tmp:
                  root = Path(tmp)
                  agents = root / ".agents" / "skills"
                  agents.mkdir(parents=True)
                  (agents / "demo").mkdir()
                  (root / ".claude").mkdir()
                  (root / ".claude" / "skills").symlink_to(agents)
      
                  result = MODULE.audit_scope("Project", root)
      
                  self.assertIsNotNone(result)
                  self.assertEqual("parent-symlink", result.mode)
                  self.assertEqual([], result.issues)
      
          def test_per_child_layout_reports_all_core_issue_types(self) -> None:
              with tempfile.TemporaryDirectory() as tmp:
                  root = Path(tmp)
                  agents = root / ".agents" / "skills"
                  claude = root / ".claude" / "skills"
                  agents.mkdir(parents=True)
                  claude.mkdir(parents=True)
                  for name in ("missing", "duplicate", "broken", "wrong"):
                      (agents / name).mkdir()
                  (claude / "duplicate").mkdir()
                  (claude / "orphan").mkdir()
                  (claude / "broken").symlink_to("../../.agents/skills/nope")
                  outside = root / "outside"
                  outside.mkdir()
                  (claude / "wrong").symlink_to(outside)
      
                  result = MODULE.audit_scope("Project", root)
                  kinds = {issue.kind for issue in result.issues}
      
                  self.assertEqual(
                      {"missing-link", "not-symlink", "orphan-in-claude", "broken-symlink", "wrong-target"},
                      kinds,
                  )
      
          def test_broken_parent_symlink_is_not_silently_clean(self) -> None:
              with tempfile.TemporaryDirectory() as tmp:
                  root = Path(tmp)
                  (root / ".claude").mkdir()
                  (root / ".claude" / "skills").symlink_to("../missing-skills")
      
                  result = MODULE.audit_scope("Project", root)
      
                  self.assertEqual("parent-symlink-broken", result.mode)
                  self.assertEqual(["broken-symlink"], [issue.kind for issue in result.issues])
      
          def test_json_mode_is_machine_readable_and_nonzero_on_findings(self) -> None:
              with tempfile.TemporaryDirectory(prefix="skill link check ") as tmp:
                  root = Path(tmp)
                  (root / ".agents" / "skills" / "demo").mkdir(parents=True)
      
                  completed = subprocess.run(
                      [sys.executable, str(SCRIPT), "--project-root", str(root), "--project-only", "--json"],
                      check=False,
                      capture_output=True,
                      text=True,
                  )
                  payload = json.loads(completed.stdout)
      
                  self.assertEqual(1, completed.returncode)
                  self.assertFalse(payload["ok"])
                  self.assertEqual(1, payload["total_issues"])
                  self.assertIn("'", payload["scopes"][0]["issues"][0]["fix"])
      
      
      if __name__ == "__main__":
          unittest.main()
      
  • check.py 12 KB
    #!/usr/bin/env python3
    """Audit .agents/skills vs .claude/skills consistency."""
    from __future__ import annotations
    
    import argparse
    import json
    import os
    import shlex
    import sys
    from collections import Counter
    from dataclasses import asdict, dataclass, field
    from pathlib import Path
    
    
    @dataclass
    class Issue:
        kind: str
        name: str
        detail: str
        fix: str
    
    
    @dataclass
    class ScopeResult:
        label: str
        root: Path
        agents: Path
        claude: Path
        mode: str = ""
        issues: list[Issue] = field(default_factory=list)
        n_agents_skills: int = 0
        n_claude_entries: int = 0
    
    
    def _q(path: Path) -> str:
        return shlex.quote(str(path))
    
    
    def _children(path: Path) -> dict[str, Path]:
        if not path.is_dir():
            return {}
        return {
            child.name: child
            for child in path.iterdir()
            if not child.name.startswith(".")
        }
    
    
    def _describe(path: Path) -> str:
        if path.is_symlink():
            target = os.readlink(path)
            suffix = " (broken)" if not path.exists() else ""
            return f"symlink -> {target}{suffix}"
        if path.is_dir():
            return "real directory"
        if path.exists():
            return "exists but not a directory"
        return "not present"
    
    
    def _relative_child_target(name: str) -> str:
        return f"../../.agents/skills/{name}"
    
    
    def audit_scope(label: str, root: Path) -> ScopeResult | None:
        root = root.expanduser().resolve()
        agents = root / ".agents" / "skills"
        claude = root / ".claude" / "skills"
        agents_present = agents.is_symlink() or agents.exists()
        claude_present = claude.is_symlink() or claude.exists()
        if not agents_present and not claude_present:
            return None
    
        result = ScopeResult(label=label, root=root, agents=agents, claude=claude)
    
        if not agents_present:
            if claude.is_symlink() and not claude.exists():
                result.mode = "parent-symlink-broken"
                result.issues.append(Issue(
                    kind="broken-symlink",
                    name="<.claude/skills>",
                    detail=f"{claude} -> {os.readlink(claude)} (target missing)",
                    fix=(
                        f"rm {_q(claude)}\n"
                        f"  mkdir -p {_q(agents)}\n"
                        f"  ln -s {_q(agents)} {_q(claude)}"
                    ),
                ))
                return result
    
            result.mode = "no-agents"
            claude_children = _children(claude)
            result.n_claude_entries = len(claude_children)
            for name, child in sorted(claude_children.items()):
                destination = agents / name
                result.issues.append(Issue(
                    kind="orphan-in-claude",
                    name=name,
                    detail=f"{child} has no source under {agents}",
                    fix=(
                        f"mkdir -p {_q(agents)}\n"
                        f"  mv {_q(child)} {_q(destination)}\n"
                        f"  ln -s {shlex.quote(_relative_child_target(name))} {_q(child)}"
                    ),
                ))
            return result
    
        agents_children = _children(agents)
        result.n_agents_skills = len(agents_children)
    
        if not claude_present:
            result.mode = "no-claude"
            for name in sorted(agents_children):
                mirror = claude / name
                result.issues.append(Issue(
                    kind="missing-link",
                    name=name,
                    detail=f"{agents / name} has no counterpart in {claude}",
                    fix=(
                        f"mkdir -p {_q(claude)}\n"
                        f"  ln -s {shlex.quote(_relative_child_target(name))} {_q(mirror)}"
                    ),
                ))
            return result
    
        if claude.is_symlink():
            try:
                resolved = claude.resolve(strict=True)
            except (FileNotFoundError, OSError):
                resolved = None
            expected = agents.resolve()
            if resolved == expected:
                result.mode = "parent-symlink"
                result.n_claude_entries = result.n_agents_skills
                return result
            kind = "broken-symlink" if resolved is None else "wrong-target"
            result.mode = f"parent-symlink-{kind}"
            detail = f"{claude} -> {os.readlink(claude)}"
            if resolved is None:
                detail += " (target missing)"
            else:
                detail += f" (resolves to {resolved}, expected {expected})"
            result.issues.append(Issue(
                kind=kind,
                name="<.claude/skills>",
                detail=detail,
                fix=f"rm {_q(claude)}\n  ln -s {_q(agents)} {_q(claude)}",
            ))
            return result
    
        result.mode = "per-child"
        claude_children = _children(claude)
        result.n_claude_entries = len(claude_children)
    
        for name in sorted(set(agents_children) | set(claude_children)):
            in_agents = name in agents_children
            in_claude = name in claude_children
    
            if in_agents and not in_claude:
                mirror = claude / name
                result.issues.append(Issue(
                    kind="missing-link",
                    name=name,
                    detail=f"{agents / name} has no symlink in {claude}",
                    fix=f"ln -s {shlex.quote(_relative_child_target(name))} {_q(mirror)}",
                ))
                continue
    
            mirror = claude_children[name]
    
            if in_claude and not in_agents:
                if mirror.is_symlink():
                    target = os.readlink(mirror)
                    if not mirror.exists():
                        result.issues.append(Issue(
                            kind="broken-symlink",
                            name=name,
                            detail=f"{mirror} -> {target} (target missing, no source in .agents)",
                            fix=f"rm {_q(mirror)}",
                        ))
                    else:
                        destination = agents / name
                        result.issues.append(Issue(
                            kind="wrong-target",
                            name=name,
                            detail=f"{mirror} -> {target} (no {destination} exists)",
                            fix=(
                                "# Decide whether to copy the external target into .agents:\n"
                                f"  cp -R {_q(mirror)}/ {_q(destination)}\n"
                                f"  rm {_q(mirror)}\n"
                                f"  ln -s {shlex.quote(_relative_child_target(name))} {_q(mirror)}"
                            ),
                        ))
                else:
                    destination = agents / name
                    result.issues.append(Issue(
                        kind="orphan-in-claude",
                        name=name,
                        detail=f"{mirror} is a real entry but {destination} does not exist",
                        fix=(
                            f"mv {_q(mirror)} {_q(destination)}\n"
                            f"  ln -s {shlex.quote(_relative_child_target(name))} {_q(mirror)}"
                        ),
                    ))
                continue
    
            source = agents / name
            if mirror.is_symlink():
                target = os.readlink(mirror)
                try:
                    resolved = mirror.resolve(strict=True)
                except (FileNotFoundError, OSError):
                    resolved = None
                expected = source.resolve()
                if resolved is None:
                    result.issues.append(Issue(
                        kind="broken-symlink",
                        name=name,
                        detail=f"{mirror} -> {target} (broken)",
                        fix=(
                            f"rm {_q(mirror)}\n"
                            f"  ln -s {shlex.quote(_relative_child_target(name))} {_q(mirror)}"
                        ),
                    ))
                elif resolved != expected:
                    result.issues.append(Issue(
                        kind="wrong-target",
                        name=name,
                        detail=f"{mirror} -> {target} (resolves to {resolved}, expected {expected})",
                        fix=(
                            f"rm {_q(mirror)}\n"
                            f"  ln -s {shlex.quote(_relative_child_target(name))} {_q(mirror)}"
                        ),
                    ))
            else:
                result.issues.append(Issue(
                    kind="not-symlink",
                    name=name,
                    detail=f"{mirror} is a real entry, duplicating {source}",
                    fix=(
                        "# Inspect for divergence first, then collapse to a symlink:\n"
                        f"  diff -rq {_q(mirror)} {_q(source)}\n"
                        f"  rm -rf {_q(mirror)}\n"
                        f"  ln -s {shlex.quote(_relative_child_target(name))} {_q(mirror)}"
                    ),
                ))
    
        return result
    
    
    def _issue_summary(issues: list[Issue]) -> str:
        counts = Counter(issue.kind for issue in issues)
        preferred = ["orphan-in-claude", "missing-link", "not-symlink", "broken-symlink", "wrong-target"]
        ordered = [(kind, counts.pop(kind)) for kind in preferred if counts[kind]]
        ordered.extend(sorted(counts.items()))
        return ", ".join(f"{count} {kind}" for kind, count in ordered)
    
    
    def print_report(results: list[ScopeResult]) -> int:
        print("Skill link check")
        print("=" * 16)
        if not results:
            print("No .agents/skills or .claude/skills found in the selected scope(s).")
            return 0
    
        total = 0
        for result in results:
            print(f"\n[{result.label}] {result.root}")
            print(f"  .agents/skills: {_describe(result.agents)} ({result.agents})")
            print(f"  .claude/skills: {_describe(result.claude)} ({result.claude})")
            print(f"  Mode: {result.mode}")
            if result.mode == "parent-symlink":
                print(f"  OK: {result.n_agents_skills} skills consistent (parent-symlink layout).")
                continue
            if not result.issues:
                print("  OK: No issues.")
                continue
            print(f"  WARN: {len(result.issues)} issue(s): {_issue_summary(result.issues)}")
            for issue in sorted(result.issues, key=lambda item: (item.kind != "orphan-in-claude", item.kind, item.name)):
                total += 1
                print(f"\n    [{issue.kind}] {issue.name}")
                print(f"      {issue.detail}")
                print("      Suggested fix (review before running):")
                for line in issue.fix.splitlines():
                    print(f"        {line}")
    
        print(f"\nTotal issues: {total}" if total else "\nAll selected scopes look healthy.")
        return total
    
    
    def json_report(results: list[ScopeResult]) -> int:
        payload = {
            "ok": not any(result.issues for result in results),
            "total_issues": sum(len(result.issues) for result in results),
            "scopes": [],
        }
        for result in results:
            item = asdict(result)
            item["root"] = str(result.root)
            item["agents"] = str(result.agents)
            item["claude"] = str(result.claude)
            item["issue_counts"] = dict(Counter(issue.kind for issue in result.issues))
            payload["scopes"].append(item)
        print(json.dumps(payload, ensure_ascii=False, indent=2))
        return payload["total_issues"]
    
    
    def parse_args(argv: list[str]) -> argparse.Namespace:
        parser = argparse.ArgumentParser(description=__doc__)
        parser.add_argument("--project-root", type=Path, default=Path.cwd(), help="project root to audit")
        scope = parser.add_mutually_exclusive_group()
        scope.add_argument("--project-only", action="store_true", help="skip the global home scope")
        scope.add_argument("--global-only", action="store_true", help="skip the project scope")
        parser.add_argument("--json", action="store_true", help="emit stable JSON evidence")
        return parser.parse_args(argv)
    
    
    def main(argv: list[str]) -> int:
        args = parse_args(argv)
        project_root = args.project_root.expanduser().resolve()
        home = Path.home().resolve()
        results: list[ScopeResult] = []
    
        if not args.global_only:
            project = audit_scope("Project", project_root)
            if project is not None:
                results.append(project)
    
        if not args.project_only and (args.global_only or project_root != home):
            global_result = audit_scope("Global", home)
            if global_result is not None:
                results.append(global_result)
    
        total = json_report(results) if args.json else print_report(results)
        return 1 if total else 0
    
    
    if __name__ == "__main__":
        sys.exit(main(sys.argv[1:]))
    
  • SKILL.md 4.3 KB
    ---
    name: skill-link-check
    description: Audit project and global .agents/skills and .claude/skills layouts. Verify that .agents/skills contains the real source and .claude/skills mirrors it through a parent or per-skill symlink. Use whenever a skill or slash command is missing, not loading, duplicated, inconsistent, or described as "skill 没生效", "skill 不一致", "为什么 skill 没识别到", "检查 skill 链接", ghost skill, broken skill link, or missing skill. Also use when a skill exists in one directory but not the other.
    ---
    
    # Skill Link Check
    
    ## 安装与更新
    
    来源:[Skills.sh](https://skills.sh/yan-labs/yan-skills)
    
    ```bash
    # 首次全局安装,或更新失败时重新安装
    npx skills add yan-labs/yan-skills --skill skill-link-check -g -y
    
    # 更新已安装的全局 Skill
    npx skills update skill-link-check -g -y
    ```
    
    若使用项目级安装,去掉安装命令中的 `-g`;项目级更新使用 `npx skills update skill-link-check -p -y`。
    
    本 Skill 只审计目录布局,不自动移动、删除或覆盖文件:
    
    - `.agents/skills/<name>/` 保存真实源文件。
    - `.claude/skills` 要么整体链接到 `.agents/skills`,要么通过逐个子项链接镜像它。
    
    最常见的漂移是 Skill 被直接创建成 `.claude/skills/<name>/` 真实目录,却没有进入 `.agents/skills/`。它看似已安装,实际不会随正常备份、同步或迁移流程保存。
    
    ## Goal Contract
    
    在 `/goal`、autopilot 或其他持续执行器中,本 Skill 的完成条件是“审计证据完整”,不是“退出码必须为 0”。发现问题是有效结果,不能为了让检查通过而擅自修复。
    
    ```xml
    <goal>审计所有适用作用域,判定 Skill 源目录与运行时镜像是否一致,并为每个问题提供可复核证据和修复命令。</goal>
    <gate>检查脚本已从用户目标项目运行;每个适用作用域都有布局模式、问题分类和退出状态。</gate>
    <done-when>无问题时明确报告 clean;有问题时完整列出数量、类别、路径和建议命令;未自动修改任何被审计目录。</done-when>
    ```
    
    因此:
    
    - 退出码 `0`:审计完成且没有发现问题。
    - 退出码 `1`:审计完成且发现问题,不代表 Skill 执行失败。
    - Python traceback、参数错误或无法读取目标:才属于执行失败,需要修复后重跑。
    
    ## 运行方式
    
    ```bash
    python3 "$(dirname "$0")/check.py"
    ```
    
    默认审计:
    
    - **Project**:当前目录下的 `.agents/skills` 与 `.claude/skills`。
    - **Global**:`$HOME` 下的同名目录。
    
    自动化或 checker 可使用:
    
    ```bash
    # 明确指定项目,避免依赖当前工作目录
    python3 "$(dirname "$0")/check.py" --project-root /path/to/project
    
    # 只查一个作用域
    python3 "$(dirname "$0")/check.py" --project-only
    python3 "$(dirname "$0")/check.py" --global-only
    
    # 输出稳定 JSON 证据;发现问题时仍返回 1
    python3 "$(dirname "$0")/check.py" --json
    ```
    
    ## 两种合法布局
    
    两种模式都应通过:
    
    1. **Parent symlink**:`.claude/skills` 本身指向 `.agents/skills`。新增 Skill 自动保持一致。
    2. **Per-child symlinks**:`.claude/skills` 是真实目录,每个 `.claude/skills/<name>` 指向 `../../.agents/skills/<name>`。
    
    脚本会自动识别模式,不要求为了统一风格而改造一个本来健康的布局。
    
    ## 问题分类
    
    - `orphan-in-claude`:`.claude/skills/` 中有真实条目,但 `.agents/skills/` 没有对应源文件。报告时优先列出。
    - `missing-link`:源 Skill 存在,但运行时镜像缺失。
    - `not-symlink`:逐子项模式下,镜像位置是重复的真实文件或目录。
    - `broken-symlink`:链接目标不存在,包括损坏的父级链接。
    - `wrong-target`:链接存在,但指向错误 Skill 或 `.agents/skills` 之外。
    
    ## 报告规则
    
    1. 先给总问题数和分类计数;有 `orphan-in-claude` 时先报告它。
    2. 每项给出名称、证据路径和一行解释。
    3. 原样附上脚本生成的建议修复命令,便于用户复核后执行。
    4. 不自动修复。孤儿目录可能是用户尚未迁移的工作,重复目录也可能已经分叉;自动移动或删除会造成数据损失。
    5. 如果全部健康,一句话说明适用作用域及布局模式即可结束。
    
    ## 验证 Skill 自身
    
    ```bash
    python3 -m unittest discover -s "$(dirname "$0")/tests" -v
    ```
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related