diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 953664493..f475c1e4e 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -231,6 +231,12 @@ "source": "./plugins/ai-sbom", "description": "Generate AI Software Bill of Materials (SBOM) declarations for PR descriptions", "version": "0.0.1" + }, + { + "name": "marketplace-ops", + "source": "./plugins/marketplace-ops", + "description": "Maintenance commands for Claude Code plugin marketplaces", + "version": "0.1.2" } ] } diff --git a/.pruneprotect b/.pruneprotect new file mode 100644 index 000000000..a20e5ecff --- /dev/null +++ b/.pruneprotect @@ -0,0 +1,12 @@ +# Plugins protected from automated pruning +# One path per line. Lines starting with # are comments. + +# Canonical example plugin +plugins/hello-world/ + +# Infrastructure plugins (hook-only, by design) +plugins/metrics/ +plugins/native-notifications/ + +# Marketplace operations plugin +plugins/marketplace-ops/ diff --git a/PLUGINS.md b/PLUGINS.md index fe4379aa5..eb3049474 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -18,6 +18,7 @@ This document lists all available Claude Code plugins and their commands in the - [Hello World](#hello-world-plugin) - [Jira](#jira-plugin) - [Lvms](#lvms-plugin) +- [Marketplace Ops](#marketplace-ops-plugin) - [Must Gather](#must-gather-plugin) - [Node](#node-plugin) - [Node Tuning](#node-tuning-plugin) @@ -236,6 +237,16 @@ LVMS (Logical Volume Manager Storage) plugin for troubleshooting and debugging s See [plugins/lvms/README.md](plugins/lvms/README.md) for detailed documentation. +### Marketplace Ops Plugin + +Maintenance commands for Claude Code plugin marketplaces + +**Commands:** +- **`/marketplace-ops:prune-update` `[PR number or URL]`** - Process /save and /drop comments on a pruning PR, restore or remove items, and update .pruneprotect +- **`/marketplace-ops:prune` `[--dry-run]`** - Analyze and prune stale plugins, commands, and skills from the marketplace + +See [plugins/marketplace-ops/README.md](plugins/marketplace-ops/README.md) for detailed documentation. + ### Must Gather Plugin A plugin to analyze and report on must-gather data diff --git a/docs/data.json b/docs/data.json index fcde4773e..2d8b9b4f0 100644 --- a/docs/data.json +++ b/docs/data.json @@ -1764,6 +1764,28 @@ } ], "version": "0.0.1" + }, + { + "commands": [ + { + "argument_hint": "[PR number or URL]", + "description": "Process /save and /drop comments on a pruning PR, restore or remove items, and update .pruneprotect", + "name": "prune-update", + "synopsis": "/marketplace-ops:prune-update [PR number or URL]" + }, + { + "argument_hint": "[--dry-run]", + "description": "Analyze and prune stale plugins, commands, and skills from the marketplace", + "name": "prune", + "synopsis": "/marketplace-ops:prune [--dry-run]" + } + ], + "description": "Maintenance commands for Claude Code plugin marketplaces", + "has_readme": true, + "hooks": [], + "name": "marketplace-ops", + "skills": [], + "version": "0.1.2" } ] } \ No newline at end of file diff --git a/plugins/marketplace-ops/.claude-plugin/plugin.json b/plugins/marketplace-ops/.claude-plugin/plugin.json new file mode 100644 index 000000000..0a2609018 --- /dev/null +++ b/plugins/marketplace-ops/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "marketplace-ops", + "description": "Maintenance commands for Claude Code plugin marketplaces", + "version": "0.1.2", + "author": { + "name": "github.com/openshift-eng" + } +} diff --git a/plugins/marketplace-ops/README.md b/plugins/marketplace-ops/README.md new file mode 100644 index 000000000..11a3871e2 --- /dev/null +++ b/plugins/marketplace-ops/README.md @@ -0,0 +1,29 @@ +# marketplace-ops + +Maintenance commands for Claude Code plugin marketplaces. Identifies stale or low-value plugins, commands, and skills, then opens a PR to remove them with a structured review workflow. + +## Commands + +### `/marketplace-ops:prune` + +Analyzes the repository for inactive content using git history, structural signals, and LLM judgment. Creates a branch removing candidates and opens a PR with a removal manifest. + +Use `--dry-run` to see what would be pruned without creating a branch or PR. + +### `/marketplace-ops:prune-update` + +Processes `/save ` comments on a pruning PR. Restores saved items, adds them to `.pruneprotect` permanently, and pushes a new commit to the PR branch. + +## Protection + +Create a `.pruneprotect` file at the repo root to permanently exclude paths from pruning: + +``` +# Canonical example plugin +plugins/hello-world/ + +# Saved by @username on 2026-05-05 +plugins/foo/ +``` + +Lines starting with `#` are comments. Each non-comment line is a path prefix that protects everything under it. diff --git a/plugins/marketplace-ops/commands/prune-update.md b/plugins/marketplace-ops/commands/prune-update.md new file mode 100644 index 000000000..f664b5cc7 --- /dev/null +++ b/plugins/marketplace-ops/commands/prune-update.md @@ -0,0 +1,244 @@ +--- +description: Process /save and /drop comments on a pruning PR, restore or remove items, and update .pruneprotect +argument-hint: "[PR number or URL]" +--- + +## Name +marketplace-ops:prune-update + +## Synopsis +```text +/marketplace-ops:prune-update [PR number or URL] +``` + +## Description +Reads comments on a pruning PR to find `/save ` and `/drop ` directives. + +For each **saved** item: +1. Restores the files from the base branch. +2. Adds the path to `.pruneprotect` permanently, with a comment noting who requested it and when. +3. Pushes a new commit to the PR branch (never force-pushes). +4. Updates the PR body to mark saved items. + +For each **dropped** item: +1. If the item was previously `/save`d: removes its files again, removes it from `.pruneprotect`. +2. If the item is a new addition (not in the original manifest): removes its files and adds a new row to the manifest. +3. For surviving plugins that lose commands or skills, bumps the patch version in `plugin.json`. +4. Pushes a new commit to the PR branch (never force-pushes). +5. Updates the PR body to reflect the drop. + +## Arguments +- `$1`: (Optional) PR number or URL. If omitted, searches for the most recent open pruning PR by the current user. + +## Implementation + +### Step 1: Find the Pruning PR + +If a PR number or URL was provided, use it directly. Otherwise, find the most recent open pruning PR: + +```bash +gh pr list --author="@me" --state=open --search="prune stale marketplace" --json number,title,url,headRefName --limit 5 +``` + +Select the first result. If no pruning PR is found, report this to the user and stop. + +### Step 2: Read PR Comments for /save and /drop Directives + +Fetch all comments on the PR (both issue comments and review comments), including the author's association to the repository: + +```bash +# Issue comments +gh api repos/{owner}/{repo}/issues/{pr_number}/comments \ + --jq '.[] | {id: .id, created_at: .created_at, author: .user.login, association: .author_association, body: .body}' + +# Review comments +gh api repos/{owner}/{repo}/pulls/{pr_number}/comments \ + --jq '.[] | {id: .id, created_at: .created_at, author: .user.login, association: .author_association, body: .body}' +``` + +Parse each comment body for lines matching `/save ` or `/drop [--force] `. The `--force` flag is optional and only valid on `/drop` directives. **Only accept directives from trusted participants** — those with `author_association` of `OWNER`, `MEMBER`, or `COLLABORATOR`. Skip directives from other associations and log a warning (e.g., "Ignoring `/drop --force` from @user — not a repository collaborator"). + +For each accepted match, record: +- The directive type (`save` or `drop`) +- The path +- The `force` flag (`true` if `--force` was present, `false` otherwise) +- The GitHub username of the commenter + +Deduplicate paths. If a path has both `/save` and `/drop` from different comments, the **latest comment wins** (last-writer-wins): sort all collected directives by `created_at` ascending, using `id` as a tiebreaker for comments with identical timestamps, so the newest directive for a given path takes precedence. If no valid directives are found, report this and stop. + +### Step 3: Validate Paths + +**For `/save` paths:** Cross-reference against the removal manifest table in the PR body. The path must appear in the manifest (and not already be marked `[SAVED]`) — if it does not, it was either already saved, was not part of this pruning cycle, or is a typo. Report invalid paths to the user but continue processing valid ones. + +**For `/drop` paths — two valid cases:** +1. **Undo a previous save:** The path appears in the manifest with `[SAVED]` strikethrough markup. This reverses the save. +2. **New drop:** The path does NOT appear in the manifest but exists on the base branch. The reviewer is requesting an additional removal beyond what the automated pruning flagged. Verify the path exists on the base branch before accepting. Also verify the path is not listed in `.pruneprotect` — if it is, warn that the item is protected and skip it unless the directive's `force` flag is `true` (i.e., the commenter used `/drop --force plugins/foo/`). + +Report paths that fail validation but continue processing valid ones. + +### Step 4: Checkout the PR Branch + +```bash +gh pr checkout {pr_number} +``` + +### Step 5: Process Saved Items + +Get the base branch from the PR: +```bash +base_branch=$(gh pr view {pr_number} --json baseRefName --jq '.baseRefName') +``` + +For each valid `/save` path, restore from the base branch: +```bash +git checkout {upstream_remote}/{base_branch} -- {path} +``` + +Use the upstream remote (not origin) to ensure the base branch is current. + +### Step 6: Process Dropped Items + +For each valid `/drop` path: + +**If undoing a previous save:** +1. Remove the files from the working tree: + ```bash + # Full plugin removal + git rm -rf {path} + # Individual file removal + git rm {path} + ``` +2. Remove the path's entry from `.pruneprotect` (including its comment line). + +**If adding a new drop:** +1. Remove the files from the working tree (same `git rm` commands as above). +2. For surviving plugins that lose commands or skills (not a full plugin removal), bump the patch version in `plugins/{plugin-name}/.claude-plugin/plugin.json` (e.g., `0.0.5` → `0.0.6`). + +### Step 7: Update .pruneprotect + +For `/save` items: append each saved path to `.pruneprotect` with a comment indicating who requested the save: + +``` +# Saved by @username on 2026-05-05 +plugins/foo/ +``` + +For `/drop` items that undo a save: remove the path and its comment from `.pruneprotect`. + +If `.pruneprotect` does not exist, create it with the saved entries. + +### Step 8: Sync and Commit + +Run `make update` to regenerate marketplace.json and docs after changes: +```bash +make update +git add -A +``` + +Create a new commit (never amend, never force-push): +```bash +git commit -m "$(cat <<'EOF' +chore: process save/drop directives from pruning PR + +Restored and added to .pruneprotect: +- plugins/foo/ — saved by @username + +Dropped: +- plugins/bar/commands/baz.md — dropped by @otherperson + +Co-Authored-By: Claude Opus 4.6 +EOF +)" +``` + +### Step 9: Push + +Push the new commit with a regular push: +```bash +git push +``` + +### Step 10: Update PR Body + +Read the current PR body: +```bash +gh pr view {pr_number} --json body --jq '.body' +``` + +**For `/save` paths:** In the removal manifest table, find the rows for saved paths and apply strikethrough with a `[SAVED]` tag. For example, change: + +``` +| plugin | `plugins/foo/` | No commits in 7 months, v0.0.1 | +``` + +To: + +``` +| ~~plugin~~ | ~~`plugins/foo/`~~ | ~~SAVED by @username~~ | +``` + +**For `/drop` paths that undo a save:** Remove the strikethrough and `[SAVED]` tag, restoring the row to its original state with the original reason (if available from git history of the PR body), or use a new reason: + +``` +| plugin | `plugins/foo/` | Dropped by @username | +``` + +**For new `/drop` paths:** Add a new row to the manifest table: + +``` +| command | `plugins/bar/commands/baz.md` | Manually dropped by @username | +``` + +Update the PR body: +```bash +gh pr edit {pr_number} --body "{updated_body}" +``` + +### Step 11: Comment on PR + +Add a summary comment: +```bash +gh pr comment {pr_number} --body "$(cat <<'EOF' +Processed `/save` and `/drop` comments. + +**Saved** (restored and added to `.pruneprotect`): +- `plugins/foo/` — saved by @username + +**Dropped** (removed): +- `plugins/bar/commands/baz.md` — dropped by @otherperson + +Remaining removals: N items. +EOF +)" +``` + +Omit the **Saved** or **Dropped** section if there are no items for that category. + +### Step 12: Report Results + +Print a summary to the user: what was restored, what was dropped, what remains in the PR, and the updated PR URL. + +## Return Value +A summary of saved/dropped items and the updated PR state. + +## Examples + +1. **Process saves and drops on a specific PR:** + ```text + /marketplace-ops:prune-update 42 + ``` + +2. **Auto-detect the pruning PR:** + ```text + /marketplace-ops:prune-update + ``` + +## Comment Format Reference + +On the pruning PR, trusted collaborators can comment: + +```text +/save plugins/foo/ # Restore and permanently protect +/drop plugins/bar/commands/baz.md # Undo a /save, or manually add a removal +/drop --force plugins/protected/ # Drop even if listed in .pruneprotect +``` diff --git a/plugins/marketplace-ops/commands/prune.md b/plugins/marketplace-ops/commands/prune.md new file mode 100644 index 000000000..7130413eb --- /dev/null +++ b/plugins/marketplace-ops/commands/prune.md @@ -0,0 +1,251 @@ +--- +description: Analyze and prune stale plugins, commands, and skills from the marketplace +argument-hint: "[--dry-run]" +--- + +## Name +marketplace-ops:prune + +## Synopsis +```text +/marketplace-ops:prune [--dry-run] +``` + +## Description +Analyzes the plugin marketplace for stale content using a two-tier system: + +1. **Plugin-level (mechanical):** A scoring script flags entire plugins for removal based on git history and structural signals. If a plugin meets the scoring threshold, it is removed — no LLM override. The script is the decision-maker. +2. **Item-level (LLM-assisted):** For plugins that survive the plugin-level cut, a second script flags individual commands and skills. The LLM then reviews flagged items and applies qualitative judgment (AI reasoning required, duplicates, dead references) to decide which to remove. + +By default, creates a branch removing identified items and opens a PR with a removal manifest and `/save` workflow. With `--dry-run`, prints the analysis report without taking action. + +## Arguments +- `--dry-run`: Report pruning candidates without taking any action. No branch, no removals, no PR. + +## Implementation + +### Step 1: Run the Plugin Scoring Script + +Run the scoring script to get a structured JSON report of all plugins scored by staleness: + +```bash +python3 plugins/marketplace-ops/scripts/score-plugins.py . +``` + +The script handles: +- Reading `.pruneprotect` and skipping protected plugins +- Inventorying all plugins (commands, skills, hooks, README, version) +- Gathering git metadata (last commit date, commit count, contributor count) +- Detecting batch-update dates (when 5+ plugins share the same last-commit date) and falling through to the second-most-recent commit +- Scoring each plugin against these heuristics (candidate threshold: score >= 3): + +| Signal | Weight | +|--------|--------| +| Last meaningful commit > 90 days ago | 1 | +| Last meaningful commit > 120 days ago | 2 | +| Last meaningful commit > 150 days ago | 3 | +| Last meaningful commit > 180 days ago | 4 | +| Number of commits <= 3 | 2 | +| Number of commits > 3 and <= 5 | 1 | +| Single contributor + inactive > 2 months | 1 | +| Has OWNERS file | -2 | +| Small plugin footprint (few things inside) | 1 | +| Minimal README or docs | 1 | + +Maturity signals (commit count, OWNERS, footprint, README) are skipped for plugins younger than 90 days. + +The JSON output contains `candidates` (score >= threshold), `protected` (skipped), and `safe` (scored but below threshold) arrays. + +### Step 2: Remove All Plugin Candidates + +**Plugin-level pruning is mechanical.** Every plugin in the `candidates` array is removed — no LLM judgment, no second-guessing the score. If the scoring threshold is met, the plugin goes. The `/save` workflow on the PR is the rescue path for false positives. + +Add all candidate plugin paths to the removal list. + +### Step 3: Command/Skill-Level Analysis (Higher Bar — LLM Judgment Required) + +For plugins that survived Step 2 (not in the candidates list and not protected), run the item-level scoring script: + +```bash +python3 plugins/marketplace-ops/scripts/score-items.py . +``` + +The script automatically skips protected plugins. To analyze only specific plugins: +```bash +python3 plugins/marketplace-ops/scripts/score-items.py --plugins ci,git,jira . +``` + +The script scores each command (individual `.md` file) and skill (full skill directory) using: +- Graduated inactivity (>90d: +1, >120d: +2, >150d: +3, >180d: +4) +- Low commit count (<=2 commits: +1) +- Single contributor (+1) +- Very small size (<500 bytes: +1) + +Items with score >= 3 appear in the `flagged` array. For each flagged item, read its content and apply LLM judgment: +- Does the command require AI reasoning/analysis/decisions? Or could it be a shell alias or Makefile target? Commands that just wrap scripts violate the repo's "AI reasoning required" rule and are candidates. +- Does the command duplicate or substantially overlap with another command in the marketplace? +- Does the command reference tools, APIs, or services that no longer exist or are deprecated? +- Would an engineering organization find this command useful? + +Only remove a command/skill if both the quantitative signals (flagged by the script) AND the qualitative judgment (low utility) agree. When in doubt, keep the item. + +### Step 4: Cross-Reference Scan + +Before finalizing the removal list, check whether any item being removed is referenced by items NOT being removed: + +```bash +# For each plugin being removed +grep -rl "/{plugin-name}:" plugins/ --include="*.md" | grep -v "plugins/{plugin-name}/" +grep -rl "plugins/{plugin-name}" plugins/ --include="*.md" | grep -v "plugins/{plugin-name}/" + +# For each command being removed +grep -rl "{command-name}" plugins/ --include="*.md" +``` + +Record any cross-references as warnings to include in the report. + +### Step 5: Generate Report + +Build a removal manifest table: + +```markdown +| Type | Path | Reason | +|------|------|--------| +| plugin | `plugins/foo/` | No commits in 7 months, v0.0.1, 1 contributor | +| command | `plugins/bar/commands/baz.md` | Single contributor, inactive 8 months, wraps shell script | +``` + +Also list any cross-reference warnings: +```markdown +## Cross-Reference Warnings +- `plugins/xyz/`: referenced by `plugins/abc/commands/def.md` +``` + +And list protected items that were skipped: +```markdown +## Protected (skipped) +- `plugins/hello-world/` — listed in .pruneprotect +``` + +### Step 6: Dry-Run Exit Point + +**If `--dry-run` was specified:** Print the full report to the user and stop. Do not create a branch, remove files, or open a PR. + +### Step 7: Create Branch and Remove Items + +```bash +git checkout -b prune/$(date +%Y%m%d-%H%M%S) main +``` + +For each item in the removal manifest: +```bash +# Full plugin removal +git rm -rf plugins/{plugin-name}/ + +# Individual command removal +git rm plugins/{plugin-name}/commands/{command}.md + +# Individual skill removal +git rm -rf plugins/{plugin-name}/skills/{skill-name}/ +``` + +### Step 8: Bump Versions for Modified Plugins + +For any surviving plugin that had commands or skills removed (i.e., not a full plugin removal), bump the patch version in its `plugin.json`. This is required by the CI version-check workflow. + +For each affected plugin: +1. Read `plugins/{plugin-name}/.claude-plugin/plugin.json` +2. Increment the patch version (e.g., `0.0.5` → `0.0.6`) +3. Write the updated file + +### Step 9: Sync and Commit + +Run `make update` to regenerate marketplace.json and documentation: +```bash +make update +git add -A +``` + +Commit: +```bash +git commit -m "$(cat <<'EOF' +chore: prune stale plugins, commands, and skills + +See PR description for full removal manifest. + +Co-Authored-By: Claude Opus 4.6 +EOF +)" +``` + +### Step 10: Push and Open PR + +Determine the correct remote for the user's fork: +```bash +git remote -v +``` + +Push to the user's fork: +```bash +git push -u {fork-remote} HEAD +``` + +Open the PR with `gh pr create`. The body must include the removal manifest, cross-reference warnings, save instructions, and protection note: + +```bash +gh pr create --title "chore: prune stale marketplace content" --body "$(cat <<'EOF' +## Summary +Automated pruning of stale/inactive plugins, commands, and skills. + +## Removal Manifest + +{paste the table from Step 5} + +## Cross-Reference Warnings +{paste warnings from Step 4, or "None" if clean} + +## How to Save or Drop Items +To keep something that's being removed, comment on this PR: + +~~~text +/save plugins/foo/ +/save plugins/bar/commands/baz.md +~~~ + +Saved items will be restored from git history and added to `.pruneprotect` so they won't be flagged in future pruning cycles. + +To manually add a removal (or undo a previous `/save`), comment: + +~~~text +/drop plugins/baz/commands/old-cmd.md +~~~ + +Run `/marketplace-ops:prune-update` to process all directives. + +## Protected Items +Items listed in `.pruneprotect` were excluded from analysis. + +{paste protected list from Step 5} +EOF +)" +``` + +### Step 11: Report Results + +Print the PR URL and a summary: how many plugins, commands, and skills were proposed for removal. + +## Return Value +- **With `--dry-run`:** A markdown report of pruning candidates with reasons, cross-references, and protected items. +- **Without `--dry-run`:** The URL of the created PR, plus a summary of removals. + +## Examples + +1. **Dry run to see candidates:** + ```text + /marketplace-ops:prune --dry-run + ``` + +2. **Full pruning with PR creation:** + ```text + /marketplace-ops:prune + ``` diff --git a/plugins/marketplace-ops/scripts/score-items.py b/plugins/marketplace-ops/scripts/score-items.py new file mode 100644 index 000000000..9e5541af9 --- /dev/null +++ b/plugins/marketplace-ops/scripts/score-items.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +""" +Score individual commands and skills within plugins for staleness. + +Complements score-plugins.py: this script analyzes items inside plugins +that were NOT flagged for full removal. It gathers per-file git metadata +and structural signals, outputting JSON for LLM-assisted review. + +Usage: score-items.py [--plugins PLUGIN1,PLUGIN2,...] [repo-root] + --plugins Comma-separated plugin names to analyze (default: all non-protected) + repo-root Path to the repository root (default: cwd) +""" + +import json +import os +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + + +def parse_args(): + plugin_filter = None + repo_root = os.getcwd() + args = sys.argv[1:] + i = 0 + while i < len(args): + if args[i] == "--plugins" and i + 1 < len(args): + plugin_filter = [p.strip() for p in args[i + 1].split(",")] + i += 2 + else: + repo_root = args[i] + i += 1 + return plugin_filter, Path(repo_root) + + +def load_pruneprotect(repo_root): + protect_file = repo_root / ".pruneprotect" + protected = [] + if protect_file.exists(): + for line in protect_file.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + protected.append(line) + return protected + + +def is_protected(path, protected): + norm_path = path.rstrip("/") + "/" + for prefix in protected: + norm_prefix = prefix.rstrip("/") + "/" + if norm_path.startswith(norm_prefix): + return True + return False + + +def git(repo_root, *args): + try: + result = subprocess.run( + ["git", "-C", str(repo_root), *args], + capture_output=True, text=True, timeout=30, check=True, + ) + except subprocess.TimeoutExpired as exc: + raise RuntimeError(f"git timed out: {' '.join(args)}") from exc + except subprocess.CalledProcessError as exc: + raise RuntimeError(f"git failed: {' '.join(args)} :: {exc.stderr.strip()}") from exc + return result.stdout.strip() + + +def get_last_commit_date(repo_root, path): + out = git(repo_root, "log", "-1", "--format=%aI", "--", path) + if not out: + return None + return datetime.fromisoformat(out) + + +def get_commit_count(repo_root, path): + out = git(repo_root, "rev-list", "--count", "HEAD", "--", path) + try: + return int(out) + except (ValueError, TypeError): + return 0 + + +def get_contributor_count(repo_root, path): + out = git(repo_root, "shortlog", "-sn", "--all", "--", path) + return len([l for l in out.splitlines() if l.strip()]) + + +def get_contributors(repo_root, path): + out = git(repo_root, "shortlog", "-sn", "--all", "--", path) + contributors = [] + for line in out.splitlines(): + line = line.strip() + if line: + parts = line.split("\t", 1) + if len(parts) == 2: + contributors.append({"commits": int(parts[0].strip()), "name": parts[1].strip()}) + return contributors + + +def get_file_size(path): + try: + return path.stat().st_size + except OSError: + return 0 + + +def read_frontmatter(filepath): + """Extract YAML frontmatter from a markdown file.""" + try: + text = filepath.read_text() + except (OSError, UnicodeDecodeError): + return {} + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return {} + fm = {} + for line in lines[1:]: + if line.strip() == "---": + break + if ":" in line: + key, _, value = line.partition(":") + fm[key.strip()] = value.strip().strip('"').strip("'") + return fm + + +def score_item(item_info, now): + score = 0 + reasons = [] + + last_date = item_info["last_commit_date"] + if last_date: + days_inactive = (now - last_date).days + item_info["days_since_last_commit"] = days_inactive + if days_inactive >= 180: + score += 4 + reasons.append(f"Last commit {days_inactive} days ago (>=180)") + elif days_inactive >= 150: + score += 3 + reasons.append(f"Last commit {days_inactive} days ago (>=150)") + elif days_inactive >= 120: + score += 2 + reasons.append(f"Last commit {days_inactive} days ago (>=120)") + elif days_inactive >= 90: + score += 1 + reasons.append(f"Last commit {days_inactive} days ago (>=90)") + else: + item_info["days_since_last_commit"] = None + + if item_info["commit_count"] <= 2: + score += 1 + reasons.append(f"Only {item_info['commit_count']} commits") + + if item_info["contributor_count"] == 1: + score += 1 + reasons.append("Single contributor") + + if item_info["size_bytes"] < 500: + score += 1 + reasons.append(f"Very small ({item_info['size_bytes']} bytes)") + + item_info["score"] = score + item_info["reasons"] = reasons + return score + + +def analyze_plugin(repo_root, plugin_dir, protected, now): + plugin_name = plugin_dir.name + commands = [] + skills = [] + + cmd_dir = plugin_dir / "commands" + if cmd_dir.is_dir(): + for cmd_file in sorted(cmd_dir.glob("*.md")): + rel_path = str(cmd_file.relative_to(repo_root)) + if is_protected(rel_path, protected): + continue + + fm = read_frontmatter(cmd_file) + last_date = get_last_commit_date(repo_root, rel_path) + + info = { + "type": "command", + "name": cmd_file.stem, + "path": rel_path, + "plugin": plugin_name, + "description": fm.get("description", ""), + "size_bytes": get_file_size(cmd_file), + "last_commit_date": last_date, + "commit_count": get_commit_count(repo_root, rel_path), + "contributor_count": get_contributor_count(repo_root, rel_path), + "contributors": get_contributors(repo_root, rel_path), + } + score_item(info, now) + commands.append(info) + + skills_dir = plugin_dir / "skills" + if skills_dir.is_dir(): + for skill_dir in sorted(skills_dir.iterdir()): + if not skill_dir.is_dir(): + continue + skill_md = skill_dir / "SKILL.md" + if not skill_md.exists(): + continue + + rel_path = f"plugins/{plugin_name}/skills/{skill_dir.name}/" + if is_protected(rel_path, protected): + continue + + fm = read_frontmatter(skill_md) + last_date = get_last_commit_date(repo_root, rel_path) + + # Skill size = total size of all files in the skill directory + total_size = sum(f.stat().st_size for f in skill_dir.rglob("*") if f.is_file()) + + info = { + "type": "skill", + "name": skill_dir.name, + "path": rel_path, + "plugin": plugin_name, + "description": fm.get("description", fm.get("name", "")), + "size_bytes": total_size, + "last_commit_date": last_date, + "commit_count": get_commit_count(repo_root, rel_path), + "contributor_count": get_contributor_count(repo_root, rel_path), + "contributors": get_contributors(repo_root, rel_path), + } + score_item(info, now) + skills.append(info) + + return commands, skills + + +def main(): + plugin_filter, repo_root = parse_args() + now = datetime.now(timezone.utc) + protected = load_pruneprotect(repo_root) + plugins_dir = repo_root / "plugins" + + if not plugins_dir.is_dir(): + print(json.dumps({"error": "No plugins/ directory found"}, indent=2)) + sys.exit(1) + + all_items = [] + + for plugin_dir in sorted(plugins_dir.iterdir()): + if not plugin_dir.is_dir(): + continue + + plugin_name = plugin_dir.name + rel_path = f"plugins/{plugin_name}/" + + # Skip protected plugins entirely + if is_protected(rel_path, protected): + continue + + # If --plugins filter specified, only analyze those + if plugin_filter and plugin_name not in plugin_filter: + continue + + commands, skills = analyze_plugin(repo_root, plugin_dir, protected, now) + all_items.extend(commands) + all_items.extend(skills) + + # Serialize datetimes + for item in all_items: + if isinstance(item.get("last_commit_date"), datetime): + item["last_commit_date"] = item["last_commit_date"].isoformat() + + flagged = [i for i in all_items if i["score"] >= 3] + clean = [i for i in all_items if i["score"] < 3] + + output = { + "generated_at": now.isoformat(), + "summary": { + "total_items": len(all_items), + "total_commands": sum(1 for i in all_items if i["type"] == "command"), + "total_skills": sum(1 for i in all_items if i["type"] == "skill"), + "flagged": len(flagged), + "clean": len(clean), + }, + "flagged": sorted(flagged, key=lambda x: -x["score"]), + "clean": sorted(clean, key=lambda x: (-x["score"], x["path"])), + } + + print(json.dumps(output, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/plugins/marketplace-ops/scripts/score-plugins.py b/plugins/marketplace-ops/scripts/score-plugins.py new file mode 100644 index 000000000..6fca4c3f6 --- /dev/null +++ b/plugins/marketplace-ops/scripts/score-plugins.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +""" +Score plugins for staleness based on git history and structural signals. + +Reads .pruneprotect, walks plugins/*, gathers git metadata, and outputs +a JSON report of scored candidates. + +Usage: score-plugins.py [--threshold N] [repo-root] + --threshold N Minimum score to flag as candidate (default: 3) + repo-root Path to the repository root (default: cwd) +""" + +import json +import os +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + + +def parse_args(): + threshold = 3 + repo_root = os.getcwd() + args = sys.argv[1:] + i = 0 + while i < len(args): + if args[i] == "--threshold" and i + 1 < len(args): + threshold = int(args[i + 1]) + i += 2 + else: + repo_root = args[i] + i += 1 + return threshold, Path(repo_root) + + +def load_pruneprotect(repo_root): + protect_file = repo_root / ".pruneprotect" + protected = [] + if protect_file.exists(): + for line in protect_file.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + protected.append(line) + return protected + + +def is_protected(path, protected): + norm_path = path.rstrip("/") + "/" + for prefix in protected: + norm_prefix = prefix.rstrip("/") + "/" + if norm_path.startswith(norm_prefix): + return True + return False + + +def git(repo_root, *args): + try: + result = subprocess.run( + ["git", "-C", str(repo_root), *args], + capture_output=True, text=True, timeout=30, check=True, + ) + except subprocess.TimeoutExpired as exc: + raise RuntimeError(f"git timed out: {' '.join(args)}") from exc + except subprocess.CalledProcessError as exc: + raise RuntimeError(f"git failed: {' '.join(args)} :: {exc.stderr.strip()}") from exc + return result.stdout.strip() + + +def get_first_commit_date(repo_root, path): + out = git(repo_root, "log", "--diff-filter=A", "--format=%aI", "--reverse", "--", path) + if not out: + return None + first_line = out.splitlines()[0].strip() + return datetime.fromisoformat(first_line) if first_line else None + + +def get_last_commit_date(repo_root, path): + out = git(repo_root, "log", "-1", "--format=%aI", "--", path) + if not out: + return None + return datetime.fromisoformat(out) + + +def get_last_two_commit_dates(repo_root, path): + out = git(repo_root, "log", "-2", "--format=%aI", "--", path) + dates = [] + for line in out.splitlines(): + line = line.strip() + if line: + dates.append(datetime.fromisoformat(line)) + return dates + + +def get_commit_count(repo_root, path): + out = git(repo_root, "rev-list", "--count", "HEAD", "--", path) + try: + return int(out) + except (ValueError, TypeError): + return 0 + + +def get_contributor_count(repo_root, path): + out = git(repo_root, "shortlog", "-sn", "--all", "--", path) + return len([l for l in out.splitlines() if l.strip()]) + + +def get_contributors(repo_root, path): + out = git(repo_root, "shortlog", "-sn", "--all", "--", path) + contributors = [] + for line in out.splitlines(): + line = line.strip() + if line: + parts = line.split("\t", 1) + if len(parts) == 2: + contributors.append({"commits": int(parts[0].strip()), "name": parts[1].strip()}) + return contributors + + +def read_plugin_json(plugin_dir): + pj = plugin_dir / ".claude-plugin" / "plugin.json" + if pj.exists(): + with open(pj) as f: + return json.load(f) + return None + + +def count_commands(plugin_dir): + cmd_dir = plugin_dir / "commands" + if not cmd_dir.is_dir(): + return 0 + return len(list(cmd_dir.glob("*.md"))) + + +def count_skills(plugin_dir): + skills_dir = plugin_dir / "skills" + if not skills_dir.is_dir(): + return 0 + return len(list(skills_dir.glob("*/SKILL.md"))) + + +def has_hooks(plugin_dir): + return (plugin_dir / "hooks").is_dir() + + +def has_owners(plugin_dir): + return (plugin_dir / "OWNERS").exists() + + +def readme_size(plugin_dir): + readme = plugin_dir / "README.md" + if readme.exists(): + return readme.stat().st_size + return 0 + + +def detect_batch_update_dates(all_dates): + """If more than 5 plugins share the same last-commit date (day), it's likely a batch update.""" + from collections import Counter + day_counts = Counter() + for d in all_dates: + if d: + day_counts[d.date()] += 1 + return {day for day, count in day_counts.items() if count > 5} + + +def score_plugin(plugin_info, now, batch_dates): + score = 0 + reasons = [] + + last_date = plugin_info["last_commit_date"] + if last_date and last_date.date() in batch_dates and plugin_info.get("second_commit_date"): + effective_date = plugin_info["second_commit_date"] + plugin_info["effective_last_date"] = effective_date.isoformat() + plugin_info["batch_update_detected"] = True + else: + effective_date = last_date + plugin_info["effective_last_date"] = last_date.isoformat() if last_date else None + plugin_info["batch_update_detected"] = False + + if effective_date: + days_inactive = (now - effective_date).days + plugin_info["days_since_last_meaningful_commit"] = days_inactive + if days_inactive >= 180: + score += 4 + reasons.append(f"Last meaningful commit {days_inactive} days ago (>=180)") + elif days_inactive >= 150: + score += 3 + reasons.append(f"Last meaningful commit {days_inactive} days ago (>=150)") + elif days_inactive >= 120: + score += 2 + reasons.append(f"Last meaningful commit {days_inactive} days ago (>=120)") + elif days_inactive >= 90: + score += 1 + reasons.append(f"Last meaningful commit {days_inactive} days ago (>=90)") + else: + plugin_info["days_since_last_meaningful_commit"] = None + + first_date = plugin_info.get("first_commit_date") + plugin_age_days = (now - first_date).days if first_date else None + plugin_info["plugin_age_days"] = plugin_age_days + is_young = plugin_age_days is not None and plugin_age_days < 90 + + if is_young: + reasons.append(f"Young plugin ({plugin_age_days} days old) — skipping maturity signals") + else: + commit_count = plugin_info["commit_count"] + if commit_count <= 3: + score += 2 + reasons.append(f"Only {commit_count} commits total (<=3)") + elif commit_count <= 5: + score += 1 + reasons.append(f"Only {commit_count} commits total (<=5)") + + contributor_count = plugin_info["contributor_count"] + if contributor_count == 1 and effective_date: + days_inactive = (now - effective_date).days + if days_inactive > 60: + score += 1 + reasons.append(f"Single contributor, inactive {days_inactive} days (>60)") + + if plugin_info.get("has_owners"): + score -= 2 + reasons.append("Has OWNERS file (-2)") + + num_commands = plugin_info["command_count"] + num_skills = plugin_info["skill_count"] + if num_commands + num_skills <= 2: + score += 1 + reasons.append(f"Small footprint ({num_commands} commands, {num_skills} skills)") + + rs = plugin_info["readme_bytes"] + if rs == 0: + score += 1 + reasons.append("No README") + elif rs < 100: + score += 1 + reasons.append(f"Minimal README ({rs} bytes)") + + plugin_info["score"] = score + plugin_info["reasons"] = reasons + return score + + +def main(): + threshold, repo_root = parse_args() + now = datetime.now(timezone.utc) + protected = load_pruneprotect(repo_root) + plugins_dir = repo_root / "plugins" + + if not plugins_dir.is_dir(): + print(json.dumps({"error": "No plugins/ directory found"}, indent=2)) + sys.exit(1) + + plugins = [] + all_last_dates = [] + + for plugin_dir in sorted(plugins_dir.iterdir()): + if not plugin_dir.is_dir(): + continue + + rel_path = f"plugins/{plugin_dir.name}/" + meta = read_plugin_json(plugin_dir) + + last_date = get_last_commit_date(repo_root, rel_path) + first_date = get_first_commit_date(repo_root, rel_path) + all_last_dates.append(last_date) + dates = get_last_two_commit_dates(repo_root, rel_path) + + info = { + "name": meta.get("name", plugin_dir.name) if meta else plugin_dir.name, + "path": rel_path, + "version": meta.get("version", "unknown") if meta else "unknown", + "description": meta.get("description", "") if meta else "", + "protected": is_protected(rel_path, protected), + "command_count": count_commands(plugin_dir), + "skill_count": count_skills(plugin_dir), + "has_hooks": has_hooks(plugin_dir), + "has_owners": has_owners(plugin_dir), + "readme_bytes": readme_size(plugin_dir), + "first_commit_date": first_date, + "last_commit_date": last_date, + "second_commit_date": dates[1] if len(dates) > 1 else None, + "commit_count": get_commit_count(repo_root, rel_path), + "contributor_count": get_contributor_count(repo_root, rel_path), + "contributors": get_contributors(repo_root, rel_path), + } + plugins.append(info) + + batch_dates = detect_batch_update_dates(all_last_dates) + + for p in plugins: + if p["protected"]: + p["score"] = 0 + p["reasons"] = ["Protected by .pruneprotect"] + p["candidate"] = False + else: + score_plugin(p, now, batch_dates) + p["candidate"] = p["score"] >= threshold + + # Serialize datetimes to ISO strings + for p in plugins: + for key in ("first_commit_date", "last_commit_date", "second_commit_date"): + if isinstance(p[key], datetime): + p[key] = p[key].isoformat() + + output = { + "generated_at": now.isoformat(), + "threshold": threshold, + "batch_update_dates": [d.isoformat() for d in sorted(batch_dates)], + "summary": { + "total_plugins": len(plugins), + "protected": sum(1 for p in plugins if p["protected"]), + "candidates": sum(1 for p in plugins if p["candidate"]), + "safe": sum(1 for p in plugins if not p["candidate"] and not p["protected"]), + }, + "candidates": [p for p in plugins if p["candidate"]], + "protected": [p for p in plugins if p["protected"]], + "safe": [p for p in plugins if not p["candidate"] and not p["protected"]], + } + + print(json.dumps(output, indent=2)) + + +if __name__ == "__main__": + main()