agent-skilldoctor 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-skilldoctor
3
+ Version: 0.1.0
4
+ Summary: Audit AI agent skill directories: broken SKILL.md files, duplicate and shadowed skills across install roots
5
+ Author: juzorei
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/juzorei/skilldoctor
8
+ Keywords: agent-skills,claude-code,codex,skill-doctor,linting,agent-skills-marketplace
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Topic :: Software Development :: Quality Assurance
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+
15
+ # skilldoctor
16
+
17
+ Audit AI agent skill installations: broken SKILL.md files, duplicate skills shadowing each other across install roots.
18
+
19
+ The silent failure mode of agent skills: the same skill installed in two directories — you edit one, the agent loads the other, and the fix "doesn't work". Or a broken one (bad frontmatter, name mismatch) that never triggers, so the model just doesn't use it — with no error anywhere.
20
+
21
+ ```text
22
+ $ skilldoctor scan
23
+ scanned 4 skill directories, found 48 skills
24
+
25
+ DUPLICATES / SHADOWING
26
+ brainstorming
27
+ -> wins project zcode C:\proj\.zcode\skills\brainstorming
28
+ user zcode C:\Users\me\.zcode\skills\brainstorming
29
+ project-level skills shadow user-level ones in agents that document precedence
30
+
31
+ BROKEN
32
+ some-skill (C:\Users\me\.claude\skills\some-skill)
33
+ - name 'different-name' != directory 'some-skill'
34
+ ```
35
+
36
+ - `skilldoctor scan` — every layout it knows (project + user): `.claude/skills`, `.codex/skills`, `.agents/skills`, `.zcode/skills`, `.cursor/skills`. Exit 1 if anything is wrong: CI-gateable. Conflicts come with a reason and, where the agent documents precedence, the marked winner.
37
+ - `skilldoctor check <skill-dir>` — validate one skill: frontmatter present, name matches the directory, kebab-case, description present and under 1024 chars.
38
+
39
+ The checks are deliberately mechanical — every one of them corresponds to a real "skill silently doesn't load" failure. `--json` everywhere. Python 3.11+, stdlib only.
40
+
41
+ Precedence honesty: where an agent documents `project over user`, the winner is marked. Where precedence between roots is undocumented (`.claude` vs `.agents`), skilldoctor reports the duplicate and refuses to guess — that guess is how edits get lost.
42
+
43
+ Install: `pip install agent-skilldoctor` — or from this repo:
44
+ `pip install "git+https://github.com/juzorei/skilldoctor#subdirectory=tools/skilldoctor"`
45
+
46
+ The companion agent skill lives in [skills/skill-hygiene-audit](../skills/skill-hygiene-audit): how to read the report, dedupe without losing local edits (diff before delete), fix the broken copies, and re-scan after every install. Skills: `npx skills add juzorei/skilldoctor`.
@@ -0,0 +1,32 @@
1
+ # skilldoctor
2
+
3
+ Audit AI agent skill installations: broken SKILL.md files, duplicate skills shadowing each other across install roots.
4
+
5
+ The silent failure mode of agent skills: the same skill installed in two directories — you edit one, the agent loads the other, and the fix "doesn't work". Or a broken one (bad frontmatter, name mismatch) that never triggers, so the model just doesn't use it — with no error anywhere.
6
+
7
+ ```text
8
+ $ skilldoctor scan
9
+ scanned 4 skill directories, found 48 skills
10
+
11
+ DUPLICATES / SHADOWING
12
+ brainstorming
13
+ -> wins project zcode C:\proj\.zcode\skills\brainstorming
14
+ user zcode C:\Users\me\.zcode\skills\brainstorming
15
+ project-level skills shadow user-level ones in agents that document precedence
16
+
17
+ BROKEN
18
+ some-skill (C:\Users\me\.claude\skills\some-skill)
19
+ - name 'different-name' != directory 'some-skill'
20
+ ```
21
+
22
+ - `skilldoctor scan` — every layout it knows (project + user): `.claude/skills`, `.codex/skills`, `.agents/skills`, `.zcode/skills`, `.cursor/skills`. Exit 1 if anything is wrong: CI-gateable. Conflicts come with a reason and, where the agent documents precedence, the marked winner.
23
+ - `skilldoctor check <skill-dir>` — validate one skill: frontmatter present, name matches the directory, kebab-case, description present and under 1024 chars.
24
+
25
+ The checks are deliberately mechanical — every one of them corresponds to a real "skill silently doesn't load" failure. `--json` everywhere. Python 3.11+, stdlib only.
26
+
27
+ Precedence honesty: where an agent documents `project over user`, the winner is marked. Where precedence between roots is undocumented (`.claude` vs `.agents`), skilldoctor reports the duplicate and refuses to guess — that guess is how edits get lost.
28
+
29
+ Install: `pip install agent-skilldoctor` — or from this repo:
30
+ `pip install "git+https://github.com/juzorei/skilldoctor#subdirectory=tools/skilldoctor"`
31
+
32
+ The companion agent skill lives in [skills/skill-hygiene-audit](../skills/skill-hygiene-audit): how to read the report, dedupe without losing local edits (diff before delete), fix the broken copies, and re-scan after every install. Skills: `npx skills add juzorei/skilldoctor`.
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "agent-skilldoctor"
7
+ version = "0.1.0"
8
+ description = "Audit AI agent skill directories: broken SKILL.md files, duplicate and shadowed skills across install roots"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ authors = [{ name = "juzorei" }]
13
+ keywords = ["agent-skills", "claude-code", "codex", "skill-doctor", "linting", "agent-skills-marketplace"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Operating System :: OS Independent",
17
+ "Topic :: Software Development :: Quality Assurance",
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/juzorei/skilldoctor"
22
+
23
+ [project.scripts]
24
+ skilldoctor = "skilldoctor.cli:main"
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-skilldoctor
3
+ Version: 0.1.0
4
+ Summary: Audit AI agent skill directories: broken SKILL.md files, duplicate and shadowed skills across install roots
5
+ Author: juzorei
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/juzorei/skilldoctor
8
+ Keywords: agent-skills,claude-code,codex,skill-doctor,linting,agent-skills-marketplace
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Topic :: Software Development :: Quality Assurance
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+
15
+ # skilldoctor
16
+
17
+ Audit AI agent skill installations: broken SKILL.md files, duplicate skills shadowing each other across install roots.
18
+
19
+ The silent failure mode of agent skills: the same skill installed in two directories — you edit one, the agent loads the other, and the fix "doesn't work". Or a broken one (bad frontmatter, name mismatch) that never triggers, so the model just doesn't use it — with no error anywhere.
20
+
21
+ ```text
22
+ $ skilldoctor scan
23
+ scanned 4 skill directories, found 48 skills
24
+
25
+ DUPLICATES / SHADOWING
26
+ brainstorming
27
+ -> wins project zcode C:\proj\.zcode\skills\brainstorming
28
+ user zcode C:\Users\me\.zcode\skills\brainstorming
29
+ project-level skills shadow user-level ones in agents that document precedence
30
+
31
+ BROKEN
32
+ some-skill (C:\Users\me\.claude\skills\some-skill)
33
+ - name 'different-name' != directory 'some-skill'
34
+ ```
35
+
36
+ - `skilldoctor scan` — every layout it knows (project + user): `.claude/skills`, `.codex/skills`, `.agents/skills`, `.zcode/skills`, `.cursor/skills`. Exit 1 if anything is wrong: CI-gateable. Conflicts come with a reason and, where the agent documents precedence, the marked winner.
37
+ - `skilldoctor check <skill-dir>` — validate one skill: frontmatter present, name matches the directory, kebab-case, description present and under 1024 chars.
38
+
39
+ The checks are deliberately mechanical — every one of them corresponds to a real "skill silently doesn't load" failure. `--json` everywhere. Python 3.11+, stdlib only.
40
+
41
+ Precedence honesty: where an agent documents `project over user`, the winner is marked. Where precedence between roots is undocumented (`.claude` vs `.agents`), skilldoctor reports the duplicate and refuses to guess — that guess is how edits get lost.
42
+
43
+ Install: `pip install agent-skilldoctor` — or from this repo:
44
+ `pip install "git+https://github.com/juzorei/skilldoctor#subdirectory=tools/skilldoctor"`
45
+
46
+ The companion agent skill lives in [skills/skill-hygiene-audit](../skills/skill-hygiene-audit): how to read the report, dedupe without losing local edits (diff before delete), fix the broken copies, and re-scan after every install. Skills: `npx skills add juzorei/skilldoctor`.
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/agent_skilldoctor.egg-info/PKG-INFO
4
+ src/agent_skilldoctor.egg-info/SOURCES.txt
5
+ src/agent_skilldoctor.egg-info/dependency_links.txt
6
+ src/agent_skilldoctor.egg-info/entry_points.txt
7
+ src/agent_skilldoctor.egg-info/top_level.txt
8
+ src/skilldoctor/__init__.py
9
+ src/skilldoctor/__main__.py
10
+ src/skilldoctor/cli.py
11
+ src/skilldoctor/scan.py
12
+ tests/test_skilldoctor.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ skilldoctor = skilldoctor.cli:main
@@ -0,0 +1,3 @@
1
+ """Agent skill hygiene: broken SKILL.md lints and duplicate/shadow detection."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,129 @@
1
+ """skilldoctor CLI: audit agent skill directories for broken, duplicate and shadowed skills."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from . import __version__
11
+ from .scan import Diagnosis, scan
12
+
13
+
14
+ def _skill_json(s):
15
+ return {"name": s.name, "agent": s.agent, "scope": s.scope, "path": str(s.path),
16
+ "ok": s.ok, "problems": s.problems}
17
+
18
+
19
+ def _conflict_json(c):
20
+ return {"name": c.name, "reason": c.reason,
21
+ "winner": str(c.winner.path) if c.winner else None,
22
+ "copies": [_skill_json(s) for s in c.copies]}
23
+
24
+
25
+ def diag_json(diag: Diagnosis) -> dict:
26
+ return {
27
+ "dirs_scanned": len(diag.scanned_dirs),
28
+ "skills_total": len(diag.skills),
29
+ "broken": [_skill_json(s) for s in diag.broken],
30
+ "conflicts": [_conflict_json(c) for c in diag.conflicts],
31
+ }
32
+
33
+
34
+ def cmd_scan(args) -> int:
35
+ diag = scan(base_dir=Path(args.directory).resolve() if args.directory else None)
36
+ if args.json:
37
+ print(json.dumps(diag_json(diag), indent=2))
38
+ else:
39
+ print(f"scanned {len(diag.scanned_dirs)} skill directories, found {len(diag.skills)} skills\n")
40
+ if diag.conflicts:
41
+ print("DUPLICATES / SHADOWING")
42
+ for c in diag.conflicts:
43
+ print(f" {c.name}")
44
+ for s in c.copies:
45
+ mark = "-> wins " if c.winner is s else " "
46
+ print(f" {mark}{s.scope:<7} {s.agent:<13} {s.path}")
47
+ print(f" {c.reason}")
48
+ print()
49
+ if diag.broken:
50
+ print("BROKEN")
51
+ for s in diag.broken:
52
+ print(f" {s.name} ({s.path})")
53
+ for p in s.problems:
54
+ print(f" - {p}")
55
+ print()
56
+ if not diag.conflicts and not diag.broken:
57
+ print("clean: no duplicates, no broken skills")
58
+ has_issues = bool(diag.conflicts or diag.broken)
59
+ return 1 if has_issues else 0
60
+
61
+
62
+ def cmd_check(args) -> int:
63
+ """Check one skill directory: frontmatter validity and lint rules."""
64
+ from .scan import MAX_DESCRIPTION, NAME_RE, frontmatter
65
+
66
+ path = Path(args.skill_dir)
67
+ skill_md = path / "SKILL.md"
68
+ problems = []
69
+ if not path.is_dir():
70
+ print(f"skilldoctor: error: {path} is not a directory", file=sys.stderr)
71
+ return 2
72
+ if not skill_md.is_file():
73
+ print(json.dumps({"ok": False, "problems": ["missing SKILL.md"]}) if args.json
74
+ else "BROKEN: missing SKILL.md")
75
+ return 1
76
+ fm = frontmatter(skill_md.read_text(encoding="utf-8", errors="replace"))
77
+ if fm is None:
78
+ problems.append("no frontmatter block")
79
+ fm = {}
80
+ name = str(fm.get("name", "")).strip()
81
+ desc = str(fm.get("description", "")).strip()
82
+ if not name:
83
+ problems.append("missing name")
84
+ else:
85
+ if name != path.name:
86
+ problems.append(f"name {name!r} != directory {path.name!r}")
87
+ if not NAME_RE.match(name):
88
+ problems.append(f"name {name!r} is not kebab-case")
89
+ if not desc:
90
+ problems.append("empty description (the skill will never trigger)")
91
+ elif len(desc) > MAX_DESCRIPTION:
92
+ problems.append(f"description {len(desc)} chars (>{MAX_DESCRIPTION}: may be rejected by some agents)")
93
+ if args.json:
94
+ print(json.dumps({"ok": not problems, "problems": problems}, indent=2))
95
+ else:
96
+ print("OK" if not problems else "BROKEN")
97
+ for p in problems:
98
+ print(f" - {p}")
99
+ return 1 if problems else 0
100
+
101
+
102
+ def build_parser() -> argparse.ArgumentParser:
103
+ parser = argparse.ArgumentParser(prog="skilldoctor", description=__doc__)
104
+ parser.add_argument("--version", action="version", version=f"skilldoctor {__version__}")
105
+ sub = parser.add_subparsers(dest="command", required=True)
106
+
107
+ p = sub.add_parser("scan", help="scan all known skill directories (project + user) for conflicts")
108
+ p.add_argument("--directory", help="project root to scan (default: cwd)")
109
+ p.add_argument("--json", action="store_true")
110
+ p.set_defaults(func=cmd_scan)
111
+
112
+ p = sub.add_parser("check", help="validate one skill directory's SKILL.md")
113
+ p.add_argument("skill_dir")
114
+ p.add_argument("--json", action="store_true")
115
+ p.set_defaults(func=cmd_check)
116
+ return parser
117
+
118
+
119
+ def main(argv: list[str] | None = None) -> int:
120
+ args = build_parser().parse_args(argv)
121
+ try:
122
+ return args.func(args)
123
+ except OSError as exc:
124
+ print(f"skilldoctor: error: {exc}", file=sys.stderr)
125
+ return 2
126
+
127
+
128
+ if __name__ == "__main__":
129
+ sys.exit(main())
@@ -0,0 +1,163 @@
1
+ """skilldoctor: find broken, duplicate and shadowed agent skills across install directories.
2
+
3
+ Agents discover skills from several directories, each with its own precedence.
4
+ The same skill name installed twice is the classic silent failure: the editor
5
+ changes one copy, the agent loads the other. skilldoctor maps every skill
6
+ directory it can find and reports the conflicts.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
16
+ MAX_DESCRIPTION = 1024
17
+
18
+ # Discovery layouts, by agent. Precedence order documented per agent; where an
19
+ # agent's docs don't specify precedence between dirs, we report duplicates
20
+ # without claiming which one wins (`winner` is None).
21
+ KNOWN_DIRS = {
22
+ "claude-code": {"project": [".claude/skills"], "user": ["~/.claude/skills"]},
23
+ "codex": {"project": [".codex/skills"], "user": ["~/.codex/skills"]},
24
+ "agents-generic": {"project": [".agents/skills"], "user": ["~/.agents/skills"]},
25
+ "zcode": {"project": [".zcode/skills"], "user": ["~/.zcode/skills"]},
26
+ "cursor": {"project": [".cursor/skills"], "user": []},
27
+ }
28
+
29
+
30
+ @dataclass
31
+ class SkillMeta:
32
+ name: str
33
+ path: Path
34
+ description: str = ""
35
+ problems: list[str] = field(default_factory=list)
36
+ agent: str = ""
37
+ scope: str = "" # project | user
38
+
39
+ @property
40
+ def ok(self) -> bool:
41
+ return not self.problems
42
+
43
+
44
+ def frontmatter(text: str) -> dict | None:
45
+ text = text.replace("\r\n", "\n")
46
+ if not text.startswith("---"):
47
+ return None
48
+ end = text.find("\n---", 3)
49
+ if end < 0:
50
+ return None
51
+ block = text[3:end].strip("\n")
52
+ fields: dict[str, str] = {}
53
+ for line in block.splitlines():
54
+ key, sep, value = line.partition(":")
55
+ if sep and not line.startswith((" ", "\t")):
56
+ fields[key.strip()] = value.strip()
57
+ return fields
58
+
59
+
60
+ def parse_skill(path: Path, agent: str, scope: str) -> SkillMeta:
61
+ meta = SkillMeta(name=path.name, path=path, agent=agent, scope=scope)
62
+ skill_md = path / "SKILL.md"
63
+ if not skill_md.is_file():
64
+ meta.problems.append("missing SKILL.md")
65
+ return meta
66
+ text = skill_md.read_text(encoding="utf-8", errors="replace")
67
+ fm = frontmatter(text)
68
+ if fm is None:
69
+ meta.problems.append("SKILL.md has no frontmatter block")
70
+ return meta
71
+ declared = str(fm.get("name", "")).strip()
72
+ desc = str(fm.get("description", "")).strip()
73
+ if not declared:
74
+ meta.problems.append("frontmatter has no name")
75
+ else:
76
+ if declared != path.name:
77
+ meta.problems.append(f"name {declared!r} != directory {path.name!r}")
78
+ if not NAME_RE.match(declared):
79
+ meta.problems.append(f"name {declared!r} is not kebab-case")
80
+ if not desc:
81
+ meta.problems.append("empty description (never triggers)")
82
+ elif len(desc) > MAX_DESCRIPTION:
83
+ meta.problems.append(f"description {len(desc)} chars (>{MAX_DESCRIPTION}: may be rejected)")
84
+ meta.description = desc
85
+ meta.name = declared or path.name
86
+ return meta
87
+
88
+
89
+ def candidates(roots_for: dict[str, dict[str, list[str]]] | None = None,
90
+ base_dir: Path | None = None) -> list[tuple[str, str, Path]]:
91
+ """Yield (agent, scope, directory) for every configured layout."""
92
+ layouts = roots_for or KNOWN_DIRS
93
+ base = base_dir or Path.cwd()
94
+ home = Path.home()
95
+ out = []
96
+ for agent, scopes in layouts.items():
97
+ for scope, dirs in scopes.items():
98
+ for d in dirs:
99
+ expanded = Path(d)
100
+ if str(expanded).startswith("~"):
101
+ expanded = home / str(expanded)[2:]
102
+ if not expanded.is_absolute():
103
+ expanded = base / expanded
104
+ out.append((agent, scope, expanded))
105
+ return out
106
+
107
+
108
+ @dataclass
109
+ class Diagnosis:
110
+ @dataclass
111
+ class Conflict:
112
+ name: str
113
+ copies: list[SkillMeta]
114
+ winner: SkillMeta | None = None
115
+ reason: str = ""
116
+
117
+ skills: list[SkillMeta] = field(default_factory=list)
118
+ conflicts: list[Conflict] = field(default_factory=list)
119
+ scanned_dirs: list[Path] = field(default_factory=list)
120
+
121
+ @property
122
+ def broken(self) -> list[SkillMeta]:
123
+ return [s for s in self.skills if not s.ok]
124
+
125
+
126
+ def scan(roots_for: dict | None = None, base_dir: Path | None = None) -> Diagnosis:
127
+ diag = Diagnosis()
128
+ # project scope beats user scope across agents that document precedence;
129
+ # within the same scope, agent-specific dirs win over the generic .agents
130
+ order = {"project": 0, "user": 1}
131
+ by_name: dict[str, list[SkillMeta]] = {}
132
+ rank: dict[int, int] = {}
133
+ seen_dirs = set()
134
+ for agent, scope, d in candidates(roots_for, base_dir):
135
+ if not d.is_dir():
136
+ continue
137
+ real = d.resolve()
138
+ if real in seen_dirs:
139
+ continue
140
+ seen_dirs.add(real)
141
+ diag.scanned_dirs.append(d)
142
+ for child in sorted(p for p in d.iterdir() if p.is_dir()):
143
+ skill = parse_skill(child, agent, scope)
144
+ diag.skills.append(skill)
145
+ by_name.setdefault(skill.name, []).append(skill)
146
+ for name, copies in by_name.items():
147
+ if len(copies) < 2:
148
+ continue
149
+ # winner: project scope first; then non-generic agent; then earlier layout
150
+ def rank_of(s: SkillMeta, i: int):
151
+ return (order.get(s.scope, 2), 1 if s.agent == "agents-generic" else 0, i)
152
+ winner = None
153
+ if all(s.ok for s in copies):
154
+ candidate_scopes = {s.scope for s in copies}
155
+ if len(candidate_scopes) > 1:
156
+ winner = min(copies, key=lambda s: order.get(s.scope, 1))
157
+ reason = "project-level skills shadow user-level ones in agents that document precedence"
158
+ else:
159
+ reason = "same skill in multiple install roots; which one wins is agent-specific — dedupe to be safe"
160
+ else:
161
+ reason = "at least one copy is broken; the working copy may still be shadowed"
162
+ diag.conflicts.append(Diagnosis.Conflict(name, copies, winner, reason))
163
+ return diag
@@ -0,0 +1,136 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from skilldoctor.cli import main
6
+ from skilldoctor.scan import KNOWN_DIRS, candidates, parse_skill, scan
7
+
8
+
9
+ def make_skill(dir_path, name=None, description="does something", fm_name=None, extra=""):
10
+ d = dir_path / (name or "test-skill") if name else dir_path / "test-skill"
11
+ d.mkdir(parents=True)
12
+ fm_name = fm_name if fm_name is not None else d.name
13
+ body = f"---\nname: {fm_name}\ndescription: {description}\n---\n\n# {d.name}\n{extra}\n"
14
+ (d / "SKILL.md").write_text(body, encoding="utf-8")
15
+ return d
16
+
17
+
18
+ def pointed_roots(tmp_path):
19
+ """Point every agent layout at fixtures under tmp_path to keep tests hermetic."""
20
+ return {
21
+ "claude-code": {"project": [str(tmp_path / "proj" / ".claude" / "skills")],
22
+ "user": [str(tmp_path / "user" / ".claude" / "skills")]},
23
+ "agents-generic": {"project": [str(tmp_path / "proj" / ".agents" / "skills")],
24
+ "user": [str(tmp_path / "user" / ".agents" / "skills")]},
25
+ }
26
+
27
+
28
+ def test_detects_project_shadowing_user(tmp_path):
29
+ roots = pointed_roots(tmp_path)
30
+ make_skill(tmp_path / "proj" / ".claude" / "skills", "my-skill")
31
+ make_skill(tmp_path / "user" / ".claude" / "skills", "my-skill")
32
+ diag = scan(roots_for=roots, base_dir=tmp_path)
33
+ assert len(diag.conflicts) == 1
34
+ conflict = diag.conflicts[0]
35
+ assert conflict.winner is not None and conflict.winner.scope == "project"
36
+ assert "shadow" in conflict.reason
37
+
38
+
39
+ def test_same_scope_duplicate_flagged_without_false_winner(tmp_path):
40
+ roots = pointed_roots(tmp_path)
41
+ make_skill(tmp_path / "user" / ".claude" / "skills", "dup")
42
+ make_skill(tmp_path / "user" / ".agents" / "skills", "dup")
43
+ diag = scan(roots_for=roots, base_dir=tmp_path)
44
+ assert len(diag.conflicts) == 1
45
+ assert diag.conflicts[0].winner is None
46
+ assert "agent-specific" in diag.conflicts[0].reason
47
+
48
+
49
+ def test_no_conflict_when_clean(tmp_path):
50
+ roots = pointed_roots(tmp_path)
51
+ make_skill(tmp_path / "proj" / ".claude" / "skills", "alpha")
52
+ make_skill(tmp_path / "user" / ".claude" / "skills", "beta")
53
+ diag = scan(roots_for=roots, base_dir=tmp_path)
54
+ assert not diag.conflicts and not diag.broken
55
+ assert len(diag.skills) == 2
56
+
57
+
58
+ def test_broken_skill_problems(tmp_path):
59
+ d = tmp_path / "broken"
60
+ d.mkdir()
61
+ (d / "SKILL.md").write_text("no frontmatter here\n", encoding="utf-8")
62
+ meta = parse_skill(d, "claude-code", "project")
63
+ assert not meta.ok and any("frontmatter" in p for p in meta.problems)
64
+
65
+ d2 = tmp_path / "wrong-name"
66
+ d2.mkdir()
67
+ (d2 / "SKILL.md").write_text(
68
+ "---\nname: different-name\ndescription: x\n---\n", encoding="utf-8")
69
+ meta2 = parse_skill(d2, "claude-code", "project")
70
+ assert any("!=" in p for p in meta2.problems)
71
+
72
+ d3 = tmp_path / "kebab"
73
+ d3.mkdir()
74
+ (d3 / "SKILL.md").write_text("---\nname: BadName\ndescription: x\n---\n", encoding="utf-8")
75
+ meta3 = parse_skill(d3, "claude-code", "project")
76
+ assert any("kebab" in p for p in meta3.problems)
77
+
78
+
79
+ def test_missing_skillmd_is_broken(tmp_path):
80
+ d = tmp_path / "empty"
81
+ d.mkdir()
82
+ meta = parse_skill(d, "claude-code", "user")
83
+ assert meta.problems == ["missing SKILL.md"]
84
+
85
+
86
+ @pytest.mark.parametrize("long_desc", [False, True])
87
+ def test_description_length_rule(tmp_path, long_desc):
88
+ d = tmp_path / ("long" if long_desc else "short")
89
+ d.mkdir()
90
+ desc = "x" * 1200 if long_desc else "short description"
91
+ (d / "SKILL.md").write_text(
92
+ f"---\nname: {d.name}\ndescription: {desc}\n---\n", encoding="utf-8")
93
+ meta = parse_skill(d, "claude-code", "project")
94
+ assert (any("1024" in p for p in meta.problems)) == long_desc
95
+
96
+
97
+ def test_same_real_dir_scanned_once(tmp_path):
98
+ # two layouts pointing at the same resolved directory must not double-count
99
+ target = str(tmp_path / "shared")
100
+ roots = {"a": {"project": [target]}, "b": {"project": [target]}}
101
+ make_skill(tmp_path / "shared", "solo")
102
+ diag = scan(roots_for=roots, base_dir=tmp_path)
103
+ assert len(diag.skills) == 1 and not diag.conflicts
104
+
105
+
106
+ def test_cli_scan_exit_codes(tmp_path, monkeypatch, capsys):
107
+ import skilldoctor.scan as scanmod
108
+
109
+ roots = pointed_roots(tmp_path)
110
+ make_skill(tmp_path / "proj" / ".claude" / "skills", "s")
111
+ make_skill(tmp_path / "user" / ".claude" / "skills", "s")
112
+ monkeypatch.setattr(scanmod, "KNOWN_DIRS", roots)
113
+ assert main(["scan", "--directory", str(tmp_path), "--json"]) == 1
114
+ out = json.loads(capsys.readouterr().out)
115
+ assert out["skills_total"] == 2 and out["conflicts"]
116
+
117
+ # a clean world: no skills anywhere -> exit 0
118
+ empty_roots = {"claude-code": {"project": [str(tmp_path / "nothing" / "here")]}}
119
+ monkeypatch.setattr(scanmod, "KNOWN_DIRS", empty_roots)
120
+ assert main(["scan", "--directory", str(tmp_path), "--json"]) == 0
121
+ assert json.loads(capsys.readouterr().out)["skills_total"] == 0
122
+
123
+
124
+ def test_cli_check(tmp_path, capsys):
125
+ ok = make_skill(tmp_path, "good-skill")
126
+ assert main(["check", str(ok)]) == 0
127
+ assert "OK" in capsys.readouterr().out
128
+ assert main(["check", str(ok), "--json"]) == 0
129
+
130
+ bad = tmp_path / "odd_name"
131
+ bad.mkdir()
132
+ (bad / "SKILL.md").write_text("---\nname: mismatch\n---\n", encoding="utf-8")
133
+ assert main(["check", str(bad)]) == 1
134
+ out = capsys.readouterr().out
135
+ assert "mismatch" in out and "description" in out
136
+ assert main(["check", str(tmp_path / "missing-dir")]) == 2