claude-skill-lint 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jeremy Renoult
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,168 @@
1
+ Metadata-Version: 2.4
2
+ Name: claude-skill-lint
3
+ Version: 0.1.0
4
+ Summary: Lint your Claude Code skills: descriptions that never trigger, silent frontmatter typos, boot-token cost.
5
+ Author-email: Jeremy Renoult <jeremy.renoult.pro@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/trinity-organism/skill-lint
8
+ Project-URL: Repository, https://github.com/trinity-organism/skill-lint
9
+ Project-URL: Changelog, https://github.com/trinity-organism/skill-lint/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/trinity-organism/skill-lint/issues
11
+ Keywords: claude-code,claude,skills,lint,frontmatter,cli
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Software Development :: Quality Assurance
24
+ Classifier: Topic :: Utilities
25
+ Requires-Python: >=3.9
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # skill-lint
33
+
34
+ [![CI](https://github.com/trinity-organism/skill-lint/actions/workflows/test.yml/badge.svg)](https://github.com/trinity-organism/skill-lint/actions/workflows/test.yml)
35
+ [![PyPI](https://img.shields.io/pypi/v/skill-lint)](https://pypi.org/project/skill-lint/)
36
+ [![Python versions](https://img.shields.io/pypi/pyversions/skill-lint)](https://pypi.org/project/skill-lint/)
37
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
38
+
39
+ A Claude Code skill lives or dies by one field: its `description`. Claude
40
+ reads the name + description of **every** skill at session start and decides
41
+ from that text alone whether your skill ever fires. A typo'd frontmatter key
42
+ is ignored **silently**. Malformed YAML loads the skill with **empty
43
+ metadata** — it will never auto-trigger and nothing tells you. A description
44
+ that says *what* but never *when* under-triggers. And every character of
45
+ every description is context you pay for at **every session**.
46
+
47
+ `skill-lint` is the linter for that one field and everything around it:
48
+ **one command, zero dependencies, tells you which skills are dead, which are
49
+ silently broken, and what your skills cost at boot.**
50
+
51
+ - **Dead skills** — malformed frontmatter, no description and nothing to
52
+ fall back on: skills that can never trigger.
53
+ - **Silent typos** — `descripton:` instead of `description:` is ignored
54
+ without a word; skill-lint catches it and suggests the fix.
55
+ - **Under-triggering descriptions** — too short, no usage cue, or truncated
56
+ past the 1,536-character listing cap so Claude never sees the tail.
57
+ - **Boot cost** — chars and estimated tokens of names + descriptions loaded
58
+ every session, per skill and total; manual-only skills
59
+ (`disable-model-invocation: true`) correctly counted at zero.
60
+
61
+ ## Install
62
+
63
+ ```
64
+ pipx install skill-lint
65
+ ```
66
+
67
+ or
68
+
69
+ ```
70
+ pip install skill-lint
71
+ ```
72
+
73
+ or straight from source:
74
+
75
+ ```
76
+ pipx install git+https://github.com/trinity-organism/skill-lint
77
+ ```
78
+
79
+ Python >= 3.9. Zero dependencies — standard library only.
80
+
81
+ ## Usage
82
+
83
+ ```
84
+ skill-lint # lint $CLAUDE_CONFIG_DIR/skills, falling back to ~/.claude/skills
85
+ skill-lint --dir DIR # lint any skills directory (a project's .claude/skills, a plugin's skills/)
86
+ skill-lint --json # machine-readable output
87
+ ```
88
+
89
+ Exit codes: `0` clean (or no skills found), `1` at least one finding,
90
+ `2` unreadable directory — so it drops straight into CI or a pre-commit hook.
91
+
92
+ ## Example
93
+
94
+ Real output on a demo skills directory:
95
+
96
+ ```text
97
+ $ skill-lint --dir skills
98
+ skill-lint — SKILL.md quality + boot cost
99
+ skills dir: skills
100
+
101
+ ok deploy-staging 170 chars (~42 tok) at boot
102
+ WARN meeting-notes 65 chars (~16 tok) at boot
103
+ - WARN [no-when-cue]: description says what the skill does but never when to use it (no 'use/when/for/trigger...' cue): skills without usage context under-trigger
104
+ WARN pdf-tools 47 chars (~11 tok) at boot
105
+ - WARN [unknown-key]: frontmatter key 'descripton' is not a documented field and is ignored silently (did you mean 'description'?)
106
+ - WARN [no-description]: no description in frontmatter: triggering relies on the first body paragraph, which was not written for matching
107
+ ERROR scratch 20 chars (~5 tok) at boot
108
+ - ERROR [broken-frontmatter]: unparseable line 4 in frontmatter: malformed YAML means empty metadata, so the skill never auto-triggers
109
+
110
+ verdict — skills: 4 · errors: 1 · warnings: 3 · boot cost: 302 chars (~75 tokens) of names+descriptions every session
111
+ $ echo $?
112
+ 1
113
+ ```
114
+
115
+ And when there is nothing to lint:
116
+
117
+ ```text
118
+ $ skill-lint --dir /some/empty/dir
119
+ skill-lint: no skills found in /some/empty/dir
120
+ Nothing to lint. If your skills live elsewhere, point at them with --dir DIR or $CLAUDE_CONFIG_DIR.
121
+ ```
122
+
123
+ ## Rules
124
+
125
+ Every rule traces to documented Claude Code behavior, not taste.
126
+
127
+ | Rule | Severity | Why it matters |
128
+ | -------------------- | -------- | --------------------------------------------------------------------------------------- |
129
+ | `broken-frontmatter` | error | Malformed YAML loads the skill with empty metadata: it never auto-triggers. |
130
+ | `undiscoverable` | error | No description and an empty body: nothing for Claude to match, ever. |
131
+ | `no-description` | warning | Matching falls back to the first body paragraph, which wasn't written for matching. |
132
+ | `no-frontmatter` | warning | Same fallback; the skill also loses every other frontmatter feature. |
133
+ | `desc-too-short` | warning | A handful of characters gives Claude nothing to match against. |
134
+ | `no-when-cue` | warning | Says *what* but never *when*: the documented under-triggering failure mode. |
135
+ | `desc-truncated` | warning | `description` + `when_to_use` past 1,536 chars is cut in the listing — the tail is invisible. |
136
+ | `unknown-key` | warning | Unknown frontmatter fields are ignored silently; usually a typo (did-you-mean included). |
137
+ | `duplicate-key` | warning | Only one value survives a duplicated key. |
138
+ | `empty-body` | warning | The skill triggers and delivers nothing. |
139
+ | `no-skill-md` | warning | A directory in the skills root with no `SKILL.md` is dead weight, never loaded. |
140
+
141
+ Folded and literal YAML blocks (`description: >` / `|`) are parsed correctly —
142
+ the classic case where naive parsers read an empty description and report
143
+ garbage.
144
+
145
+ ## Why lint descriptions at all?
146
+
147
+ Claude Code loads skill names and descriptions into a context budget
148
+ (about 1% of the model's context window). When it overflows, descriptions
149
+ get dropped. Skills that never trigger still bill you that budget at every
150
+ session — a config that only ever grows, with nothing pruning it. Lint it,
151
+ fix the dead weight, keep the budget for skills that fire.
152
+
153
+ Auditing the rest of your config (hooks, duplicate levers)? That's this
154
+ tool's sibling: [`bridle-audit`](https://github.com/trinity-organism/bridle-audit).
155
+
156
+ ## Development
157
+
158
+ ```
159
+ pip install -e ".[dev]"
160
+ pytest
161
+ ```
162
+
163
+ The test suite fabricates synthetic skills in temp directories — it never
164
+ reads your real `~/.claude`.
165
+
166
+ ## License
167
+
168
+ MIT
@@ -0,0 +1,137 @@
1
+ # skill-lint
2
+
3
+ [![CI](https://github.com/trinity-organism/skill-lint/actions/workflows/test.yml/badge.svg)](https://github.com/trinity-organism/skill-lint/actions/workflows/test.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/skill-lint)](https://pypi.org/project/skill-lint/)
5
+ [![Python versions](https://img.shields.io/pypi/pyversions/skill-lint)](https://pypi.org/project/skill-lint/)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
7
+
8
+ A Claude Code skill lives or dies by one field: its `description`. Claude
9
+ reads the name + description of **every** skill at session start and decides
10
+ from that text alone whether your skill ever fires. A typo'd frontmatter key
11
+ is ignored **silently**. Malformed YAML loads the skill with **empty
12
+ metadata** — it will never auto-trigger and nothing tells you. A description
13
+ that says *what* but never *when* under-triggers. And every character of
14
+ every description is context you pay for at **every session**.
15
+
16
+ `skill-lint` is the linter for that one field and everything around it:
17
+ **one command, zero dependencies, tells you which skills are dead, which are
18
+ silently broken, and what your skills cost at boot.**
19
+
20
+ - **Dead skills** — malformed frontmatter, no description and nothing to
21
+ fall back on: skills that can never trigger.
22
+ - **Silent typos** — `descripton:` instead of `description:` is ignored
23
+ without a word; skill-lint catches it and suggests the fix.
24
+ - **Under-triggering descriptions** — too short, no usage cue, or truncated
25
+ past the 1,536-character listing cap so Claude never sees the tail.
26
+ - **Boot cost** — chars and estimated tokens of names + descriptions loaded
27
+ every session, per skill and total; manual-only skills
28
+ (`disable-model-invocation: true`) correctly counted at zero.
29
+
30
+ ## Install
31
+
32
+ ```
33
+ pipx install skill-lint
34
+ ```
35
+
36
+ or
37
+
38
+ ```
39
+ pip install skill-lint
40
+ ```
41
+
42
+ or straight from source:
43
+
44
+ ```
45
+ pipx install git+https://github.com/trinity-organism/skill-lint
46
+ ```
47
+
48
+ Python >= 3.9. Zero dependencies — standard library only.
49
+
50
+ ## Usage
51
+
52
+ ```
53
+ skill-lint # lint $CLAUDE_CONFIG_DIR/skills, falling back to ~/.claude/skills
54
+ skill-lint --dir DIR # lint any skills directory (a project's .claude/skills, a plugin's skills/)
55
+ skill-lint --json # machine-readable output
56
+ ```
57
+
58
+ Exit codes: `0` clean (or no skills found), `1` at least one finding,
59
+ `2` unreadable directory — so it drops straight into CI or a pre-commit hook.
60
+
61
+ ## Example
62
+
63
+ Real output on a demo skills directory:
64
+
65
+ ```text
66
+ $ skill-lint --dir skills
67
+ skill-lint — SKILL.md quality + boot cost
68
+ skills dir: skills
69
+
70
+ ok deploy-staging 170 chars (~42 tok) at boot
71
+ WARN meeting-notes 65 chars (~16 tok) at boot
72
+ - WARN [no-when-cue]: description says what the skill does but never when to use it (no 'use/when/for/trigger...' cue): skills without usage context under-trigger
73
+ WARN pdf-tools 47 chars (~11 tok) at boot
74
+ - WARN [unknown-key]: frontmatter key 'descripton' is not a documented field and is ignored silently (did you mean 'description'?)
75
+ - WARN [no-description]: no description in frontmatter: triggering relies on the first body paragraph, which was not written for matching
76
+ ERROR scratch 20 chars (~5 tok) at boot
77
+ - ERROR [broken-frontmatter]: unparseable line 4 in frontmatter: malformed YAML means empty metadata, so the skill never auto-triggers
78
+
79
+ verdict — skills: 4 · errors: 1 · warnings: 3 · boot cost: 302 chars (~75 tokens) of names+descriptions every session
80
+ $ echo $?
81
+ 1
82
+ ```
83
+
84
+ And when there is nothing to lint:
85
+
86
+ ```text
87
+ $ skill-lint --dir /some/empty/dir
88
+ skill-lint: no skills found in /some/empty/dir
89
+ Nothing to lint. If your skills live elsewhere, point at them with --dir DIR or $CLAUDE_CONFIG_DIR.
90
+ ```
91
+
92
+ ## Rules
93
+
94
+ Every rule traces to documented Claude Code behavior, not taste.
95
+
96
+ | Rule | Severity | Why it matters |
97
+ | -------------------- | -------- | --------------------------------------------------------------------------------------- |
98
+ | `broken-frontmatter` | error | Malformed YAML loads the skill with empty metadata: it never auto-triggers. |
99
+ | `undiscoverable` | error | No description and an empty body: nothing for Claude to match, ever. |
100
+ | `no-description` | warning | Matching falls back to the first body paragraph, which wasn't written for matching. |
101
+ | `no-frontmatter` | warning | Same fallback; the skill also loses every other frontmatter feature. |
102
+ | `desc-too-short` | warning | A handful of characters gives Claude nothing to match against. |
103
+ | `no-when-cue` | warning | Says *what* but never *when*: the documented under-triggering failure mode. |
104
+ | `desc-truncated` | warning | `description` + `when_to_use` past 1,536 chars is cut in the listing — the tail is invisible. |
105
+ | `unknown-key` | warning | Unknown frontmatter fields are ignored silently; usually a typo (did-you-mean included). |
106
+ | `duplicate-key` | warning | Only one value survives a duplicated key. |
107
+ | `empty-body` | warning | The skill triggers and delivers nothing. |
108
+ | `no-skill-md` | warning | A directory in the skills root with no `SKILL.md` is dead weight, never loaded. |
109
+
110
+ Folded and literal YAML blocks (`description: >` / `|`) are parsed correctly —
111
+ the classic case where naive parsers read an empty description and report
112
+ garbage.
113
+
114
+ ## Why lint descriptions at all?
115
+
116
+ Claude Code loads skill names and descriptions into a context budget
117
+ (about 1% of the model's context window). When it overflows, descriptions
118
+ get dropped. Skills that never trigger still bill you that budget at every
119
+ session — a config that only ever grows, with nothing pruning it. Lint it,
120
+ fix the dead weight, keep the budget for skills that fire.
121
+
122
+ Auditing the rest of your config (hooks, duplicate levers)? That's this
123
+ tool's sibling: [`bridle-audit`](https://github.com/trinity-organism/bridle-audit).
124
+
125
+ ## Development
126
+
127
+ ```
128
+ pip install -e ".[dev]"
129
+ pytest
130
+ ```
131
+
132
+ The test suite fabricates synthetic skills in temp directories — it never
133
+ reads your real `~/.claude`.
134
+
135
+ ## License
136
+
137
+ MIT
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "claude-skill-lint"
7
+ description = "Lint your Claude Code skills: descriptions that never trigger, silent frontmatter typos, boot-token cost."
8
+ readme = "README.md"
9
+ license = "MIT"
10
+ license-files = ["LICENSE"]
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Jeremy Renoult", email = "jeremy.renoult.pro@gmail.com" }]
13
+ keywords = ["claude-code", "claude", "skills", "lint", "frontmatter", "cli"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Software Development :: Quality Assurance",
27
+ "Topic :: Utilities",
28
+ ]
29
+ dependencies = []
30
+ dynamic = ["version"]
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["pytest"]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/trinity-organism/skill-lint"
37
+ Repository = "https://github.com/trinity-organism/skill-lint"
38
+ Changelog = "https://github.com/trinity-organism/skill-lint/blob/main/CHANGELOG.md"
39
+ Issues = "https://github.com/trinity-organism/skill-lint/issues"
40
+
41
+ [project.scripts]
42
+ skill-lint = "skill_lint.cli:main"
43
+
44
+ [tool.setuptools.dynamic]
45
+ version = { attr = "skill_lint.__version__" }
46
+
47
+ [tool.setuptools.packages.find]
48
+ where = ["src"]
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,168 @@
1
+ Metadata-Version: 2.4
2
+ Name: claude-skill-lint
3
+ Version: 0.1.0
4
+ Summary: Lint your Claude Code skills: descriptions that never trigger, silent frontmatter typos, boot-token cost.
5
+ Author-email: Jeremy Renoult <jeremy.renoult.pro@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/trinity-organism/skill-lint
8
+ Project-URL: Repository, https://github.com/trinity-organism/skill-lint
9
+ Project-URL: Changelog, https://github.com/trinity-organism/skill-lint/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/trinity-organism/skill-lint/issues
11
+ Keywords: claude-code,claude,skills,lint,frontmatter,cli
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Software Development :: Quality Assurance
24
+ Classifier: Topic :: Utilities
25
+ Requires-Python: >=3.9
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # skill-lint
33
+
34
+ [![CI](https://github.com/trinity-organism/skill-lint/actions/workflows/test.yml/badge.svg)](https://github.com/trinity-organism/skill-lint/actions/workflows/test.yml)
35
+ [![PyPI](https://img.shields.io/pypi/v/skill-lint)](https://pypi.org/project/skill-lint/)
36
+ [![Python versions](https://img.shields.io/pypi/pyversions/skill-lint)](https://pypi.org/project/skill-lint/)
37
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
38
+
39
+ A Claude Code skill lives or dies by one field: its `description`. Claude
40
+ reads the name + description of **every** skill at session start and decides
41
+ from that text alone whether your skill ever fires. A typo'd frontmatter key
42
+ is ignored **silently**. Malformed YAML loads the skill with **empty
43
+ metadata** — it will never auto-trigger and nothing tells you. A description
44
+ that says *what* but never *when* under-triggers. And every character of
45
+ every description is context you pay for at **every session**.
46
+
47
+ `skill-lint` is the linter for that one field and everything around it:
48
+ **one command, zero dependencies, tells you which skills are dead, which are
49
+ silently broken, and what your skills cost at boot.**
50
+
51
+ - **Dead skills** — malformed frontmatter, no description and nothing to
52
+ fall back on: skills that can never trigger.
53
+ - **Silent typos** — `descripton:` instead of `description:` is ignored
54
+ without a word; skill-lint catches it and suggests the fix.
55
+ - **Under-triggering descriptions** — too short, no usage cue, or truncated
56
+ past the 1,536-character listing cap so Claude never sees the tail.
57
+ - **Boot cost** — chars and estimated tokens of names + descriptions loaded
58
+ every session, per skill and total; manual-only skills
59
+ (`disable-model-invocation: true`) correctly counted at zero.
60
+
61
+ ## Install
62
+
63
+ ```
64
+ pipx install skill-lint
65
+ ```
66
+
67
+ or
68
+
69
+ ```
70
+ pip install skill-lint
71
+ ```
72
+
73
+ or straight from source:
74
+
75
+ ```
76
+ pipx install git+https://github.com/trinity-organism/skill-lint
77
+ ```
78
+
79
+ Python >= 3.9. Zero dependencies — standard library only.
80
+
81
+ ## Usage
82
+
83
+ ```
84
+ skill-lint # lint $CLAUDE_CONFIG_DIR/skills, falling back to ~/.claude/skills
85
+ skill-lint --dir DIR # lint any skills directory (a project's .claude/skills, a plugin's skills/)
86
+ skill-lint --json # machine-readable output
87
+ ```
88
+
89
+ Exit codes: `0` clean (or no skills found), `1` at least one finding,
90
+ `2` unreadable directory — so it drops straight into CI or a pre-commit hook.
91
+
92
+ ## Example
93
+
94
+ Real output on a demo skills directory:
95
+
96
+ ```text
97
+ $ skill-lint --dir skills
98
+ skill-lint — SKILL.md quality + boot cost
99
+ skills dir: skills
100
+
101
+ ok deploy-staging 170 chars (~42 tok) at boot
102
+ WARN meeting-notes 65 chars (~16 tok) at boot
103
+ - WARN [no-when-cue]: description says what the skill does but never when to use it (no 'use/when/for/trigger...' cue): skills without usage context under-trigger
104
+ WARN pdf-tools 47 chars (~11 tok) at boot
105
+ - WARN [unknown-key]: frontmatter key 'descripton' is not a documented field and is ignored silently (did you mean 'description'?)
106
+ - WARN [no-description]: no description in frontmatter: triggering relies on the first body paragraph, which was not written for matching
107
+ ERROR scratch 20 chars (~5 tok) at boot
108
+ - ERROR [broken-frontmatter]: unparseable line 4 in frontmatter: malformed YAML means empty metadata, so the skill never auto-triggers
109
+
110
+ verdict — skills: 4 · errors: 1 · warnings: 3 · boot cost: 302 chars (~75 tokens) of names+descriptions every session
111
+ $ echo $?
112
+ 1
113
+ ```
114
+
115
+ And when there is nothing to lint:
116
+
117
+ ```text
118
+ $ skill-lint --dir /some/empty/dir
119
+ skill-lint: no skills found in /some/empty/dir
120
+ Nothing to lint. If your skills live elsewhere, point at them with --dir DIR or $CLAUDE_CONFIG_DIR.
121
+ ```
122
+
123
+ ## Rules
124
+
125
+ Every rule traces to documented Claude Code behavior, not taste.
126
+
127
+ | Rule | Severity | Why it matters |
128
+ | -------------------- | -------- | --------------------------------------------------------------------------------------- |
129
+ | `broken-frontmatter` | error | Malformed YAML loads the skill with empty metadata: it never auto-triggers. |
130
+ | `undiscoverable` | error | No description and an empty body: nothing for Claude to match, ever. |
131
+ | `no-description` | warning | Matching falls back to the first body paragraph, which wasn't written for matching. |
132
+ | `no-frontmatter` | warning | Same fallback; the skill also loses every other frontmatter feature. |
133
+ | `desc-too-short` | warning | A handful of characters gives Claude nothing to match against. |
134
+ | `no-when-cue` | warning | Says *what* but never *when*: the documented under-triggering failure mode. |
135
+ | `desc-truncated` | warning | `description` + `when_to_use` past 1,536 chars is cut in the listing — the tail is invisible. |
136
+ | `unknown-key` | warning | Unknown frontmatter fields are ignored silently; usually a typo (did-you-mean included). |
137
+ | `duplicate-key` | warning | Only one value survives a duplicated key. |
138
+ | `empty-body` | warning | The skill triggers and delivers nothing. |
139
+ | `no-skill-md` | warning | A directory in the skills root with no `SKILL.md` is dead weight, never loaded. |
140
+
141
+ Folded and literal YAML blocks (`description: >` / `|`) are parsed correctly —
142
+ the classic case where naive parsers read an empty description and report
143
+ garbage.
144
+
145
+ ## Why lint descriptions at all?
146
+
147
+ Claude Code loads skill names and descriptions into a context budget
148
+ (about 1% of the model's context window). When it overflows, descriptions
149
+ get dropped. Skills that never trigger still bill you that budget at every
150
+ session — a config that only ever grows, with nothing pruning it. Lint it,
151
+ fix the dead weight, keep the budget for skills that fire.
152
+
153
+ Auditing the rest of your config (hooks, duplicate levers)? That's this
154
+ tool's sibling: [`bridle-audit`](https://github.com/trinity-organism/bridle-audit).
155
+
156
+ ## Development
157
+
158
+ ```
159
+ pip install -e ".[dev]"
160
+ pytest
161
+ ```
162
+
163
+ The test suite fabricates synthetic skills in temp directories — it never
164
+ reads your real `~/.claude`.
165
+
166
+ ## License
167
+
168
+ MIT
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/claude_skill_lint.egg-info/PKG-INFO
5
+ src/claude_skill_lint.egg-info/SOURCES.txt
6
+ src/claude_skill_lint.egg-info/dependency_links.txt
7
+ src/claude_skill_lint.egg-info/entry_points.txt
8
+ src/claude_skill_lint.egg-info/requires.txt
9
+ src/claude_skill_lint.egg-info/top_level.txt
10
+ src/skill_lint/__init__.py
11
+ src/skill_lint/__main__.py
12
+ src/skill_lint/cli.py
13
+ tests/test_cli.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ skill-lint = skill_lint.cli:main
@@ -0,0 +1,3 @@
1
+ """Lint Claude Code skills: dead descriptions, silent typos, boot-token cost."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,7 @@
1
+ """Entry point for `python -m skill_lint`."""
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
@@ -0,0 +1,295 @@
1
+ """skill-lint — lint Claude Code skills: dead descriptions, silent typos, boot cost.
2
+
3
+ Skill descriptions are the triggering mechanism: Claude reads name + description
4
+ of every skill at session start and decides from that text alone whether a skill
5
+ ever fires. Each check below traces to documented Claude Code behavior:
6
+
7
+ - broken frontmatter -> skill loads with empty metadata, never auto-triggers
8
+ - missing description -> matching falls back to the first body paragraph
9
+ - description + when_to_use is truncated at 1,536 chars in the skill listing
10
+ - unknown frontmatter keys are ignored silently (a typo kills the field)
11
+ - `disable-model-invocation: true` removes the skill from context (0 boot cost)
12
+
13
+ Exit codes: 0 clean (or no skills found), 1 at least one finding, 2 unreadable dir.
14
+ """
15
+ import argparse
16
+ import difflib
17
+ import json
18
+ import os
19
+ import re
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from . import __version__
24
+
25
+ LISTING_CAP = 1536 # combined description + when_to_use cap in the listing
26
+ SHORT_DESC = 20 # below this, there is nothing to match on
27
+ CHARS_PER_TOKEN = 4 # rough boot-cost estimate
28
+
29
+ # Documented Claude Code fields + agentskills.io open-standard extras.
30
+ KNOWN_KEYS = {
31
+ "name", "description", "when_to_use", "argument-hint", "arguments",
32
+ "disable-model-invocation", "user-invocable", "allowed-tools",
33
+ "disallowed-tools", "model", "effort", "context", "agent", "hooks",
34
+ "paths", "shell", "license", "metadata", "version",
35
+ }
36
+
37
+ KEY_RE = re.compile(r"^([A-Za-z0-9_-]+)\s*:(.*)$")
38
+ BLOCK_MARKERS = {">", "|", ">-", "|-", ">+", "|+"}
39
+ CUE_RE = re.compile(
40
+ r"\b(use[sd]?|using|when|whenever|for|trigger\w*|invoke\w*)\b", re.I)
41
+
42
+
43
+ def parse_frontmatter(text):
44
+ """YAML-lite frontmatter parse: (fields, keys, state).
45
+
46
+ fields: top-level key -> joined string value (folded/literal blocks and
47
+ plain continuations joined with spaces; surrounding quotes stripped).
48
+ keys: every top-level key in order (duplicates preserved).
49
+ state: "ok" | "none" (no frontmatter) | "unclosed" | "junk:<line>".
50
+ Nested structures (hooks:, arguments:) keep their key; values are unused.
51
+ """
52
+ lines = text.lstrip("\ufeff").splitlines()
53
+ if not lines or lines[0].strip() != "---":
54
+ return {}, [], "none", text
55
+ fields, keys, current = {}, [], None
56
+ for i, ln in enumerate(lines[1:], start=1):
57
+ if ln.strip() == "---":
58
+ body = "\n".join(lines[i + 1:])
59
+ return fields, keys, "ok", body
60
+ if not ln.strip() or ln.lstrip().startswith("#"):
61
+ continue
62
+ if ln[0] in " \t": # continuation / nested line
63
+ if current is None:
64
+ return fields, keys, "junk:%d" % i, ""
65
+ more = ln.strip()
66
+ fields[current] = (fields[current] + " " + more).strip()
67
+ continue
68
+ m = KEY_RE.match(ln)
69
+ if not m: # top-level junk breaks YAML
70
+ return fields, keys, "junk:%d" % i, ""
71
+ current, val = m.group(1), m.group(2).strip()
72
+ keys.append(current)
73
+ if val in BLOCK_MARKERS:
74
+ val = ""
75
+ elif len(val) >= 2 and val[0] == val[-1] and val[0] in "'\"":
76
+ val = val[1:-1]
77
+ fields[current] = val
78
+ return fields, keys, "unclosed", ""
79
+
80
+
81
+ def first_paragraph(body):
82
+ for block in re.split(r"\n\s*\n", body.strip()):
83
+ block = block.strip()
84
+ if block:
85
+ return " ".join(block.split())
86
+ return ""
87
+
88
+
89
+ def is_true(value):
90
+ return str(value).strip().lower() == "true"
91
+
92
+
93
+ def lint_skill(md_path):
94
+ """Lint one SKILL.md; returns the per-skill report dict."""
95
+ dir_name = md_path.parent.name
96
+ findings = []
97
+
98
+ def flag(severity, rule, message):
99
+ findings.append({"severity": severity, "rule": rule, "message": message})
100
+
101
+ try:
102
+ text = md_path.read_text(errors="replace")
103
+ except OSError as exc:
104
+ flag("error", "unreadable", "cannot read SKILL.md: %s" % exc)
105
+ return {"skill": dir_name, "path": str(md_path), "findings": findings,
106
+ "listing_chars": 0, "manual_only": False}
107
+
108
+ fields, keys, state, body = parse_frontmatter(text)
109
+ body = body.strip()
110
+ desc = fields.get("description", "").strip()
111
+ when = fields.get("when_to_use", "").strip()
112
+ manual_only = is_true(fields.get("disable-model-invocation", ""))
113
+
114
+ if state == "unclosed":
115
+ flag("error", "broken-frontmatter",
116
+ "opening --- is never closed: Claude loads this skill with "
117
+ "empty metadata, so it never auto-triggers")
118
+ elif state.startswith("junk:"):
119
+ flag("error", "broken-frontmatter",
120
+ "unparseable line %s in frontmatter: malformed YAML means empty "
121
+ "metadata, so the skill never auto-triggers" % state[5:])
122
+ elif state == "none":
123
+ flag("warning", "no-frontmatter",
124
+ "no YAML frontmatter: Claude falls back to the first paragraph "
125
+ "of the body to decide when to use this skill")
126
+
127
+ if state == "ok":
128
+ for key in sorted(set(keys)):
129
+ if keys.count(key) > 1:
130
+ flag("warning", "duplicate-key",
131
+ "frontmatter key '%s' appears %d times; only one value "
132
+ "survives" % (key, keys.count(key)))
133
+ for key in dict.fromkeys(keys): # unique, in order
134
+ if key not in KNOWN_KEYS:
135
+ close = difflib.get_close_matches(key, KNOWN_KEYS, n=1)
136
+ hint = " (did you mean '%s'?)" % close[0] if close else ""
137
+ flag("warning", "unknown-key",
138
+ "frontmatter key '%s' is not a documented field and is "
139
+ "ignored silently%s" % (key, hint))
140
+
141
+ if not manual_only and state in ("ok", "none"):
142
+ matchable = desc or first_paragraph(body)
143
+ if not matchable:
144
+ flag("error", "undiscoverable",
145
+ "no description and an empty body: nothing for Claude to "
146
+ "match, this skill can never trigger")
147
+ elif state == "none":
148
+ pass # no-frontmatter already flagged
149
+ elif not desc:
150
+ flag("warning", "no-description",
151
+ "no description in frontmatter: triggering relies on the "
152
+ "first body paragraph, which was not written for matching")
153
+ else:
154
+ if len(desc) < SHORT_DESC:
155
+ flag("warning", "desc-too-short",
156
+ "description is %d chars: too little for Claude to "
157
+ "match against" % len(desc))
158
+ elif not CUE_RE.search(desc + " " + when):
159
+ flag("warning", "no-when-cue",
160
+ "description says what the skill does but never when to "
161
+ "use it (no 'use/when/for/trigger...' cue): skills "
162
+ "without usage context under-trigger")
163
+ combined = len(desc) + (len(when) + 1 if when else 0)
164
+ if combined > LISTING_CAP:
165
+ flag("warning", "desc-truncated",
166
+ "description + when_to_use is %d chars; the listing "
167
+ "caps it at %d, the last %d chars are invisible to "
168
+ "Claude" % (combined, LISTING_CAP, combined - LISTING_CAP))
169
+
170
+ if state == "ok" and not body:
171
+ flag("warning", "empty-body",
172
+ "frontmatter but no instructions: the skill triggers and "
173
+ "delivers nothing")
174
+
175
+ shown_name = fields.get("name", "").strip() or dir_name
176
+ if manual_only:
177
+ listing = 0 # removed from context entirely
178
+ else:
179
+ combined = (desc + (" " + when if when else "")) or \
180
+ ("" if state not in ("ok", "none") else first_paragraph(body))
181
+ listing = len(shown_name) + min(len(combined), LISTING_CAP)
182
+
183
+ return {"skill": dir_name, "path": str(md_path), "findings": findings,
184
+ "listing_chars": listing, "manual_only": manual_only}
185
+
186
+
187
+ def resolve_skills_dir(cli_value=None):
188
+ """Precedence: --dir flag > $CLAUDE_CONFIG_DIR/skills > ~/.claude/skills."""
189
+ if cli_value:
190
+ return Path(cli_value).expanduser()
191
+ env = os.environ.get("CLAUDE_CONFIG_DIR")
192
+ if env:
193
+ return Path(env).expanduser() / "skills"
194
+ return Path.home() / ".claude" / "skills"
195
+
196
+
197
+ def lint_dir(skills_dir):
198
+ reports = []
199
+ for entry in sorted(skills_dir.iterdir()):
200
+ if not entry.is_dir() or entry.name.startswith("."):
201
+ continue
202
+ md = entry / "SKILL.md"
203
+ if md.is_file():
204
+ reports.append(lint_skill(md))
205
+ else:
206
+ reports.append({
207
+ "skill": entry.name, "path": str(entry), "findings": [
208
+ {"severity": "warning", "rule": "no-skill-md",
209
+ "message": "directory has no SKILL.md: it is not a "
210
+ "skill and is never loaded"}],
211
+ "listing_chars": 0, "manual_only": False})
212
+ return reports
213
+
214
+
215
+ def print_report(skills_dir, reports, verdict):
216
+ print("skill-lint — SKILL.md quality + boot cost")
217
+ print("skills dir: %s" % skills_dir)
218
+ print()
219
+ width = max((len(r["skill"]) for r in reports), default=0)
220
+ sev = {"error": "ERROR", "warning": "WARN"}
221
+ for r in reports:
222
+ worst = ("ERROR" if any(f["severity"] == "error" for f in r["findings"])
223
+ else "WARN " if r["findings"] else "ok ")
224
+ cost = ("manual-only, 0 chars at boot" if r["manual_only"]
225
+ else "%d chars (~%d tok) at boot"
226
+ % (r["listing_chars"], r["listing_chars"] // CHARS_PER_TOKEN))
227
+ print("%s %-*s %s" % (worst, width, r["skill"], cost))
228
+ for f in r["findings"]:
229
+ print(" - %s [%s]: %s" % (sev[f["severity"]], f["rule"],
230
+ f["message"]))
231
+ print()
232
+ print("verdict — skills: %d · errors: %d · warnings: %d · boot cost: "
233
+ "%d chars (~%d tokens) of names+descriptions every session"
234
+ % (verdict["skills"], verdict["errors"], verdict["warnings"],
235
+ verdict["listing_chars"], verdict["est_tokens"]))
236
+
237
+
238
+ def main(argv=None):
239
+ parser = argparse.ArgumentParser(
240
+ prog="skill-lint",
241
+ description="Lint Claude Code skills: descriptions that never "
242
+ "trigger, silent frontmatter typos, boot-token cost.")
243
+ parser.add_argument("--dir", metavar="DIR",
244
+ help="skills directory (default: "
245
+ "$CLAUDE_CONFIG_DIR/skills, then ~/.claude/skills)")
246
+ parser.add_argument("--json", action="store_true",
247
+ help="machine-readable output")
248
+ parser.add_argument("--version", action="version",
249
+ version="%(prog)s " + __version__)
250
+ args = parser.parse_args(argv)
251
+
252
+ skills_dir = resolve_skills_dir(args.dir)
253
+
254
+ if skills_dir.exists() and not skills_dir.is_dir():
255
+ print("skill-lint: %s is not a directory" % skills_dir,
256
+ file=sys.stderr)
257
+ return 2
258
+
259
+ if not skills_dir.is_dir():
260
+ reports = []
261
+ else:
262
+ try:
263
+ reports = lint_dir(skills_dir)
264
+ except OSError as exc:
265
+ print("skill-lint: cannot read %s: %s" % (skills_dir, exc),
266
+ file=sys.stderr)
267
+ return 2
268
+
269
+ errors = sum(1 for r in reports for f in r["findings"]
270
+ if f["severity"] == "error")
271
+ warnings = sum(1 for r in reports for f in r["findings"]
272
+ if f["severity"] == "warning")
273
+ listing = sum(r["listing_chars"] for r in reports)
274
+ code = 1 if (errors or warnings) else 0
275
+ verdict = {"skills": len(reports), "errors": errors, "warnings": warnings,
276
+ "listing_chars": listing,
277
+ "est_tokens": listing // CHARS_PER_TOKEN, "exit": code}
278
+
279
+ if args.json:
280
+ print(json.dumps({"skills_dir": str(skills_dir), "skills": reports,
281
+ "verdict": verdict}, ensure_ascii=False, indent=2))
282
+ return code
283
+
284
+ if not reports:
285
+ print("skill-lint: no skills found in %s" % skills_dir)
286
+ print("Nothing to lint. If your skills live elsewhere, point at them "
287
+ "with --dir DIR or $CLAUDE_CONFIG_DIR.")
288
+ return 0
289
+
290
+ print_report(skills_dir, reports, verdict)
291
+ return code
292
+
293
+
294
+ if __name__ == "__main__":
295
+ sys.exit(main())
@@ -0,0 +1,367 @@
1
+ """Tests for skill-lint.
2
+
3
+ Every test runs against synthetic skills fabricated in a pytest tmp_path.
4
+ Nothing reads the host machine's real ~/.claude: an autouse fixture strips
5
+ $CLAUDE_CONFIG_DIR and every invocation points at a fixture directory.
6
+ """
7
+ import json
8
+ import os
9
+ import subprocess
10
+ import sys
11
+
12
+ import pytest
13
+
14
+ from skill_lint import __version__
15
+ from skill_lint.cli import main, parse_frontmatter, resolve_skills_dir
16
+
17
+ HEALTHY_DESC = ("Use when testing alpha things. Covers folded YAML "
18
+ "descriptions across several lines.")
19
+
20
+
21
+ @pytest.fixture(autouse=True)
22
+ def isolate_env(monkeypatch):
23
+ """Never let the host's real config leak into a test."""
24
+ monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
25
+
26
+
27
+ # --- helpers -----------------------------------------------------------------
28
+
29
+ def write_skill(root, name, content):
30
+ md = root / name / "SKILL.md"
31
+ md.parent.mkdir(parents=True, exist_ok=True)
32
+ md.write_text(content)
33
+ return md
34
+
35
+
36
+ def healthy(root, name="alpha"):
37
+ return write_skill(root, name,
38
+ "---\nname: %s\ndescription: >\n Use when testing "
39
+ "alpha things. Covers folded YAML\n descriptions "
40
+ "across several lines.\nlicense: MIT\n---\n\n"
41
+ "# %s\n\nDo the thing.\n" % (name, name))
42
+
43
+
44
+ def run_json(capsys, argv):
45
+ code = main(argv)
46
+ return code, json.loads(capsys.readouterr().out)
47
+
48
+
49
+ def rules(report):
50
+ return [f["rule"] for f in report["findings"]]
51
+
52
+
53
+ def one_skill(data):
54
+ (skill,) = data["skills"]
55
+ return skill
56
+
57
+
58
+ # --- no skills ---------------------------------------------------------------
59
+
60
+ def test_missing_dir_is_clean(tmp_path, capsys):
61
+ code = main(["--dir", str(tmp_path / "nowhere")])
62
+ out = capsys.readouterr().out
63
+ assert code == 0
64
+ assert "no skills found" in out
65
+ assert "--dir DIR or $CLAUDE_CONFIG_DIR" in out
66
+
67
+
68
+ def test_empty_dir_is_clean_json(tmp_path, capsys):
69
+ root = tmp_path / "skills"
70
+ root.mkdir()
71
+ code, data = run_json(capsys, ["--dir", str(root), "--json"])
72
+ assert code == 0
73
+ assert data["skills"] == []
74
+ assert data["verdict"] == {"skills": 0, "errors": 0, "warnings": 0,
75
+ "listing_chars": 0, "est_tokens": 0, "exit": 0}
76
+
77
+
78
+ def test_root_is_a_file_exits_2(tmp_path, capsys):
79
+ f = tmp_path / "skills"
80
+ f.write_text("not a dir")
81
+ assert main(["--dir", str(f)]) == 2
82
+ assert "not a directory" in capsys.readouterr().err
83
+
84
+
85
+ # --- healthy skill -----------------------------------------------------------
86
+
87
+ def test_healthy_skill_is_clean(tmp_path, capsys):
88
+ healthy(tmp_path)
89
+ code = main(["--dir", str(tmp_path)])
90
+ out = capsys.readouterr().out
91
+ assert code == 0
92
+ assert "ok" in out and "alpha" in out
93
+ assert "ERROR" not in out and "WARN" not in out
94
+
95
+
96
+ def test_folded_description_counted_exactly(tmp_path, capsys):
97
+ """The classic folded-YAML (`>`) description must be read, not skipped."""
98
+ healthy(tmp_path)
99
+ code, data = run_json(capsys, ["--dir", str(tmp_path), "--json"])
100
+ skill = one_skill(data)
101
+ assert code == 0
102
+ assert skill["findings"] == []
103
+ assert skill["listing_chars"] == len("alpha") + len(HEALTHY_DESC)
104
+
105
+
106
+ def test_quoted_description_unquoted(tmp_path, capsys):
107
+ write_skill(tmp_path, "q",
108
+ '---\ndescription: "Use when quoting things."\n---\nBody.\n')
109
+ _, data = run_json(capsys, ["--dir", str(tmp_path), "--json"])
110
+ skill = one_skill(data)
111
+ assert skill["findings"] == []
112
+ assert skill["listing_chars"] == len("q") + len("Use when quoting things.")
113
+
114
+
115
+ # --- broken / missing frontmatter ---------------------------------------------
116
+
117
+ def test_unclosed_frontmatter_is_error(tmp_path, capsys):
118
+ write_skill(tmp_path, "broken",
119
+ "---\nname: broken\ndescription: never closed\n\nBody text.\n")
120
+ code, data = run_json(capsys, ["--dir", str(tmp_path), "--json"])
121
+ assert code == 1
122
+ assert rules(one_skill(data)) == ["broken-frontmatter"]
123
+ assert data["verdict"]["errors"] == 1
124
+
125
+
126
+ def test_junk_line_in_frontmatter_is_error(tmp_path, capsys):
127
+ write_skill(tmp_path, "junky",
128
+ "---\nname: junky\n- stray list item\n---\nBody.\n")
129
+ code, data = run_json(capsys, ["--dir", str(tmp_path), "--json"])
130
+ assert code == 1
131
+ assert rules(one_skill(data)) == ["broken-frontmatter"]
132
+
133
+
134
+ def test_no_frontmatter_warns_and_falls_back(tmp_path, capsys):
135
+ write_skill(tmp_path, "bare", "Use when running bare skills.\n\nMore.\n")
136
+ code, data = run_json(capsys, ["--dir", str(tmp_path), "--json"])
137
+ skill = one_skill(data)
138
+ assert code == 1
139
+ assert rules(skill) == ["no-frontmatter"]
140
+ assert skill["listing_chars"] == \
141
+ len("bare") + len("Use when running bare skills.")
142
+
143
+
144
+ # --- description quality -------------------------------------------------------
145
+
146
+ def test_missing_description_warns(tmp_path, capsys):
147
+ write_skill(tmp_path, "nodesc", "---\nname: nodesc\n---\nSome body.\n")
148
+ code, data = run_json(capsys, ["--dir", str(tmp_path), "--json"])
149
+ assert code == 1
150
+ assert rules(one_skill(data)) == ["no-description"]
151
+
152
+
153
+ def test_no_description_empty_body_is_undiscoverable(tmp_path, capsys):
154
+ write_skill(tmp_path, "ghost", "---\nname: ghost\n---\n")
155
+ code, data = run_json(capsys, ["--dir", str(tmp_path), "--json"])
156
+ skill = one_skill(data)
157
+ assert code == 1
158
+ assert set(rules(skill)) == {"undiscoverable", "empty-body"}
159
+ assert data["verdict"]["errors"] == 1
160
+ assert data["verdict"]["warnings"] == 1
161
+
162
+
163
+ def test_short_description_warns(tmp_path, capsys):
164
+ write_skill(tmp_path, "tiny", "---\ndescription: Does stuff.\n---\nBody.\n")
165
+ code, data = run_json(capsys, ["--dir", str(tmp_path), "--json"])
166
+ assert code == 1
167
+ assert rules(one_skill(data)) == ["desc-too-short"]
168
+
169
+
170
+ def test_no_when_cue_warns(tmp_path, capsys):
171
+ write_skill(tmp_path, "vague",
172
+ "---\ndescription: Formats JSON documents beautifully.\n---\n"
173
+ "Body.\n")
174
+ code, data = run_json(capsys, ["--dir", str(tmp_path), "--json"])
175
+ assert code == 1
176
+ assert rules(one_skill(data)) == ["no-when-cue"]
177
+
178
+
179
+ def test_when_cue_via_when_to_use_passes(tmp_path, capsys):
180
+ write_skill(tmp_path, "cued",
181
+ "---\ndescription: Formats JSON documents beautifully.\n"
182
+ "when_to_use: Use when the user pastes raw JSON.\n---\nBody.\n")
183
+ assert main(["--dir", str(tmp_path)]) == 0
184
+
185
+
186
+ def test_overlong_listing_text_warns_with_overflow(tmp_path, capsys):
187
+ desc = "Use when testing. " + "x" * 982 # 1000 chars
188
+ when = "y" * 600
189
+ write_skill(tmp_path, "fat",
190
+ "---\ndescription: %s\nwhen_to_use: %s\n---\nBody.\n"
191
+ % (desc, when))
192
+ code, data = run_json(capsys, ["--dir", str(tmp_path), "--json"])
193
+ skill = one_skill(data)
194
+ assert code == 1
195
+ assert rules(skill) == ["desc-truncated"]
196
+ assert "the last 65 chars are invisible" in skill["findings"][0]["message"]
197
+ assert skill["listing_chars"] == len("fat") + 1536
198
+
199
+
200
+ # --- frontmatter keys ----------------------------------------------------------
201
+
202
+ def test_unknown_key_suggests_close_match(tmp_path, capsys):
203
+ write_skill(tmp_path, "typo",
204
+ "---\ndescripton: Use when spelling fails.\n---\nBody.\n")
205
+ code, data = run_json(capsys, ["--dir", str(tmp_path), "--json"])
206
+ skill = one_skill(data)
207
+ assert code == 1
208
+ assert "unknown-key" in rules(skill)
209
+ assert "no-description" in rules(skill) # the typo's real casualty
210
+ joined = " ".join(f["message"] for f in skill["findings"])
211
+ assert "did you mean 'description'?" in joined
212
+
213
+
214
+ def test_duplicate_key_warns(tmp_path, capsys):
215
+ write_skill(tmp_path, "twice",
216
+ "---\ndescription: Use when one.\ndescription: Use when two.\n"
217
+ "---\nBody.\n")
218
+ code, data = run_json(capsys, ["--dir", str(tmp_path), "--json"])
219
+ assert code == 1
220
+ assert "duplicate-key" in rules(one_skill(data))
221
+
222
+
223
+ def test_documented_keys_accepted(tmp_path, capsys):
224
+ write_skill(tmp_path, "full",
225
+ "---\nname: full\ndescription: Use when everything is set.\n"
226
+ "when_to_use: Whenever.\nargument-hint: '[file]'\n"
227
+ "allowed-tools: Read Grep\nmodel: inherit\nuser-invocable: "
228
+ "true\npaths: src/**\nlicense: MIT\n---\nBody.\n")
229
+ assert main(["--dir", str(tmp_path)]) == 0
230
+
231
+
232
+ # --- manual-only skills --------------------------------------------------------
233
+
234
+ def test_manual_only_skips_trigger_checks(tmp_path, capsys):
235
+ write_skill(tmp_path, "manual",
236
+ "---\nname: manual\ndisable-model-invocation: true\n---\n"
237
+ "Run the deploy.\n")
238
+ code, data = run_json(capsys, ["--dir", str(tmp_path), "--json"])
239
+ skill = one_skill(data)
240
+ assert code == 0
241
+ assert skill["findings"] == []
242
+ assert skill["manual_only"] is True
243
+ assert skill["listing_chars"] == 0 # removed from context
244
+ assert data["verdict"]["listing_chars"] == 0
245
+
246
+
247
+ # --- empty body ----------------------------------------------------------------
248
+
249
+ def test_empty_body_warns(tmp_path, capsys):
250
+ write_skill(tmp_path, "hollow",
251
+ "---\ndescription: Use when hollow skills are needed.\n---\n")
252
+ code, data = run_json(capsys, ["--dir", str(tmp_path), "--json"])
253
+ assert code == 1
254
+ assert rules(one_skill(data)) == ["empty-body"]
255
+
256
+
257
+ # --- stray directories -----------------------------------------------------------
258
+
259
+ def test_dir_without_skill_md_warns_hidden_ignored(tmp_path, capsys):
260
+ (tmp_path / "stray").mkdir()
261
+ (tmp_path / ".git").mkdir()
262
+ healthy(tmp_path)
263
+ code, data = run_json(capsys, ["--dir", str(tmp_path), "--json"])
264
+ assert code == 1
265
+ names = [s["skill"] for s in data["skills"]]
266
+ assert names == ["alpha", "stray"]
267
+ stray = data["skills"][1]
268
+ assert rules(stray) == ["no-skill-md"]
269
+
270
+
271
+ # --- directory resolution --------------------------------------------------------
272
+
273
+ def test_dir_flag_beats_env(tmp_path, monkeypatch, capsys):
274
+ flag_root = tmp_path / "flag"
275
+ healthy(flag_root)
276
+ env_cfg = tmp_path / "envcfg"
277
+ healthy(env_cfg / "skills", name="from-env")
278
+ monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(env_cfg))
279
+ code, data = run_json(capsys, ["--dir", str(flag_root), "--json"])
280
+ assert data["skills_dir"] == str(flag_root)
281
+ assert [s["skill"] for s in data["skills"]] == ["alpha"]
282
+
283
+
284
+ def test_env_var_honored(tmp_path, monkeypatch, capsys):
285
+ cfg = tmp_path / "cfg"
286
+ healthy(cfg / "skills")
287
+ monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(cfg))
288
+ code, data = run_json(capsys, ["--json"])
289
+ assert code == 0
290
+ assert data["skills_dir"] == str(cfg / "skills")
291
+
292
+
293
+ def test_default_falls_back_to_home_dot_claude(tmp_path, monkeypatch):
294
+ monkeypatch.setenv("HOME", str(tmp_path))
295
+ monkeypatch.setenv("USERPROFILE", str(tmp_path)) # windows
296
+ assert resolve_skills_dir(None) == tmp_path / ".claude" / "skills"
297
+
298
+
299
+ # --- output shape -----------------------------------------------------------------
300
+
301
+ def test_json_shape_and_verdict_consistency(tmp_path, capsys):
302
+ healthy(tmp_path)
303
+ write_skill(tmp_path, "ghost", "---\n---\n")
304
+ code, data = run_json(capsys, ["--dir", str(tmp_path), "--json"])
305
+ assert code == 1
306
+ assert set(data) == {"skills_dir", "skills", "verdict"}
307
+ assert set(data["skills"][0]) == {"skill", "path", "findings",
308
+ "listing_chars", "manual_only"}
309
+ v = data["verdict"]
310
+ assert v["skills"] == 2
311
+ assert v["errors"] == sum(1 for s in data["skills"]
312
+ for f in s["findings"]
313
+ if f["severity"] == "error")
314
+ assert v["est_tokens"] == v["listing_chars"] // 4
315
+ assert v["exit"] == 1
316
+
317
+
318
+ def test_human_output_verdict_line(tmp_path, capsys):
319
+ healthy(tmp_path)
320
+ main(["--dir", str(tmp_path)])
321
+ out = capsys.readouterr().out
322
+ assert "verdict — skills: 1" in out
323
+ assert "every session" in out
324
+
325
+
326
+ # --- parser unit -------------------------------------------------------------------
327
+
328
+ @pytest.mark.parametrize("text,expected_desc", [
329
+ ("---\ndescription: plain value\n---\n", "plain value"),
330
+ ("---\ndescription: >\n folded one\n folded two\n---\n",
331
+ "folded one folded two"),
332
+ ("---\ndescription: |\n literal one\n literal two\n---\n",
333
+ "literal one literal two"),
334
+ ("---\ndescription: >-\n chomped\n---\n", "chomped"),
335
+ ("\ufeff---\ndescription: after bom\n---\n", "after bom"),
336
+ ])
337
+ def test_parse_frontmatter_descriptions(text, expected_desc):
338
+ fields, keys, state, _ = parse_frontmatter(text)
339
+ assert state == "ok"
340
+ assert fields["description"] == expected_desc
341
+
342
+
343
+ def test_parse_frontmatter_states():
344
+ assert parse_frontmatter("no marker\n")[2] == "none"
345
+ assert parse_frontmatter("---\nkey: val\n")[2] == "unclosed"
346
+ assert parse_frontmatter("---\n- junk\n---\n")[2] == "junk:1"
347
+
348
+
349
+ # --- entry points --------------------------------------------------------------------
350
+
351
+ def test_version_flag(capsys):
352
+ with pytest.raises(SystemExit) as exc:
353
+ main(["--version"])
354
+ assert exc.value.code == 0
355
+ assert __version__ in capsys.readouterr().out
356
+
357
+
358
+ def test_python_dash_m_entrypoint(tmp_path):
359
+ cfg = tmp_path / "cfg"
360
+ healthy(cfg / "skills")
361
+ env = {**os.environ, "CLAUDE_CONFIG_DIR": str(cfg)}
362
+ proc = subprocess.run([sys.executable, "-m", "skill_lint", "--json"],
363
+ capture_output=True, text=True, env=env)
364
+ assert proc.returncode == 0
365
+ data = json.loads(proc.stdout)
366
+ assert data["skills_dir"] == str(cfg / "skills")
367
+ assert [s["skill"] for s in data["skills"]] == ["alpha"]