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.
Files changed (49) hide show
  1. skill_linter-0.1.0/.github/workflows/ci.yml +23 -0
  2. skill_linter-0.1.0/.github/workflows/publish.yml +21 -0
  3. skill_linter-0.1.0/.gitignore +5 -0
  4. skill_linter-0.1.0/LICENSE +21 -0
  5. skill_linter-0.1.0/PKG-INFO +120 -0
  6. skill_linter-0.1.0/README.md +107 -0
  7. skill_linter-0.1.0/pyproject.toml +30 -0
  8. skill_linter-0.1.0/src/skill_linter/__init__.py +1 -0
  9. skill_linter-0.1.0/src/skill_linter/__main__.py +5 -0
  10. skill_linter-0.1.0/src/skill_linter/cli.py +216 -0
  11. skill_linter-0.1.0/src/skill_linter/commands.py +117 -0
  12. skill_linter-0.1.0/src/skill_linter/config.py +356 -0
  13. skill_linter-0.1.0/src/skill_linter/engine.py +105 -0
  14. skill_linter-0.1.0/src/skill_linter/frontmatter.py +217 -0
  15. skill_linter-0.1.0/src/skill_linter/mdutil.py +71 -0
  16. skill_linter-0.1.0/src/skill_linter/model.py +96 -0
  17. skill_linter-0.1.0/src/skill_linter/names.py +84 -0
  18. skill_linter-0.1.0/src/skill_linter/release.py +550 -0
  19. skill_linter-0.1.0/src/skill_linter/repo.py +82 -0
  20. skill_linter-0.1.0/src/skill_linter/report.py +72 -0
  21. skill_linter-0.1.0/src/skill_linter/rules/__init__.py +11 -0
  22. skill_linter-0.1.0/src/skill_linter/rules/conventions.py +263 -0
  23. skill_linter-0.1.0/src/skill_linter/rules/custom.py +152 -0
  24. skill_linter-0.1.0/src/skill_linter/rules/description.py +89 -0
  25. skill_linter-0.1.0/src/skill_linter/rules/hidden.py +534 -0
  26. skill_linter-0.1.0/src/skill_linter/rules/markdown.py +146 -0
  27. skill_linter-0.1.0/src/skill_linter/rules/policy.py +188 -0
  28. skill_linter-0.1.0/src/skill_linter/rules/spec.py +220 -0
  29. skill_linter-0.1.0/src/skill_linter/rules/workflows.py +301 -0
  30. skill_linter-0.1.0/src/skill_linter/stamp.py +291 -0
  31. skill_linter-0.1.0/tests/adoption/claude-skills.toml +84 -0
  32. skill_linter-0.1.0/tests/adoption/writing-with-agents.toml +85 -0
  33. skill_linter-0.1.0/tests/conftest.py +114 -0
  34. skill_linter-0.1.0/tests/test_adoption.py +186 -0
  35. skill_linter-0.1.0/tests/test_cli.py +137 -0
  36. skill_linter-0.1.0/tests/test_config.py +139 -0
  37. skill_linter-0.1.0/tests/test_engine.py +143 -0
  38. skill_linter-0.1.0/tests/test_frontmatter.py +131 -0
  39. skill_linter-0.1.0/tests/test_mdutil.py +61 -0
  40. skill_linter-0.1.0/tests/test_names.py +112 -0
  41. skill_linter-0.1.0/tests/test_release.py +466 -0
  42. skill_linter-0.1.0/tests/test_rules_conventions.py +275 -0
  43. skill_linter-0.1.0/tests/test_rules_hidden.py +327 -0
  44. skill_linter-0.1.0/tests/test_rules_markdown.py +63 -0
  45. skill_linter-0.1.0/tests/test_rules_policy.py +288 -0
  46. skill_linter-0.1.0/tests/test_rules_spec.py +213 -0
  47. skill_linter-0.1.0/tests/test_rules_workflows.py +255 -0
  48. skill_linter-0.1.0/tests/test_stamp.py +283 -0
  49. 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,5 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ .pytest_cache/
@@ -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,5 @@
1
+ import sys
2
+
3
+ from skill_linter.cli import main
4
+
5
+ sys.exit(main())
@@ -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