skillcheck 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 (34) hide show
  1. skillcheck-0.1.0/.claude/settings.local.json +21 -0
  2. skillcheck-0.1.0/.github/banner.svg +26 -0
  3. skillcheck-0.1.0/.gitignore +10 -0
  4. skillcheck-0.1.0/LICENSE +21 -0
  5. skillcheck-0.1.0/PKG-INFO +142 -0
  6. skillcheck-0.1.0/README.md +128 -0
  7. skillcheck-0.1.0/SKILL.md +65 -0
  8. skillcheck-0.1.0/pyproject.toml +31 -0
  9. skillcheck-0.1.0/src/skillcheck/__init__.py +15 -0
  10. skillcheck-0.1.0/src/skillcheck/cli.py +228 -0
  11. skillcheck-0.1.0/src/skillcheck/config.py +21 -0
  12. skillcheck-0.1.0/src/skillcheck/core.py +50 -0
  13. skillcheck-0.1.0/src/skillcheck/parser.py +62 -0
  14. skillcheck-0.1.0/src/skillcheck/result.py +30 -0
  15. skillcheck-0.1.0/src/skillcheck/rules/__init__.py +49 -0
  16. skillcheck-0.1.0/src/skillcheck/rules/frontmatter.py +230 -0
  17. skillcheck-0.1.0/src/skillcheck/rules/patterns.py +11 -0
  18. skillcheck-0.1.0/src/skillcheck/rules/sizing.py +47 -0
  19. skillcheck-0.1.0/src/skillcheck/tokenizer.py +39 -0
  20. skillcheck-0.1.0/tests/conftest.py +10 -0
  21. skillcheck-0.1.0/tests/fixtures/bad_body_long.md +505 -0
  22. skillcheck-0.1.0/tests/fixtures/bad_desc_empty.md +6 -0
  23. skillcheck-0.1.0/tests/fixtures/bad_desc_person.md +6 -0
  24. skillcheck-0.1.0/tests/fixtures/bad_field_typo.md +7 -0
  25. skillcheck-0.1.0/tests/fixtures/bad_name_caps.md +6 -0
  26. skillcheck-0.1.0/tests/fixtures/bad_name_long.md +6 -0
  27. skillcheck-0.1.0/tests/fixtures/bad_name_reserved.md +6 -0
  28. skillcheck-0.1.0/tests/fixtures/bad_no_frontmatter.md +4 -0
  29. skillcheck-0.1.0/tests/fixtures/valid_basic.md +6 -0
  30. skillcheck-0.1.0/tests/fixtures/valid_full.md +23 -0
  31. skillcheck-0.1.0/tests/test_cli.py +223 -0
  32. skillcheck-0.1.0/tests/test_frontmatter.py +305 -0
  33. skillcheck-0.1.0/tests/test_parser.py +72 -0
  34. skillcheck-0.1.0/tests/test_sizing.py +96 -0
