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 ADDED
@@ -0,0 +1,3 @@
1
+ """skilldex — search, install, validate, and audit Claude Code skills, subagents, and MCP servers."""
2
+
3
+ __version__ = "0.1.0"
skilldex/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m skilldex`."""
2
+
3
+ from .cli import app
4
+
5
+ app()
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
+ [![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,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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ skilldex = skilldex.cli:app
@@ -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.