#!/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()