@@ -0,0 +1,21 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(pip install:*)",
5
+ "Bash(skillcheck --version)",
6
+ "Bash(python -m pytest tests/ -v)",
7
+ "Bash(skillcheck tests/fixtures/valid_basic.md)",
8
+ "Bash(skillcheck tests/fixtures/bad_name_caps.md)",
9
+ "Bash(echo \"exit: $?\")",
10
+ "Bash(skillcheck tests/fixtures/bad_name_caps.md --format json)",
11
+ "Bash(python3 -m json.tool --no-ensure-ascii)",
12
+ "Bash(for f:*)",
13
+ "Bash(do echo:*)",
14
+ "Bash(python3 -m pytest tests/ -v)",
15
+ "Bash(skillcheck /home/brad/projects/skillcheck/SKILL.md)",
16
+ "Bash(skillcheck /home/brad/projects/skillcheck/SKILL.md --format json)",
17
+ "Bash(python3 -m pytest tests/test_sizing.py -v)",
18
+ "Bash(python3 -m pytest tests/)"
19
+ ]
20
+ }
21
+ }
@@ -0,0 +1,26 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 200" fill="none">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0" y1="0" x2="800" y2="200" gradientUnits="userSpaceOnUse">
4
+ <stop offset="0%" stop-color="#0f0f23"/>
5
+ <stop offset="100%" stop-color="#1a1a3e"/>
6
+ </linearGradient>
7
+ <linearGradient id="accent" x1="0" y1="0" x2="1" y2="1">
8
+ <stop offset="0%" stop-color="#d4a574"/>
9
+ <stop offset="100%" stop-color="#e8c9a0"/>
10
+ </linearGradient>
11
+ </defs>
12
+ <rect width="800" height="200" rx="12" fill="url(#bg)"/>
13
+ <!-- check icon -->
14
+ <g transform="translate(280, 55)">
15
+ <circle cx="40" cy="40" r="36" stroke="url(#accent)" stroke-width="3" fill="none" opacity="0.9"/>
16
+ <polyline points="22,42 36,56 60,28" stroke="#4ade80" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
17
+ </g>
18
+ <!-- title -->
19
+ <text x="400" y="108" text-anchor="middle" font-family="ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace" font-size="42" font-weight="700" fill="url(#accent)">
20
+ <tspan dx="52">skillcheck</tspan>
21
+ </text>
22
+ <!-- subtitle -->
23
+ <text x="400" y="142" text-anchor="middle" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif" font-size="15" fill="#8b8ba7" letter-spacing="0.5">
24
+ Static analyzer for Claude Code SKILL.md files
25
+ </text>
26
+ </svg>
@@ -0,0 +1,10 @@
1
+ .env
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ *.egg
10
+ CLAUDE.md
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 skillcheck contributors
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,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: skillcheck
3
+ Version: 0.1.0
4
+ Summary: Validates Claude Code SKILL.md files against the Anthropic skill specification
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: pyyaml>=6.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=8.0; extra == 'dev'
11
+ Provides-Extra: tiktoken
12
+ Requires-Dist: tiktoken>=0.7; extra == 'tiktoken'
13
+ Description-Content-Type: text/markdown
14
+
15
+ <div align="center">
16
+
17
+ <picture>
18
+ <source media="(prefers-color-scheme: dark)" srcset=".github/banner.svg">
19
+ <source media="(prefers-color-scheme: light)" srcset=".github/banner.svg">
20
+ <img alt="skillcheck" src=".github/banner.svg" width="600">
21
+ </picture>
22
+
23
+ <br/>
24
+
25
+ **Static analyzer for Claude Code `SKILL.md` files.**<br/>
26
+ Validates frontmatter structure and body sizing against the Anthropic skill specification.
27
+
28
+ <br/>
29
+
30
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
31
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-3776ab.svg)](https://python.org)
32
+ [![PyYAML](https://img.shields.io/badge/deps-PyYAML-yellow.svg)](https://pyyaml.org)
33
+ [![Tests](https://img.shields.io/badge/tests-73%20passed-brightgreen.svg)](#testing)
34
+
35
+ </div>
36
+
37
+ ---
38
+
39
+ ## What It Does
40
+
41
+ `skillcheck` catches problems in your `SKILL.md` files before they hit production:
42
+
43
+ - **Frontmatter validation** — required fields, character constraints, length limits, reserved words, first/second-person voice, XML tags, unknown fields
44
+ - **Body sizing** — line count and token estimate warnings to keep skills within context-window budgets
45
+ - **CI-friendly** — JSON output, deterministic exit codes, zero config
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install skillcheck
51
+ ```
52
+
53
+ Or install from source with dev dependencies:
54
+
55
+ ```bash
56
+ pip install -e ".[dev]"
57
+ ```
58
+
59
+ ## Quick Start
60
+
61
+ ```bash
62
+ # Validate a single file
63
+ skillcheck path/to/SKILL.md
64
+
65
+ # Scan a directory recursively for all SKILL.md files
66
+ skillcheck skills/
67
+
68
+ # Machine-readable output for CI pipelines
69
+ skillcheck skills/ --format json
70
+ ```
71
+
72
+ ## Example Output
73
+
74
+ ```
75
+ ✔ PASS skills/deploy.md
76
+
77
+ ✗ FAIL skills/pdf-tool.md
78
+ line 2 ✗ error frontmatter.name.invalid-chars Name contains uppercase chars
79
+ line 3 ⚠ warning frontmatter.field.unknown Unknown field 'author'
80
+
81
+ Checked 2 files: 1 passed, 1 failed, 1 warning
82
+ ```
83
+
84
+ ## Options
85
+
86
+ | Flag | Description |
87
+ |---|---|
88
+ | `--format json` | Machine-readable JSON output |
89
+ | `--max-lines N` | Override line-count threshold (default: 500) |
90
+ | `--max-tokens N` | Override token-count threshold (default: 8000) |
91
+ | `--ignore PREFIX` | Suppress rules matching a prefix (repeatable) |
92
+ | `--no-color` | Disable colored output |
93
+ | `--version` | Show version |
94
+
95
+ ### Examples
96
+
97
+ ```bash
98
+ # Override sizing thresholds
99
+ skillcheck skills/ --max-lines 800 --max-tokens 6000
100
+
101
+ # Suppress specific rule categories
102
+ skillcheck SKILL.md --ignore frontmatter.description
103
+
104
+ # Pipe-friendly plain output
105
+ skillcheck SKILL.md --no-color
106
+ ```
107
+
108
+ ## Exit Codes
109
+
110
+ | Code | Meaning |
111
+ |---|---|
112
+ | `0` | No errors (warnings are allowed) |
113
+ | `1` | One or more errors found |
114
+ | `2` | Input error (missing file, empty directory) |
115
+
116
+ ## Rules
117
+
118
+ | Rule ID | Severity | What it checks |
119
+ |---|---|---|
120
+ | `frontmatter.name.required` | error | `name` field must exist |
121
+ | `frontmatter.name.max-length` | error | Name ≤ 64 characters |
122
+ | `frontmatter.name.invalid-chars` | error | Lowercase, numbers, hyphens only |
123
+ | `frontmatter.name.reserved` | error | Not a reserved word (`claude`, `anthropic`, …) |
124
+ | `frontmatter.description.required` | error | `description` field must exist |
125
+ | `frontmatter.description.empty` | error | Description must not be blank |
126
+ | `frontmatter.description.max-length` | error | Description ≤ 1024 characters |
127
+ | `frontmatter.description.xml-tags` | error | No XML/HTML tags in description |
128
+ | `frontmatter.description.person-voice` | error | No first/second-person pronouns |
129
+ | `frontmatter.field.unknown` | warning | Flags fields not in the spec |
130
+ | `sizing.body.line-count` | warning | Body exceeds line threshold |
131
+ | `sizing.body.token-estimate` | warning | Body exceeds token threshold |
132
+
133
+ ## Testing
134
+
135
+ ```bash
136
+ pip install -e ".[dev]"
137
+ python3 -m pytest tests/ -v
138
+ ```
139
+
140
+ ## License
141
+
142
+ MIT
@@ -0,0 +1,128 @@
1
+ <div align="center">
2
+
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset=".github/banner.svg">
5
+ <source media="(prefers-color-scheme: light)" srcset=".github/banner.svg">
6
+ <img alt="skillcheck" src=".github/banner.svg" width="600">
7
+ </picture>
8
+
9
+ <br/>
10
+
11
+ **Static analyzer for Claude Code `SKILL.md` files.**<br/>
12
+ Validates frontmatter structure and body sizing against the Anthropic skill specification.
13
+
14
+ <br/>
15
+
16
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
17
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-3776ab.svg)](https://python.org)
18
+ [![PyYAML](https://img.shields.io/badge/deps-PyYAML-yellow.svg)](https://pyyaml.org)
19
+ [![Tests](https://img.shields.io/badge/tests-73%20passed-brightgreen.svg)](#testing)
20
+
21
+ </div>
22
+
23
+ ---
24
+
25
+ ## What It Does
26
+
27
+ `skillcheck` catches problems in your `SKILL.md` files before they hit production:
28
+
29
+ - **Frontmatter validation** — required fields, character constraints, length limits, reserved words, first/second-person voice, XML tags, unknown fields
30
+ - **Body sizing** — line count and token estimate warnings to keep skills within context-window budgets
31
+ - **CI-friendly** — JSON output, deterministic exit codes, zero config
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install skillcheck
37
+ ```
38
+
39
+ Or install from source with dev dependencies:
40
+
41
+ ```bash
42
+ pip install -e ".[dev]"
43
+ ```
44
+
45
+ ## Quick Start
46
+
47
+ ```bash
48
+ # Validate a single file
49
+ skillcheck path/to/SKILL.md
50
+
51
+ # Scan a directory recursively for all SKILL.md files
52
+ skillcheck skills/
53
+
54
+ # Machine-readable output for CI pipelines
55
+ skillcheck skills/ --format json
56
+ ```
57
+
58
+ ## Example Output
59
+
60
+ ```
61
+ ✔ PASS skills/deploy.md
62
+
63
+ ✗ FAIL skills/pdf-tool.md
64
+ line 2 ✗ error frontmatter.name.invalid-chars Name contains uppercase chars
65
+ line 3 ⚠ warning frontmatter.field.unknown Unknown field 'author'
66
+
67
+ Checked 2 files: 1 passed, 1 failed, 1 warning
68
+ ```
69
+
70
+ ## Options
71
+
72
+ | Flag | Description |
73
+ |---|---|
74
+ | `--format json` | Machine-readable JSON output |
75
+ | `--max-lines N` | Override line-count threshold (default: 500) |
76
+ | `--max-tokens N` | Override token-count threshold (default: 8000) |
77
+ | `--ignore PREFIX` | Suppress rules matching a prefix (repeatable) |
78
+ | `--no-color` | Disable colored output |
79
+ | `--version` | Show version |
80
+
81
+ ### Examples
82
+
83
+ ```bash
84
+ # Override sizing thresholds
85
+ skillcheck skills/ --max-lines 800 --max-tokens 6000
86
+
87
+ # Suppress specific rule categories
88
+ skillcheck SKILL.md --ignore frontmatter.description
89
+
90
+ # Pipe-friendly plain output
91
+ skillcheck SKILL.md --no-color
92
+ ```
93
+
94
+ ## Exit Codes
95
+
96
+ | Code | Meaning |
97
+ |---|---|
98
+ | `0` | No errors (warnings are allowed) |
99
+ | `1` | One or more errors found |
100
+ | `2` | Input error (missing file, empty directory) |
101
+
102
+ ## Rules
103
+
104
+ | Rule ID | Severity | What it checks |
105
+ |---|---|---|
106
+ | `frontmatter.name.required` | error | `name` field must exist |
107
+ | `frontmatter.name.max-length` | error | Name ≤ 64 characters |
108
+ | `frontmatter.name.invalid-chars` | error | Lowercase, numbers, hyphens only |
109
+ | `frontmatter.name.reserved` | error | Not a reserved word (`claude`, `anthropic`, …) |
110
+ | `frontmatter.description.required` | error | `description` field must exist |
111
+ | `frontmatter.description.empty` | error | Description must not be blank |
112
+ | `frontmatter.description.max-length` | error | Description ≤ 1024 characters |
113
+ | `frontmatter.description.xml-tags` | error | No XML/HTML tags in description |
114
+ | `frontmatter.description.person-voice` | error | No first/second-person pronouns |
115
+ | `frontmatter.field.unknown` | warning | Flags fields not in the spec |
116
+ | `sizing.body.line-count` | warning | Body exceeds line threshold |
117
+ | `sizing.body.token-estimate` | warning | Body exceeds token threshold |
118
+
119
+ ## Testing
120
+
121
+ ```bash
122
+ pip install -e ".[dev]"
123
+ python3 -m pytest tests/ -v
124
+ ```
125
+
126
+ ## License
127
+
128
+ MIT
@@ -0,0 +1,65 @@
1
+ ---
2
+ name: git-commit-crafter
3
+ description: Generates conventional commit messages from staged diffs, enforcing semantic versioning conventions and team-defined scopes.
4
+ version: "1.0.0"
5
+ author: brad
6
+ tags:
7
+ - git
8
+ - commits
9
+ - devops
10
+ allowed-tools:
11
+ - Bash
12
+ user-invocable: true
13
+ ---
14
+
15
+ # git-commit-crafter
16
+
17
+ Generates conventional commit messages from staged git diffs. Reads the diff, infers the change type and scope, and produces a formatted commit message ready to paste or pipe directly into `git commit`.
18
+
19
+ ## Trigger
20
+
21
+ Invoke when the user has staged changes and needs a commit message:
22
+
23
+ ```
24
+ /git-commit-crafter
25
+ ```
26
+
27
+ ## Behavior
28
+
29
+ 1. Run `git diff --cached` to read the staged changes.
30
+ 2. Identify the change type from the diff: `feat`, `fix`, `chore`, `docs`, `refactor`, `test`, or `perf`.
31
+ 3. Infer the scope from the files touched (e.g., `auth`, `api`, `cli`, `db`).
32
+ 4. Write a subject line under 72 characters in the form `type(scope): imperative summary`.
33
+ 5. If the diff spans more than one logical concern, add a short body paragraph per concern.
34
+ 6. If any file path contains `BREAKING` or the diff removes a public API surface, append `BREAKING CHANGE:` footer.
35
+
36
+ ## Output format
37
+
38
+ ```
39
+ feat(auth): add OAuth2 PKCE flow for CLI clients
40
+
41
+ Replaces the implicit grant with PKCE to comply with RFC 9700.
42
+ Adds a local redirect server on a random port for the callback.
43
+
44
+ Closes #418
45
+ ```
46
+
47
+ ## Constraints
48
+
49
+ - Never invent change details not present in the diff.
50
+ - Do not reference internal ticket numbers unless they appear in branch name or diff.
51
+ - Keep the subject line imperative mood: "add", "fix", "remove", not "added" or "fixes".
52
+ - If the diff is empty, output: `No staged changes found. Run git add first.`
53
+
54
+ ## Scope inference rules
55
+
56
+ | File path pattern | Scope |
57
+ |------------------------|------------|
58
+ | `src/auth/**` | `auth` |
59
+ | `src/api/**` | `api` |
60
+ | `src/cli/**` | `cli` |
61
+ | `src/db/**` | `db` |
62
+ | `tests/**` | `test` |
63
+ | `docs/**` | `docs` |
64
+ | `*.toml`, `*.cfg` | `config` |
65
+ | Mixed or root-level | (omit scope) |
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "skillcheck"
7
+ version = "0.1.0"
8
+ description = "Validates Claude Code SKILL.md files against the Anthropic skill specification"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "pyyaml>=6.0",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ dev = [
18
+ "pytest>=8.0",
19
+ ]
20
+ tiktoken = [
21
+ "tiktoken>=0.7",
22
+ ]
23
+
24
+ [project.scripts]
25
+ skillcheck = "skillcheck.cli:main"
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["src/skillcheck"]
29
+
30
+ [tool.pytest.ini_options]
31
+ testpaths = ["tests"]
@@ -0,0 +1,15 @@
1
+ from skillcheck.core import validate
2
+ from skillcheck.parser import ParsedSkill, ParseError
3
+ from skillcheck.result import Diagnostic, Severity, ValidationResult
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = [
8
+ "validate",
9
+ "ValidationResult",
10
+ "Diagnostic",
11
+ "Severity",
12
+ "ParsedSkill",
13
+ "ParseError",
14
+ "__version__",
15
+ ]
@@ -0,0 +1,228 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from skillcheck import __version__
9
+ from skillcheck.core import validate
10
+ from skillcheck.result import Severity, ValidationResult
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # ANSI helpers (zero dependencies)
14
+ # ---------------------------------------------------------------------------
15
+
16
+ _RESET = "\033[0m"
17
+ _BOLD = "\033[1m"
18
+ _DIM = "\033[2m"
19
+ _RED = "\033[31m"
20
+ _GREEN = "\033[32m"
21
+ _YELLOW = "\033[33m"
22
+
23
+ _SEV_SYMBOL = {Severity.ERROR: "✗", Severity.WARNING: "⚠", Severity.INFO: "·"}
24
+ _SEV_COLOR = {Severity.ERROR: _RED, Severity.WARNING: _YELLOW, Severity.INFO: _DIM}
25
+
26
+
27
+ def _style(text: str, *codes: str, color: bool = True) -> str:
28
+ """Wrap *text* in ANSI escape codes when *color* is enabled."""
29
+ if not color:
30
+ return text
31
+ return "".join(codes) + text + _RESET
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Path collection
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ def _collect_paths(target: Path) -> list[Path]:
40
+ """Return a list of SKILL.md files to validate.
41
+
42
+ For a directory, recursively finds all files named exactly 'SKILL.md'.
43
+ For a file, returns it directly without name filtering.
44
+ """
45
+ if target.is_dir():
46
+ return sorted(target.rglob("SKILL.md"))
47
+ return [target]
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Formatters
52
+ # ---------------------------------------------------------------------------
53
+
54
+
55
+ def _format_text(results: list[ValidationResult], *, color: bool = False) -> str:
56
+ lines: list[str] = []
57
+ for result in results:
58
+ if result.valid:
59
+ tag = _style("✔ PASS", _BOLD, _GREEN, color=color)
60
+ else:
61
+ tag = _style("✗ FAIL", _BOLD, _RED, color=color)
62
+ lines.append(f"{tag} {result.path}")
63
+
64
+ for d in result.diagnostics:
65
+ sym = _SEV_SYMBOL.get(d.severity, "·")
66
+ sev_col = _SEV_COLOR.get(d.severity, "")
67
+ loc = f"line {d.line}" if d.line is not None else ""
68
+ sev_label = _style(f"{sym} {d.severity.value}", sev_col, color=color)
69
+ rule = _style(d.rule, _DIM, color=color)
70
+ lines.append(f" {loc:>8} {sev_label:<18s} {rule} {d.message}")
71
+ if d.context:
72
+ ctx = _style(d.context, _DIM, color=color)
73
+ lines.append(f"{'':>12} {ctx}")
74
+
75
+ # summary
76
+ total = len(results)
77
+ passed = sum(1 for r in results if r.valid)
78
+ failed = total - passed
79
+ warn_count = sum(
80
+ 1 for r in results for d in r.diagnostics if d.severity == Severity.WARNING
81
+ )
82
+ noun = "file" if total == 1 else "files"
83
+
84
+ parts = [
85
+ _style(f"{passed} passed", _GREEN, color=color),
86
+ _style(f"{failed} failed", _RED, color=color) if failed else f"{failed} failed",
87
+ ]
88
+ if warn_count:
89
+ w = f"{warn_count} warning{'s' if warn_count != 1 else ''}"
90
+ parts.append(_style(w, _YELLOW, color=color))
91
+
92
+ lines.append(f"\nChecked {total} {noun}: {', '.join(parts)}")
93
+ return "\n".join(lines)
94
+
95
+
96
+ def _format_json(results: list[ValidationResult], version: str) -> str:
97
+ passed = sum(1 for r in results if r.valid)
98
+ payload = {
99
+ "version": version,
100
+ "files_checked": len(results),
101
+ "files_passed": passed,
102
+ "files_failed": len(results) - passed,
103
+ "results": [
104
+ {
105
+ "path": str(r.path),
106
+ "valid": r.valid,
107
+ "diagnostics": [
108
+ {
109
+ "rule": d.rule,
110
+ "severity": d.severity.value,
111
+ "message": d.message,
112
+ "line": d.line,
113
+ "context": d.context,
114
+ }
115
+ for d in r.diagnostics
116
+ ],
117
+ }
118
+ for r in results
119
+ ],
120
+ }
121
+ return json.dumps(payload, indent=2)
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Argument parser
126
+ # ---------------------------------------------------------------------------
127
+
128
+ _EPILOG = """\
129
+ examples:
130
+ skillcheck SKILL.md validate a single file
131
+ skillcheck skills/ scan a directory recursively
132
+ skillcheck SKILL.md --format json machine-readable output for CI
133
+ skillcheck SKILL.md --max-lines 800 override sizing thresholds
134
+ skillcheck SKILL.md --ignore frontmatter suppress a rule category
135
+ """
136
+
137
+
138
+ def _build_parser() -> argparse.ArgumentParser:
139
+ parser = argparse.ArgumentParser(
140
+ prog="skillcheck",
141
+ description="Validate Claude Code SKILL.md files against the Anthropic skill specification.",
142
+ epilog=_EPILOG,
143
+ formatter_class=argparse.RawDescriptionHelpFormatter,
144
+ )
145
+ parser.add_argument(
146
+ "path",
147
+ type=Path,
148
+ help="Path to a SKILL.md file or a directory to scan recursively.",
149
+ )
150
+ parser.add_argument(
151
+ "--format",
152
+ choices=["text", "json"],
153
+ default="text",
154
+ help="Output format (default: text).",
155
+ )
156
+ parser.add_argument(
157
+ "--max-lines",
158
+ type=int,
159
+ default=None,
160
+ metavar="N",
161
+ help="Override the line-count threshold (default: 500).",
162
+ )
163
+ parser.add_argument(
164
+ "--max-tokens",
165
+ type=int,
166
+ default=None,
167
+ metavar="N",
168
+ help="Override the token-count threshold (default: 8000).",
169
+ )
170
+ parser.add_argument(
171
+ "--ignore",
172
+ action="append",
173
+ dest="ignore_prefixes",
174
+ metavar="PREFIX",
175
+ default=[],
176
+ help="Suppress rules matching this prefix. Can be repeated.",
177
+ )
178
+ parser.add_argument(
179
+ "--no-color",
180
+ action="store_true",
181
+ default=False,
182
+ help="Disable colored output.",
183
+ )
184
+ parser.add_argument(
185
+ "--version",
186
+ action="version",
187
+ version=f"%(prog)s {__version__}",
188
+ )
189
+ return parser
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Entry point
194
+ # ---------------------------------------------------------------------------
195
+
196
+
197
+ def main() -> None:
198
+ parser = _build_parser()
199
+ args = parser.parse_args()
200
+
201
+ target: Path = args.path
202
+ if not target.exists():
203
+ print(f"Error: path not found: {target}", file=sys.stderr)
204
+ sys.exit(2)
205
+
206
+ paths = _collect_paths(target)
207
+ if not paths:
208
+ print(f"No SKILL.md files found under: {target}", file=sys.stderr)
209
+ sys.exit(2)
210
+
211
+ results = [
212
+ validate(
213
+ p,
214
+ max_lines=args.max_lines,
215
+ max_tokens=args.max_tokens,
216
+ ignore_prefixes=args.ignore_prefixes or None,
217
+ )
218
+ for p in paths
219
+ ]
220
+
221
+ if args.format == "json":
222
+ print(_format_json(results, __version__))
223
+ else:
224
+ use_color = not args.no_color and sys.stdout.isatty()
225
+ print(_format_text(results, color=use_color))
226
+
227
+ any_errors = any(not r.valid for r in results)
228
+ sys.exit(1 if any_errors else 0)