skilldex 0.1.0__py3-none-any.whl
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.
- skilldex/__init__.py +3 -0
- skilldex/__main__.py +5 -0
- skilldex/audit.py +62 -0
- skilldex/cli.py +196 -0
- skilldex/installer.py +67 -0
- skilldex/registry.py +98 -0
- skilldex/validator.py +187 -0
- skilldex-0.1.0.dist-info/METADATA +87 -0
- skilldex-0.1.0.dist-info/RECORD +12 -0
- skilldex-0.1.0.dist-info/WHEEL +4 -0
- skilldex-0.1.0.dist-info/entry_points.txt +2 -0
- skilldex-0.1.0.dist-info/licenses/LICENSE +21 -0
skilldex/__init__.py
ADDED
skilldex/__main__.py
ADDED
skilldex/audit.py
ADDED
|
@@ -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
|
skilldex/cli.py
ADDED
|
@@ -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)
|
skilldex/installer.py
ADDED
|
@@ -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
|
skilldex/registry.py
ADDED
|
@@ -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
|
skilldex/validator.py
ADDED
|
@@ -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,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
|
+
[](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,12 @@
|
|
|
1
|
+
skilldex/__init__.py,sha256=pC6sy16PemVhydZCFsIjURLqmZSpcQ9cRerqxBfcDDc,127
|
|
2
|
+
skilldex/__main__.py,sha256=gBinljzlCpfT7P987YhgW8nT_zAe4rlYTmyFkrd9fDA,74
|
|
3
|
+
skilldex/audit.py,sha256=EO5jmJXC_k6WTN44PJhoy7tnVxQSkje4KVtZAB-wiZQ,2549
|
|
4
|
+
skilldex/cli.py,sha256=RuVgJpR64hDKejbqUWpmkzSK2FqlA5oyCpebatuEgCw,7113
|
|
5
|
+
skilldex/installer.py,sha256=UsoQnYU_hKQ4JyajpBv-d7-W_B52lYEUDOM4FTWKOOI,2436
|
|
6
|
+
skilldex/registry.py,sha256=pUgOB2W4jDSNwLpVZt7SveON3RCBo0zCVgNqq051RZM,3400
|
|
7
|
+
skilldex/validator.py,sha256=Mzie7Fox7sltnTBBNHdWrgp3ICZT5VR73uPKfXFvas8,6729
|
|
8
|
+
skilldex-0.1.0.dist-info/METADATA,sha256=9WR8K7060GAAB0GEDkyeTEiaMzgPjMbOMvR4rJH3FX4,3569
|
|
9
|
+
skilldex-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
10
|
+
skilldex-0.1.0.dist-info/entry_points.txt,sha256=A5ozl-jtsUswj_Ak20HxAcvl1yeY9f4D-CFAvJU7km0,46
|
|
11
|
+
skilldex-0.1.0.dist-info/licenses/LICENSE,sha256=j17hvurdyFCR3-s_NXj2Zf-CmKRWPzAocPyn3WJRxSM,1068
|
|
12
|
+
skilldex-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|