skilldex 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ 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-version: ["3.10", "3.12", "3.13"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - run: pip install -e ".[dev]"
20
+ - run: ruff check .
21
+ - run: pytest -q
@@ -0,0 +1,33 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-python@v5
13
+ with:
14
+ python-version: "3.12"
15
+ - run: pip install build
16
+ - run: python -m build
17
+ - uses: actions/upload-artifact@v4
18
+ with:
19
+ name: dist
20
+ path: dist/
21
+
22
+ publish:
23
+ needs: build
24
+ runs-on: ubuntu-latest
25
+ environment: pypi
26
+ permissions:
27
+ id-token: write # trusted publishing — no tokens or passwords
28
+ steps:
29
+ - uses: actions/download-artifact@v4
30
+ with:
31
+ name: dist
32
+ path: dist/
33
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — unreleased
4
+
5
+ Initial release.
6
+
7
+ - `skilldex search / show` — query the community registry of Claude Code skills,
8
+ subagents, and MCP servers
9
+ - `skilldex install <id> [--project]` — install skills into `~/.claude/skills`
10
+ (or `./.claude`), subagents into `agents/`, MCP servers into `./.mcp.json`
11
+ - `skilldex list` — everything installed locally
12
+ - `skilldex validate` — lint SKILL.md files, subagent files, and registry entries
13
+ - `skilldex audit` — scan local Claude Code config for broken frontmatter and
14
+ plaintext secrets in `.mcp.json`
15
+ - Registry index served from GitHub Pages with CDN-stale fallback and
16
+ refetch-on-miss, cached locally for 1 hour (`--refresh` to bypass,
17
+ `SKILLDEX_REGISTRY_URL` to override)
@@ -0,0 +1,32 @@
1
+ # Contributing to skilldex
2
+
3
+ PRs welcome — this project is intentionally small and readable.
4
+
5
+ ## Setup
6
+
7
+ ```bash
8
+ git clone https://github.com/drewn-ed/skilldex
9
+ cd skilldex
10
+ python3 -m venv .venv
11
+ .venv/bin/pip install -e ".[dev]" # plain `pip install .` also works
12
+ .venv/bin/pytest # tests run straight from src/
13
+ .venv/bin/ruff check .
14
+ ```
15
+
16
+ ## Layout
17
+
18
+ | File | What it does |
19
+ |---|---|
20
+ | `src/skilldex/cli.py` | Typer commands, Rich output |
21
+ | `src/skilldex/registry.py` | index fetch/cache/search |
22
+ | `src/skilldex/installer.py` | GitHub downloads, `.mcp.json` merging |
23
+ | `src/skilldex/validator.py` | all validation rules + secret detection |
24
+ | `src/skilldex/audit.py` | local config scanning |
25
+
26
+ ## Ground rules
27
+
28
+ - Every behavior change comes with a test.
29
+ - `ruff check .` and `pytest` must pass (CI runs them on 3.10/3.12/3.13).
30
+ - Keep dependencies minimal — currently typer, rich, httpx, pyyaml.
31
+ - Want to list your skill/agent/MCP server? That PR goes to
32
+ [skilldex-registry](https://github.com/drewn-ed/skilldex-registry), not here.
skilldex-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Cecko
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,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: skilldex
3
+ Version: 0.1.0
4
+ Summary: Search, install, validate, and audit Claude Code skills, subagents, and MCP servers from a community registry.
5
+ Project-URL: Homepage, https://github.com/drewn-ed/skilldex
6
+ Project-URL: Registry, https://github.com/drewn-ed/skilldex-registry
7
+ Author-email: David Cecko <ceckodavid@gmail.com>
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: claude,claude-code,cli,mcp,registry,skills,subagents
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Build Tools
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: httpx>=0.27
23
+ Requires-Dist: pyyaml>=6.0
24
+ Requires-Dist: rich>=13.0
25
+ Requires-Dist: typer>=0.12
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=8.0; extra == 'dev'
28
+ Requires-Dist: ruff>=0.6; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # skilldex
32
+
33
+ [![CI](https://github.com/drewn-ed/skilldex/actions/workflows/ci.yml/badge.svg)](https://github.com/drewn-ed/skilldex/actions/workflows/ci.yml)
34
+
35
+ **Search, install, validate, and audit Claude Code skills, subagents, and MCP servers — from one community registry.**
36
+
37
+ Claude Code extensions live scattered across hundreds of repos. skilldex gives you a package-manager-style workflow for all three extension types:
38
+
39
+ ```bash
40
+ # no install needed
41
+ uvx skilldex search pdf
42
+ uvx skilldex install pdf # → ~/.claude/skills/pdf
43
+ uvx skilldex install fetch # MCP server → ./.mcp.json
44
+ uvx skilldex install code-reviewer -p # subagent → ./.claude/agents (project scope)
45
+ ```
46
+
47
+ Entries come from the community-maintained [skilldex-registry](https://github.com/drewn-ed/skilldex-registry) — every entry is CI-validated before it's merged. Browse it in your browser at [drewn-ed.github.io/skilldex-registry](https://drewn-ed.github.io/skilldex-registry/).
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ pip install skilldex # or: uv tool install skilldex
53
+ ```
54
+
55
+ ## Commands
56
+
57
+ | Command | What it does |
58
+ |---|---|
59
+ | `skilldex search <query> [-t skill\|agent\|mcp]` | Search the registry |
60
+ | `skilldex show <id>` | Full registry entry as JSON |
61
+ | `skilldex install <id> [--project]` | Install a skill/agent (user or project scope) or add an MCP server to `.mcp.json` |
62
+ | `skilldex list` | Everything installed locally |
63
+ | `skilldex validate <path>` | Lint a `SKILL.md`, subagent `.md`, or registry entry `.json` |
64
+ | `skilldex audit` | Scan local Claude Code config for problems (broken frontmatter, plaintext secrets in `.mcp.json`, …) |
65
+
66
+ ## Why validate/audit?
67
+
68
+ - A skill with a bad `description` silently never triggers — `validate` catches the common frontmatter mistakes before you wonder why.
69
+ - `.mcp.json` is usually committed to git; `audit` flags API keys pasted where a `${VAR}` placeholder belongs.
70
+
71
+ ## Registry
72
+
73
+ Want your skill, subagent, or MCP server listed? Open a PR adding one JSON file to
74
+ [skilldex-registry](https://github.com/drewn-ed/skilldex-registry) — CI validates it automatically,
75
+ and `skilldex validate` runs the same checks locally.
76
+
77
+ Point the CLI at a different (or local) index with `SKILLDEX_REGISTRY_URL`.
78
+
79
+ ## Development
80
+
81
+ ```bash
82
+ pip install -e ".[dev]"
83
+ pytest
84
+ ruff check .
85
+ ```
86
+
87
+ MIT licensed. Not affiliated with Anthropic.
@@ -0,0 +1,57 @@
1
+ # skilldex
2
+
3
+ [![CI](https://github.com/drewn-ed/skilldex/actions/workflows/ci.yml/badge.svg)](https://github.com/drewn-ed/skilldex/actions/workflows/ci.yml)
4
+
5
+ **Search, install, validate, and audit Claude Code skills, subagents, and MCP servers — from one community registry.**
6
+
7
+ Claude Code extensions live scattered across hundreds of repos. skilldex gives you a package-manager-style workflow for all three extension types:
8
+
9
+ ```bash
10
+ # no install needed
11
+ uvx skilldex search pdf
12
+ uvx skilldex install pdf # → ~/.claude/skills/pdf
13
+ uvx skilldex install fetch # MCP server → ./.mcp.json
14
+ uvx skilldex install code-reviewer -p # subagent → ./.claude/agents (project scope)
15
+ ```
16
+
17
+ Entries come from the community-maintained [skilldex-registry](https://github.com/drewn-ed/skilldex-registry) — every entry is CI-validated before it's merged. Browse it in your browser at [drewn-ed.github.io/skilldex-registry](https://drewn-ed.github.io/skilldex-registry/).
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pip install skilldex # or: uv tool install skilldex
23
+ ```
24
+
25
+ ## Commands
26
+
27
+ | Command | What it does |
28
+ |---|---|
29
+ | `skilldex search <query> [-t skill\|agent\|mcp]` | Search the registry |
30
+ | `skilldex show <id>` | Full registry entry as JSON |
31
+ | `skilldex install <id> [--project]` | Install a skill/agent (user or project scope) or add an MCP server to `.mcp.json` |
32
+ | `skilldex list` | Everything installed locally |
33
+ | `skilldex validate <path>` | Lint a `SKILL.md`, subagent `.md`, or registry entry `.json` |
34
+ | `skilldex audit` | Scan local Claude Code config for problems (broken frontmatter, plaintext secrets in `.mcp.json`, …) |
35
+
36
+ ## Why validate/audit?
37
+
38
+ - A skill with a bad `description` silently never triggers — `validate` catches the common frontmatter mistakes before you wonder why.
39
+ - `.mcp.json` is usually committed to git; `audit` flags API keys pasted where a `${VAR}` placeholder belongs.
40
+
41
+ ## Registry
42
+
43
+ Want your skill, subagent, or MCP server listed? Open a PR adding one JSON file to
44
+ [skilldex-registry](https://github.com/drewn-ed/skilldex-registry) — CI validates it automatically,
45
+ and `skilldex validate` runs the same checks locally.
46
+
47
+ Point the CLI at a different (or local) index with `SKILLDEX_REGISTRY_URL`.
48
+
49
+ ## Development
50
+
51
+ ```bash
52
+ pip install -e ".[dev]"
53
+ pytest
54
+ ruff check .
55
+ ```
56
+
57
+ MIT licensed. Not affiliated with Anthropic.
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "skilldex"
7
+ version = "0.1.0"
8
+ description = "Search, install, validate, and audit Claude Code skills, subagents, and MCP servers from a community registry."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "David Cecko", email = "ceckodavid@gmail.com" }]
13
+ keywords = ["claude", "claude-code", "skills", "subagents", "mcp", "registry", "cli"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Software Development :: Build Tools",
25
+ ]
26
+ dependencies = [
27
+ "typer>=0.12",
28
+ "rich>=13.0",
29
+ "httpx>=0.27",
30
+ "pyyaml>=6.0",
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ dev = ["pytest>=8.0", "ruff>=0.6"]
35
+
36
+ [project.scripts]
37
+ skilldex = "skilldex.cli:app"
38
+
39
+ [project.urls]
40
+ Homepage = "https://github.com/drewn-ed/skilldex"
41
+ Registry = "https://github.com/drewn-ed/skilldex-registry"
42
+
43
+ [tool.ruff]
44
+ line-length = 100
45
+ target-version = "py310"
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
49
+ pythonpath = ["src"]
@@ -0,0 +1,3 @@
1
+ """skilldex — search, install, validate, and audit Claude Code skills, subagents, and MCP servers."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m skilldex`."""
2
+
3
+ from .cli import app
4
+
5
+ app()
@@ -0,0 +1,62 @@
1
+ """Audit local Claude Code configuration for common problems."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from .validator import looks_like_secret, validate_agent_md, validate_skill_md
9
+
10
+
11
+ def audit(project_root: Path | None = None, home: Path | None = None) -> list[tuple[str, str]]:
12
+ """Scan local Claude Code config. Returns (path, problem) findings."""
13
+ root = project_root or Path.cwd()
14
+ home = home or Path.home()
15
+ findings: list[tuple[str, str]] = []
16
+
17
+ mcp_file = root / ".mcp.json"
18
+ if mcp_file.exists():
19
+ findings.extend(_audit_mcp_config(mcp_file))
20
+
21
+ for skills_dir in (home / ".claude" / "skills", root / ".claude" / "skills"):
22
+ if skills_dir.is_dir():
23
+ for skill_md in sorted(skills_dir.glob("*/SKILL.md")):
24
+ findings.extend((str(skill_md), error) for error in validate_skill_md(skill_md))
25
+
26
+ for agents_dir in (home / ".claude" / "agents", root / ".claude" / "agents"):
27
+ if agents_dir.is_dir():
28
+ for agent_md in sorted(agents_dir.rglob("*.md")):
29
+ findings.extend((str(agent_md), error) for error in validate_agent_md(agent_md))
30
+
31
+ return findings
32
+
33
+
34
+ def _audit_mcp_config(mcp_file: Path) -> list[tuple[str, str]]:
35
+ findings: list[tuple[str, str]] = []
36
+ try:
37
+ config = json.loads(mcp_file.read_text(encoding="utf-8"))
38
+ except (json.JSONDecodeError, OSError) as exc:
39
+ return [(str(mcp_file), f"cannot parse: {exc}")]
40
+ for name, server in (config.get("mcpServers") or {}).items():
41
+ if not isinstance(server, dict):
42
+ findings.append((str(mcp_file), f"server {name!r}: definition is not an object"))
43
+ continue
44
+ for key, value in (server.get("env") or {}).items():
45
+ if looks_like_secret(value):
46
+ findings.append(
47
+ (
48
+ str(mcp_file),
49
+ f"server {name!r}: env {key} contains what looks like a plaintext "
50
+ "secret — .mcp.json is usually committed; use a ${VAR} placeholder",
51
+ )
52
+ )
53
+ for header, value in (server.get("headers") or {}).items():
54
+ if looks_like_secret(value):
55
+ findings.append(
56
+ (
57
+ str(mcp_file),
58
+ f"server {name!r}: header {header} contains what looks like a "
59
+ "plaintext secret — use a ${VAR} placeholder",
60
+ )
61
+ )
62
+ return findings
@@ -0,0 +1,196 @@
1
+ """skilldex command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import httpx
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from . import __version__
15
+ from .audit import audit as run_audit
16
+ from .installer import agent_dest, install_mcp, install_source, skill_dest
17
+ from .registry import fetch_index, get_entry_fresh
18
+ from .registry import search as search_index
19
+ from .validator import check_skill_md, validate_agent_md, validate_entry
20
+
21
+ app = typer.Typer(
22
+ help="Search, install, validate, and audit Claude Code skills, subagents, and MCP servers.",
23
+ no_args_is_help=True,
24
+ )
25
+ console = Console()
26
+
27
+ TYPE_COLORS = {"skill": "green", "agent": "magenta", "mcp": "cyan"}
28
+
29
+
30
+ def _version_callback(value: bool) -> None:
31
+ if value:
32
+ console.print(f"skilldex {__version__}")
33
+ raise typer.Exit()
34
+
35
+
36
+ @app.callback()
37
+ def main(
38
+ version: bool = typer.Option(
39
+ False, "--version", callback=_version_callback, is_eager=True, help="Show version."
40
+ ),
41
+ ) -> None:
42
+ pass
43
+
44
+
45
+ def _load_index(refresh: bool = False) -> dict:
46
+ try:
47
+ return fetch_index(force=refresh)
48
+ except (httpx.HTTPError, json.JSONDecodeError) as exc:
49
+ console.print(f"[red]Could not fetch registry index:[/] {exc}")
50
+ raise typer.Exit(2) from exc
51
+
52
+
53
+ @app.command()
54
+ def search(
55
+ query: str = typer.Argument(..., help="Search terms, e.g. 'pdf' or 'github issues'."),
56
+ type: Optional[str] = typer.Option(None, "--type", "-t", help="Filter: skill, agent, or mcp."),
57
+ refresh: bool = typer.Option(False, "--refresh", help="Bypass the local index cache."),
58
+ ) -> None:
59
+ """Search the registry."""
60
+ results = search_index(_load_index(refresh), query, type)
61
+ if not results:
62
+ console.print("[yellow]No matches.[/]")
63
+ raise typer.Exit(1)
64
+ table = Table(box=None, pad_edge=False)
65
+ table.add_column("ID", style="bold")
66
+ table.add_column("Type")
67
+ table.add_column("Description", overflow="fold")
68
+ for entry in results:
69
+ etype = entry.get("type", "?")
70
+ table.add_row(
71
+ entry.get("id", "?"),
72
+ f"[{TYPE_COLORS.get(etype, 'white')}]{etype}[/]",
73
+ entry.get("description", ""),
74
+ )
75
+ console.print(table)
76
+
77
+
78
+ @app.command()
79
+ def show(entry_id: str = typer.Argument(..., help="Registry entry id.")) -> None:
80
+ """Show the full registry entry."""
81
+ entry = get_entry_fresh(_load_index(), entry_id)
82
+ if entry is None:
83
+ console.print(f"[red]No entry with id {entry_id!r}.[/] Try: skilldex search {entry_id}")
84
+ raise typer.Exit(1)
85
+ console.print_json(json.dumps(entry))
86
+
87
+
88
+ @app.command()
89
+ def install(
90
+ entry_id: str = typer.Argument(..., help="Registry entry id."),
91
+ project: bool = typer.Option(
92
+ False, "--project", "-p", help="Install into ./.claude instead of ~/.claude."
93
+ ),
94
+ ) -> None:
95
+ """Install a skill, subagent, or MCP server."""
96
+ entry = get_entry_fresh(_load_index(), entry_id)
97
+ if entry is None:
98
+ console.print(f"[red]No entry with id {entry_id!r}.[/] Try: skilldex search {entry_id}")
99
+ raise typer.Exit(1)
100
+
101
+ etype = entry.get("type")
102
+ if etype == "skill":
103
+ dest = install_source(entry, skill_dest(project))
104
+ console.print(f"[green]Installed skill[/] {entry_id} → {dest}")
105
+ elif etype == "agent":
106
+ dest = install_source(entry, agent_dest(project))
107
+ console.print(f"[green]Installed agent[/] {entry_id} → {dest}")
108
+ elif etype == "mcp":
109
+ config_path = install_mcp(entry)
110
+ console.print(f"[green]Added MCP server[/] {entry_id} → {config_path}")
111
+ env = entry.get("mcp", {}).get("env") or {}
112
+ placeholders = [k for k, v in env.items() if isinstance(v, str) and v.startswith("${")]
113
+ if placeholders:
114
+ console.print(
115
+ f"[yellow]Set these environment variables:[/] {', '.join(placeholders)}"
116
+ )
117
+ else:
118
+ console.print(f"[red]Unknown entry type {etype!r}.[/]")
119
+ raise typer.Exit(2)
120
+
121
+
122
+ @app.command()
123
+ def validate(
124
+ path: Path = typer.Argument(..., exists=True, help="SKILL.md, agent .md, entry .json, or a skill directory."),
125
+ ) -> None:
126
+ """Validate a SKILL.md, subagent file, or registry entry."""
127
+ target = path / "SKILL.md" if path.is_dir() and (path / "SKILL.md").exists() else path
128
+ warnings: list[str] = []
129
+ if target.suffix == ".json":
130
+ try:
131
+ entry = json.loads(target.read_text(encoding="utf-8"))
132
+ except json.JSONDecodeError as exc:
133
+ console.print(f"[red]{target}: invalid JSON:[/] {exc}")
134
+ raise typer.Exit(1) from exc
135
+ errors = validate_entry(entry, filename=str(target))
136
+ elif target.name == "SKILL.md":
137
+ errors, warnings = check_skill_md(target)
138
+ elif target.suffix == ".md":
139
+ errors = validate_agent_md(target)
140
+ else:
141
+ console.print(f"[red]Don't know how to validate {target}[/] (expected .md or .json)")
142
+ raise typer.Exit(2)
143
+
144
+ for warning in warnings:
145
+ console.print(f"[yellow]![/] {target}: {warning}")
146
+ if errors:
147
+ for error in errors:
148
+ console.print(f"[red]✗[/] {target}: {error}")
149
+ raise typer.Exit(1)
150
+ console.print(f"[green]✓[/] {target} is valid")
151
+
152
+
153
+ @app.command()
154
+ def audit() -> None:
155
+ """Audit local Claude Code config (skills, agents, .mcp.json) for problems."""
156
+ findings = run_audit()
157
+ if not findings:
158
+ console.print("[green]✓ No problems found.[/]")
159
+ return
160
+ for location, problem in findings:
161
+ console.print(f"[red]✗[/] {location}: {problem}")
162
+ raise typer.Exit(1)
163
+
164
+
165
+ @app.command("list")
166
+ def list_installed() -> None:
167
+ """List locally installed skills, agents, and project MCP servers."""
168
+ table = Table(box=None, pad_edge=False)
169
+ table.add_column("Type")
170
+ table.add_column("Name", style="bold")
171
+ table.add_column("Location")
172
+ rows = 0
173
+ for scope, base in (("user", Path.home()), ("project", Path.cwd())):
174
+ skills = base / ".claude" / "skills"
175
+ if skills.is_dir():
176
+ for skill in sorted(p for p in skills.iterdir() if (p / "SKILL.md").exists()):
177
+ table.add_row("[green]skill[/]", skill.name, f"{scope}: {skill}")
178
+ rows += 1
179
+ agents = base / ".claude" / "agents"
180
+ if agents.is_dir():
181
+ for agent in sorted(agents.rglob("*.md")):
182
+ table.add_row("[magenta]agent[/]", agent.stem, f"{scope}: {agent}")
183
+ rows += 1
184
+ mcp_file = Path.cwd() / ".mcp.json"
185
+ if mcp_file.exists():
186
+ try:
187
+ config = json.loads(mcp_file.read_text(encoding="utf-8"))
188
+ for name in config.get("mcpServers") or {}:
189
+ table.add_row("[cyan]mcp[/]", name, f"project: {mcp_file}")
190
+ rows += 1
191
+ except json.JSONDecodeError:
192
+ console.print(f"[yellow]Warning: {mcp_file} is not valid JSON.[/]")
193
+ if rows == 0:
194
+ console.print("[yellow]Nothing installed yet.[/] Try: skilldex search <topic>")
195
+ return
196
+ console.print(table)
@@ -0,0 +1,67 @@
1
+ """Install skills/agents from GitHub and MCP servers into .mcp.json."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import httpx
9
+
10
+ GITHUB_API = "https://api.github.com"
11
+
12
+ MCP_SERVER_KEYS = ("type", "command", "args", "env", "url", "headers")
13
+
14
+
15
+ def skill_dest(project: bool) -> Path:
16
+ base = Path.cwd() if project else Path.home()
17
+ return base / ".claude" / "skills"
18
+
19
+
20
+ def agent_dest(project: bool) -> Path:
21
+ base = Path.cwd() if project else Path.home()
22
+ return base / ".claude" / "agents"
23
+
24
+
25
+ def install_source(entry: dict, dest_root: Path) -> Path:
26
+ """Download the entry's source directory from GitHub into dest_root/<id>."""
27
+ source = entry["source"]
28
+ dest = dest_root / entry["id"]
29
+ dest.mkdir(parents=True, exist_ok=True)
30
+ _download_dir(source["repo"], source["path"], source.get("ref"), dest)
31
+ return dest
32
+
33
+
34
+ def _list_contents(repo: str, path: str, ref: str | None) -> list[dict]:
35
+ response = httpx.get(
36
+ f"{GITHUB_API}/repos/{repo}/contents/{path}",
37
+ params={"ref": ref} if ref else None,
38
+ headers={"Accept": "application/vnd.github+json"},
39
+ follow_redirects=True,
40
+ timeout=30,
41
+ )
42
+ response.raise_for_status()
43
+ data = response.json()
44
+ return data if isinstance(data, list) else [data]
45
+
46
+
47
+ def _download_dir(repo: str, path: str, ref: str | None, dest: Path) -> None:
48
+ for item in _list_contents(repo, path, ref):
49
+ if item["type"] == "dir":
50
+ subdir = dest / item["name"]
51
+ subdir.mkdir(exist_ok=True)
52
+ _download_dir(repo, item["path"], ref, subdir)
53
+ elif item["type"] == "file" and item.get("download_url"):
54
+ response = httpx.get(item["download_url"], follow_redirects=True, timeout=30)
55
+ response.raise_for_status()
56
+ (dest / item["name"]).write_bytes(response.content)
57
+
58
+
59
+ def install_mcp(entry: dict, config_path: Path | None = None) -> Path:
60
+ """Merge the entry's MCP server definition into the project's .mcp.json."""
61
+ config_path = config_path or Path.cwd() / ".mcp.json"
62
+ config = json.loads(config_path.read_text(encoding="utf-8")) if config_path.exists() else {}
63
+ servers = config.setdefault("mcpServers", {})
64
+ mcp = entry["mcp"]
65
+ servers[entry["id"]] = {key: mcp[key] for key in MCP_SERVER_KEYS if mcp.get(key)}
66
+ config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
67
+ return config_path
@@ -0,0 +1,98 @@
1
+ """Fetching, caching, and searching the skilldex registry index."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ from pathlib import Path
9
+
10
+ import httpx
11
+
12
+ # GitHub Pages is primary — raw.githubusercontent's CDN can pin stale variants
13
+ # for a long time, which made freshly merged entries invisible to the CLI.
14
+ DEFAULT_INDEX_URL = "https://drewn-ed.github.io/skilldex-registry/index.json"
15
+ FALLBACK_INDEX_URL = (
16
+ "https://raw.githubusercontent.com/drewn-ed/skilldex-registry/main/index.json"
17
+ )
18
+ CACHE_TTL_SECONDS = 3600
19
+ CACHE_PATH = Path.home() / ".cache" / "skilldex" / "index.json"
20
+
21
+
22
+ def index_url() -> str:
23
+ return os.environ.get("SKILLDEX_REGISTRY_URL", DEFAULT_INDEX_URL)
24
+
25
+
26
+ def fetch_index(force: bool = False) -> dict:
27
+ """Return the registry index, using a local cache with a 1-hour TTL.
28
+
29
+ SKILLDEX_REGISTRY_URL may point at an alternative index — either an
30
+ https:// URL or a local file path (useful for registry development).
31
+ """
32
+ url = index_url()
33
+ local = Path(url)
34
+ if not url.startswith(("http://", "https://")) and local.exists():
35
+ return json.loads(local.read_text(encoding="utf-8"))
36
+
37
+ if (
38
+ not force
39
+ and CACHE_PATH.exists()
40
+ and time.time() - CACHE_PATH.stat().st_mtime < CACHE_TTL_SECONDS
41
+ ):
42
+ try:
43
+ return json.loads(CACHE_PATH.read_text(encoding="utf-8"))
44
+ except (json.JSONDecodeError, OSError):
45
+ pass # fall through to a fresh fetch
46
+
47
+ try:
48
+ response = httpx.get(url, follow_redirects=True, timeout=15)
49
+ response.raise_for_status()
50
+ data = response.json()
51
+ except (httpx.HTTPError, json.JSONDecodeError):
52
+ if url != DEFAULT_INDEX_URL: # custom URL: no fallback, surface the error
53
+ raise
54
+ response = httpx.get(FALLBACK_INDEX_URL, follow_redirects=True, timeout=15)
55
+ response.raise_for_status()
56
+ data = response.json()
57
+ CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
58
+ CACHE_PATH.write_text(json.dumps(data), encoding="utf-8")
59
+ return data
60
+
61
+
62
+ def search(index: dict, query: str, etype: str | None = None) -> list[dict]:
63
+ """Case-insensitive all-tokens-match search over id, name, description, and tags."""
64
+ tokens = query.lower().split()
65
+ results = []
66
+ for entry in index.get("entries", []):
67
+ if etype and entry.get("type") != etype:
68
+ continue
69
+ haystack = " ".join(
70
+ [
71
+ entry.get("id", ""),
72
+ entry.get("name", ""),
73
+ entry.get("description", ""),
74
+ " ".join(entry.get("tags", [])),
75
+ ]
76
+ ).lower()
77
+ if all(token in haystack for token in tokens):
78
+ results.append(entry)
79
+ return results
80
+
81
+
82
+ def get_entry(index: dict, entry_id: str) -> dict | None:
83
+ for entry in index.get("entries", []):
84
+ if entry.get("id") == entry_id:
85
+ return entry
86
+ return None
87
+
88
+
89
+ def get_entry_fresh(index: dict, entry_id: str, refetch=fetch_index) -> dict | None:
90
+ """Like get_entry, but on a miss refetch the index once — the local cache
91
+ (or a CDN edge) may predate a just-merged entry."""
92
+ entry = get_entry(index, entry_id)
93
+ if entry is None:
94
+ try:
95
+ entry = get_entry(refetch(force=True), entry_id)
96
+ except (httpx.HTTPError, json.JSONDecodeError, OSError):
97
+ return None
98
+ return entry
@@ -0,0 +1,187 @@
1
+ """Validation for registry entries, SKILL.md files, and subagent definition files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+
10
+ ENTRY_TYPES = {"skill", "agent", "mcp"}
11
+ ID_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
12
+ REPO_RE = re.compile(r"^[\w.-]+/[\w.-]+$")
13
+
14
+ REQUIRED_COMMON = ("id", "type", "name", "description")
15
+ MAX_NAME = 64
16
+ MAX_DESCRIPTION = 1024
17
+ MAX_BODY_WORDS = 5000
18
+
19
+ # Common credential shapes: OpenAI/Anthropic-style keys, GitHub tokens, Slack tokens,
20
+ # AWS access keys, and JWTs.
21
+ SECRET_RE = re.compile(
22
+ r"(sk-[A-Za-z0-9_-]{8,}"
23
+ r"|ghp_[A-Za-z0-9]{20,}"
24
+ r"|github_pat_[A-Za-z0-9_]{20,}"
25
+ r"|xox[baprs]-[A-Za-z0-9-]{10,}"
26
+ r"|AKIA[A-Z0-9]{12,}"
27
+ r"|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{5,})"
28
+ )
29
+
30
+ FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?(.*)\Z", re.S)
31
+
32
+
33
+ def looks_like_secret(value: object) -> bool:
34
+ return isinstance(value, str) and bool(SECRET_RE.search(value))
35
+
36
+
37
+ def parse_frontmatter(text: str) -> tuple[dict | None, str]:
38
+ """Split a markdown document into (frontmatter dict, body).
39
+
40
+ Returns (None, text) when there is no frontmatter block.
41
+ Raises ValueError on malformed YAML or a non-mapping frontmatter.
42
+ """
43
+ match = FRONTMATTER_RE.match(text)
44
+ if not match:
45
+ return None, text
46
+ try:
47
+ data = yaml.safe_load(match.group(1))
48
+ except yaml.YAMLError as exc:
49
+ raise ValueError(f"invalid YAML frontmatter: {exc}") from exc
50
+ if data is None:
51
+ data = {}
52
+ if not isinstance(data, dict):
53
+ raise ValueError("frontmatter must be a YAML mapping")
54
+ return data, match.group(2)
55
+
56
+
57
+ def validate_entry(entry: dict, *, filename: str | None = None) -> list[str]:
58
+ """Validate a skilldex registry entry. Returns a list of error strings."""
59
+ errors: list[str] = []
60
+ for field in REQUIRED_COMMON:
61
+ if not entry.get(field):
62
+ errors.append(f"missing required field: {field}")
63
+
64
+ etype = entry.get("type")
65
+ if etype and etype not in ENTRY_TYPES:
66
+ errors.append(f"invalid type {etype!r}, must be one of: {', '.join(sorted(ENTRY_TYPES))}")
67
+
68
+ eid = entry.get("id")
69
+ if eid:
70
+ if not ID_RE.match(str(eid)):
71
+ errors.append(f"id {eid!r} must be kebab-case (lowercase letters, digits, hyphens)")
72
+ if filename and Path(filename).stem != eid:
73
+ errors.append(f"id {eid!r} must match filename {Path(filename).name!r}")
74
+
75
+ description = entry.get("description") or ""
76
+ if len(description) > MAX_DESCRIPTION:
77
+ errors.append(f"description longer than {MAX_DESCRIPTION} characters")
78
+
79
+ tags = entry.get("tags")
80
+ if tags is not None and not (isinstance(tags, list) and all(isinstance(t, str) for t in tags)):
81
+ errors.append("tags must be a list of strings")
82
+
83
+ if etype in ("skill", "agent"):
84
+ source = entry.get("source") or {}
85
+ repo = source.get("repo")
86
+ if not repo:
87
+ errors.append(f"{etype} entries need source.repo ('owner/repo')")
88
+ elif not REPO_RE.match(repo):
89
+ errors.append(f"source.repo {repo!r} must look like 'owner/repo'")
90
+ if not source.get("path"):
91
+ errors.append(f"{etype} entries need source.path (directory within the repo)")
92
+
93
+ if etype == "mcp":
94
+ mcp = entry.get("mcp") or {}
95
+ transport = mcp.get("type", "stdio")
96
+ if transport == "stdio":
97
+ if not mcp.get("command"):
98
+ errors.append("mcp stdio entries need mcp.command")
99
+ elif transport in ("http", "sse"):
100
+ if not mcp.get("url"):
101
+ errors.append(f"mcp {transport} entries need mcp.url")
102
+ else:
103
+ errors.append(f"mcp.type {transport!r} must be stdio, http, or sse")
104
+ for key, value in (mcp.get("env") or {}).items():
105
+ if looks_like_secret(value):
106
+ errors.append(
107
+ f"mcp.env.{key} looks like a hardcoded secret — use a ${{VAR}} placeholder"
108
+ )
109
+
110
+ return errors
111
+
112
+
113
+ def validate_skill_md(path: Path) -> list[str]:
114
+ """Validate a Claude Code SKILL.md file. Returns hard errors only."""
115
+ return check_skill_md(path)[0]
116
+
117
+
118
+ def check_skill_md(path: Path) -> tuple[list[str], list[str]]:
119
+ """Full SKILL.md check. Returns (errors, warnings).
120
+
121
+ Errors break the skill (Claude won't load or trigger it correctly);
122
+ warnings are best-practice advice.
123
+ """
124
+ try:
125
+ text = path.read_text(encoding="utf-8")
126
+ except (OSError, UnicodeDecodeError) as exc:
127
+ return [f"cannot read file: {exc}"], []
128
+ try:
129
+ frontmatter, body = parse_frontmatter(text)
130
+ except ValueError as exc:
131
+ return [str(exc)], []
132
+ if frontmatter is None:
133
+ return ["missing YAML frontmatter ('---' block) at the top of SKILL.md"], []
134
+
135
+ errors: list[str] = []
136
+ warnings: list[str] = []
137
+ name = frontmatter.get("name")
138
+ if not name:
139
+ errors.append("frontmatter missing 'name'")
140
+ else:
141
+ name = str(name)
142
+ if not ID_RE.match(name):
143
+ errors.append(f"name {name!r} should be kebab-case")
144
+ if len(name) > MAX_NAME:
145
+ errors.append(f"name longer than {MAX_NAME} characters")
146
+
147
+ description = frontmatter.get("description")
148
+ if not description:
149
+ errors.append(
150
+ "frontmatter missing 'description' — Claude uses it to decide when to load the skill"
151
+ )
152
+ elif len(str(description)) > MAX_DESCRIPTION:
153
+ errors.append(f"description longer than {MAX_DESCRIPTION} characters")
154
+
155
+ if not body.strip():
156
+ errors.append("SKILL.md body is empty")
157
+ elif len(body.split()) > MAX_BODY_WORDS:
158
+ warnings.append(
159
+ f"body over {MAX_BODY_WORDS} words — consider moving details into files "
160
+ "the skill references"
161
+ )
162
+ return errors, warnings
163
+
164
+
165
+ def validate_agent_md(path: Path) -> list[str]:
166
+ """Validate a Claude Code subagent definition (.md with frontmatter)."""
167
+ try:
168
+ text = path.read_text(encoding="utf-8")
169
+ except (OSError, UnicodeDecodeError) as exc:
170
+ return [f"cannot read file: {exc}"]
171
+ try:
172
+ frontmatter, body = parse_frontmatter(text)
173
+ except ValueError as exc:
174
+ return [str(exc)]
175
+ if frontmatter is None:
176
+ return ["missing YAML frontmatter ('---' block) at the top of the agent file"]
177
+
178
+ errors: list[str] = []
179
+ if not frontmatter.get("name"):
180
+ errors.append("frontmatter missing 'name'")
181
+ if not frontmatter.get("description"):
182
+ errors.append(
183
+ "frontmatter missing 'description' — Claude uses it to decide when to delegate"
184
+ )
185
+ if not body.strip():
186
+ errors.append("agent file has no system prompt body")
187
+ return errors
@@ -0,0 +1,71 @@
1
+ import json
2
+
3
+ import httpx
4
+
5
+ from skilldex.registry import fetch_index, get_entry, get_entry_fresh, search
6
+
7
+ INDEX = {
8
+ "entries": [
9
+ {
10
+ "id": "pdf",
11
+ "type": "skill",
12
+ "name": "PDF Toolkit",
13
+ "description": "Extract text and tables from PDF files",
14
+ "tags": ["documents"],
15
+ },
16
+ {
17
+ "id": "fetch",
18
+ "type": "mcp",
19
+ "name": "Fetch",
20
+ "description": "Fetch web pages as markdown",
21
+ "tags": ["web", "http"],
22
+ },
23
+ ]
24
+ }
25
+
26
+
27
+ def test_search_matches_description():
28
+ assert [e["id"] for e in search(INDEX, "tables")] == ["pdf"]
29
+
30
+
31
+ def test_search_matches_tags():
32
+ assert [e["id"] for e in search(INDEX, "http")] == ["fetch"]
33
+
34
+
35
+ def test_search_all_tokens_must_match():
36
+ assert search(INDEX, "pdf web") == []
37
+
38
+
39
+ def test_search_type_filter():
40
+ assert [e["id"] for e in search(INDEX, "f", etype="mcp")] == ["fetch"]
41
+
42
+
43
+ def test_get_entry():
44
+ assert get_entry(INDEX, "pdf")["name"] == "PDF Toolkit"
45
+ assert get_entry(INDEX, "nope") is None
46
+
47
+
48
+ def test_get_entry_fresh_hit_needs_no_refetch():
49
+ def boom(force=False):
50
+ raise AssertionError("should not refetch on a hit")
51
+
52
+ assert get_entry_fresh(INDEX, "pdf", refetch=boom)["name"] == "PDF Toolkit"
53
+
54
+
55
+ def test_get_entry_fresh_refetches_on_miss():
56
+ fresh = {"entries": INDEX["entries"] + [{"id": "brand-new", "type": "skill", "name": "New"}]}
57
+ assert get_entry_fresh(INDEX, "brand-new", refetch=lambda force: fresh)["name"] == "New"
58
+
59
+
60
+ def test_get_entry_fresh_miss_with_failing_refetch():
61
+ def fail(force=False):
62
+ raise httpx.ConnectError("offline")
63
+
64
+ assert get_entry_fresh(INDEX, "nope", refetch=fail) is None
65
+
66
+
67
+ def test_fetch_index_local_path(tmp_path, monkeypatch):
68
+ index_file = tmp_path / "index.json"
69
+ index_file.write_text(json.dumps(INDEX))
70
+ monkeypatch.setenv("SKILLDEX_REGISTRY_URL", str(index_file))
71
+ assert fetch_index() == INDEX
@@ -0,0 +1,151 @@
1
+ from pathlib import Path
2
+
3
+ from skilldex.validator import (
4
+ check_skill_md,
5
+ looks_like_secret,
6
+ parse_frontmatter,
7
+ validate_agent_md,
8
+ validate_entry,
9
+ validate_skill_md,
10
+ )
11
+
12
+ VALID_SKILL_ENTRY = {
13
+ "id": "pdf",
14
+ "type": "skill",
15
+ "name": "PDF Toolkit",
16
+ "description": "Work with PDFs.",
17
+ "source": {"repo": "anthropics/skills", "path": "skills/pdf"},
18
+ }
19
+
20
+ VALID_MCP_ENTRY = {
21
+ "id": "fetch",
22
+ "type": "mcp",
23
+ "name": "Fetch",
24
+ "description": "Fetch web pages.",
25
+ "mcp": {"type": "stdio", "command": "uvx", "args": ["mcp-server-fetch"]},
26
+ }
27
+
28
+
29
+ def test_valid_skill_entry():
30
+ assert validate_entry(VALID_SKILL_ENTRY) == []
31
+
32
+
33
+ def test_valid_mcp_entry():
34
+ assert validate_entry(VALID_MCP_ENTRY) == []
35
+
36
+
37
+ def test_missing_fields():
38
+ errors = validate_entry({"id": "x"})
39
+ assert any("type" in e for e in errors)
40
+ assert any("name" in e for e in errors)
41
+ assert any("description" in e for e in errors)
42
+
43
+
44
+ def test_bad_id_format():
45
+ entry = {**VALID_SKILL_ENTRY, "id": "Bad_ID"}
46
+ assert any("kebab-case" in e for e in validate_entry(entry))
47
+
48
+
49
+ def test_id_must_match_filename():
50
+ errors = validate_entry(VALID_SKILL_ENTRY, filename="registry/skills/other.json")
51
+ assert any("match filename" in e for e in errors)
52
+ assert validate_entry(VALID_SKILL_ENTRY, filename="registry/skills/pdf.json") == []
53
+
54
+
55
+ def test_skill_requires_source_repo():
56
+ entry = {**VALID_SKILL_ENTRY, "source": {"path": "skills/pdf"}}
57
+ assert any("source.repo" in e for e in validate_entry(entry))
58
+
59
+
60
+ def test_mcp_http_requires_url():
61
+ entry = {**VALID_MCP_ENTRY, "mcp": {"type": "http"}}
62
+ assert any("mcp.url" in e for e in validate_entry(entry))
63
+
64
+
65
+ def test_mcp_entry_rejects_hardcoded_secret():
66
+ entry = {
67
+ **VALID_MCP_ENTRY,
68
+ "mcp": {
69
+ "type": "stdio",
70
+ "command": "uvx",
71
+ "env": {"API_KEY": "sk-abcdefghijklmnop1234"},
72
+ },
73
+ }
74
+ assert any("secret" in e for e in validate_entry(entry))
75
+
76
+
77
+ def test_looks_like_secret():
78
+ assert looks_like_secret("ghp_" + "a" * 30)
79
+ assert not looks_like_secret("${GITHUB_TOKEN}")
80
+
81
+
82
+ def test_parse_frontmatter_roundtrip():
83
+ fm, body = parse_frontmatter("---\nname: x\n---\nbody here")
84
+ assert fm == {"name": "x"}
85
+ assert body == "body here"
86
+
87
+
88
+ def test_parse_frontmatter_absent():
89
+ fm, body = parse_frontmatter("just a doc")
90
+ assert fm is None
91
+ assert body == "just a doc"
92
+
93
+
94
+ def _write(tmp_path: Path, name: str, content: str) -> Path:
95
+ p = tmp_path / name
96
+ p.write_text(content, encoding="utf-8")
97
+ return p
98
+
99
+
100
+ def test_valid_skill_md(tmp_path):
101
+ p = _write(
102
+ tmp_path,
103
+ "SKILL.md",
104
+ "---\nname: my-skill\ndescription: Does a thing when asked.\n---\n# Usage\nDo it.\n",
105
+ )
106
+ assert validate_skill_md(p) == []
107
+
108
+
109
+ def test_skill_md_missing_frontmatter(tmp_path):
110
+ p = _write(tmp_path, "SKILL.md", "# No frontmatter here\n")
111
+ assert any("frontmatter" in e for e in validate_skill_md(p))
112
+
113
+
114
+ def test_skill_md_bad_name(tmp_path):
115
+ p = _write(
116
+ tmp_path,
117
+ "SKILL.md",
118
+ "---\nname: My Skill\ndescription: ok\n---\nbody\n",
119
+ )
120
+ assert any("kebab-case" in e for e in validate_skill_md(p))
121
+
122
+
123
+ def test_skill_md_long_body_is_warning_not_error(tmp_path):
124
+ body = "word " * 5001
125
+ p = _write(
126
+ tmp_path,
127
+ "SKILL.md",
128
+ f"---\nname: my-skill\ndescription: ok\n---\n{body}",
129
+ )
130
+ errors, warnings = check_skill_md(p)
131
+ assert errors == []
132
+ assert any("5000 words" in w for w in warnings)
133
+
134
+
135
+ def test_skill_md_empty_body(tmp_path):
136
+ p = _write(tmp_path, "SKILL.md", "---\nname: my-skill\ndescription: ok\n---\n")
137
+ assert any("body is empty" in e for e in validate_skill_md(p))
138
+
139
+
140
+ def test_agent_md_valid(tmp_path):
141
+ p = _write(
142
+ tmp_path,
143
+ "reviewer.md",
144
+ "---\nname: reviewer\ndescription: Reviews code.\n---\nYou are a code reviewer.\n",
145
+ )
146
+ assert validate_agent_md(p) == []
147
+
148
+
149
+ def test_agent_md_missing_description(tmp_path):
150
+ p = _write(tmp_path, "reviewer.md", "---\nname: reviewer\n---\nprompt\n")
151
+ assert any("description" in e for e in validate_agent_md(p))