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.
- agent_skilldoctor-0.1.0/PKG-INFO +46 -0
- agent_skilldoctor-0.1.0/README.md +32 -0
- agent_skilldoctor-0.1.0/pyproject.toml +27 -0
- agent_skilldoctor-0.1.0/setup.cfg +4 -0
- agent_skilldoctor-0.1.0/src/agent_skilldoctor.egg-info/PKG-INFO +46 -0
- agent_skilldoctor-0.1.0/src/agent_skilldoctor.egg-info/SOURCES.txt +12 -0
- agent_skilldoctor-0.1.0/src/agent_skilldoctor.egg-info/dependency_links.txt +1 -0
- agent_skilldoctor-0.1.0/src/agent_skilldoctor.egg-info/entry_points.txt +2 -0
- agent_skilldoctor-0.1.0/src/agent_skilldoctor.egg-info/top_level.txt +1 -0
- agent_skilldoctor-0.1.0/src/skilldoctor/__init__.py +3 -0
- agent_skilldoctor-0.1.0/src/skilldoctor/__main__.py +5 -0
- agent_skilldoctor-0.1.0/src/skilldoctor/cli.py +129 -0
- agent_skilldoctor-0.1.0/src/skilldoctor/scan.py +163 -0
- agent_skilldoctor-0.1.0/tests/test_skilldoctor.py +136 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
skilldoctor
|
|
@@ -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
|