skill-linter 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.
- skill_linter-0.1.0/.github/workflows/ci.yml +23 -0
- skill_linter-0.1.0/.github/workflows/publish.yml +21 -0
- skill_linter-0.1.0/.gitignore +5 -0
- skill_linter-0.1.0/LICENSE +21 -0
- skill_linter-0.1.0/PKG-INFO +120 -0
- skill_linter-0.1.0/README.md +107 -0
- skill_linter-0.1.0/pyproject.toml +30 -0
- skill_linter-0.1.0/src/skill_linter/__init__.py +1 -0
- skill_linter-0.1.0/src/skill_linter/__main__.py +5 -0
- skill_linter-0.1.0/src/skill_linter/cli.py +216 -0
- skill_linter-0.1.0/src/skill_linter/commands.py +117 -0
- skill_linter-0.1.0/src/skill_linter/config.py +356 -0
- skill_linter-0.1.0/src/skill_linter/engine.py +105 -0
- skill_linter-0.1.0/src/skill_linter/frontmatter.py +217 -0
- skill_linter-0.1.0/src/skill_linter/mdutil.py +71 -0
- skill_linter-0.1.0/src/skill_linter/model.py +96 -0
- skill_linter-0.1.0/src/skill_linter/names.py +84 -0
- skill_linter-0.1.0/src/skill_linter/release.py +550 -0
- skill_linter-0.1.0/src/skill_linter/repo.py +82 -0
- skill_linter-0.1.0/src/skill_linter/report.py +72 -0
- skill_linter-0.1.0/src/skill_linter/rules/__init__.py +11 -0
- skill_linter-0.1.0/src/skill_linter/rules/conventions.py +263 -0
- skill_linter-0.1.0/src/skill_linter/rules/custom.py +152 -0
- skill_linter-0.1.0/src/skill_linter/rules/description.py +89 -0
- skill_linter-0.1.0/src/skill_linter/rules/hidden.py +534 -0
- skill_linter-0.1.0/src/skill_linter/rules/markdown.py +146 -0
- skill_linter-0.1.0/src/skill_linter/rules/policy.py +188 -0
- skill_linter-0.1.0/src/skill_linter/rules/spec.py +220 -0
- skill_linter-0.1.0/src/skill_linter/rules/workflows.py +301 -0
- skill_linter-0.1.0/src/skill_linter/stamp.py +291 -0
- skill_linter-0.1.0/tests/adoption/claude-skills.toml +84 -0
- skill_linter-0.1.0/tests/adoption/writing-with-agents.toml +85 -0
- skill_linter-0.1.0/tests/conftest.py +114 -0
- skill_linter-0.1.0/tests/test_adoption.py +186 -0
- skill_linter-0.1.0/tests/test_cli.py +137 -0
- skill_linter-0.1.0/tests/test_config.py +139 -0
- skill_linter-0.1.0/tests/test_engine.py +143 -0
- skill_linter-0.1.0/tests/test_frontmatter.py +131 -0
- skill_linter-0.1.0/tests/test_mdutil.py +61 -0
- skill_linter-0.1.0/tests/test_names.py +112 -0
- skill_linter-0.1.0/tests/test_release.py +466 -0
- skill_linter-0.1.0/tests/test_rules_conventions.py +275 -0
- skill_linter-0.1.0/tests/test_rules_hidden.py +327 -0
- skill_linter-0.1.0/tests/test_rules_markdown.py +63 -0
- skill_linter-0.1.0/tests/test_rules_policy.py +288 -0
- skill_linter-0.1.0/tests/test_rules_spec.py +213 -0
- skill_linter-0.1.0/tests/test_rules_workflows.py +255 -0
- skill_linter-0.1.0/tests/test_stamp.py +283 -0
- skill_linter-0.1.0/uv.lock +167 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python: ["3.11", "3.12", "3.13"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: astral-sh/setup-uv@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python }}
|
|
19
|
+
- run: uv sync --locked
|
|
20
|
+
- run: uv run ruff check .
|
|
21
|
+
- run: uv run ruff format --check .
|
|
22
|
+
# The adoption tests skip here: they need local checkouts of the skill repos.
|
|
23
|
+
- run: uv run pytest -q
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Runs only when a version tag is pushed. PyPI uses trusted publishing, so no token is stored.
|
|
4
|
+
on:
|
|
5
|
+
push:
|
|
6
|
+
tags: ["v*"]
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
environment: pypi
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read # checkout needs it once permissions are listed
|
|
14
|
+
id-token: write
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: astral-sh/setup-uv@v5
|
|
18
|
+
- name: Tag matches the package version
|
|
19
|
+
run: test "${GITHUB_REF_NAME#v}" = "$(uv version --short)"
|
|
20
|
+
- run: uv build
|
|
21
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 [Jeff Smolinski](https://github.com/Jeffallan)
|
|
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,120 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: skill-linter
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Validate Agent Skills repos: spec checks, shared conventions, per-repo policy, count stamping and plugin release checks
|
|
5
|
+
Project-URL: Repository, https://github.com/Jeffallan/skill-linter
|
|
6
|
+
Project-URL: Issues, https://github.com/Jeffallan/skill-linter/issues
|
|
7
|
+
Author: Jeff Smolinski
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: pyyaml>=6
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# skill-linter
|
|
15
|
+
|
|
16
|
+
skill-linter checks a repo of Agent Skills before you commit or release it, and keeps the counts and version numbers in its docs current. One `[tool.skill-linter]` table in `pyproject.toml` configures it, so a new skill repo gets the same checks as the others without copying `validate-skills.py`, `validate-markdown.py` and `update-docs.py` into it.
|
|
17
|
+
|
|
18
|
+
It needs Python 3.11 or later. PyYAML is the only dependency, used for workflow YAML.
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
uvx skill-linter check # run without installing
|
|
22
|
+
pipx install skill-linter # or install it once
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Status: early (0.x). Rule names and config keys may still change between minor versions.
|
|
26
|
+
|
|
27
|
+
## Commands
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
skill-linter check [--only GROUP[,GROUP]] [--skill NAME] [--format text|json] [--root PATH]
|
|
31
|
+
skill-linter stamp [--check] [--dry-run] [--root PATH]
|
|
32
|
+
skill-linter release [--format text|json] [--root PATH]
|
|
33
|
+
skill-linter rules [--format text|json]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- `check` runs every enabled rule except the release checks.
|
|
37
|
+
- `stamp` counts skills, references, workflows and domains, then writes those counts and the version from `version.json` into the files you list. `--check` writes nothing and exits 1 when anything is stale.
|
|
38
|
+
- `release` checks the tree the plugin directory will read (`plugin_root` or a `ship` list), then runs `check`.
|
|
39
|
+
- `rules` lists every rule with its group, layer and severity after your overrides.
|
|
40
|
+
|
|
41
|
+
`skill-linter --version` prints the installed version. Exit codes: 0 when there are no errors, 1 when there are errors or stale stamps, 2 for a usage or config error. `python -m skill_linter` runs the same CLI.
|
|
42
|
+
|
|
43
|
+
## Rules
|
|
44
|
+
|
|
45
|
+
Rules come in three layers:
|
|
46
|
+
|
|
47
|
+
1. The [Agent Skills specification](https://agentskills.io/specification) (`SPEC-01` to `SPEC-10`). You can't switch these off.
|
|
48
|
+
2. Conventions shared across the skill repos: frontmatter metadata, the description rule, Markdown lint, workflow definitions, hidden conventions such as the docs backlink and title safety, and the plugin-directory release checks (`CONV`, `DESC`, `MD`, `WF`, `H`, `P`). Each one can be switched off or lowered to a warning.
|
|
49
|
+
3. Per-repo policy (`POL`, `LINK`, and your own `X-` rules). A policy rule runs only once you give it a value.
|
|
50
|
+
|
|
51
|
+
Every rule has an ID and a name, such as `POL-03` and `section-order`. Output shows both, and the config accepts either. Run `skill-linter rules` for the full list.
|
|
52
|
+
|
|
53
|
+
### Cross-skill references
|
|
54
|
+
|
|
55
|
+
A skill can cite a file in a sibling skill or in a shared folder, written from the skill root: `` `../vue-expert/references/components.md` `` or `` `../_shared/markup-convention.md` ``. The spec wants every reference to stay inside the skill folder, so `SPEC-10` (`reference-depth`) leaves these paths to two other rules:
|
|
56
|
+
|
|
57
|
+
- `CONV-15` (`cross-skill-references`) checks that the file exists, that the folder is a skill or is listed in `shared_dirs`, and that a cited skill appears in `metadata.related-skills` (a warning when it doesn't).
|
|
58
|
+
- `P17` (`cross-skill-targets-ship`) checks that the cited file ships with the plugin. A skill installed on its own won't find its sibling, so these references only work when the whole plugin is installed.
|
|
59
|
+
|
|
60
|
+
## Configuration
|
|
61
|
+
|
|
62
|
+
Everything lives under `[tool.skill-linter]` in the repo's `pyproject.toml`. Unknown keys and unknown rule IDs are errors, so a typo can't switch a check off without anyone noticing. `tests/adoption/` holds complete configs for claude-skills and writing-with-agents.
|
|
63
|
+
|
|
64
|
+
```toml
|
|
65
|
+
[tool.skill-linter]
|
|
66
|
+
skills_dir = "plugin/skills"
|
|
67
|
+
shared_dirs = ["_shared"]
|
|
68
|
+
commands_dir = "plugin/commands"
|
|
69
|
+
plugin_root = "plugin"
|
|
70
|
+
docs_url = "https://example.github.io/my-skills/skills/{domain}/{name}/"
|
|
71
|
+
|
|
72
|
+
[tool.skill-linter.policy]
|
|
73
|
+
workflow_steps = [5, 7]
|
|
74
|
+
license = "MIT"
|
|
75
|
+
|
|
76
|
+
[tool.skill-linter.severity]
|
|
77
|
+
plugin-icon = "off" # or P10
|
|
78
|
+
|
|
79
|
+
[[tool.skill-linter.rules.custom]]
|
|
80
|
+
id = "X-lead-support"
|
|
81
|
+
target = "body"
|
|
82
|
+
pattern = '(?is)\blead\b.*\bsupport\b'
|
|
83
|
+
severity = "warning"
|
|
84
|
+
message = "Name a lead and a support role"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Custom rules are regular expressions applied to a frontmatter field, a section, the last line, the body or the reference files. Config never runs code.
|
|
88
|
+
|
|
89
|
+
### Stamping
|
|
90
|
+
|
|
91
|
+
Each `[[tool.skill-linter.stamp.files]]` entry names a file and how to update it:
|
|
92
|
+
|
|
93
|
+
```toml
|
|
94
|
+
[[tool.skill-linter.stamp.files]]
|
|
95
|
+
path = "README.md" # .md, .mdx and .html files get marker stamping by default
|
|
96
|
+
patterns = [
|
|
97
|
+
{ key = "version", regex = 'version-([\d.]+)-blue\.svg' },
|
|
98
|
+
{ key = "referenceFileCount.{skill}", regex = '(?m)^\s+(?P<skill>[\w-]+)/\s+\(SKILL\.md \+ (?P<count>\d+) reference(?P<s>s?)\)' },
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
[[tool.skill-linter.stamp.files]]
|
|
102
|
+
path = ".claude-plugin/marketplace.json"
|
|
103
|
+
json = { "/metadata/version" = "version", "/plugins/0/version" = "version" }
|
|
104
|
+
patterns = [{ key = "skillCount", regex = '(\d+) specialized skills' }]
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
- Markers: `<!-- SKILL_COUNT -->67<!-- /SKILL_COUNT -->`, and likewise `WORKFLOW_COUNT`, `REFERENCE_COUNT`, `DOMAIN_COUNT` and `VERSION`. `SKILL_COUNT:<domain>` counts one domain and `REFERENCE_COUNT:<skill>` counts one skill's references.
|
|
108
|
+
- `json` maps a JSON Pointer to a key and replaces only that value, leaving the file's formatting alone.
|
|
109
|
+
- `patterns` replace the one group in each regex. A pattern that matches nothing is an error, which catches a sentence reworded after its pattern was written.
|
|
110
|
+
- The key `referenceFileCount.{skill}` stamps a count per skill, for places where an HTML comment would show up as text, such as a file tree inside a code block. Its regex names the skill and count groups. The optional `s` group becomes `s` unless the count is 1, and a line that names no skill is an error.
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
uv sync
|
|
116
|
+
uv run pytest
|
|
117
|
+
uv run ruff check . && uv run ruff format --check .
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The adoption tests export pinned commits of the two skill repos with `git archive` and skip when those checkouts aren't present. One of them runs each repo's own `update-docs.py` and `skill-linter stamp` on the same change and checks that every file comes out identical.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# skill-linter
|
|
2
|
+
|
|
3
|
+
skill-linter checks a repo of Agent Skills before you commit or release it, and keeps the counts and version numbers in its docs current. One `[tool.skill-linter]` table in `pyproject.toml` configures it, so a new skill repo gets the same checks as the others without copying `validate-skills.py`, `validate-markdown.py` and `update-docs.py` into it.
|
|
4
|
+
|
|
5
|
+
It needs Python 3.11 or later. PyYAML is the only dependency, used for workflow YAML.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
uvx skill-linter check # run without installing
|
|
9
|
+
pipx install skill-linter # or install it once
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Status: early (0.x). Rule names and config keys may still change between minor versions.
|
|
13
|
+
|
|
14
|
+
## Commands
|
|
15
|
+
|
|
16
|
+
```text
|
|
17
|
+
skill-linter check [--only GROUP[,GROUP]] [--skill NAME] [--format text|json] [--root PATH]
|
|
18
|
+
skill-linter stamp [--check] [--dry-run] [--root PATH]
|
|
19
|
+
skill-linter release [--format text|json] [--root PATH]
|
|
20
|
+
skill-linter rules [--format text|json]
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
- `check` runs every enabled rule except the release checks.
|
|
24
|
+
- `stamp` counts skills, references, workflows and domains, then writes those counts and the version from `version.json` into the files you list. `--check` writes nothing and exits 1 when anything is stale.
|
|
25
|
+
- `release` checks the tree the plugin directory will read (`plugin_root` or a `ship` list), then runs `check`.
|
|
26
|
+
- `rules` lists every rule with its group, layer and severity after your overrides.
|
|
27
|
+
|
|
28
|
+
`skill-linter --version` prints the installed version. Exit codes: 0 when there are no errors, 1 when there are errors or stale stamps, 2 for a usage or config error. `python -m skill_linter` runs the same CLI.
|
|
29
|
+
|
|
30
|
+
## Rules
|
|
31
|
+
|
|
32
|
+
Rules come in three layers:
|
|
33
|
+
|
|
34
|
+
1. The [Agent Skills specification](https://agentskills.io/specification) (`SPEC-01` to `SPEC-10`). You can't switch these off.
|
|
35
|
+
2. Conventions shared across the skill repos: frontmatter metadata, the description rule, Markdown lint, workflow definitions, hidden conventions such as the docs backlink and title safety, and the plugin-directory release checks (`CONV`, `DESC`, `MD`, `WF`, `H`, `P`). Each one can be switched off or lowered to a warning.
|
|
36
|
+
3. Per-repo policy (`POL`, `LINK`, and your own `X-` rules). A policy rule runs only once you give it a value.
|
|
37
|
+
|
|
38
|
+
Every rule has an ID and a name, such as `POL-03` and `section-order`. Output shows both, and the config accepts either. Run `skill-linter rules` for the full list.
|
|
39
|
+
|
|
40
|
+
### Cross-skill references
|
|
41
|
+
|
|
42
|
+
A skill can cite a file in a sibling skill or in a shared folder, written from the skill root: `` `../vue-expert/references/components.md` `` or `` `../_shared/markup-convention.md` ``. The spec wants every reference to stay inside the skill folder, so `SPEC-10` (`reference-depth`) leaves these paths to two other rules:
|
|
43
|
+
|
|
44
|
+
- `CONV-15` (`cross-skill-references`) checks that the file exists, that the folder is a skill or is listed in `shared_dirs`, and that a cited skill appears in `metadata.related-skills` (a warning when it doesn't).
|
|
45
|
+
- `P17` (`cross-skill-targets-ship`) checks that the cited file ships with the plugin. A skill installed on its own won't find its sibling, so these references only work when the whole plugin is installed.
|
|
46
|
+
|
|
47
|
+
## Configuration
|
|
48
|
+
|
|
49
|
+
Everything lives under `[tool.skill-linter]` in the repo's `pyproject.toml`. Unknown keys and unknown rule IDs are errors, so a typo can't switch a check off without anyone noticing. `tests/adoption/` holds complete configs for claude-skills and writing-with-agents.
|
|
50
|
+
|
|
51
|
+
```toml
|
|
52
|
+
[tool.skill-linter]
|
|
53
|
+
skills_dir = "plugin/skills"
|
|
54
|
+
shared_dirs = ["_shared"]
|
|
55
|
+
commands_dir = "plugin/commands"
|
|
56
|
+
plugin_root = "plugin"
|
|
57
|
+
docs_url = "https://example.github.io/my-skills/skills/{domain}/{name}/"
|
|
58
|
+
|
|
59
|
+
[tool.skill-linter.policy]
|
|
60
|
+
workflow_steps = [5, 7]
|
|
61
|
+
license = "MIT"
|
|
62
|
+
|
|
63
|
+
[tool.skill-linter.severity]
|
|
64
|
+
plugin-icon = "off" # or P10
|
|
65
|
+
|
|
66
|
+
[[tool.skill-linter.rules.custom]]
|
|
67
|
+
id = "X-lead-support"
|
|
68
|
+
target = "body"
|
|
69
|
+
pattern = '(?is)\blead\b.*\bsupport\b'
|
|
70
|
+
severity = "warning"
|
|
71
|
+
message = "Name a lead and a support role"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Custom rules are regular expressions applied to a frontmatter field, a section, the last line, the body or the reference files. Config never runs code.
|
|
75
|
+
|
|
76
|
+
### Stamping
|
|
77
|
+
|
|
78
|
+
Each `[[tool.skill-linter.stamp.files]]` entry names a file and how to update it:
|
|
79
|
+
|
|
80
|
+
```toml
|
|
81
|
+
[[tool.skill-linter.stamp.files]]
|
|
82
|
+
path = "README.md" # .md, .mdx and .html files get marker stamping by default
|
|
83
|
+
patterns = [
|
|
84
|
+
{ key = "version", regex = 'version-([\d.]+)-blue\.svg' },
|
|
85
|
+
{ key = "referenceFileCount.{skill}", regex = '(?m)^\s+(?P<skill>[\w-]+)/\s+\(SKILL\.md \+ (?P<count>\d+) reference(?P<s>s?)\)' },
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
[[tool.skill-linter.stamp.files]]
|
|
89
|
+
path = ".claude-plugin/marketplace.json"
|
|
90
|
+
json = { "/metadata/version" = "version", "/plugins/0/version" = "version" }
|
|
91
|
+
patterns = [{ key = "skillCount", regex = '(\d+) specialized skills' }]
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
- Markers: `<!-- SKILL_COUNT -->67<!-- /SKILL_COUNT -->`, and likewise `WORKFLOW_COUNT`, `REFERENCE_COUNT`, `DOMAIN_COUNT` and `VERSION`. `SKILL_COUNT:<domain>` counts one domain and `REFERENCE_COUNT:<skill>` counts one skill's references.
|
|
95
|
+
- `json` maps a JSON Pointer to a key and replaces only that value, leaving the file's formatting alone.
|
|
96
|
+
- `patterns` replace the one group in each regex. A pattern that matches nothing is an error, which catches a sentence reworded after its pattern was written.
|
|
97
|
+
- The key `referenceFileCount.{skill}` stamps a count per skill, for places where an HTML comment would show up as text, such as a file tree inside a code block. Its regex names the skill and count groups. The optional `s` group becomes `s` unless the count is 1, and a line that names no skill is an error.
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
uv sync
|
|
103
|
+
uv run pytest
|
|
104
|
+
uv run ruff check . && uv run ruff format --check .
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The adoption tests export pinned commits of the two skill repos with `git archive` and skip when those checkouts aren't present. One of them runs each repo's own `update-docs.py` and `skill-linter stamp` on the same change and checks that every file comes out identical.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "skill-linter"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Validate Agent Skills repos: spec checks, shared conventions, per-repo policy, count stamping and plugin release checks"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [{ name = "Jeff Smolinski" }]
|
|
9
|
+
dependencies = ["pyyaml>=6"]
|
|
10
|
+
|
|
11
|
+
[project.urls]
|
|
12
|
+
Repository = "https://github.com/Jeffallan/skill-linter"
|
|
13
|
+
Issues = "https://github.com/Jeffallan/skill-linter/issues"
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
skill-linter = "skill_linter.cli:main"
|
|
17
|
+
|
|
18
|
+
[dependency-groups]
|
|
19
|
+
dev = ["pytest>=8", "ruff>=0.17"]
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["hatchling"]
|
|
23
|
+
build-backend = "hatchling.build"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/skill_linter"]
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
testpaths = ["tests"]
|
|
30
|
+
markers = ["adoption: runs against pinned commits of the real skill repos (skips when absent)"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""skill-linter: validate Agent Skills repos."""
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Command-line interface: skill-linter check | release | stamp | rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import difflib
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
from importlib.metadata import version
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from skill_linter import model, release, stamp
|
|
14
|
+
from skill_linter.config import Config, ConfigError, find_root, load_config
|
|
15
|
+
from skill_linter.engine import all_rules, effective_severity, run, validate_config
|
|
16
|
+
from skill_linter.repo import Repo
|
|
17
|
+
from skill_linter.report import format_json, format_text
|
|
18
|
+
|
|
19
|
+
EXIT_OK, EXIT_ERRORS, EXIT_USAGE = 0, 1, 2
|
|
20
|
+
CHECK_EXCLUDES = frozenset({"release"})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parser() -> argparse.ArgumentParser:
|
|
24
|
+
parser = argparse.ArgumentParser(
|
|
25
|
+
prog="skill-linter", description="Validate an Agent Skills repo."
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--version", action="version", version=f"skill-linter {version('skill-linter')}"
|
|
29
|
+
)
|
|
30
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
31
|
+
|
|
32
|
+
check = sub.add_parser(
|
|
33
|
+
"check", help="run every enabled rule except the release checks"
|
|
34
|
+
)
|
|
35
|
+
check.add_argument("--only", help="comma-separated rule groups to run")
|
|
36
|
+
check.add_argument(
|
|
37
|
+
"--skill", help="limit skill rules to one skill and skip repo rules"
|
|
38
|
+
)
|
|
39
|
+
check.add_argument("--format", choices=("text", "json"), default="text")
|
|
40
|
+
check.add_argument(
|
|
41
|
+
"--root",
|
|
42
|
+
type=Path,
|
|
43
|
+
help="repo root (default: nearest pyproject.toml with the table)",
|
|
44
|
+
)
|
|
45
|
+
check.add_argument(
|
|
46
|
+
"-v",
|
|
47
|
+
"--verbose",
|
|
48
|
+
action="store_true",
|
|
49
|
+
help="also show info issues and skipped rules",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
release_cmd = sub.add_parser(
|
|
53
|
+
"release", help="run the plugin release checks on the shipped tree, then check"
|
|
54
|
+
)
|
|
55
|
+
release_cmd.add_argument("--format", choices=("text", "json"), default="text")
|
|
56
|
+
release_cmd.add_argument("--root", type=Path)
|
|
57
|
+
release_cmd.add_argument("-v", "--verbose", action="store_true")
|
|
58
|
+
|
|
59
|
+
stamp = sub.add_parser(
|
|
60
|
+
"stamp",
|
|
61
|
+
help="write counts and the version into version.json and the stamp files",
|
|
62
|
+
)
|
|
63
|
+
stamp.add_argument(
|
|
64
|
+
"--check",
|
|
65
|
+
action="store_true",
|
|
66
|
+
help="write nothing; exit 1 if anything is stale",
|
|
67
|
+
)
|
|
68
|
+
stamp.add_argument(
|
|
69
|
+
"--dry-run", action="store_true", help="print the diff; write nothing"
|
|
70
|
+
)
|
|
71
|
+
stamp.add_argument("--root", type=Path)
|
|
72
|
+
|
|
73
|
+
rules = sub.add_parser("rules", help="list every rule")
|
|
74
|
+
rules.add_argument("--format", choices=("text", "json"), default="text")
|
|
75
|
+
rules.add_argument("--root", type=Path)
|
|
76
|
+
return parser
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _load(root: Path | None) -> Config:
|
|
80
|
+
cfg = load_config(root.resolve()) if root else load_config(find_root(Path.cwd()))
|
|
81
|
+
validate_config(cfg)
|
|
82
|
+
return cfg
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _groups() -> set[str]:
|
|
86
|
+
return {r.group for r in model.REGISTRY.values()} | {"custom"}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cmd_check(args) -> int:
|
|
90
|
+
groups = None
|
|
91
|
+
if args.only:
|
|
92
|
+
groups = {g.strip() for g in args.only.split(",") if g.strip()}
|
|
93
|
+
unknown = groups - _groups()
|
|
94
|
+
if unknown:
|
|
95
|
+
print(
|
|
96
|
+
f"unknown rule group(s): {', '.join(sorted(unknown))}", file=sys.stderr
|
|
97
|
+
)
|
|
98
|
+
return EXIT_USAGE
|
|
99
|
+
cfg = _load(args.root)
|
|
100
|
+
report = run(
|
|
101
|
+
Repo(cfg),
|
|
102
|
+
groups=groups,
|
|
103
|
+
skill=args.skill,
|
|
104
|
+
exclude_groups=CHECK_EXCLUDES - (groups or set()),
|
|
105
|
+
)
|
|
106
|
+
print(
|
|
107
|
+
format_json(report, cfg.root)
|
|
108
|
+
if args.format == "json"
|
|
109
|
+
else format_text(report, args.verbose)
|
|
110
|
+
)
|
|
111
|
+
return EXIT_ERRORS if report.has_errors else EXIT_OK
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cmd_release(args) -> int:
|
|
115
|
+
cfg = _load(args.root)
|
|
116
|
+
repo = Repo(cfg)
|
|
117
|
+
release.shipped_tree(
|
|
118
|
+
repo
|
|
119
|
+
) # config error (exit 2) when neither plugin_root nor ship is set
|
|
120
|
+
report = run(repo)
|
|
121
|
+
print(
|
|
122
|
+
format_json(report, cfg.root)
|
|
123
|
+
if args.format == "json"
|
|
124
|
+
else format_text(report, args.verbose)
|
|
125
|
+
)
|
|
126
|
+
return EXIT_ERRORS if report.has_errors else EXIT_OK
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def cmd_stamp(args) -> int:
|
|
130
|
+
repo = Repo(_load(args.root))
|
|
131
|
+
changes, errors = stamp.plan(repo)
|
|
132
|
+
for message in errors:
|
|
133
|
+
print(f"error: {message}", file=sys.stderr)
|
|
134
|
+
if errors:
|
|
135
|
+
return EXIT_ERRORS
|
|
136
|
+
if args.dry_run:
|
|
137
|
+
for rel, (old, new) in changes.items():
|
|
138
|
+
sys.stdout.writelines(
|
|
139
|
+
difflib.unified_diff(
|
|
140
|
+
old.splitlines(True), new.splitlines(True), f"a/{rel}", f"b/{rel}"
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
return EXIT_OK
|
|
144
|
+
if args.check:
|
|
145
|
+
for rel in changes:
|
|
146
|
+
print(f"stale: {rel}")
|
|
147
|
+
return EXIT_ERRORS if changes else EXIT_OK
|
|
148
|
+
stamp.write(repo, changes)
|
|
149
|
+
for rel in changes:
|
|
150
|
+
print(f"stamped: {rel}")
|
|
151
|
+
return EXIT_OK
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
LAYERS = {1: "spec", 2: "convention", 3: "policy"}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _rule_order(r) -> tuple:
|
|
158
|
+
"""Registration order of groups, then IDs in numeric order (H01 before H16)."""
|
|
159
|
+
groups = list(dict.fromkeys(x.group for x in model.REGISTRY.values()))
|
|
160
|
+
prefix, number = re.match(r"^(.*?)(\d*)$", r.id).groups()
|
|
161
|
+
rank = groups.index(r.group) if r.group in groups else len(groups)
|
|
162
|
+
return (rank, prefix, int(number) if number else 0, r.id)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def cmd_rules(args) -> int:
|
|
166
|
+
try:
|
|
167
|
+
cfg = _load(args.root)
|
|
168
|
+
except ConfigError:
|
|
169
|
+
if args.root:
|
|
170
|
+
raise
|
|
171
|
+
cfg = None
|
|
172
|
+
rows = []
|
|
173
|
+
rules = all_rules(cfg) if cfg is not None else list(model.REGISTRY.values())
|
|
174
|
+
for r in sorted(rules, key=_rule_order):
|
|
175
|
+
row = {
|
|
176
|
+
"id": r.id,
|
|
177
|
+
"name": r.name,
|
|
178
|
+
"group": r.group,
|
|
179
|
+
"layer": r.layer,
|
|
180
|
+
"default": r.severity.value,
|
|
181
|
+
"overridable": r.overridable,
|
|
182
|
+
"summary": r.summary,
|
|
183
|
+
}
|
|
184
|
+
if cfg is not None:
|
|
185
|
+
row["effective"] = effective_severity(cfg, r).value
|
|
186
|
+
rows.append(row)
|
|
187
|
+
if args.format == "json":
|
|
188
|
+
print(json.dumps(rows, indent=2))
|
|
189
|
+
else:
|
|
190
|
+
name_width = max(len(row["name"]) for row in rows)
|
|
191
|
+
for row in rows:
|
|
192
|
+
sev = row.get("effective", row["default"])
|
|
193
|
+
print(
|
|
194
|
+
f"{row['id']:<8} {row['name']:<{name_width}} {row['group']:<12} "
|
|
195
|
+
f"{LAYERS[row['layer']]:<10} {sev:<7} {row['summary']}"
|
|
196
|
+
)
|
|
197
|
+
return EXIT_OK
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def main(argv: list[str] | None = None) -> int:
|
|
201
|
+
args = _parser().parse_args(argv)
|
|
202
|
+
try:
|
|
203
|
+
commands = {
|
|
204
|
+
"check": cmd_check,
|
|
205
|
+
"release": cmd_release,
|
|
206
|
+
"stamp": cmd_stamp,
|
|
207
|
+
"rules": cmd_rules,
|
|
208
|
+
}
|
|
209
|
+
return commands[args.command](args)
|
|
210
|
+
except ConfigError as exc:
|
|
211
|
+
print(f"config error: {exc}", file=sys.stderr)
|
|
212
|
+
return EXIT_USAGE
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
sys.exit(main())
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Command files, their YAML definitions, and plugin locations, shared by the hidden and workflow rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from skill_linter.repo import Repo
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Definition:
|
|
16
|
+
file: Path
|
|
17
|
+
rel: str
|
|
18
|
+
data: dict[str, Any] | None
|
|
19
|
+
error: str | None
|
|
20
|
+
|
|
21
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
22
|
+
return self.data.get(key, default) if isinstance(self.data, dict) else default
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_yaml(text: str) -> tuple[Any, str | None]:
|
|
26
|
+
try:
|
|
27
|
+
return yaml.safe_load(text), None
|
|
28
|
+
except yaml.YAMLError as exc:
|
|
29
|
+
return None, str(exc).replace("\n", " ")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def commands_dir(repo: Repo) -> Path | None:
|
|
33
|
+
return repo.cfg.path(repo.cfg.commands_dir) if repo.cfg.commands_dir else None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def manifest_path(repo: Repo) -> Path | None:
|
|
37
|
+
if repo.cfg.manifest:
|
|
38
|
+
return repo.cfg.path(repo.cfg.manifest)
|
|
39
|
+
base = commands_dir(repo)
|
|
40
|
+
return base / "workflow-manifest.yaml" if base else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def definitions(repo: Repo) -> list[Definition]:
|
|
44
|
+
cached = getattr(repo, "_definitions", None)
|
|
45
|
+
if cached is not None:
|
|
46
|
+
return cached
|
|
47
|
+
base = commands_dir(repo)
|
|
48
|
+
found: list[Definition] = []
|
|
49
|
+
manifest = manifest_path(repo)
|
|
50
|
+
if base and base.is_dir():
|
|
51
|
+
for path in sorted(base.rglob("*.yaml")):
|
|
52
|
+
if manifest and path.resolve() == manifest.resolve():
|
|
53
|
+
continue
|
|
54
|
+
data, error = load_yaml(repo.read(path))
|
|
55
|
+
if error is None and not isinstance(data, dict):
|
|
56
|
+
error = "definition must be a YAML mapping"
|
|
57
|
+
data = None
|
|
58
|
+
found.append(Definition(path, repo.rel(path), data, error))
|
|
59
|
+
repo._definitions = found
|
|
60
|
+
return found
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def command_files(repo: Repo) -> list[Path]:
|
|
64
|
+
"""Every .md under the commands folder, except files inside a references/ folder."""
|
|
65
|
+
base = commands_dir(repo)
|
|
66
|
+
if not base or not base.is_dir():
|
|
67
|
+
return []
|
|
68
|
+
return sorted(
|
|
69
|
+
p for p in base.rglob("*.md") if "references" not in p.relative_to(base).parts
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def frontmatter_block(text: str) -> str | None:
|
|
74
|
+
lines = text.splitlines()
|
|
75
|
+
if not lines or lines[0].strip() != "---":
|
|
76
|
+
return None
|
|
77
|
+
for index in range(1, len(lines)):
|
|
78
|
+
if lines[index].strip() == "---":
|
|
79
|
+
return "\n".join(lines[1:index])
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def command_frontmatter(text: str) -> tuple[dict[str, Any] | None, str | None]:
|
|
84
|
+
"""(data, error). data is None with no error when the file has no frontmatter."""
|
|
85
|
+
block = frontmatter_block(text)
|
|
86
|
+
if block is None:
|
|
87
|
+
if text.splitlines()[:1] == ["---"]:
|
|
88
|
+
return None, "frontmatter opened with --- but never closed"
|
|
89
|
+
return None, None
|
|
90
|
+
data, error = load_yaml(block)
|
|
91
|
+
if error:
|
|
92
|
+
return None, error
|
|
93
|
+
if data is None:
|
|
94
|
+
return {}, None
|
|
95
|
+
if not isinstance(data, dict):
|
|
96
|
+
return None, "frontmatter must be a YAML mapping"
|
|
97
|
+
return data, None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def plugin_dir(repo: Repo) -> Path:
|
|
101
|
+
return repo.cfg.path(repo.cfg.plugin_root) if repo.cfg.plugin_root else repo.root
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def plugin_json_path(repo: Repo) -> Path:
|
|
105
|
+
return plugin_dir(repo) / ".claude-plugin" / "plugin.json"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def marketplace_path(repo: Repo) -> Path:
|
|
109
|
+
return repo.root / ".claude-plugin" / "marketplace.json"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def skill_and_command_files(repo: Repo, pattern: str = "*.md") -> list[Path]:
|
|
113
|
+
paths: list[Path] = []
|
|
114
|
+
for base in (repo.skills_dir, commands_dir(repo)):
|
|
115
|
+
if base and base.is_dir():
|
|
116
|
+
paths.extend(p for p in sorted(base.rglob(pattern)) if p.is_file())
|
|
117
|
+
return paths
|