Files
wehub-resource-sync bb5c75ce05
Component Security Validation / Security Audit (push) Waiting to run
Deploy to Cloudflare Pages / deploy (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:38:58 +08:00

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()