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.
- claude_skill_lint-0.1.0/LICENSE +21 -0
- claude_skill_lint-0.1.0/PKG-INFO +168 -0
- claude_skill_lint-0.1.0/README.md +137 -0
- claude_skill_lint-0.1.0/pyproject.toml +51 -0
- claude_skill_lint-0.1.0/setup.cfg +4 -0
- claude_skill_lint-0.1.0/src/claude_skill_lint.egg-info/PKG-INFO +168 -0
- claude_skill_lint-0.1.0/src/claude_skill_lint.egg-info/SOURCES.txt +13 -0
- claude_skill_lint-0.1.0/src/claude_skill_lint.egg-info/dependency_links.txt +1 -0
- claude_skill_lint-0.1.0/src/claude_skill_lint.egg-info/entry_points.txt +2 -0
- claude_skill_lint-0.1.0/src/claude_skill_lint.egg-info/requires.txt +3 -0
- claude_skill_lint-0.1.0/src/claude_skill_lint.egg-info/top_level.txt +1 -0
- claude_skill_lint-0.1.0/src/skill_lint/__init__.py +3 -0
- claude_skill_lint-0.1.0/src/skill_lint/__main__.py +7 -0
- claude_skill_lint-0.1.0/src/skill_lint/cli.py +295 -0
- claude_skill_lint-0.1.0/tests/test_cli.py +367 -0
|
@@ -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
|
+
[](https://github.com/trinity-organism/skill-lint/actions/workflows/test.yml)
|
|
35
|
+
[](https://pypi.org/project/skill-lint/)
|
|
36
|
+
[](https://pypi.org/project/skill-lint/)
|
|
37
|
+
[](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
|
+
[](https://github.com/trinity-organism/skill-lint/actions/workflows/test.yml)
|
|
4
|
+
[](https://pypi.org/project/skill-lint/)
|
|
5
|
+
[](https://pypi.org/project/skill-lint/)
|
|
6
|
+
[](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,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
|
+
[](https://github.com/trinity-organism/skill-lint/actions/workflows/test.yml)
|
|
35
|
+
[](https://pypi.org/project/skill-lint/)
|
|
36
|
+
[](https://pypi.org/project/skill-lint/)
|
|
37
|
+
[](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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
skill_lint
|
|
@@ -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"]
|