661 lines
25 KiB
Python
661 lines
25 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Plugins JSON Generator Script
|
|
Fetches .claude-plugin/ data from GitHub repos and generates plugins.json
|
|
with real marketplace.json and plugin.json contents.
|
|
|
|
Usage:
|
|
python scripts/generate_plugins_json.py
|
|
|
|
Requires:
|
|
- GITHUB_TOKEN env var (or gh CLI authenticated)
|
|
- pip install requests python-dotenv
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
import subprocess
|
|
|
|
# Build a clean env for gh CLI (avoid conflicting tokens)
|
|
_GH_ENV = {k: v for k, v in os.environ.items()}
|
|
# Remove any stale GitHub tokens that might override gh CLI auth
|
|
for _key in ("GITHUB_TOKEN", "GH_TOKEN", "GITHUB_ENTERPRISE_TOKEN"):
|
|
_GH_ENV.pop(_key, None)
|
|
|
|
# --- Configuration ---
|
|
|
|
# Repos to scan: (owner/repo, optional website URL)
|
|
REPOS = [
|
|
("anthropics/claude-plugins-official", None),
|
|
("anthropics/knowledge-work-plugins", None),
|
|
("affaan-m/everything-claude-code", None),
|
|
("thedotmack/claude-mem", None),
|
|
("jarrodwatts/claude-hud", None),
|
|
("EveryInc/compound-engineering-plugin", None),
|
|
("alirezarezvani/claude-skills", None),
|
|
("davepoon/buildwithclaude", "https://buildwithclaude.com/"),
|
|
("Nyanbalaji28/best-claude-skills", "https://augmentclaude.com/"),
|
|
("lackeyjb/playwright-skill", None),
|
|
("nyldn/claude-octopus", None),
|
|
("jeremylongshore/claude-code-plugins-plus-skills", None),
|
|
("timescale/pg-aiguide", None),
|
|
("CloudAI-X/claude-workflow-v2", None),
|
|
("numman-ali/n-skills", None),
|
|
("obra/superpowers-marketplace", None),
|
|
("muratcankoylan/ralph-wiggum-marketer", None),
|
|
("team-attention/plugins-for-claude-natives", None),
|
|
("ananddtyagi/cc-marketplace", None),
|
|
("agent-sh/agentsys", None),
|
|
("ccplugins/awesome-claude-code-plugins", None),
|
|
("hamelsmu/claude-review-loop", None),
|
|
("sangrokjung/claude-forge", None),
|
|
("gmickel/gmickel-claude-marketplace", None),
|
|
("kingbootoshi/cartographer", None),
|
|
("zscole/adversarial-spec", None),
|
|
("hashicorp/agent-skills", None),
|
|
("quant-sentiment-ai/claude-equity-research", None),
|
|
("777genius/claude-notifications-go", None),
|
|
("Piebald-AI/claude-code-lsps", None),
|
|
("chu2bard/pinion-os", None),
|
|
("Airtable/skills", None),
|
|
]
|
|
|
|
OUTPUT_PATH = os.path.join(os.path.dirname(__file__), "..", "dashboard", "public", "plugins.json")
|
|
|
|
# --- GitHub API helpers (uses gh CLI) ---
|
|
|
|
_request_count = 0
|
|
|
|
|
|
def gh_api(endpoint, retries=3):
|
|
"""Call GitHub API via gh CLI subprocess."""
|
|
global _request_count
|
|
|
|
for attempt in range(retries):
|
|
_request_count += 1
|
|
# Respect rate limits
|
|
if _request_count % 50 == 0:
|
|
time.sleep(1)
|
|
|
|
try:
|
|
cmd = ["/usr/local/bin/gh", "api", endpoint.lstrip("/")]
|
|
result = subprocess.run(
|
|
cmd, capture_output=True, text=True, timeout=30, env=_GH_ENV
|
|
)
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
return json.loads(result.stdout)
|
|
if "Not Found" in result.stderr or "404" in result.stderr:
|
|
return None
|
|
if "rate limit" in result.stderr.lower():
|
|
wait = 30 * (attempt + 1)
|
|
print(f" ⏳ Rate limited. Waiting {wait}s...")
|
|
time.sleep(wait)
|
|
continue
|
|
if result.returncode != 0:
|
|
if attempt < retries - 1:
|
|
time.sleep(2 ** attempt)
|
|
continue
|
|
return None
|
|
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
|
|
if attempt < retries - 1:
|
|
time.sleep(2 ** attempt)
|
|
else:
|
|
print(f" ⚠️ gh CLI failed: {e}")
|
|
return None
|
|
return None
|
|
|
|
|
|
def gh_file_content(repo, path):
|
|
"""Fetch and decode a file from a GitHub repo using gh CLI."""
|
|
global _request_count
|
|
_request_count += 1
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["/usr/local/bin/gh", "api", f"repos/{repo}/contents/{path}", "--jq", ".content"],
|
|
capture_output=True, text=True, timeout=30, env=_GH_ENV
|
|
)
|
|
if result.returncode != 0 or not result.stdout.strip():
|
|
return None
|
|
import base64
|
|
return base64.b64decode(result.stdout.strip()).decode("utf-8")
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def gh_dir_listing(repo, path):
|
|
"""List files/dirs at a path in a GitHub repo."""
|
|
data = gh_api(f"repos/{repo}/contents/{path}")
|
|
if not data or not isinstance(data, list):
|
|
return []
|
|
return data
|
|
|
|
|
|
# --- Plugin data extraction ---
|
|
|
|
def parse_json_safe(text):
|
|
"""Parse JSON, tolerating trailing commas and minor issues."""
|
|
if not text:
|
|
return None
|
|
try:
|
|
return json.loads(text)
|
|
except json.JSONDecodeError:
|
|
# Try stripping BOM or trailing garbage
|
|
text = text.strip().lstrip("\ufeff")
|
|
try:
|
|
return json.loads(text)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
|
|
def extract_plugin_components(plugin_json):
|
|
"""Extract component counts from a plugin.json manifest."""
|
|
if not plugin_json:
|
|
return {}
|
|
counts = {}
|
|
for key in ("skills", "agents", "commands"):
|
|
val = plugin_json.get(key)
|
|
if isinstance(val, list):
|
|
# Count only actual file entries, not directory paths
|
|
file_entries = [v for v in val if isinstance(v, str) and not v.endswith("/")]
|
|
dir_entries = [v for v in val if isinstance(v, str) and v.endswith("/")]
|
|
# File entries count directly; dir entries count as 1 (minimum)
|
|
count = len(file_entries) + len(dir_entries)
|
|
if count > 0:
|
|
counts[key] = count
|
|
elif isinstance(val, str):
|
|
counts[key] = 1
|
|
# Hooks
|
|
hooks = plugin_json.get("hooks")
|
|
if hooks:
|
|
if isinstance(hooks, list):
|
|
counts["hooks"] = len(hooks)
|
|
elif isinstance(hooks, str):
|
|
counts["hooks"] = 1
|
|
# MCPs
|
|
mcps = plugin_json.get("mcpServers")
|
|
if isinstance(mcps, dict) and len(mcps) > 0:
|
|
counts["mcps"] = len(mcps)
|
|
# LSPs
|
|
lsps = plugin_json.get("lspServers")
|
|
if isinstance(lsps, dict) and len(lsps) > 0:
|
|
counts["lsps"] = len(lsps)
|
|
# Rules
|
|
rules = plugin_json.get("rules")
|
|
if rules:
|
|
counts["rules"] = 1 if isinstance(rules, str) else len(rules)
|
|
return counts
|
|
|
|
|
|
def classify_repo_type(marketplace, plugin_json):
|
|
"""Determine if repo is a marketplace or single plugin."""
|
|
if not marketplace:
|
|
return "plugin"
|
|
plugins_list = marketplace.get("plugins", [])
|
|
if len(plugins_list) > 1:
|
|
return "marketplace"
|
|
# Single plugin in marketplace = it's a plugin repo
|
|
return "plugin"
|
|
|
|
|
|
def extract_tags_from_components(components):
|
|
"""Derive tags from component counts."""
|
|
tag_map = {
|
|
"skills": "skills",
|
|
"agents": "agents",
|
|
"commands": "commands",
|
|
"hooks": "hooks",
|
|
"mcps": "mcps",
|
|
"lsps": "lsps",
|
|
"rules": "rules",
|
|
}
|
|
return [tag_map[k] for k in tag_map if components.get(k, 0) > 0]
|
|
|
|
|
|
def get_local_source_path(plugin_entry):
|
|
"""Extract local source path from a plugin entry, or None if external."""
|
|
source = plugin_entry.get("source", "")
|
|
if isinstance(source, str):
|
|
if source.startswith("./") or source.startswith("../") or source == "./" or source == ".":
|
|
return source.rstrip("/")
|
|
# Could be a short-form like "owner/repo" — that's external
|
|
if source.startswith("http") or source.endswith(".git") or "/" in source:
|
|
return None
|
|
return None
|
|
elif isinstance(source, dict):
|
|
src_type = source.get("source", "")
|
|
if src_type in ("url", "git-subdir", "github"):
|
|
return None
|
|
# Local path in source dict
|
|
path = source.get("path", "")
|
|
if path.startswith("./"):
|
|
return path.rstrip("/")
|
|
return None
|
|
|
|
|
|
def _strip_component_ext(name):
|
|
for ext in (".md", ".json", ".js", ".ts", ".py"):
|
|
if name.endswith(ext):
|
|
return name[: -len(ext)]
|
|
return name
|
|
|
|
|
|
def extract_frontmatter_description(content):
|
|
"""Pull a `description:` value out of a markdown file's YAML frontmatter."""
|
|
if not content or not content.startswith("---"):
|
|
return ""
|
|
end = content.find("---", 3)
|
|
if end == -1:
|
|
return ""
|
|
frontmatter = content[3:end]
|
|
for line in frontmatter.split("\n"):
|
|
line = line.strip()
|
|
if line.startswith("description:"):
|
|
desc = line.split("description:", 1)[1].strip()
|
|
if len(desc) >= 2 and desc[0] == desc[-1] and desc[0] in ("'", '"'):
|
|
desc = desc[1:-1]
|
|
return desc
|
|
return ""
|
|
|
|
|
|
def scan_plugin_dir_components(repo, dir_path, fetch_descriptions=True):
|
|
"""Scan a plugin directory for skills/, agents/, commands/, hooks/ subdirs
|
|
and list the files inside each. Returns (counts, items) where `counts` is
|
|
a dict of type -> count and `items` is a dict of type -> list of
|
|
{"name", "description"} dicts, for types where names were resolved."""
|
|
components = {}
|
|
component_items = {}
|
|
# List the plugin's root directory
|
|
listing = gh_dir_listing(repo, dir_path)
|
|
if not listing:
|
|
return components, component_items
|
|
|
|
dir_names = {item["name"]: item["type"] for item in listing if isinstance(item, dict)}
|
|
component_dirs = ["skills", "agents", "commands", "hooks"]
|
|
|
|
for comp_dir in component_dirs:
|
|
if comp_dir in dir_names and dir_names[comp_dir] == "dir":
|
|
# List items inside this component directory
|
|
items = gh_dir_listing(repo, f"{dir_path}/{comp_dir}")
|
|
if items:
|
|
entries = []
|
|
for item in items:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
if item["type"] == "dir":
|
|
item_name = item["name"]
|
|
description = ""
|
|
if fetch_descriptions:
|
|
md = gh_file_content(repo, f"{dir_path}/{comp_dir}/{item_name}/SKILL.md")
|
|
if not md:
|
|
md = gh_file_content(repo, f"{dir_path}/{comp_dir}/{item_name}/{item_name}.md")
|
|
description = extract_frontmatter_description(md)
|
|
entries.append({"name": item_name, "description": description})
|
|
elif item["name"].endswith((".md", ".json", ".js", ".ts", ".py")):
|
|
item_name = _strip_component_ext(item["name"])
|
|
description = ""
|
|
if fetch_descriptions and item["name"].endswith(".md"):
|
|
md = gh_file_content(repo, f"{dir_path}/{comp_dir}/{item['name']}")
|
|
description = extract_frontmatter_description(md)
|
|
entries.append({"name": item_name, "description": description})
|
|
if entries:
|
|
components[comp_dir] = len(entries)
|
|
component_items[comp_dir] = entries
|
|
|
|
# Check for mcpServers in plugin.json
|
|
plugin_json_raw = gh_file_content(repo, f"{dir_path}/.claude-plugin/plugin.json")
|
|
if plugin_json_raw:
|
|
pj = parse_json_safe(plugin_json_raw)
|
|
if pj:
|
|
mcps = pj.get("mcpServers")
|
|
if isinstance(mcps, dict) and len(mcps) > 0:
|
|
components["mcps"] = len(mcps)
|
|
component_items["mcps"] = [{"name": k, "description": ""} for k in mcps.keys()]
|
|
lsps = pj.get("lspServers")
|
|
if isinstance(lsps, dict) and len(lsps) > 0:
|
|
components["lsps"] = len(lsps)
|
|
component_items["lsps"] = [{"name": k, "description": ""} for k in lsps.keys()]
|
|
|
|
return components, component_items
|
|
|
|
|
|
def extract_marketplace_plugins_detail(marketplace, repo=None, scan_local=True):
|
|
"""Extract detailed plugin list from marketplace.json.
|
|
If scan_local=True and repo is provided, scan local plugin directories
|
|
for actual component counts."""
|
|
if not marketplace:
|
|
return []
|
|
plugins_list = marketplace.get("plugins", [])
|
|
details = []
|
|
local_scan_count = 0
|
|
max_local_scans = 50 # Limit API calls
|
|
|
|
for p in plugins_list:
|
|
entry = {
|
|
"name": p.get("name", "unknown"),
|
|
"description": p.get("description", ""),
|
|
}
|
|
# Extract source info
|
|
source = p.get("source", "")
|
|
local_path = get_local_source_path(p)
|
|
|
|
if isinstance(source, str):
|
|
entry["source"] = source
|
|
elif isinstance(source, dict):
|
|
entry["source"] = source.get("url", source.get("repo", ""))
|
|
if source.get("path"):
|
|
entry["source_path"] = source["path"]
|
|
|
|
# Extract author if present
|
|
author = p.get("author")
|
|
if isinstance(author, dict):
|
|
entry["author"] = author.get("name", "")
|
|
elif isinstance(author, str):
|
|
entry["author"] = author
|
|
|
|
# Tags/keywords
|
|
tags = p.get("tags", p.get("keywords", []))
|
|
if tags:
|
|
entry["tags"] = tags
|
|
|
|
# Inline components from marketplace.json
|
|
components = {}
|
|
component_items = {}
|
|
can_fetch_local_desc = bool(local_path and repo and scan_local and local_scan_count < max_local_scans)
|
|
used_local_desc_fetch = False
|
|
for key in ("skills", "agents", "commands"):
|
|
val = p.get(key)
|
|
if isinstance(val, list) and len(val) > 0:
|
|
components[key] = len(val)
|
|
entries = []
|
|
for v in val:
|
|
if isinstance(v, str):
|
|
base = v.rstrip("/").replace("\\", "/").split("/")[-1]
|
|
item_name = _strip_component_ext(base)
|
|
description = ""
|
|
if can_fetch_local_desc and v.endswith(".md"):
|
|
rel = v.lstrip("./")
|
|
md = gh_file_content(repo, f"{local_path.lstrip('./')}/{rel}")
|
|
description = extract_frontmatter_description(md)
|
|
used_local_desc_fetch = True
|
|
entries.append({"name": item_name, "description": description})
|
|
elif isinstance(v, dict):
|
|
n = v.get("name") or v.get("path", "")
|
|
if n:
|
|
item_name = _strip_component_ext(str(n).rstrip("/").split("/")[-1])
|
|
entries.append({"name": item_name, "description": v.get("description", "")})
|
|
if entries:
|
|
component_items[key] = entries
|
|
mcps = p.get("mcpServers")
|
|
if isinstance(mcps, dict) and len(mcps) > 0:
|
|
components["mcps"] = len(mcps)
|
|
component_items["mcps"] = [{"name": k, "description": ""} for k in mcps.keys()]
|
|
lsps = p.get("lspServers")
|
|
if isinstance(lsps, dict) and len(lsps) > 0:
|
|
components["lsps"] = len(lsps)
|
|
component_items["lsps"] = [{"name": k, "description": ""} for k in lsps.keys()]
|
|
|
|
if used_local_desc_fetch:
|
|
local_scan_count += 1
|
|
|
|
# If no inline components and this is a local plugin, scan its directory
|
|
if not components and local_path and repo and scan_local and local_scan_count < max_local_scans:
|
|
scanned_counts, scanned_items = scan_plugin_dir_components(repo, local_path.lstrip("./"))
|
|
if scanned_counts:
|
|
components = scanned_counts
|
|
component_items = scanned_items
|
|
local_scan_count += 1
|
|
|
|
if components:
|
|
entry["components"] = components
|
|
if component_items:
|
|
entry["components_items"] = component_items
|
|
|
|
details.append(entry)
|
|
return details
|
|
|
|
|
|
def aggregate_marketplace_components(plugins_detail):
|
|
"""Sum component counts across all plugins in a marketplace."""
|
|
totals = {}
|
|
for p in plugins_detail:
|
|
for key, count in p.get("components", {}).items():
|
|
totals[key] = totals.get(key, 0) + count
|
|
return totals
|
|
|
|
|
|
def build_highlights(marketplace, plugin_json, repo_type, repo_info):
|
|
"""Auto-generate highlights from real data."""
|
|
highlights = []
|
|
owner = repo_info.get("owner", {}).get("login", "")
|
|
|
|
if owner == "anthropics":
|
|
highlights.append("Official Anthropic repository")
|
|
elif owner == "hashicorp":
|
|
highlights.append("Official HashiCorp release")
|
|
elif owner == "timescale":
|
|
highlights.append("Official Timescale plugin")
|
|
|
|
if marketplace:
|
|
plugins_list = marketplace.get("plugins", [])
|
|
if len(plugins_list) > 1:
|
|
highlights.append(f"{len(plugins_list)} plugins in marketplace")
|
|
|
|
if plugin_json:
|
|
parts = []
|
|
for key, label in [("skills", "skills"), ("agents", "agents"), ("commands", "commands"),
|
|
("hooks", "hooks"), ("mcpServers", "MCPs"), ("lspServers", "LSPs")]:
|
|
val = plugin_json.get(key)
|
|
if isinstance(val, list) and len(val) > 0:
|
|
parts.append(f"{len(val)} {label}")
|
|
elif isinstance(val, dict) and len(val) > 0:
|
|
parts.append(f"{len(val)} {label}")
|
|
if parts:
|
|
highlights.append("Plugin declares: " + ", ".join(parts))
|
|
|
|
# Website
|
|
website = repo_info.get("homepage")
|
|
if website and website.startswith("http"):
|
|
highlights.append(f"Web UI: {website}")
|
|
|
|
return highlights[:3] # Max 3
|
|
|
|
|
|
def process_repo(repo_full, website_override=None):
|
|
"""Process a single repo and return its plugin data."""
|
|
owner, name = repo_full.split("/")
|
|
print(f"\n📦 Processing {repo_full}...")
|
|
|
|
# 1. Get repo info
|
|
repo_info = gh_api(f"repos/{repo_full}")
|
|
if not repo_info:
|
|
print(f" ❌ Repo not found")
|
|
return None
|
|
|
|
stars = repo_info.get("stargazers_count", 0)
|
|
description = repo_info.get("description", "")
|
|
repo_owner = repo_info.get("owner", {}).get("login", owner)
|
|
|
|
# 2. Read marketplace.json
|
|
marketplace_raw = gh_file_content(repo_full, ".claude-plugin/marketplace.json")
|
|
marketplace = parse_json_safe(marketplace_raw)
|
|
if not marketplace:
|
|
print(f" ⚠️ No valid marketplace.json")
|
|
return None
|
|
|
|
# 3. Read plugin.json (optional)
|
|
plugin_raw = gh_file_content(repo_full, ".claude-plugin/plugin.json")
|
|
plugin_json = parse_json_safe(plugin_raw)
|
|
|
|
# 4. Classify type
|
|
repo_type = classify_repo_type(marketplace, plugin_json)
|
|
print(f" Type: {repo_type} | Stars: {stars:,}")
|
|
|
|
# 5. Extract marketplace plugin details (scan local dirs for component counts)
|
|
plugins_detail = extract_marketplace_plugins_detail(marketplace, repo=repo_full, scan_local=True)
|
|
marketplace_component_totals = aggregate_marketplace_components(plugins_detail)
|
|
|
|
# 6. Extract single plugin components
|
|
single_components = extract_plugin_components(plugin_json) if plugin_json else {}
|
|
|
|
# 7. Build contains
|
|
if repo_type == "marketplace":
|
|
contains = {"plugins": len(marketplace.get("plugins", []))}
|
|
if marketplace_component_totals:
|
|
contains["components"] = marketplace_component_totals
|
|
else:
|
|
contains = single_components if single_components else {}
|
|
|
|
# 8. Build tags
|
|
all_components = {**marketplace_component_totals, **single_components}
|
|
tags = extract_tags_from_components(all_components)
|
|
# If no tags from components, infer from plugin descriptions and marketplace data
|
|
if not tags:
|
|
tag_set = set()
|
|
# Check plugin entry tags/keywords
|
|
for p in plugins_detail:
|
|
for t in p.get("tags", []):
|
|
t_lower = t.lower()
|
|
if t_lower in ("skills", "agents", "commands", "hooks", "mcps", "lsps"):
|
|
tag_set.add(t_lower)
|
|
# Scan descriptions for clues
|
|
all_text = " ".join(p.get("description", "") for p in plugins_detail).lower()
|
|
all_text += " " + description.lower()
|
|
keyword_map = {
|
|
"skill": "skills", "agent": "agents", "subagent": "agents",
|
|
"command": "commands", "slash command": "commands",
|
|
"hook": "hooks", "mcp": "mcps", "lsp": "lsps",
|
|
}
|
|
for keyword, tag in keyword_map.items():
|
|
if keyword in all_text:
|
|
tag_set.add(tag)
|
|
tags = sorted(tag_set)
|
|
|
|
# 9. Build highlights
|
|
highlights = build_highlights(marketplace, plugin_json, repo_type, repo_info)
|
|
|
|
# 10. Determine name — prefer repo name humanized over marketplace slug
|
|
repo_display = repo_info.get("name", name)
|
|
mp_name = marketplace.get("name", "")
|
|
plugin_name = plugin_json.get("name", "") if plugin_json else ""
|
|
# Use marketplace/plugin name only if it looks human-readable (has spaces or capitals)
|
|
if mp_name and (mp_name != mp_name.lower().replace(" ", "-") or " " in mp_name):
|
|
display_name = mp_name
|
|
elif plugin_name and (plugin_name != plugin_name.lower().replace(" ", "-") or " " in plugin_name):
|
|
display_name = plugin_name
|
|
else:
|
|
display_name = repo_display.replace("-", " ").title()
|
|
|
|
# 11. Slug
|
|
slug = name.lower()
|
|
# Avoid collisions by prepending owner for common names
|
|
if slug in ("claude-skills", "awesome-claude-skills", "awesome-claude-code-plugins"):
|
|
slug = f"{owner.lower()}-{slug}"
|
|
|
|
# 12. Website
|
|
website = website_override or repo_info.get("homepage") or None
|
|
if website and not website.startswith("http"):
|
|
website = None
|
|
|
|
# 13. Build result
|
|
result = {
|
|
"slug": slug,
|
|
"name": display_name,
|
|
"author": repo_owner,
|
|
"description": description or marketplace.get("description", ""),
|
|
"github": f"https://github.com/{repo_full}",
|
|
"stars": stars,
|
|
"type": repo_type,
|
|
"tags": tags,
|
|
"contains": contains,
|
|
"highlights": highlights,
|
|
}
|
|
|
|
if website:
|
|
result["website"] = website
|
|
|
|
# 14. Add marketplace plugins list (for individual pages)
|
|
if repo_type == "marketplace" and plugins_detail:
|
|
result["plugins_list"] = plugins_detail
|
|
|
|
# 15. Add plugin.json details for single plugins
|
|
if plugin_json and repo_type == "plugin":
|
|
plugin_detail = {}
|
|
for key in ("skills", "agents", "commands"):
|
|
val = plugin_json.get(key)
|
|
if isinstance(val, list) and val:
|
|
plugin_detail[key] = val
|
|
mcps = plugin_json.get("mcpServers")
|
|
if isinstance(mcps, dict) and mcps:
|
|
plugin_detail["mcpServers"] = list(mcps.keys())
|
|
lsps = plugin_json.get("lspServers")
|
|
if isinstance(lsps, dict) and lsps:
|
|
plugin_detail["lspServers"] = list(lsps.keys())
|
|
if plugin_detail:
|
|
result["plugin_manifest"] = plugin_detail
|
|
|
|
print(f" ✅ {display_name} — {repo_type} — {stars:,} stars — tags: {tags}")
|
|
return result
|
|
|
|
|
|
# --- Main ---
|
|
|
|
def main():
|
|
print("🔌 Generating plugins.json from GitHub .claude-plugin/ data...\n")
|
|
|
|
# Check gh CLI is available
|
|
try:
|
|
test = subprocess.run(
|
|
["/usr/local/bin/gh", "api", "rate_limit", "--jq", ".rate.remaining"],
|
|
capture_output=True, text=True, timeout=10, env=_GH_ENV
|
|
)
|
|
if test.returncode == 0:
|
|
print(f"✅ gh CLI authenticated. API requests remaining: {test.stdout.strip()}\n")
|
|
else:
|
|
print("⚠️ gh CLI may not be authenticated. Run `gh auth login` first.\n")
|
|
except FileNotFoundError:
|
|
print("❌ gh CLI not found. Install it: https://cli.github.com\n")
|
|
sys.exit(1)
|
|
|
|
results = []
|
|
errors = []
|
|
|
|
for repo_full, website in REPOS:
|
|
try:
|
|
data = process_repo(repo_full, website)
|
|
if data:
|
|
results.append(data)
|
|
else:
|
|
errors.append(repo_full)
|
|
except Exception as e:
|
|
print(f" ❌ Error processing {repo_full}: {e}")
|
|
errors.append(repo_full)
|
|
|
|
# Sort by stars descending
|
|
results.sort(key=lambda x: x["stars"], reverse=True)
|
|
|
|
# Write output
|
|
output_path = os.path.abspath(OUTPUT_PATH)
|
|
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
with open(output_path, "w", encoding="utf-8") as f:
|
|
json.dump(results, f, indent=2, ensure_ascii=False)
|
|
|
|
print(f"\n{'='*60}")
|
|
print(f"✅ Generated {output_path}")
|
|
print(f" {len(results)} plugins/marketplaces written")
|
|
print(f" {len(errors)} errors: {errors}" if errors else " 0 errors")
|
|
print(f" Total API requests: {_request_count}")
|
|
|
|
# Summary
|
|
marketplaces = [r for r in results if r["type"] == "marketplace"]
|
|
plugins = [r for r in results if r["type"] == "plugin"]
|
|
print(f"\n 📊 {len(marketplaces)} marketplaces | {len(plugins)} individual plugins")
|
|
total_stars = sum(r["stars"] for r in results)
|
|
print(f" ⭐ {total_stars:,} total stars across all repos")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|