diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6f29b6b..d09477c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,7 +5,7 @@ "email": "support@claudecodeplugins.dev" }, "metadata": { - "description": "Awesome Claude Code plugins — a curated list of slash commands, subagents, MCP servers, and hooks for Claude Code", + "description": "Awesome Claude Code plugins \u2014 a curated list of slash commands, subagents, MCP servers, and hooks for Claude Code", "version": "0.0.1", "homepage": "https://claudecodeplugins.dev" }, @@ -73,7 +73,7 @@ { "name": "ultrathink", "source": "./plugins/ultrathink", - "description": "Use /ultrathink to launch a Coordinator Agent that directs four specialist sub-agents—Architect, Research, Coder, and Tester—to analyze, design, implement, and validate your coding task. The process breaks the task into clear steps, gathers insights, and synthesizes a cohesive solution with actionable outputs. Relevant files can be referenced ad-hoc using @ filename syntax.", + "description": "Use /ultrathink to launch a Coordinator Agent that directs four specialist sub-agents\u2014Architect, Research, Coder, and Tester\u2014to analyze, design, implement, and validate your coding task. The process breaks the task into clear steps, gathers insights, and synthesizes a cohesive solution with actionable outputs. Relevant files can be referenced ad-hoc using @ filename syntax.", "version": "1.0.0", "author": { "name": "Jeronim Morina" @@ -847,7 +847,7 @@ "description": "Use this agent when setting up CI/CD pipelines, configuring Docker containers, deploying applications to cloud platforms, setting up Kubernetes clusters, implementing infrastructure as code, or automating deployment workflows. Examples: Context: User is setting up a new project and needs deployment automation. user: \"I've built a FastAPI application and need to deploy it to production with proper CI/CD\" assistant: \"I'll use the deployment-engineer agent to set up a complete deployment pipeline with Docker, GitHub Actions, and production-ready configurations.\" Context: User mentions containerization or deployment issues. user: \"Our deployment process is manual and error-prone. We need to automate it.\" assistant: \"Let me use the deployment-engineer agent to design an automated CI/CD pipeline that eliminates manual steps and ensures reliable deployments.\"", "version": "1.0.0", "author": { - "name": "Jure Šunić" + "name": "Jure \u0160uni\u0107" }, "category": "Automation DevOps", "homepage": "https://github.com/ccplugins/awesome-claude-code-plugins/tree/main/plugins/deployment-engineer", @@ -1141,7 +1141,7 @@ "description": "Use this agent when you need to design, build, or validate n8n automation workflows. This agent specializes in creating efficient n8n workflows using proper validation techniques and MCP tools integration.\\n\\nExamples:\\n- \\n Context: User wants to create a Slack notification workflow when a new GitHub issue is created.\\n user: \"I need to create an n8n workflow that sends a Slack message whenever a new GitHub issue is opened\"\\n assistant: \"I'll use the n8n-workflow-builder agent to design and build this GitHub-to-Slack automation workflow with proper validation.\"\\n \\n The user needs n8n workflow creation, so use the n8n-workflow-builder agent to handle the complete workflow design, validation, and deployment process.\\n \\n\\n- \\n Context: User has an existing n8n workflow that needs debugging and optimization.\\n user: \"My n8n workflow keeps failing at the HTTP Request node, can you help me fix it?\"\\n assistant: \"I'll use the n8n-workflow-builder agent to analyze and debug your workflow, focusing on the HTTP Request node configuration.\"\\n \\n Since this involves n8n workflow troubleshooting and validation, use the n8n-workflow-builder agent to diagnose and fix the issue.\\n \\n\\n- \\n Context: User wants to understand n8n best practices and available nodes for a specific use case.\\n user: \"What are the best n8n nodes for processing CSV data and sending email reports?\"\\n assistant: \"I'll use the n8n-workflow-builder agent to explore the available nodes and recommend the best approach for CSV processing and email automation.\"\\n \\n This requires n8n expertise and node discovery, so use the n8n-workflow-builder agent to provide comprehensive guidance.\\n \\n", "version": "1.0.0", "author": { - "name": "Jure Šunić" + "name": "Jure \u0160uni\u0107" }, "category": "Automation DevOps", "homepage": "https://github.com/ccplugins/awesome-claude-code-plugins/tree/main/plugins/n8n-workflow-builder", @@ -1197,7 +1197,7 @@ "description": "Use this agent when you need to create comprehensive Product Requirements Documents (PRDs) that combine business strategy, technical architecture, and user research. Examples: Context: The user needs to create a PRD for a new feature or product launch. user: \"I need to create a PRD for our new user authentication system that will support SSO and multi-factor authentication\" assistant: \"I'll use the prd-specialist agent to create a comprehensive PRD that covers the strategic foundation, technical requirements, and implementation blueprint for your authentication system.\" Context: The user is planning a major product initiative and needs strategic documentation. user: \"We're launching a mobile app for our e-commerce platform and need a detailed PRD to guide development\" assistant: \"Let me engage the prd-specialist agent to develop a thorough PRD that includes market analysis, user research integration, technical architecture, and implementation roadmap for your mobile app initiative.\"", "version": "1.0.0", "author": { - "name": "Jure Šunić" + "name": "Jure \u0160uni\u0107" }, "category": "Project & Product Management", "homepage": "https://github.com/ccplugins/awesome-claude-code-plugins/tree/main/plugins/prd-specialist", @@ -1281,7 +1281,7 @@ "description": "Use this agent when working with Python code that requires advanced features, performance optimization, or comprehensive refactoring. Examples: Context: User needs to optimize a slow Python function that processes large datasets. user: \"This function is taking too long to process our data, can you help optimize it?\" assistant: \"I'll use the python-expert agent to analyze and optimize your Python code with advanced techniques and performance profiling.\" Context: User wants to implement async/await patterns in their existing synchronous Python code. user: \"I need to convert this synchronous code to use async/await for better performance\" assistant: \"Let me use the python-expert agent to refactor your code with proper async/await patterns and concurrent programming techniques.\" Context: User needs help implementing complex Python design patterns. user: \"I want to implement a factory pattern with decorators for my API endpoints\" assistant: \"I'll use the python-expert agent to implement advanced Python patterns with decorators and proper design principles.\"", "version": "1.0.0", "author": { - "name": "Jure Šunić" + "name": "Jure \u0160uni\u0107" }, "category": "Development Engineering", "homepage": "https://github.com/ccplugins/awesome-claude-code-plugins/tree/main/plugins/python-expert", @@ -1670,6 +1670,193 @@ "security", "compliance" ] + }, + { + "name": "commit-narrator", + "source": "./plugins/commit-narrator", + "description": "Generate semantic commit messages from staged diffs, including the *why* behind changes \u2014 deterministic Python helper wrapped as a slash command.", + "version": "0.1.0", + "author": { + "name": "Mehmet Turac", + "url": "https://github.com/mturac" + }, + "category": "Git Workflow", + "homepage": "https://github.com/mturac/pluginpool-commit-narrator", + "keywords": [ + "git", + "commit", + "conventional-commits", + "diff", + "python" + ] + }, + { + "name": "pr-storyteller", + "source": "./plugins/pr-storyteller", + "description": "Generate PR title, body, and test plan from commits and diff vs base branch.", + "version": "0.1.0", + "author": { + "name": "Mehmet Turac", + "url": "https://github.com/mturac" + }, + "category": "Git Workflow", + "homepage": "https://github.com/mturac/pluginpool-pr-storyteller", + "keywords": [ + "git", + "pr", + "pull-request", + "github", + "python" + ] + }, + { + "name": "test-gap", + "source": "./plugins/test-gap", + "description": "Identify lines in your diff lacking test coverage (Cobertura / lcov / coverage.json).", + "version": "0.1.0", + "author": { + "name": "Mehmet Turac", + "url": "https://github.com/mturac" + }, + "category": "Code Quality Testing", + "homepage": "https://github.com/mturac/pluginpool-test-gap", + "keywords": [ + "testing", + "coverage", + "cobertura", + "lcov", + "python" + ] + }, + { + "name": "deps-doctor", + "source": "./plugins/deps-doctor", + "description": "Multi-ecosystem dependency audit (npm, pip, cargo, go) producing one unified report.", + "version": "0.1.0", + "author": { + "name": "Mehmet Turac", + "url": "https://github.com/mturac" + }, + "category": "Security, Compliance, & Legal", + "homepage": "https://github.com/mturac/pluginpool-deps-doctor", + "keywords": [ + "security", + "dependencies", + "audit", + "npm", + "pip", + "cargo", + "go" + ] + }, + { + "name": "env-lint", + "source": "./plugins/env-lint", + "description": "Compare .env vs .env.example key parity \u2014 never prints values.", + "version": "0.1.0", + "author": { + "name": "Mehmet Turac", + "url": "https://github.com/mturac" + }, + "category": "Code Quality Testing", + "homepage": "https://github.com/mturac/pluginpool-env-lint", + "keywords": [ + "env", + "dotenv", + "config", + "lint" + ] + }, + { + "name": "secret-guard", + "source": "./plugins/secret-guard", + "description": "Pre-commit secret scanner using pattern and entropy detection, with redacted output.", + "version": "0.1.0", + "author": { + "name": "Mehmet Turac", + "url": "https://github.com/mturac" + }, + "category": "Security, Compliance, & Legal", + "homepage": "https://github.com/mturac/pluginpool-secret-guard", + "keywords": [ + "security", + "secrets", + "pre-commit", + "entropy", + "scanner" + ] + }, + { + "name": "standup-gen", + "source": "./plugins/standup-gen", + "description": "Generate daily standup notes from git activity across one or many repos.", + "version": "0.1.0", + "author": { + "name": "Mehmet Turac", + "url": "https://github.com/mturac" + }, + "category": "Development Engineering", + "homepage": "https://github.com/mturac/pluginpool-standup-gen", + "keywords": [ + "git", + "standup", + "productivity", + "reporting" + ] + }, + { + "name": "todo-harvest", + "source": "./plugins/todo-harvest", + "description": "Scan for TODO/FIXME/HACK comments with git blame author and age.", + "version": "0.1.0", + "author": { + "name": "Mehmet Turac", + "url": "https://github.com/mturac" + }, + "category": "Code Quality Testing", + "homepage": "https://github.com/mturac/pluginpool-todo-harvest", + "keywords": [ + "todo", + "fixme", + "code-quality", + "git-blame" + ] + }, + { + "name": "flaky-detector", + "source": "./plugins/flaky-detector", + "description": "Run a test command N times and report per-test flakiness percentage.", + "version": "0.1.0", + "author": { + "name": "Mehmet Turac", + "url": "https://github.com/mturac" + }, + "category": "Code Quality Testing", + "homepage": "https://github.com/mturac/pluginpool-flaky-detector", + "keywords": [ + "testing", + "flaky-tests", + "reliability", + "ci" + ] + }, + { + "name": "changelog-forge", + "source": "./plugins/changelog-forge", + "description": "Convert conventional commits into a CHANGELOG section with a semver bump suggestion.", + "version": "0.1.0", + "author": { + "name": "Mehmet Turac", + "url": "https://github.com/mturac" + }, + "category": "Documentation", + "homepage": "https://github.com/mturac/pluginpool-changelog-forge", + "keywords": [ + "changelog", + "semver", + "conventional-commits", + "release" + ] } ] } \ No newline at end of file diff --git a/README.md b/README.md index e4de615..b16bc8e 100644 --- a/README.md +++ b/README.md @@ -91,12 +91,16 @@ Install or disable them dynamically with the `/plugin` command — enabling you - [debug-session](./plugins/debug-session) - [debugger](./plugins/debugger) - [double-check](./plugins/double-check) +- [env-lint](./plugins/env-lint) +- [flaky-detector](./plugins/flaky-detector) - [optimize](./plugins/optimize) - [performance-benchmarker](./plugins/performance-benchmarker) - [refractor](./plugins/refractor) - [test-file](./plugins/test-file) +- [test-gap](./plugins/test-gap) - [test-results-analyzer](./plugins/test-results-analyzer) - [test-writer-fixer](./plugins/test-writer-fixer) +- [todo-harvest](./plugins/todo-harvest) - [unit-test-generator](./plugins/unit-test-generator) ### Data Analytics @@ -130,11 +134,13 @@ Install or disable them dynamically with the `/plugin` command — enabling you - [python-expert](./plugins/python-expert) - [rapid-prototyper](./plugins/rapid-prototyper) - [react-native-dev](./plugins/react-native-dev) +- [standup-gen](./plugins/standup-gen) - [vision-specialist](./plugins/vision-specialist) - [web-dev](./plugins/web-dev) ### Documentation - [analyze-codebase](./plugins/analyze-codebase) +- [changelog-forge](./plugins/changelog-forge) - [changelog-generator](./plugins/changelog-generator) - [codebase-documenter](./plugins/codebase-documenter) - [context7-docs-fetcher](./plugins/context7-docs-fetcher) @@ -147,6 +153,7 @@ Install or disable them dynamically with the `/plugin` command — enabling you - [analyze-issue](./plugins/analyze-issue) - [bug-fix](./plugins/bug-fix) - [commit](./plugins/commit) +- [commit-narrator](./plugins/commit-narrator) - [create-pr](./plugins/create-pr) - [create-pull-request](./plugins/create-pull-request) - [create-worktrees](./plugins/create-worktrees) @@ -157,6 +164,7 @@ Install or disable them dynamically with the `/plugin` command — enabling you - [husky](./plugins/husky) - [pr-issue-resolve](./plugins/pr-issue-resolve) - [pr-review](./plugins/pr-review) +- [pr-storyteller](./plugins/pr-storyteller) - [update-branch-name](./plugins/update-branch-name) ### Marketing Growth @@ -185,9 +193,11 @@ Install or disable them dynamically with the `/plugin` command — enabling you - [audit](./plugins/audit) - [compliance-automation-specialist](./plugins/compliance-automation-specialist) - [data-privacy-engineer](./plugins/data-privacy-engineer) +- [deps-doctor](./plugins/deps-doctor) - [enterprise-security-reviewer](./plugins/enterprise-security-reviewer) - [legal-advisor](./plugins/legal-advisor) - [legal-compliance-checker](./plugins/legal-compliance-checker) +- [secret-guard](./plugins/secret-guard) ## Tutorials diff --git a/plugins/changelog-forge/.claude-plugin/plugin.json b/plugins/changelog-forge/.claude-plugin/plugin.json new file mode 100644 index 0000000..8006be8 --- /dev/null +++ b/plugins/changelog-forge/.claude-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "changelog-forge", + "version": "0.1.0", + "description": "Group conventional commits since last tag into a CHANGELOG section with semver bump suggestion.", + "author": "pluginpool" +} diff --git a/plugins/changelog-forge/LICENSE b/plugins/changelog-forge/LICENSE new file mode 100644 index 0000000..266f59c --- /dev/null +++ b/plugins/changelog-forge/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 pluginpool contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/changelog-forge/README.md b/plugins/changelog-forge/README.md new file mode 100644 index 0000000..203bb53 --- /dev/null +++ b/plugins/changelog-forge/README.md @@ -0,0 +1,122 @@ +![hero](./assets/hero.svg) + +# changelog-forge + +**Turn your conventional commits into a real CHANGELOG entry. Plus a semver bump suggestion.** + +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE) +[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/) +[![Claude Code Plugin](https://img.shields.io/badge/claude--code-plugin-7c3aed.svg)](https://docs.claude.com/en/docs/claude-code/overview) +[![Tests: 13 passing](https://img.shields.io/badge/tests-13%20passing-success.svg)](./tests) + +> **TL;DR:** `/changelog-forge` → grouped CHANGELOG section + suggested `major | minor | patch`, derived from your commit history since the last tag. + +## Why this exists + +CHANGELOGs that are written *during* a release are always wrong. The person writing them has forgotten half the work, and the half they remember they describe inaccurately. `changelog-forge` reads the commits since the last tag, groups them by conventional-commit type, surfaces every `BREAKING CHANGE`, and suggests the right semver bump — so the changelog tracks ground truth. + +## Install (Claude Code) + +```sh +git clone https://github.com/mturac/pluginpool-changelog-forge ~/.claude/plugins/changelog-forge +``` + +Restart Claude Code; the slash command `/changelog-forge` appears. + +## Quick start + +```sh +/changelog-forge +``` + +Or directly: + +```sh +python3 scripts/forge.py --format md +python3 scripts/forge.py --from v1.2.0 --to HEAD --format json +python3 scripts/forge.py --write # prepend (idempotently) to CHANGELOG.md +``` + +## Flags + +| Flag | Default | Description | +|---|---|---| +| `--cwd` | cwd | Repo path | +| `--from` | last tag | Base ref/tag | +| `--to` | `HEAD` | End ref | +| `--format` | `md` | `md` or `json` | +| `--write` | off | Prepend (idempotently) to `CHANGELOG.md`; correctly inserts AFTER any leading `# Changelog` title | +| `--changelog` | `CHANGELOG.md` | Target file | + +## Conventional-commit reference + +Headers parsed as `()!: `. Recognised types: + +`feat`, `fix`, `perf`, `refactor`, `docs`, `test`, `build`, `ci`, `chore`, `revert`. + +Anything else falls into an `other` bucket. + +Breaking changes are detected when: + +- the header has `!` before `:` (e.g. `feat!: drop /v0`), or +- the body contains `BREAKING CHANGE: …`. + +## Semver bump heuristic + +| Trigger | Bump | +|---|---| +| any breaking | `major` | +| any `feat` | `minor` | +| any `fix` / `perf` | `patch` | +| everything else | `patch` (effectively a no-op) | + +When a base tag is present (e.g. `v0.4.2`), the JSON output also returns `suggested_version`. + +## Example output (markdown) + +``` +## [Unreleased] - 2026-05-16 + +### Features +- **api**: paginate search endpoint (a1b2c3d) + +### Bug Fixes +- handle null user in cookie middleware (e4f5g6h) + +### Performance +- cache token-bucket counters (12ab34c) + +_Suggested bump: **minor**_ +_Suggested version: **0.5.0**_ +``` + +## Idempotency + +Running `--write` twice with the same input produces the same file — both the diff and the SHA are stable. There's a dedicated test (`test_write_changelog_idempotent`) that runs the writer twice and asserts byte-for-byte equality. + +## Limitations + +- Flat conventional-commit grammar — multi-paragraph footers (`Refs:` blocks etc.) aren't merged. +- `--write` only manages the `## [Unreleased]` block. Promoting it to a versioned section at release time is a manual step. + +## Examples + +Step-by-step walkthroughs with real input fixtures and the helper's actual output live in [`examples/`](./examples/README.md). Three or four scenarios per plugin — from the happy path to the edge cases the test suite guards. + +## Part of the pluginpool family + +Ten focused Claude Code plugins for everyday productivity: +[commit-narrator](https://github.com/mturac/pluginpool-commit-narrator) · +[pr-storyteller](https://github.com/mturac/pluginpool-pr-storyteller) · +[test-gap](https://github.com/mturac/pluginpool-test-gap) · +[deps-doctor](https://github.com/mturac/pluginpool-deps-doctor) · +[env-lint](https://github.com/mturac/pluginpool-env-lint) · +[secret-guard](https://github.com/mturac/pluginpool-secret-guard) · +[standup-gen](https://github.com/mturac/pluginpool-standup-gen) · +[todo-harvest](https://github.com/mturac/pluginpool-todo-harvest) · +[flaky-detector](https://github.com/mturac/pluginpool-flaky-detector) · +[changelog-forge](https://github.com/mturac/pluginpool-changelog-forge) + +## License + +MIT — see [`LICENSE`](./LICENSE). Contributions welcome. diff --git a/plugins/changelog-forge/commands/changelog-forge.md b/plugins/changelog-forge/commands/changelog-forge.md new file mode 100644 index 0000000..9fbd4f9 --- /dev/null +++ b/plugins/changelog-forge/commands/changelog-forge.md @@ -0,0 +1,16 @@ +--- +description: Group conventional commits since last tag into a CHANGELOG entry +allowed-tools: Bash +--- + +Role: act as a release scribe. Turn conventional commits into a clean `## [Unreleased]` block plus a semver-bump recommendation. + +Run the helper: + +```bash +python3 scripts/forge.py --format md +``` + +Useful flags: `--from v1.2.0`, `--to HEAD`, `--write` (prepend to `CHANGELOG.md`, idempotent), `--format json`. + +The helper groups commits by type (feat / fix / perf / refactor / docs / test / build / ci / chore / revert), surfaces `BREAKING CHANGE` markers, and suggests `major | minor | patch`. Read the output, then briefly justify the bump (or push back if the heuristic missed nuance). Only run `--write` when the user explicitly asks for it. diff --git a/plugins/changelog-forge/scripts/forge.py b/plugins/changelog-forge/scripts/forge.py new file mode 100755 index 0000000..5f50f6b --- /dev/null +++ b/plugins/changelog-forge/scripts/forge.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Group conventional commits since last tag into a CHANGELOG section.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import re +import subprocess +import sys +from typing import Iterable + + +CONVENTIONAL_TYPES = ( + "feat", + "fix", + "perf", + "refactor", + "docs", + "test", + "build", + "ci", + "chore", + "revert", +) + +_HEADER = re.compile( + r"^(?P[a-z]+)(?:\((?P[^)]+)\))?(?P!?):\s*(?P.+)$" +) + +# Use RS (0x1e) record separator and US (0x1f) field separator +_FMT = "%H%x1f%s%x1f%b%x1e" + + +def _run(args: list[str], cwd: str) -> str: + res = subprocess.run(args, cwd=cwd, capture_output=True, text=True, check=False) + return res.stdout + + +def _last_tag(cwd: str) -> str | None: + out = _run(["git", "describe", "--tags", "--abbrev=0"], cwd).strip() + return out or None + + +def _git_log(cwd: str, frm: str | None, to: str) -> str: + rng = f"{frm}..{to}" if frm else to + return _run(["git", "log", rng, f"--pretty=format:{_FMT}"], cwd) + + +def _split_log(raw: str) -> list[tuple[str, str, str]]: + items: list[tuple[str, str, str]] = [] + for record in raw.split("\x1e"): + record = record.strip("\n\r") + if not record: + continue + parts = record.split("\x1f") + if len(parts) < 3: + parts += [""] * (3 - len(parts)) + h, subj, body = parts[0].strip(), parts[1], parts[2] + if h: + items.append((h, subj, body)) + return items + + +def parse_commit(hash_: str, subject: str, body: str) -> dict: + m = _HEADER.match(subject) + if not m: + return {"hash": hash_, "type": "other", "scope": None, "breaking": False, + "subject": subject, "body": body} + typ = m.group("type") + scope = m.group("scope") + breaking = bool(m.group("bang")) or ("BREAKING CHANGE" in body) + return { + "hash": hash_, + "type": typ if typ in CONVENTIONAL_TYPES else "other", + "scope": scope, + "breaking": breaking, + "subject": m.group("subject").strip(), + "body": body, + } + + +def group_commits(commits: Iterable[dict]) -> dict[str, list[dict]]: + sections: dict[str, list[dict]] = {t: [] for t in CONVENTIONAL_TYPES} + sections["other"] = [] + for c in commits: + sections.setdefault(c["type"], []).append(c) + return sections + + +def suggested_bump(commits: list[dict]) -> str: + if any(c["breaking"] for c in commits): + return "major" + if any(c["type"] == "feat" for c in commits): + return "minor" + if any(c["type"] in ("fix", "perf") for c in commits): + return "patch" + return "patch" + + +def bump_version(version: str, bump: str) -> str: + v = version.lstrip("v") + parts = v.split(".") + while len(parts) < 3: + parts.append("0") + try: + major, minor, patch = (int(parts[0]), int(parts[1]), int(parts[2])) + except ValueError: + return v + if bump == "major": + return f"{major + 1}.0.0" + if bump == "minor": + return f"{major}.{minor + 1}.0" + return f"{major}.{minor}.{patch + 1}" + + +_PRETTY_TITLES = { + "feat": "Features", + "fix": "Bug Fixes", + "perf": "Performance", + "refactor": "Refactor", + "docs": "Docs", + "test": "Tests", + "build": "Build", + "ci": "CI", + "chore": "Chores", + "revert": "Reverts", + "other": "Other", +} + +_ORDER = ("feat", "fix", "perf", "refactor", "docs", "test", "build", "ci", "chore", "revert", "other") + + +def render_markdown(report: dict, today: dt.date | None = None) -> str: + today = today or dt.date.today() + out = [f"## [Unreleased] - {today.isoformat()}", ""] + for typ in _ORDER: + entries = report["sections"].get(typ, []) + if not entries: + continue + out.append(f"### {_PRETTY_TITLES[typ]}") + for c in entries: + scope = f"**{c['scope']}**: " if c["scope"] else "" + bang = " ⚠ BREAKING" if c["breaking"] else "" + out.append(f"- {scope}{c['subject']}{bang} ({c['hash'][:7]})") + out.append("") + out.append(f"_Suggested bump: **{report['suggested_bump']}**_") + if report.get("suggested_version"): + out.append(f"_Suggested version: **{report['suggested_version']}**_") + out.append("") + return "\n".join(out) + + +_UNRELEASED_BLOCK = re.compile( + r"(?ms)^## \[Unreleased\][^\n]*\n.*?(?=^## \[|\Z)" +) + + +def write_changelog(path: str, section_md: str) -> None: + section = section_md.rstrip() + "\n\n" + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + existing = f.read() + if _UNRELEASED_BLOCK.search(existing): + new = _UNRELEASED_BLOCK.sub(section, existing, count=1) + else: + # Insert the new section AFTER any leading `# Title` block, not before it. + stripped = existing.lstrip("\n") + if stripped.startswith("# "): + head_end = stripped.find("\n\n") + if head_end == -1: + head_end = len(stripped) + head = stripped[: head_end + 2] if head_end < len(stripped) else stripped + "\n\n" + tail = stripped[head_end + 2 :] if head_end < len(stripped) else "" + new = head + section + tail + else: + new = section + existing + else: + new = "# Changelog\n\n" + section + with open(path, "w", encoding="utf-8") as f: + f.write(new) + + +def build_report(cwd: str, frm: str | None, to: str) -> dict: + raw = _git_log(cwd, frm, to) + parsed = [parse_commit(*c) for c in _split_log(raw)] + sections = group_commits(parsed) + bump = suggested_bump(parsed) + suggested_version = None + base_tag = frm or _last_tag(cwd) + if base_tag: + suggested_version = bump_version(base_tag, bump) + return { + "from": frm or base_tag, + "to": to, + "commits": parsed, + "sections": sections, + "breaking_changes": [ + {"commit": c["hash"], "note": c["subject"]} for c in parsed if c["breaking"] + ], + "suggested_bump": bump, + "suggested_version": suggested_version, + } + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="Generate a CHANGELOG section from conventional commits.") + p.add_argument("--cwd", default=os.getcwd()) + p.add_argument("--from", dest="frm", default=None, help="Base ref or tag (default: last tag).") + p.add_argument("--to", default="HEAD") + p.add_argument("--format", choices=["json", "md"], default="md") + p.add_argument("--write", action="store_true", help="Prepend (idempotently) to CHANGELOG.md.") + p.add_argument("--changelog", default="CHANGELOG.md") + args = p.parse_args(argv) + + frm = args.frm or _last_tag(args.cwd) + report = build_report(args.cwd, frm, args.to) + md = render_markdown(report) + + if args.write: + write_changelog(os.path.join(args.cwd, args.changelog), md) + + if args.format == "md": + sys.stdout.write(md) + else: + json.dump(report, sys.stdout, indent=2) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/commit-narrator/.claude-plugin/plugin.json b/plugins/commit-narrator/.claude-plugin/plugin.json new file mode 100644 index 0000000..cc5c48b --- /dev/null +++ b/plugins/commit-narrator/.claude-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "commit-narrator", + "version": "0.1.0", + "description": "Generate conventional commit messages from staged diffs.", + "author": "pluginpool" +} diff --git a/plugins/commit-narrator/LICENSE b/plugins/commit-narrator/LICENSE new file mode 100644 index 0000000..266f59c --- /dev/null +++ b/plugins/commit-narrator/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 pluginpool contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/commit-narrator/README.md b/plugins/commit-narrator/README.md new file mode 100644 index 0000000..12b2eac --- /dev/null +++ b/plugins/commit-narrator/README.md @@ -0,0 +1,94 @@ +![hero](./assets/hero.svg) + +# commit-narrator + +**Write commit messages that say *why*, not just *what*.** + +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE) +[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/) +[![Claude Code Plugin](https://img.shields.io/badge/claude--code-plugin-7c3aed.svg)](https://docs.claude.com/en/docs/claude-code/overview) +[![Tests: 6 passing](https://img.shields.io/badge/tests-6%20passing-success.svg)](./tests) + +> **TL;DR:** `git add -p && /commit-narrator` → a conventional-commit message with a real rationale, not "fix stuff". + +## Why this exists + +Most "AI commit message" tools paraphrase the diff back at you. That's noise. A good commit message answers *why* the change was made, grounded in what the diff actually proves. This plugin runs a deterministic Python helper to classify the change and assemble the skeleton, and then leaves Claude to fill in the rationale from the diff itself — no LLM guesses about your repo's intent. + +## Install (Claude Code) + +```sh +git clone https://github.com/mturac/pluginpool-commit-narrator ~/.claude/plugins/commit-narrator +``` + +Restart Claude Code; the slash command `/commit-narrator` appears. + +## Quick start + +```sh +git add -p +/commit-narrator +``` + +Or invoke the helper directly: + +```sh +git diff --staged | python3 scripts/narrate.py --diff - +python3 scripts/narrate.py --format json +``` + +## Flags + +| Flag | Default | Description | +|---|---|---| +| `--diff PATH` / `-` | `git diff --cached` | Read diff from path or stdin | +| `--format` | `text` | `text` or `json` | + +## Example output + +```text +feat(api): add login throttle + +Changed files: +- src/api/auth.py +- tests/test_auth.py + +Throttle prevents brute-force enumeration on the /login endpoint by +rate-limiting on (ip, email) — the abuse pattern surfaced in the +2026-Q2 incident review. +``` + +## How it works + +1. Reads the staged diff (or a path you give it). +2. Classifies the change type — `feat / fix / refactor / docs / test / chore / perf` — using path patterns and patch-hunk heuristics. +3. Derives a scope from the most-touched top-level directory. +4. Emits a conventional-commit subject plus a body listing changed files and a placeholder `WHY` line. +5. Claude reads the helper's output and the diff, then replaces the placeholder with rationale that the diff actually supports. + +## Limitations + +- Heuristics are intentionally conservative. You'll get `chore` for messy mixed diffs — that's a signal to split the commit, not a bug. +- The helper itself never calls a network service; only the slash command's Claude pass adds language. + +## Examples + +Step-by-step walkthroughs with real input fixtures and the helper's actual output live in [`examples/`](./examples/README.md). Three or four scenarios per plugin — from the happy path to the edge cases the test suite guards. + +## Part of the pluginpool family + +Ten focused Claude Code plugins for everyday productivity: +[commit-narrator](https://github.com/mturac/pluginpool-commit-narrator) · +[pr-storyteller](https://github.com/mturac/pluginpool-pr-storyteller) · +[test-gap](https://github.com/mturac/pluginpool-test-gap) · +[deps-doctor](https://github.com/mturac/pluginpool-deps-doctor) · +[env-lint](https://github.com/mturac/pluginpool-env-lint) · +[secret-guard](https://github.com/mturac/pluginpool-secret-guard) · +[standup-gen](https://github.com/mturac/pluginpool-standup-gen) · +[todo-harvest](https://github.com/mturac/pluginpool-todo-harvest) · +[flaky-detector](https://github.com/mturac/pluginpool-flaky-detector) · +[changelog-forge](https://github.com/mturac/pluginpool-changelog-forge) + +## License + +MIT — see [`LICENSE`](./LICENSE). Contributions welcome. diff --git a/plugins/commit-narrator/commands/commit-narrator.md b/plugins/commit-narrator/commands/commit-narrator.md new file mode 100644 index 0000000..eb4c183 --- /dev/null +++ b/plugins/commit-narrator/commands/commit-narrator.md @@ -0,0 +1,16 @@ +--- +description: Generate a conventional commit message from the staged diff +allowed-tools: Bash +--- + +Role: act as the commit-message author responsible only for producing one conventional commit message for the current repository. + +Run the local helper with Bash: + +```bash +git diff --staged --binary | python3 scripts/narrate.py --diff - +``` + +The helper accepts an empty diff and returns no text. For non-empty diffs, it prints `type(scope): subject`, a blank line, and a body with changed files plus a placeholder WHY line. Valid types are `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, and `perf`. + +Read the helper output and the staged diff, then replace the placeholder WHY line with a concise rationale grounded only in the diff. Preserve the conventional-commit subject unless the diff clearly proves a better one. Print only the final commit message. diff --git a/plugins/commit-narrator/scripts/narrate.py b/plugins/commit-narrator/scripts/narrate.py new file mode 100755 index 0000000..33b598d --- /dev/null +++ b/plugins/commit-narrator/scripts/narrate.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Generate a conventional commit message from a git diff.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path + + +TYPES = ("feat", "fix", "refactor", "docs", "test", "chore", "perf") + + +def run_git_diff() -> str: + try: + proc = subprocess.run( + ["git", "diff", "--staged", "--binary"], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + except OSError: + return "" + return proc.stdout if proc.returncode == 0 else "" + + +def read_diff(source: str | None) -> str: + if source == "-": + return sys.stdin.read() + return run_git_diff() + + +_DIFF_GIT_LINE = re.compile( + r'^diff --git (?:"a/(?P(?:[^"\\]|\\.)*)"|a/(?P\S+)) (?:"b/(?P(?:[^"\\]|\\.)*)"|b/(?P.+))$' +) + + +def changed_files(diff: str) -> list[str]: + """Collect unique paths from a unified diff. Two sources, in order: + - `+++ b/` (tab-separated from any trailing timestamp) — preferred, robust to spaces. + - `diff --git a/X b/Y` header — supports both unquoted and "quoted/with spaces" forms. + """ + files: list[str] = [] + seen: set[str] = set() + + def _add(path: str) -> None: + if path and path != "/dev/null" and path not in seen: + seen.add(path) + files.append(path) + + saw_plusplus = False + for line in diff.splitlines(): + if line.startswith("+++ b/"): + saw_plusplus = True + _add(line[len("+++ b/"):].split("\t", 1)[0].rstrip()) + elif line.startswith("Binary files "): + match = re.search(r" b/(.+?) differ$", line) + if match: + _add(match.group(1)) + + if saw_plusplus: + return files + + # Fallback for compact diffs that omit the +++/--- headers + for line in diff.splitlines(): + m = _DIFF_GIT_LINE.match(line) + if m: + _add(m.group("bq") or m.group("bp") or "") + return files + + +def classify(files: list[str], diff: str) -> str: + lower_files = [path.lower() for path in files] + lower_diff = diff.lower() + if any("/test" in f or f.startswith("test") or f.endswith("_test.py") or "spec." in f for f in lower_files): + return "test" + if any(f.startswith(("docs/", "doc/")) or Path(f).name.lower() in {"readme.md", "license"} for f in lower_files): + return "docs" + if any(word in lower_diff for word in ("performance", "optimize", "optimise", "benchmark", "cache hit")): + return "perf" + if any(word in lower_diff for word in ("bug", "fix", "error", "exception", "regression", "crash")): + return "fix" + if any(word in lower_diff for word in ("refactor", "rename", "extract", "cleanup")): + return "refactor" + if any(line.startswith("new file mode") for line in diff.splitlines()) or any( + f.startswith(("src/", "app/", "commands/", "scripts/")) for f in lower_files + ): + return "feat" + return "chore" + + +def infer_scope(files: list[str]) -> str: + if not files: + return "" + first = files[0] + parts = first.split("/") + if len(parts) > 1: + return re.sub(r"[^a-z0-9-]+", "-", parts[0].lower()).strip("-") or "repo" + stem = Path(first).stem.lower() + return re.sub(r"[^a-z0-9-]+", "-", stem).strip("-") or "repo" + + +def subject_for(change_type: str, scope: str, files: list[str]) -> str: + target = scope or "repository" + if change_type == "docs": + return f"update {target} documentation" + if change_type == "test": + return f"add {target} coverage" + if change_type == "fix": + return f"fix {target} behavior" + if change_type == "refactor": + return f"refactor {target} structure" + if change_type == "perf": + return f"improve {target} performance" + if change_type == "feat": + action = "add" if any("new file mode" in line for line in "\n".join(files).splitlines()) else "update" + return f"{action} {target} support" + return f"update {target} maintenance" + + +def narrate(diff: str) -> dict[str, object]: + files = changed_files(diff) + if not diff.strip() or not files: + return {"type": "", "scope": "", "subject": "", "body": "", "files": []} + change_type = classify(files, diff) + scope = infer_scope(files) + subject = subject_for(change_type, scope, files) + listed = "\n".join(f"- {path}" for path in files[:5]) + body = f"Changed files:\n{listed}\n\nWHY: Explain why this change is needed based on the diff." + return {"type": change_type, "scope": scope, "subject": subject, "body": body, "files": files} + + +def render_text(result: dict[str, object]) -> str: + if not result["type"]: + return "" + return f"{result['type']}({result['scope']}): {result['subject']}\n\n{result['body']}" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--diff", default=None, help="Diff source. Use '-' to read from stdin; omit to read staged git diff.") + parser.add_argument("--format", choices=("text", "json"), default="text", help="Output format.") + args = parser.parse_args(argv) + + result = narrate(read_diff(args.diff)) + if args.format == "json": + print(json.dumps(result, indent=2, sort_keys=True)) + else: + text = render_text(result) + if text: + print(text) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/deps-doctor/.claude-plugin/plugin.json b/plugins/deps-doctor/.claude-plugin/plugin.json new file mode 100644 index 0000000..bb04429 --- /dev/null +++ b/plugins/deps-doctor/.claude-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "deps-doctor", + "version": "0.1.0", + "description": "/deps-doctor runs dependency health audits across supported ecosystems.", + "author": "pluginpool" +} diff --git a/plugins/deps-doctor/LICENSE b/plugins/deps-doctor/LICENSE new file mode 100644 index 0000000..266f59c --- /dev/null +++ b/plugins/deps-doctor/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 pluginpool contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/deps-doctor/README.md b/plugins/deps-doctor/README.md new file mode 100644 index 0000000..7ddf8ea --- /dev/null +++ b/plugins/deps-doctor/README.md @@ -0,0 +1,107 @@ +![hero](./assets/hero.svg) + +# deps-doctor + +**One slash command. Four ecosystems. Knows when an audit tool is missing — and doesn't pretend otherwise.** + +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE) +[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/) +[![Claude Code Plugin](https://img.shields.io/badge/claude--code-plugin-7c3aed.svg)](https://docs.claude.com/en/docs/claude-code/overview) +[![Tests: 7 passing](https://img.shields.io/badge/tests-7%20passing-success.svg)](./tests) + +> **TL;DR:** `/deps-doctor` → unified vuln/outdated/license report across npm, pip, cargo, and go in one pass. + +## Why this exists + +Polyglot repos make dependency hygiene awkward — you end up with three different reports in three different terminals, and you forget which one was for staging. `deps-doctor` detects which ecosystems are present, shells out to the audit tools you actually have installed, and emits one consistent report. Missing tool? It says "skipped: tool not installed" — never silently green. + +## Install (Claude Code) + +```sh +git clone https://github.com/mturac/pluginpool-deps-doctor ~/.claude/plugins/deps-doctor +``` + +Restart Claude Code; the slash command `/deps-doctor` appears. + +## Quick start + +```sh +/deps-doctor +``` + +Or directly: + +```sh +python3 scripts/doctor.py --format md +python3 scripts/doctor.py --severity high --ecosystem npm,pip +``` + +## Flags + +| Flag | Default | Description | +|---|---|---| +| `--format` | `json` | `json` or `md` | +| `--severity` | _all_ | Minimum severity: `low | moderate | high | critical` | +| `--ecosystem` | _all detected_ | Comma-separated subset, e.g. `npm,pip` | + +## Supported ecosystems + +| Ecosystem | Audit tool | Detected by | +|---|---|---| +| npm | `npm audit --json` | `package.json` | +| pip | `pip-audit --format=json` | `requirements.txt` / `pyproject.toml` | +| cargo | `cargo audit --json` | `Cargo.toml` | +| go | `govulncheck -json ./...` | `go.mod` | + +Missing tools are reported as `"status": "skipped: tool not installed"`, never as a clean pass. + +## Example output (markdown) + +``` +## deps-doctor report + +### npm — 2 critical, 1 high +- **CVE-2024-XXXX** in `axios@0.21.1` → fixed in `0.21.4` (critical) +- **CVE-2024-YYYY** in `lodash@4.17.20` → fixed in `4.17.21` (high) + +### pip — clean + +### cargo — _skipped: cargo audit not installed_ + +### go — clean +``` + +## How it works + +1. Scans the project root for marker files (`package.json`, `Cargo.toml`, …). +2. Runs each ecosystem's audit tool via `subprocess` — no shell, no injection surface. +3. Normalizes the JSON shapes from each tool into one schema. +4. Aggregates by severity and renders. + +## Limitations + +- Network-only audits (registry lookups) inherit the speed of the underlying tool. +- License auditing is a placeholder field (`license_warnings: []`) — populate via a future plugin. +- Audit-tool installation is up to you; this plugin is glue, not an installer. + +## Examples + +Step-by-step walkthroughs with real input fixtures and the helper's actual output live in [`examples/`](./examples/README.md). Three or four scenarios per plugin — from the happy path to the edge cases the test suite guards. + +## Part of the pluginpool family + +Ten focused Claude Code plugins for everyday productivity: +[commit-narrator](https://github.com/mturac/pluginpool-commit-narrator) · +[pr-storyteller](https://github.com/mturac/pluginpool-pr-storyteller) · +[test-gap](https://github.com/mturac/pluginpool-test-gap) · +[deps-doctor](https://github.com/mturac/pluginpool-deps-doctor) · +[env-lint](https://github.com/mturac/pluginpool-env-lint) · +[secret-guard](https://github.com/mturac/pluginpool-secret-guard) · +[standup-gen](https://github.com/mturac/pluginpool-standup-gen) · +[todo-harvest](https://github.com/mturac/pluginpool-todo-harvest) · +[flaky-detector](https://github.com/mturac/pluginpool-flaky-detector) · +[changelog-forge](https://github.com/mturac/pluginpool-changelog-forge) + +## License + +MIT — see [`LICENSE`](./LICENSE). Contributions welcome. diff --git a/plugins/deps-doctor/commands/deps-doctor.md b/plugins/deps-doctor/commands/deps-doctor.md new file mode 100644 index 0000000..844dbb5 --- /dev/null +++ b/plugins/deps-doctor/commands/deps-doctor.md @@ -0,0 +1,16 @@ +--- +description: Run dependency health audits across supported ecosystems +allowed-tools: Bash +--- + +Run `python3 scripts/doctor.py "$@"` from the deps-doctor plugin root. + +Purpose: detect supported dependency ecosystems, run available local audit tools, and summarize advisories without failing when tools are missing. + +Inputs: optional `--format`, `--severity`, and `--ecosystem` arguments supplied by the user. + +Boundaries: do not edit files, do not install packages, do not use the network directly, and do not hide skipped tools. + +Output contract: return either the helper output or a concise failure summary with command, exit status, and next step. + +Verification contract: accept success only when the helper exits 0 and prints JSON or markdown matching the requested format. diff --git a/plugins/deps-doctor/scripts/doctor.py b/plugins/deps-doctor/scripts/doctor.py new file mode 100755 index 0000000..a5b3c39 --- /dev/null +++ b/plugins/deps-doctor/scripts/doctor.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Run dependency health audits across supported ecosystems.""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Any + + +SEVERITY_ORDER = {"low": 0, "moderate": 1, "medium": 1, "high": 2, "critical": 3} +COMMANDS = { + "npm": ["npm", "audit", "--json"], + "pip": ["pip-audit", "--format=json"], + "cargo": ["cargo", "audit", "--json"], + "go": ["govulncheck", "-json", "./..."], +} + + +def detect_ecosystems(root: Path = Path(".")) -> list[str]: + ecosystems: list[str] = [] + if (root / "package.json").exists(): + ecosystems.append("npm") + if (root / "requirements.txt").exists() or (root / "pyproject.toml").exists(): + ecosystems.append("pip") + if (root / "Cargo.toml").exists(): + ecosystems.append("cargo") + if (root / "go.mod").exists(): + ecosystems.append("go") + return ecosystems + + +def run_audit(name: str) -> tuple[str | None, str | None]: + command = COMMANDS[name] + if shutil.which(command[0]) is None: + return None, "skipped: tool not installed" + result = subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if result.stdout: + return result.stdout, None + if result.returncode != 0: + return None, result.stderr.strip() or f"{name} audit failed" + return "{}", None + + +def normalize_severity(value: Any) -> str: + text = str(value or "unknown").lower() + return "moderate" if text == "medium" else text + + +def fixed_in(value: Any) -> list[str]: + if value in (None, False): + return [] + if isinstance(value, str): + return [value] + if isinstance(value, list): + return [str(item) for item in value] + if isinstance(value, dict): + version = value.get("version") + if version: + return [str(version)] + return [] + + +def npm_advisories(payload: dict[str, Any]) -> list[dict[str, Any]]: + advisories: list[dict[str, Any]] = [] + for package, vuln in payload.get("vulnerabilities", {}).items(): + via = vuln.get("via", []) + source = next((item for item in via if isinstance(item, dict)), {}) + advisories.append({ + "id": str(source.get("source") or source.get("url") or vuln.get("name") or package), + "severity": normalize_severity(vuln.get("severity") or source.get("severity")), + "package": package, + "version": ",".join(vuln.get("nodes", [])) or "", + "fixed_in": fixed_in(vuln.get("fixAvailable")), + }) + for advisory in payload.get("advisories", {}).values(): + advisories.append({ + "id": str(advisory.get("id") or advisory.get("url") or advisory.get("module_name")), + "severity": normalize_severity(advisory.get("severity")), + "package": advisory.get("module_name", ""), + "version": advisory.get("vulnerable_versions", ""), + "fixed_in": fixed_in(advisory.get("patched_versions")), + }) + return advisories + + +def pip_advisories(payload: dict[str, Any]) -> list[dict[str, Any]]: + advisories: list[dict[str, Any]] = [] + for dep in payload.get("dependencies", []): + for vuln in dep.get("vulns", []): + advisories.append({ + "id": str(vuln.get("id") or vuln.get("aliases", [""])[0]), + "severity": normalize_severity(vuln.get("severity")), + "package": dep.get("name", ""), + "version": dep.get("version", ""), + "fixed_in": fixed_in(vuln.get("fix_versions")), + }) + return advisories + + +def cargo_advisories(payload: dict[str, Any]) -> list[dict[str, Any]]: + advisories: list[dict[str, Any]] = [] + for item in payload.get("vulnerabilities", {}).get("list", []): + advisory = item.get("advisory", {}) + package = item.get("package", {}) + versions = item.get("versions", {}) + advisories.append({ + "id": str(advisory.get("id", "")), + "severity": normalize_severity(advisory.get("severity")), + "package": package.get("name", ""), + "version": package.get("version", ""), + "fixed_in": fixed_in(versions.get("patched")), + }) + return advisories + + +def go_advisories(output: str) -> list[dict[str, Any]]: + advisories: list[dict[str, Any]] = [] + for line in output.splitlines(): + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + finding = event.get("finding") or event.get("vulnerability") or {} + osv = finding.get("osv") or finding + if not osv: + continue + advisories.append({ + "id": str(osv.get("id", "")), + "severity": normalize_severity(osv.get("database_specific", {}).get("severity")), + "package": finding.get("package", ""), + "version": "", + "fixed_in": fixed_in(osv.get("affected", [])), + }) + return advisories + + +def parse_advisories(name: str, output: str) -> list[dict[str, Any]]: + # govulncheck emits NDJSON (one JSON object per line); pass raw text through + # so the go parser can handle line-by-line. Other tools emit a single JSON document. + if name == "go": + return go_advisories(output) + try: + payload = json.loads(output or "{}") + except json.JSONDecodeError: + return [] + if name == "npm": + return npm_advisories(payload) + if name == "pip": + return pip_advisories(payload) + if name == "cargo": + return cargo_advisories(payload) + return [] + + +def severity_allowed(advisory: dict[str, Any], minimum: str) -> bool: + return SEVERITY_ORDER.get(advisory.get("severity", "unknown"), -1) >= SEVERITY_ORDER[minimum] + + +def audit(ecosystems: list[str], minimum: str = "low") -> dict[str, list[dict[str, Any]]]: + results: list[dict[str, Any]] = [] + for name in ecosystems: + output, skipped = run_audit(name) + advisories = [] if output is None else [item for item in parse_advisories(name, output) if severity_allowed(item, minimum)] + results.append({ + "name": name, + "advisories": advisories, + "outdated_count": 0, + "license_warnings": [], + **({"skipped": skipped} if skipped else {}), + }) + return {"ecosystems": results} + + +def markdown(result: dict[str, list[dict[str, Any]]]) -> str: + grouped: dict[str, list[tuple[str, dict[str, Any]]]] = {key: [] for key in ("critical", "high", "moderate", "low", "unknown")} + skipped: list[str] = [] + for eco in result["ecosystems"]: + if eco.get("skipped"): + skipped.append(f"- {eco['name']}: {eco['skipped']}") + for advisory in eco["advisories"]: + grouped.setdefault(advisory.get("severity", "unknown"), []).append((eco["name"], advisory)) + + lines = ["# Dependency Doctor"] + for severity in ("critical", "high", "moderate", "low", "unknown"): + items = grouped.get(severity, []) + if not items: + continue + lines.extend([f"## {severity.title()}", "| Ecosystem | ID | Package | Version | Fixed In |", "| --- | --- | --- | --- | --- |"]) + for ecosystem, advisory in items: + lines.append( + f"| {ecosystem} | {advisory['id']} | {advisory['package']} | " + f"{advisory['version']} | {','.join(advisory['fixed_in']) or '-'} |" + ) + if skipped: + lines.append("## Skipped") + lines.extend(skipped) + if len(lines) == 1: + lines.append("No advisories found.") + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Run dependency health audits across ecosystems.") + parser.add_argument("--format", choices=("json", "md"), default="json") + parser.add_argument("--severity", choices=("low", "moderate", "high", "critical"), default="low") + parser.add_argument("--ecosystem", help="Comma-separated ecosystem filter, e.g. npm,pip.") + args = parser.parse_args(argv) + + detected = detect_ecosystems() + if args.ecosystem: + allowed = {item.strip() for item in args.ecosystem.split(",") if item.strip()} + detected = [item for item in detected if item in allowed] + result = audit(detected, args.severity) + print(markdown(result) if args.format == "md" else json.dumps(result, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/env-lint/.claude-plugin/plugin.json b/plugins/env-lint/.claude-plugin/plugin.json new file mode 100644 index 0000000..f5a5b28 --- /dev/null +++ b/plugins/env-lint/.claude-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "env-lint", + "version": "0.1.0", + "description": "Validate .env files against .env.example without printing values.", + "author": "pluginpool" +} diff --git a/plugins/env-lint/LICENSE b/plugins/env-lint/LICENSE new file mode 100644 index 0000000..266f59c --- /dev/null +++ b/plugins/env-lint/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 pluginpool contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/env-lint/README.md b/plugins/env-lint/README.md new file mode 100644 index 0000000..8b3bf9a --- /dev/null +++ b/plugins/env-lint/README.md @@ -0,0 +1,103 @@ +![hero](./assets/hero.svg) + +# env-lint + +**Catch missing/extra env-var keys before they bite you in prod. Never prints values.** + +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE) +[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/) +[![Claude Code Plugin](https://img.shields.io/badge/claude--code-plugin-7c3aed.svg)](https://docs.claude.com/en/docs/claude-code/overview) +[![Tests: 7 passing](https://img.shields.io/badge/tests-7%20passing-success.svg)](./tests) + +> **TL;DR:** `/env-lint` → "you're missing `STRIPE_WEBHOOK_SECRET` in `.env.local` (referenced in `.env.example`)" — without ever logging the secret value. + +## Why this exists + +Every team has the same outage: someone adds a new env var to `.env.example`, forgets to mention it in the PR, and a teammate's local server quietly 500s on a code path that needs it. Or worse, prod is missing a key. `env-lint` diffs your live env file against `.env.example` and tells you which keys are missing or extra — and *only* the keys, never the values. Safe to paste in chat, safe to log to CI. + +## Install (Claude Code) + +```sh +git clone https://github.com/mturac/pluginpool-env-lint ~/.claude/plugins/env-lint +``` + +Restart Claude Code; the slash command `/env-lint` appears. + +## Quick start + +```sh +/env-lint +``` + +Or directly: + +```sh +python3 scripts/envlint.py --format md +python3 scripts/envlint.py --example .env.example --env .env.production +``` + +## Flags + +| Flag | Default | Description | +|---|---|---| +| `--example` | auto-detect | Path to template (`.env.example`) | +| `--env` | auto-detect | Path to actual env file | +| `--format` | `json` | `json` or `md` | + +When run without `--example/--env`, it scans the cwd for known pairs: `(.env, .env.example)`, `(.env.local, .env.example)`, `(.env.production, .env.example)`. + +## Example output + +``` +# env-lint report + +## .env.example ↔ .env.local +- **missing in env**: STRIPE_WEBHOOK_SECRET, SENTRY_DSN +- **extra in env**: LEGACY_API_KEY +- **empty values for**: REDIS_URL +``` + +## Safety guarantee + +A test in the suite (`test_never_emits_values_in_json_or_markdown`) writes a fake value `SUPERSECRETVALUE123` into a temp `.env` and asserts that string never appears in either the JSON output or the markdown report. The CI badge above represents that invariant. + +## Exit codes + +| Code | Meaning | +|---|---| +| `0` | All required keys present (extras are warnings, not errors) | +| `1` | At least one required key is missing | + +## How it works + +1. Parses each `.env` file as `KEY=VALUE` (comments and blank lines skipped). +2. Computes the key-set difference both ways. +3. Reports missing keys (template has them, env doesn't), extra keys (env has them, template doesn't), and empty values. + +## Limitations + +- Doesn't expand `${VAR}` references — it only checks key presence. +- Quoted values are detected as "present" even if the quoted content is empty (`KEY=""` is *empty*, not missing). +- Custom env-file naming conventions need explicit `--example/--env`. + +## Examples + +Step-by-step walkthroughs with real input fixtures and the helper's actual output live in [`examples/`](./examples/README.md). Three or four scenarios per plugin — from the happy path to the edge cases the test suite guards. + +## Part of the pluginpool family + +Ten focused Claude Code plugins for everyday productivity: +[commit-narrator](https://github.com/mturac/pluginpool-commit-narrator) · +[pr-storyteller](https://github.com/mturac/pluginpool-pr-storyteller) · +[test-gap](https://github.com/mturac/pluginpool-test-gap) · +[deps-doctor](https://github.com/mturac/pluginpool-deps-doctor) · +[env-lint](https://github.com/mturac/pluginpool-env-lint) · +[secret-guard](https://github.com/mturac/pluginpool-secret-guard) · +[standup-gen](https://github.com/mturac/pluginpool-standup-gen) · +[todo-harvest](https://github.com/mturac/pluginpool-todo-harvest) · +[flaky-detector](https://github.com/mturac/pluginpool-flaky-detector) · +[changelog-forge](https://github.com/mturac/pluginpool-changelog-forge) + +## License + +MIT — see [`LICENSE`](./LICENSE). Contributions welcome. diff --git a/plugins/env-lint/commands/env-lint.md b/plugins/env-lint/commands/env-lint.md new file mode 100644 index 0000000..4bd2d13 --- /dev/null +++ b/plugins/env-lint/commands/env-lint.md @@ -0,0 +1,18 @@ +--- +description: Validate .env files against .env.example without printing values +allowed-tools: Bash +--- + +Role: act as an env-var auditor. Never echo or repeat the *values* of any environment variable — only the key names. + +Run the helper: + +```sh +python3 scripts/envlint.py --format md +``` + +Useful flags: `--example .env.example --env .env.local`, `--format json`. + +The helper auto-detects common pairs (`.env` vs `.env.example`, `.env.local` vs `.env.example`, `.env.production` vs `.env.example`) or accepts an explicit pair. It reports missing keys, extra keys, and keys with empty values. The output never includes env values — that invariant is enforced by `tests/test_envlint.py::test_never_emits_values_in_json_or_markdown`. + +Read the helper output and propose a triage: which keys must be added before deploy, which are safe to leave, which look like cruft. diff --git a/plugins/env-lint/scripts/envlint.py b/plugins/env-lint/scripts/envlint.py new file mode 100755 index 0000000..89d4a6d --- /dev/null +++ b/plugins/env-lint/scripts/envlint.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Validate .env files against .env.example without emitting values.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from typing import Dict, Iterable, List, Sequence, Tuple + + +KEY_RE = re.compile(r"^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=") +DEFAULT_PAIRS = ( + (".env.example", ".env"), + (".env.example", ".env.local"), + (".env.example", ".env.production"), +) + + +def parse_env(path: str) -> Tuple[List[str], Dict[str, bool]]: + keys: List[str] = [] + empty: Dict[str, bool] = {} + seen = set() + with open(path, "r", encoding="utf-8") as handle: + for raw_line in handle: + line = raw_line.strip() + if not line or line.startswith("#"): + continue + match = KEY_RE.match(line) + if not match: + continue + key = match.group(1) + if key not in seen: + keys.append(key) + seen.add(key) + value = line.split("=", 1)[1].strip() + empty[key] = value in {"", '""', "''"} + return keys, empty + + +def existing_pairs() -> List[Tuple[str, str]]: + return [(example, env) for example, env in DEFAULT_PAIRS if os.path.exists(example) and os.path.exists(env)] + + +def compare_pair(example: str, env: str) -> dict: + example_keys, _ = parse_env(example) + env_keys, env_empty = parse_env(env) + example_set = set(example_keys) + env_set = set(env_keys) + return { + "example": example, + "env": env, + "missing_in_env": [key for key in example_keys if key not in env_set], + "extra_in_env": [key for key in env_keys if key not in example_set], + "empty_values": [key for key in env_keys if env_empty.get(key, False)], + } + + +def to_markdown(results: Sequence[dict]) -> str: + lines = ["# env-lint report", ""] + if not results: + lines.append("No existing .env pairs found.") + return "\n".join(lines) + "\n" + for item in results: + lines.extend( + [ + f"## {item['env']} vs {item['example']}", + "", + f"- Missing in env: {', '.join(item['missing_in_env']) or 'none'}", + f"- Extra in env: {', '.join(item['extra_in_env']) or 'none'}", + f"- Empty values: {', '.join(item['empty_values']) or 'none'}", + "", + ] + ) + return "\n".join(lines) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Validate .env files against .env.example without printing values.") + parser.add_argument("--example", help="Example env file path") + parser.add_argument("--env", help="Environment file path") + parser.add_argument("--format", choices=("json", "md"), default="json", help="Output format") + return parser + + +def main(argv: Iterable[str] | None = None) -> int: + args = build_parser().parse_args(argv) + if bool(args.example) != bool(args.env): + raise SystemExit("--example and --env must be provided together") + + pairs = [(args.example, args.env)] if args.example else existing_pairs() + results = [compare_pair(example, env) for example, env in pairs] + payload = {"pairs": results} + if args.format == "md": + sys.stdout.write(to_markdown(results)) + else: + sys.stdout.write(json.dumps(payload, indent=2, sort_keys=True) + "\n") + return 1 if any(pair["missing_in_env"] for pair in results) else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/flaky-detector/.claude-plugin/plugin.json b/plugins/flaky-detector/.claude-plugin/plugin.json new file mode 100644 index 0000000..8adc4a4 --- /dev/null +++ b/plugins/flaky-detector/.claude-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "flaky-detector", + "version": "0.1.0", + "description": "Run a test command N times and report per-test flakiness percentages.", + "author": "pluginpool" +} diff --git a/plugins/flaky-detector/LICENSE b/plugins/flaky-detector/LICENSE new file mode 100644 index 0000000..266f59c --- /dev/null +++ b/plugins/flaky-detector/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 pluginpool contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/flaky-detector/README.md b/plugins/flaky-detector/README.md new file mode 100644 index 0000000..f6c518a --- /dev/null +++ b/plugins/flaky-detector/README.md @@ -0,0 +1,106 @@ +![hero](./assets/hero.svg) + +# flaky-detector + +**Run your test command N times. Find out which tests don't always agree with themselves.** + +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE) +[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/) +[![Claude Code Plugin](https://img.shields.io/badge/claude--code-plugin-7c3aed.svg)](https://docs.claude.com/en/docs/claude-code/overview) +[![Tests: 11 passing](https://img.shields.io/badge/tests-11%20passing-success.svg)](./tests) + +> **TL;DR:** `/flaky-detector --cmd "pytest -v" --runs 10` → per-test flakiness %, sorted worst-first, ready to triage. + +## Why this exists + +A test that fails 1-in-20 wastes more team time than a test that never fails. CI flakes erode trust; everyone learns to "just re-run it". This tool runs your suite N times, parses pass/fail per test, and tells you exactly which tests are flaky and at what rate — so you can decide: rerun, isolate, mark `@pytest.mark.flaky`, or fix the underlying race. + +## Install (Claude Code) + +```sh +git clone https://github.com/mturac/pluginpool-flaky-detector ~/.claude/plugins/flaky-detector +``` + +Restart Claude Code; the slash command `/flaky-detector` appears. + +## Quick start + +```sh +python3 scripts/flaky.py --cmd "pytest -v" --runs 10 --format md +python3 scripts/flaky.py --cmd "go test ./..." --runs 20 --parallel 4 --out report.json +python3 scripts/flaky.py --cmd "jest --ci" --parser jest --runs 5 +``` + +> Tip: prefer `pytest -v` over `pytest -q` so every result lands on its own line. +> If you use `-q`, flaky-detector still picks up the tail `FAILED path::test` summary +> lines and exits non-zero with a warning rather than reporting a false green. + +## Flags + +| Flag | Default | Description | +|---|---|---| +| `--cmd` | required | The test command (single line, no shell wrapping) | +| `--runs` | `10` | How many times to invoke the command | +| `--parallel` | `1` | Concurrent runs (only safe for parallel-clean suites) | +| `--parser` | `auto` | `pytest`, `jest`, `gotest`, `tap`, or `auto` | +| `--out` | _none_ | Write JSON report to this path | +| `--format` | `json` | `json` or `md` | + +## Supported parsers + +| Parser | Matches | +|---|---| +| `pytest` | `tests/foo.py::test_bar PASSED|FAILED|ERROR|SKIPPED` plus the `-q` tail summary | +| `jest` / `vitest` | `✓ name`, `✗ name`, `PASS file`, `FAIL file` | +| `gotest` | `--- PASS:`, `--- FAIL:`, `--- SKIP:` | +| `tap` | `ok N - name`, `not ok N - name` | + +## Exit codes + +| Code | Meaning | +|---|---| +| `0` | No flakies, no always-failing | +| `1` | At least one test is flaky (`0 < flakiness_pct < 100`) | +| `2` | At least one test is always-failing | +| `3` | Zero tests parsed but the runner reported activity — re-run with `-v` | + +## Example output (markdown) + +``` +# Flaky-detector report (10 runs) + +- flaky: **2** | always-failing: **0** | always-passing: 47 + +| test | pass | fail | flakiness % | +|---|---|---|---| +| tests/test_payment.py::test_idempotency | 6 | 4 | 40.0 | +| tests/test_search.py::test_index_warmup | 8 | 2 | 20.0 | +``` + +## Limitations + +- `--parallel > 1` only works for parallel-safe suites; otherwise concurrent runs share state and lie. +- Streaming stdout from very long suites is buffered — be patient on the first run. +- The parser is tuned for default reporters. Custom plugins (pytest-rich, etc.) may need a tweak. + +## Examples + +Step-by-step walkthroughs with real input fixtures and the helper's actual output live in [`examples/`](./examples/README.md). Three or four scenarios per plugin — from the happy path to the edge cases the test suite guards. + +## Part of the pluginpool family + +Ten focused Claude Code plugins for everyday productivity: +[commit-narrator](https://github.com/mturac/pluginpool-commit-narrator) · +[pr-storyteller](https://github.com/mturac/pluginpool-pr-storyteller) · +[test-gap](https://github.com/mturac/pluginpool-test-gap) · +[deps-doctor](https://github.com/mturac/pluginpool-deps-doctor) · +[env-lint](https://github.com/mturac/pluginpool-env-lint) · +[secret-guard](https://github.com/mturac/pluginpool-secret-guard) · +[standup-gen](https://github.com/mturac/pluginpool-standup-gen) · +[todo-harvest](https://github.com/mturac/pluginpool-todo-harvest) · +[flaky-detector](https://github.com/mturac/pluginpool-flaky-detector) · +[changelog-forge](https://github.com/mturac/pluginpool-changelog-forge) + +## License + +MIT — see [`LICENSE`](./LICENSE). Contributions welcome. diff --git a/plugins/flaky-detector/commands/flaky-detector.md b/plugins/flaky-detector/commands/flaky-detector.md new file mode 100644 index 0000000..fa28bc4 --- /dev/null +++ b/plugins/flaky-detector/commands/flaky-detector.md @@ -0,0 +1,16 @@ +--- +description: Detect flaky tests by running a test command N times +allowed-tools: Bash +--- + +Role: act as a flake hunter. Identify tests that don't always agree with themselves. + +Run the helper: + +```bash +python3 scripts/flaky.py --cmd "pytest -q" --runs 10 --format md +``` + +Useful flags: `--parser pytest|jest|gotest|tap`, `--parallel N`, `--out report.json`. + +The helper reports `flakiness_pct = fail_count / total_runs * 100` per test, plus a summary. Exit codes: `0` clean, `1` flaky tests found, `2` always-failing tests found. Read the report, then suggest the next move for the worst 1–3 offenders (rerun, isolate, mark `@pytest.mark.flaky`, or root-cause). Don't speculate beyond what the output supports. diff --git a/plugins/flaky-detector/scripts/flaky.py b/plugins/flaky-detector/scripts/flaky.py new file mode 100755 index 0000000..3540e80 --- /dev/null +++ b/plugins/flaky-detector/scripts/flaky.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""Run a test command N times and report per-test flakiness.""" + +from __future__ import annotations + +import argparse +import concurrent.futures +import json +import re +import shlex +import subprocess +import sys +from collections import defaultdict +from typing import Callable + + +# ---------- parsers ---------- + +_PYTEST_LINE = re.compile( + r"^(?P\S+::\S+(?:::\S+)?)\s+(?PPASSED|FAILED|ERROR|SKIPPED|XFAIL|XPASS)" +) +# Short-form `pytest -q` only prints `FAILED path::test - reason` / `ERROR path::test` in the +# tail summary, plus a final tally line. We pick those up to avoid a false-green report when +# the user runs the default short reporter. +_PYTEST_TAIL = re.compile(r"^(?PFAILED|ERROR|PASSED)\s+(?P\S+::\S+(?:::\S+)?)") +_PYTEST_TALLY = re.compile(r"=+\s*(?:(\d+) failed[, ]*)?(?:(\d+) passed[, ]*)?") + + +def parse_pytest(stdout: str) -> dict[str, str]: + """Parse pytest -v and pytest -q output. Returns {test_id: 'pass'|'fail'|'skip'}.""" + results: dict[str, str] = {} + for line in stdout.splitlines(): + m = _PYTEST_LINE.search(line) + if m: + status = m.group("status") + test_id = m.group("path") + if status in ("PASSED", "XPASS"): + results[test_id] = "pass" + elif status in ("FAILED", "ERROR", "XFAIL"): + results[test_id] = "fail" + elif status == "SKIPPED": + results[test_id] = "skip" + continue + m2 = _PYTEST_TAIL.match(line) + if m2: + status, tid = m2.group("status"), m2.group("path") + if status == "PASSED": + results.setdefault(tid, "pass") + else: + results[tid] = "fail" + return results + + +def _pytest_tally(stdout: str) -> tuple[int, int]: + """Return (failed, passed) from the pytest tail tally line, or (0, 0) if absent.""" + failed = passed = 0 + for line in reversed(stdout.splitlines()): + m = _PYTEST_TALLY.search(line) + if m and (m.group(1) or m.group(2)): + failed = int(m.group(1) or 0) + passed = int(m.group(2) or 0) + break + return failed, passed + + +_JEST_LINE = re.compile(r"^\s*(✓|✗|×|PASS|FAIL)\s+(.+?)(?:\s+\(\d+\s*m?s\))?\s*$") + + +def parse_jest(stdout: str) -> dict[str, str]: + results: dict[str, str] = {} + for line in stdout.splitlines(): + m = _JEST_LINE.match(line) + if not m: + continue + token, name = m.group(1), m.group(2).strip() + if token in ("✓", "PASS"): + results[name] = "pass" + elif token in ("✗", "×", "FAIL"): + results[name] = "fail" + return results + + +_GOTEST_LINE = re.compile(r"^---\s+(PASS|FAIL|SKIP):\s+(\S+)") + + +def parse_gotest(stdout: str) -> dict[str, str]: + results: dict[str, str] = {} + for line in stdout.splitlines(): + m = _GOTEST_LINE.match(line) + if m: + status, name = m.group(1), m.group(2) + results[name] = {"PASS": "pass", "FAIL": "fail", "SKIP": "skip"}[status] + return results + + +_TAP_LINE = re.compile(r"^(ok|not ok)\s+\d+\s*-?\s*(.*?)\s*$") + + +def parse_tap(stdout: str) -> dict[str, str]: + results: dict[str, str] = {} + for line in stdout.splitlines(): + m = _TAP_LINE.match(line) + if m: + ok, name = m.group(1), m.group(2) + name = name or f"" + results[name] = "pass" if ok == "ok" else "fail" + return results + + +PARSERS: dict[str, Callable[[str], dict[str, str]]] = { + "pytest": parse_pytest, + "jest": parse_jest, + "gotest": parse_gotest, + "tap": parse_tap, +} + + +def auto_parser(cmd: str) -> str: + c = cmd.lower() + if "jest" in c or "vitest" in c: + return "jest" + if "go test" in c: + return "gotest" + if "tap" in c: + return "tap" + return "pytest" + + +# ---------- runner ---------- + +def _run_once(cmd: str) -> str: + res = subprocess.run(shlex.split(cmd), capture_output=True, text=True, check=False) + return (res.stdout or "") + "\n" + (res.stderr or "") + + +def run_many(cmd: str, runs: int, parallel: int) -> list[str]: + if parallel <= 1 or runs == 1: + return [_run_once(cmd) for _ in range(runs)] + outputs: list[str] = [""] * runs + with concurrent.futures.ThreadPoolExecutor(max_workers=parallel) as ex: + futures = {ex.submit(_run_once, cmd): i for i in range(runs)} + for fut in concurrent.futures.as_completed(futures): + i = futures[fut] + outputs[i] = fut.result() + return outputs + + +# ---------- aggregator ---------- + +def aggregate(per_run: list[dict[str, str]]) -> dict: + total = len(per_run) + counts: dict[str, dict[str, int]] = defaultdict(lambda: {"pass": 0, "fail": 0, "skip": 0}) + for run in per_run: + for tid, status in run.items(): + counts[tid][status] += 1 + + tests = [] + for tid, c in counts.items(): + runs_seen = c["pass"] + c["fail"] + pct = (c["fail"] / runs_seen * 100.0) if runs_seen else 0.0 + tests.append( + { + "id": tid, + "pass_count": c["pass"], + "fail_count": c["fail"], + "skip_count": c["skip"], + "flakiness_pct": round(pct, 2), + } + ) + + flaky = [t for t in tests if 0 < t["flakiness_pct"] < 100] + always_failing = [t for t in tests if t["pass_count"] == 0 and t["fail_count"] > 0] + always_passing = [t for t in tests if t["fail_count"] == 0 and t["pass_count"] > 0] + + tests.sort(key=lambda t: (-t["flakiness_pct"], t["id"])) + return { + "tests": tests, + "summary": { + "total_runs": total, + "flaky_tests": len(flaky), + "always_failing": len(always_failing), + "always_passing": len(always_passing), + }, + } + + +def render_markdown(report: dict) -> str: + s = report["summary"] + lines = [ + f"# Flaky-detector report ({s['total_runs']} runs)", + "", + f"- flaky: **{s['flaky_tests']}** | always-failing: **{s['always_failing']}** | always-passing: {s['always_passing']}", + "", + "| test | pass | fail | flakiness % |", + "|---|---|---|---|", + ] + for t in report["tests"]: + if t["fail_count"] == 0: + continue + lines.append(f"| {t['id']} | {t['pass_count']} | {t['fail_count']} | {t['flakiness_pct']} |") + return "\n".join(lines) + "\n" + + +# ---------- main ---------- + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="Detect flaky tests by running a command N times.") + p.add_argument("--cmd", required=True) + p.add_argument("--runs", type=int, default=10) + p.add_argument("--parallel", type=int, default=1) + p.add_argument("--parser", choices=list(PARSERS.keys()) + ["auto"], default="auto") + p.add_argument("--out", default=None) + p.add_argument("--format", choices=["json", "md"], default="json") + args = p.parse_args(argv) + + parser_name = auto_parser(args.cmd) if args.parser == "auto" else args.parser + parse = PARSERS[parser_name] + + outputs = run_many(args.cmd, args.runs, args.parallel) + per_run = [parse(o) for o in outputs] + report = aggregate(per_run) + report["parser"] = parser_name + + # Guard against silent false-green: if zero tests parsed yet the tally suggests + # tests actually ran, surface a warning + non-zero exit. + warnings: list[str] = [] + if parser_name == "pytest" and not any(per_run) and outputs: + for o in outputs: + failed, passed = _pytest_tally(o) + if failed + passed > 0: + warnings.append( + f"pytest reported {failed} failed / {passed} passed but no per-test " + "lines parsed — re-run with `-v` so flaky-detector can attribute results." + ) + break + report["warnings"] = warnings + + if args.out: + with open(args.out, "w", encoding="utf-8") as f: + json.dump(report, f, indent=2) + + if args.format == "md": + sys.stdout.write(render_markdown(report)) + else: + json.dump(report, sys.stdout, indent=2) + sys.stdout.write("\n") + + if report["summary"]["always_failing"] > 0: + return 2 + if report["summary"]["flaky_tests"] > 0: + return 1 + if warnings: + sys.stderr.write("\n".join(warnings) + "\n") + return 3 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/pr-storyteller/.claude-plugin/plugin.json b/plugins/pr-storyteller/.claude-plugin/plugin.json new file mode 100644 index 0000000..8bbad90 --- /dev/null +++ b/plugins/pr-storyteller/.claude-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "pr-storyteller", + "version": "0.1.0", + "description": "Generate PR story data from commits and diffs.", + "author": "pluginpool" +} diff --git a/plugins/pr-storyteller/LICENSE b/plugins/pr-storyteller/LICENSE new file mode 100644 index 0000000..266f59c --- /dev/null +++ b/plugins/pr-storyteller/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 pluginpool contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/pr-storyteller/README.md b/plugins/pr-storyteller/README.md new file mode 100644 index 0000000..ff5ac7f --- /dev/null +++ b/plugins/pr-storyteller/README.md @@ -0,0 +1,111 @@ +![hero](./assets/hero.svg) + +# pr-storyteller + +**Stop writing PR descriptions from a blank cursor. Generate a real one in seconds.** + +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE) +[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/) +[![Claude Code Plugin](https://img.shields.io/badge/claude--code-plugin-7c3aed.svg)](https://docs.claude.com/en/docs/claude-code/overview) +[![Tests: 6 passing](https://img.shields.io/badge/tests-6%20passing-success.svg)](./tests) + +> **TL;DR:** `/pr-storyteller` → PR title + summary + test plan, drafted from your actual commits and diff. + +## Why this exists + +PR descriptions decay the moment they leave the author's keyboard. The reviewer skims, the author forgets to update later, and three months in nobody knows *why* the PR landed. `pr-storyteller` gives you a draft grounded in the commit history and the diff against your base branch — Claude then writes the prose, you tweak, you ship. + +## Install (Claude Code) + +```sh +git clone https://github.com/mturac/pluginpool-pr-storyteller ~/.claude/plugins/pr-storyteller +``` + +Restart Claude Code; the slash command `/pr-storyteller` appears. + +## Quick start + +```sh +/pr-storyteller +``` + +Or invoke the helper directly: + +```sh +python3 scripts/story.py +python3 scripts/story.py --base develop +``` + +## Flags + +| Flag | Default | Description | +|---|---|---| +| `--base` | `main` (falls back to `master`) | Base branch to diff against | + +## Example output (JSON) + +```json +{ + "commits": [ + {"hash": "abc1234", "subject": "feat(auth): rotate refresh tokens"}, + {"hash": "def5678", "subject": "test(auth): cover stale-token replay"} + ], + "files_changed": [ + {"path": "src/auth/refresh.py", "additions": 84, "deletions": 12}, + {"path": "tests/test_refresh.py", "additions": 41, "deletions": 0} + ], + "suggested_title": "feat(auth): rotate refresh tokens" +} +``` + +Claude turns that into: + +```markdown +### Summary +- Rotate the refresh-token secret on every successful refresh +- Reject stale refresh tokens that have already been redeemed + +### Changes +- `src/auth/refresh.py` — token rotation + replay rejection +- `tests/test_refresh.py` — coverage for the replay path + +### Test plan +- [ ] Successful login → both access and refresh tokens issued +- [ ] Reuse a refresh token → 401 + token family invalidated +- [ ] Clock skew tolerance still passes +``` + +## How it works + +1. Reads `git log ..HEAD` for commits (hash + subject). +2. Reads `git diff --stat ..HEAD` for changed files + additions/deletions. +3. Suggests a title from the latest commit subject. +4. Claude composes a real PR body from that scaffold. + +## Limitations + +- Doesn't fetch from the remote — make sure your local `` is current. +- Handles empty diffs and non-git directories gracefully (returns empty JSON). +- Binary files surface via `git diff --numstat` and are reported with `-` for additions/deletions. + +## Examples + +Step-by-step walkthroughs with real input fixtures and the helper's actual output live in [`examples/`](./examples/README.md). Three or four scenarios per plugin — from the happy path to the edge cases the test suite guards. + +## Part of the pluginpool family + +Ten focused Claude Code plugins for everyday productivity: +[commit-narrator](https://github.com/mturac/pluginpool-commit-narrator) · +[pr-storyteller](https://github.com/mturac/pluginpool-pr-storyteller) · +[test-gap](https://github.com/mturac/pluginpool-test-gap) · +[deps-doctor](https://github.com/mturac/pluginpool-deps-doctor) · +[env-lint](https://github.com/mturac/pluginpool-env-lint) · +[secret-guard](https://github.com/mturac/pluginpool-secret-guard) · +[standup-gen](https://github.com/mturac/pluginpool-standup-gen) · +[todo-harvest](https://github.com/mturac/pluginpool-todo-harvest) · +[flaky-detector](https://github.com/mturac/pluginpool-flaky-detector) · +[changelog-forge](https://github.com/mturac/pluginpool-changelog-forge) + +## License + +MIT — see [`LICENSE`](./LICENSE). Contributions welcome. diff --git a/plugins/pr-storyteller/commands/pr-storyteller.md b/plugins/pr-storyteller/commands/pr-storyteller.md new file mode 100644 index 0000000..f200cf2 --- /dev/null +++ b/plugins/pr-storyteller/commands/pr-storyteller.md @@ -0,0 +1,20 @@ +--- +description: Generate a PR title, body, and test plan from commits and diff +allowed-tools: Bash +--- + +Role: act as the PR description author responsible only for producing a PR title and markdown body for the current repository. + +Run the local helper with Bash: + +```bash +python3 scripts/story.py --base main +``` + +If the result has no commits and no changed files, rerun with: + +```bash +python3 scripts/story.py --base master +``` + +The helper returns JSON with `commits`, `files_changed`, and `suggested_title`. Use only that JSON plus local diff evidence. Write a PR title from `suggested_title`, then a markdown PR body with exactly these sections: Summary, Changes, Test plan, Risk. Print only the PR title and markdown body. diff --git a/plugins/pr-storyteller/scripts/story.py b/plugins/pr-storyteller/scripts/story.py new file mode 100755 index 0000000..27ea0df --- /dev/null +++ b/plugins/pr-storyteller/scripts/story.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Collect PR story data from local git history.""" + +from __future__ import annotations + +import argparse +import json +import subprocess + + +def git(args: list[str]) -> tuple[int, str]: + try: + proc = subprocess.run( + ["git", *args], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + except OSError: + return 1, "" + return proc.returncode, proc.stdout + + +def is_repo() -> bool: + code, out = git(["rev-parse", "--is-inside-work-tree"]) + return code == 0 and out.strip() == "true" + + +def ref_exists(ref: str) -> bool: + code, _ = git(["rev-parse", "--verify", "--quiet", ref]) + return code == 0 + + +def choose_base(base: str) -> str | None: + if ref_exists(base): + return base + if base == "main" and ref_exists("master"): + return "master" + return None + + +def commits_since(base: str | None) -> list[dict[str, str]]: + if base: + code, out = git(["log", "--pretty=format:%h%x00%s", f"{base}..HEAD"]) + else: + code, out = git(["log", "--pretty=format:%h%x00%s", "-n", "20"]) + if code != 0: + return [] + commits = [] + for line in out.splitlines(): + if "\0" in line: + short, subject = line.split("\0", 1) + commits.append({"hash": short, "subject": subject}) + return commits + + +def changed_files(base: str | None) -> list[dict[str, int | str]]: + args = ["diff", "--numstat", f"{base}...HEAD"] if base else ["diff", "--numstat", "HEAD"] + code, out = git(args) + if code != 0: + code, out = git(["diff", "--numstat"]) + if code != 0: + return [] + files = [] + for line in out.splitlines(): + parts = line.split("\t") + if len(parts) < 3: + continue + additions = 0 if parts[0] == "-" else int(parts[0]) + deletions = 0 if parts[1] == "-" else int(parts[1]) + files.append({"path": parts[2], "additions": additions, "deletions": deletions}) + return files + + +def story(base: str) -> dict[str, object]: + if not is_repo(): + return {"commits": [], "files_changed": [], "suggested_title": ""} + selected = choose_base(base) + commits = commits_since(selected) + files = changed_files(selected) + title = commits[0]["subject"] if commits else "Update changes" + return {"commits": commits, "files_changed": files, "suggested_title": title} + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", default="main", help="Base branch or ref. Defaults to main.") + args = parser.parse_args(argv) + print(json.dumps(story(args.base), indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/secret-guard/.claude-plugin/plugin.json b/plugins/secret-guard/.claude-plugin/plugin.json new file mode 100644 index 0000000..626ee16 --- /dev/null +++ b/plugins/secret-guard/.claude-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "secret-guard", + "version": "0.1.0", + "description": "Scan staged diffs or files for leaked secrets with redacted output.", + "author": "pluginpool" +} diff --git a/plugins/secret-guard/LICENSE b/plugins/secret-guard/LICENSE new file mode 100644 index 0000000..266f59c --- /dev/null +++ b/plugins/secret-guard/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 pluginpool contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/secret-guard/README.md b/plugins/secret-guard/README.md new file mode 100644 index 0000000..ebb2c1e --- /dev/null +++ b/plugins/secret-guard/README.md @@ -0,0 +1,112 @@ +![hero](./assets/hero.svg) + +# secret-guard + +**Block leaked API keys before they hit `origin`. Pattern + entropy. Redacted output.** + +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE) +[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/) +[![Claude Code Plugin](https://img.shields.io/badge/claude--code-plugin-7c3aed.svg)](https://docs.claude.com/en/docs/claude-code/overview) +[![Tests: 10 passing](https://img.shields.io/badge/tests-10%20passing-success.svg)](./tests) + +> **TL;DR:** `/secret-guard` → scans your staged diff for AWS / GitHub / Slack / Stripe / Google API keys, JWTs, private keys, and high-entropy base64 strings. Blocks the commit before the secret leaves your machine. + +## Why this exists + +Most "secret scanners" run in CI — *after* the leak is already in git history. By then, rotating the key is the only fix, because git history is forever. `secret-guard` runs in your pre-commit hook (or via Claude Code on demand) and catches the leak *before* the commit object exists. And the output is redacted by design: you can paste a finding in chat, in a PR, in Slack, without leaking the very thing you're trying to protect. + +## Install (Claude Code + pre-commit) + +```sh +git clone https://github.com/mturac/pluginpool-secret-guard ~/.claude/plugins/secret-guard +``` + +To wire as a pre-commit hook: + +```sh +cp ~/.claude/plugins/secret-guard/hooks/pre-commit .git/hooks/pre-commit +chmod +x .git/hooks/pre-commit +``` + +## Quick start + +```sh +/secret-guard # scan staged diff +python3 scripts/guard.py # same, directly +python3 scripts/guard.py --files src/config.py .env # scan specific files +python3 scripts/guard.py --allowlist .secretignore # suppress known false positives +``` + +## Flags + +| Flag | Default | Description | +|---|---|---| +| `--files F…` | _staged diff_ | Scan specific files instead of the staged diff | +| `--allowlist PATH` | _none_ | Regexes (one per line) that suppress matching findings | +| `--format` | `json` | `json` or `md` | + +## Detected patterns + +| Rule | Match | +|---|---| +| AWS Access Key ID | `AKIA[0-9A-Z]{16}` | +| GitHub PAT | `gh[pousr]_[A-Za-z0-9]{36,}` | +| Slack token | `xox[abpr]-[A-Za-z0-9-]{10,}` | +| Stripe key | `sk_(live|test)_[A-Za-z0-9]{24,}` | +| Google API key | `AIza[0-9A-Za-z_-]{35}` | +| JWT | `eyJ…\.eyJ…\.…` | +| Private key | `-----BEGIN … PRIVATE KEY-----` | +| Generic high-entropy | base64-ish ≥32 chars, Shannon entropy ≥ 4.5 | + +## Example output (markdown) + +``` +# secret-guard report + +| file | line | rule | snippet | +|---|---|---|---| +| src/config.py | 12 | aws-access-key | AKIA… | +| .env | 4 | stripe-key | sk_l… | +``` + +Note: snippets are always `rule + first 4 chars + …` — never the full secret. + +## Exit codes + +| Code | Meaning | +|---|---| +| `0` | Clean — no secrets found | +| `1` | At least one finding (commit is blocked when used as a hook) | + +## Safety guarantees + +- `test_redaction_contains_only_rule_and_first_four` asserts the raw secret never appears in JSON or markdown output. +- `test_files_mode_does_not_crash_on_non_utf8` ensures non-UTF-8 / binary files are skipped, not crashed on. + +## Limitations + +- Pattern lists drift; submit PRs for new providers. +- Entropy heuristic produces some false positives on minified bundles — use `--allowlist` to suppress. +- Doesn't scan repo history; pair with `git-secrets` or `trufflehog` for that. + +## Examples + +Step-by-step walkthroughs with real input fixtures and the helper's actual output live in [`examples/`](./examples/README.md). Three or four scenarios per plugin — from the happy path to the edge cases the test suite guards. + +## Part of the pluginpool family + +Ten focused Claude Code plugins for everyday productivity: +[commit-narrator](https://github.com/mturac/pluginpool-commit-narrator) · +[pr-storyteller](https://github.com/mturac/pluginpool-pr-storyteller) · +[test-gap](https://github.com/mturac/pluginpool-test-gap) · +[deps-doctor](https://github.com/mturac/pluginpool-deps-doctor) · +[env-lint](https://github.com/mturac/pluginpool-env-lint) · +[secret-guard](https://github.com/mturac/pluginpool-secret-guard) · +[standup-gen](https://github.com/mturac/pluginpool-standup-gen) · +[todo-harvest](https://github.com/mturac/pluginpool-todo-harvest) · +[flaky-detector](https://github.com/mturac/pluginpool-flaky-detector) · +[changelog-forge](https://github.com/mturac/pluginpool-changelog-forge) + +## License + +MIT — see [`LICENSE`](./LICENSE). Contributions welcome. diff --git a/plugins/secret-guard/commands/secret-guard.md b/plugins/secret-guard/commands/secret-guard.md new file mode 100644 index 0000000..8696fb3 --- /dev/null +++ b/plugins/secret-guard/commands/secret-guard.md @@ -0,0 +1,24 @@ +--- +description: Scan staged diff (or specified files) for leaked secrets with redacted output +allowed-tools: Bash +--- + +Role: act as a pre-merge security gate. Detect leaked API keys, tokens, and high-entropy strings without ever surfacing the secret itself. + +Run the helper: + +```sh +python3 scripts/guard.py --format md +``` + +Useful flags: `--files app.py config.txt` (scan explicit files instead of staged diff), `--allowlist .secret-allowlist` (suppress known false positives). + +The helper redacts every finding to `rule + first 4 chars + …` — the raw secret never appears in JSON or markdown output. This invariant is enforced by `tests/test_guard.py::test_redaction_contains_only_rule_and_first_four`. + +For pre-commit integration: + +```sh +cp hooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit +``` + +Exit codes: `0` clean, `1` finding present. Read the report and recommend: rotate, allowlist, or fix. diff --git a/plugins/secret-guard/scripts/guard.py b/plugins/secret-guard/scripts/guard.py new file mode 100755 index 0000000..a687e17 --- /dev/null +++ b/plugins/secret-guard/scripts/guard.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Scan staged diffs or files for secrets with redacted output.""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import re +import subprocess +import sys +from typing import Iterable, Iterator, List, Pattern, Sequence, Tuple + + +NAMED_PATTERNS: Sequence[Tuple[str, Pattern[str]]] = ( + ("AWS Access Key", re.compile(r"AKIA[0-9A-Z]{16}")), + ("GitHub PAT", re.compile(r"gh[pousr]_[A-Za-z0-9]{36,}")), + ("Slack token", re.compile(r"xox[abpr]-[A-Za-z0-9-]{10,}")), + ("Stripe", re.compile(r"sk_(live|test)_[A-Za-z0-9]{24,}")), + ("Google API", re.compile(r"AIza[0-9A-Za-z_-]{35}")), + ("JWT", re.compile(r"eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+")), + ("Private key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")), +) +ENTROPY_RE = re.compile(r"[A-Za-z0-9+/=_-]{32,}") + + +def shannon_entropy(value: str) -> float: + if not value: + return 0.0 + total = len(value) + return -sum((value.count(char) / total) * math.log2(value.count(char) / total) for char in set(value)) + + +def load_allowlist(path: str | None) -> List[Pattern[str]]: + if not path: + return [] + rules = [] + with open(path, "r", encoding="utf-8") as handle: + for raw in handle: + line = raw.strip() + if line and not line.startswith("#"): + rules.append(re.compile(line)) + return rules + + +def is_allowed(rules: Sequence[Pattern[str]], line: str, secret: str) -> bool: + return any(rule.search(line) or rule.search(secret) for rule in rules) + + +def redact(rule: str, secret: str) -> str: + return f"{rule}: {secret[:4]}\u2026" + + +def scan_line(file_name: str, line_no: int, line: str, allowlist: Sequence[Pattern[str]]) -> List[dict]: + findings = [] + for rule, pattern in NAMED_PATTERNS: + for match in pattern.finditer(line): + secret = match.group(0) + if not is_allowed(allowlist, line, secret): + findings.append({"file": file_name, "line": line_no, "pattern": rule, "snippet_redacted": redact(rule, secret)}) + if findings: + return findings + for match in ENTROPY_RE.finditer(line): + secret = match.group(0) + if shannon_entropy(secret) >= 4.5 and not is_allowed(allowlist, line, secret): + findings.append( + {"file": file_name, "line": line_no, "pattern": "Generic high-entropy", "snippet_redacted": redact("Generic high-entropy", secret)} + ) + return findings + + +def staged_added_lines() -> Iterator[Tuple[str, int, str]]: + proc = subprocess.run( + ["git", "diff", "--cached", "--unified=0", "--no-ext-diff"], + text=True, + capture_output=True, + check=False, + ) + if proc.returncode not in (0, 1): + raise RuntimeError(proc.stderr.strip() or "git diff failed") + + current_file = None + next_line = 0 + for raw in proc.stdout.splitlines(): + if raw.startswith("+++ b/"): + current_file = raw[6:] + continue + if raw.startswith("@@"): + match = re.search(r"\+(\d+)(?:,(\d+))?", raw) + next_line = int(match.group(1)) if match else 0 + continue + if raw.startswith("+") and not raw.startswith("+++"): + if current_file is not None: + yield current_file, next_line, raw[1:] + next_line += 1 + + +def _looks_binary(path: str) -> bool: + try: + with open(path, "rb") as handle: + chunk = handle.read(1024) + return b"\x00" in chunk + except OSError: + return True + + +def file_lines(paths: Sequence[str]) -> Iterator[Tuple[str, int, str]]: + for path in paths: + if _looks_binary(path): + continue + try: + with open(path, "r", encoding="utf-8", errors="replace") as handle: + for index, line in enumerate(handle, 1): + yield path, index, line.rstrip("\n") + except OSError: + continue + + +def scan_sources(sources: Iterable[Tuple[str, int, str]], allowlist: Sequence[Pattern[str]]) -> List[dict]: + findings = [] + for file_name, line_no, line in sources: + findings.extend(scan_line(file_name, line_no, line, allowlist)) + return findings + + +def to_markdown(findings: Sequence[dict]) -> str: + lines = ["# secret-guard report", ""] + if not findings: + lines.append("No findings.") + return "\n".join(lines) + "\n" + lines.append("| File | Line | Pattern | Snippet |") + lines.append("| --- | ---: | --- | --- |") + for item in findings: + lines.append(f"| {item['file']} | {item['line']} | {item['pattern']} | {item['snippet_redacted']} |") + return "\n".join(lines) + "\n" + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Scan staged diffs or files for leaked secrets.") + parser.add_argument("--files", nargs="+", help="Scan specific files instead of staged diff") + parser.add_argument("--format", choices=("json", "md"), default="json", help="Output format") + parser.add_argument("--allowlist", help="Regex allowlist file, one pattern per line") + return parser + + +def main(argv: Iterable[str] | None = None) -> int: + args = build_parser().parse_args(argv) + allowlist = load_allowlist(args.allowlist) + sources = file_lines(args.files) if args.files else staged_added_lines() + findings = scan_sources(sources, allowlist) + if args.format == "md": + sys.stdout.write(to_markdown(findings)) + else: + sys.stdout.write(json.dumps(findings, indent=2, sort_keys=True) + "\n") + return 1 if findings else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/standup-gen/.claude-plugin/plugin.json b/plugins/standup-gen/.claude-plugin/plugin.json new file mode 100644 index 0000000..9e7a16d --- /dev/null +++ b/plugins/standup-gen/.claude-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "standup-gen", + "version": "0.1.0", + "description": "Generate daily standup notes from git activity across one or many repos.", + "author": "pluginpool" +} diff --git a/plugins/standup-gen/LICENSE b/plugins/standup-gen/LICENSE new file mode 100644 index 0000000..266f59c --- /dev/null +++ b/plugins/standup-gen/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 pluginpool contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/standup-gen/README.md b/plugins/standup-gen/README.md new file mode 100644 index 0000000..9f3d15f --- /dev/null +++ b/plugins/standup-gen/README.md @@ -0,0 +1,103 @@ +![hero](./assets/hero.svg) + +# standup-gen + +**Skip the "what did I do yesterday?" tax. Generate the answer from git.** + +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE) +[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/) +[![Claude Code Plugin](https://img.shields.io/badge/claude--code-plugin-7c3aed.svg)](https://docs.claude.com/en/docs/claude-code/overview) +[![Tests: 8 passing](https://img.shields.io/badge/tests-8%20passing-success.svg)](./tests) + +> **TL;DR:** `/standup-gen` → markdown `Yesterday / Today / Blockers` block, pre-filled from your commits across every repo you care about. + +## Why this exists + +Standup writes itself if you only let it. Most engineers spend 5–10 minutes every morning scrolling commits to remember what they shipped — across two or three repos, across a long weekend, across timezones. `standup-gen` queries each repo's `git log` since the last business day, attributes commits to you (or the team), and hands you a clean draft. Claude fills in *Today (planned)* and *Blockers* if context allows. + +## Install (Claude Code) + +```sh +git clone https://github.com/mturac/pluginpool-standup-gen ~/.claude/plugins/standup-gen +``` + +Restart Claude Code; the slash command `/standup-gen` appears. + +## Quick start + +```sh +/standup-gen +``` + +Or directly: + +```sh +python3 scripts/standup.py --format md +python3 scripts/standup.py --repos /work/api,/work/web --format md +python3 scripts/standup.py --since 2026-05-13 --author all --format json +``` + +## Flags + +| Flag | Default | Description | +|---|---|---| +| `--repos` | cwd | Comma-separated repo paths | +| `--since` | last business day | ISO date or `yesterday` | +| `--until` | today (00:00, exclusive) | ISO date upper-bound, or `none` to drop the bound | +| `--author` | `git config user.email` | Email filter, or `all` | +| `--format` | `json` | `json` or `md` | + +The default `--until=today` is intentional — without it, "yesterday" silently leaks today's commits into the standup. + +## Example output + +```markdown +# Standup — 2026-05-16 + +## Yesterday +### api +- 2026-05-15 `4c3d39e` feat: add login throttle +- 2026-05-15 `8d424a0` test: cover replay path +### web +- 2026-05-15 `93a8826` fix: navbar overlap on small screens + +## Today (planned) +- TODO + +## Blockers +- TODO +``` + +## How it works + +1. For each repo, runs `git log --since= --until=` with your email as `--author`. +2. Groups commits by date and renders the markdown skeleton. +3. Claude can then propose `Today (planned)` items from in-flight files, and flag obvious blockers from commit messages (`WIP:`, `revert`, etc.). + +## Limitations + +- Only counts authored commits — co-authored trailers aren't matched. +- "Yesterday" is calendar-day, not work-session-day. Burnouts who commit at 3am may want `--since=yesterday`. +- Multi-repo only — no GitHub PR/issue activity (yet). + +## Examples + +Step-by-step walkthroughs with real input fixtures and the helper's actual output live in [`examples/`](./examples/README.md). Three or four scenarios per plugin — from the happy path to the edge cases the test suite guards. + +## Part of the pluginpool family + +Ten focused Claude Code plugins for everyday productivity: +[commit-narrator](https://github.com/mturac/pluginpool-commit-narrator) · +[pr-storyteller](https://github.com/mturac/pluginpool-pr-storyteller) · +[test-gap](https://github.com/mturac/pluginpool-test-gap) · +[deps-doctor](https://github.com/mturac/pluginpool-deps-doctor) · +[env-lint](https://github.com/mturac/pluginpool-env-lint) · +[secret-guard](https://github.com/mturac/pluginpool-secret-guard) · +[standup-gen](https://github.com/mturac/pluginpool-standup-gen) · +[todo-harvest](https://github.com/mturac/pluginpool-todo-harvest) · +[flaky-detector](https://github.com/mturac/pluginpool-flaky-detector) · +[changelog-forge](https://github.com/mturac/pluginpool-changelog-forge) + +## License + +MIT — see [`LICENSE`](./LICENSE). Contributions welcome. diff --git a/plugins/standup-gen/commands/standup-gen.md b/plugins/standup-gen/commands/standup-gen.md new file mode 100644 index 0000000..7a203e1 --- /dev/null +++ b/plugins/standup-gen/commands/standup-gen.md @@ -0,0 +1,16 @@ +--- +description: Generate daily standup notes from git activity +allowed-tools: Bash +--- + +Role: act as a standup-note author. Produce Yesterday / Today / Blockers sections grounded only in the user's git activity. + +Run the helper: + +```bash +python3 scripts/standup.py --format md +``` + +Useful flags: `--since 2026-05-15`, `--since yesterday`, `--repos /path/repoA,/path/repoB`, `--author all`, `--format json`. + +The helper fills "Yesterday" from commit subjects and leaves "Today (planned)" and "Blockers" as placeholders. Read the commit list, then propose 1–3 candidate "Today" items derived from in-flight files (e.g. files with WIP or follow-up commits). Mark blockers only if the diffs or commit messages clearly evidence one. Print the final markdown. diff --git a/plugins/standup-gen/scripts/standup.py b/plugins/standup-gen/scripts/standup.py new file mode 100755 index 0000000..87dd9ca --- /dev/null +++ b/plugins/standup-gen/scripts/standup.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Generate daily standup notes from `git log` across one or many repos.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import subprocess +import sys +from typing import Iterable + + +def _run(args: list[str], cwd: str) -> str: + res = subprocess.run(args, cwd=cwd, capture_output=True, text=True, check=False) + return res.stdout + + +def _git_user_email(cwd: str) -> str: + return _run(["git", "config", "user.email"], cwd).strip() + + +def _last_business_day(today: dt.date) -> dt.date: + if today.weekday() == 0: # Monday → Friday + return today - dt.timedelta(days=3) + if today.weekday() == 6: # Sunday → Friday + return today - dt.timedelta(days=2) + return today - dt.timedelta(days=1) + + +def _parse_since(value: str | None, today: dt.date | None = None) -> str: + today = today or dt.date.today() + if not value or value == "yesterday": + return _last_business_day(today).isoformat() + return value + + +def _collect_commits( + repo: str, + since_iso: str, + author: str | None, + until_iso: str | None = None, +) -> list[dict]: + if not os.path.isdir(os.path.join(repo, ".git")): + return [] + args = [ + "git", + "log", + f"--since={since_iso} 00:00:00", + "--pretty=format:%H%x09%ad%x09%s", + "--date=short", + ] + if until_iso: + args.append(f"--until={until_iso} 00:00:00") + if author and author != "all": + args.append(f"--author={author}") + out = _run(args, repo) + commits: list[dict] = [] + for line in out.splitlines(): + parts = line.split("\t", 2) + if len(parts) != 3: + continue + commits.append({"hash": parts[0], "date": parts[1], "subject": parts[2]}) + return commits + + +def _render_markdown(report: dict) -> str: + lines: list[str] = [] + lines.append(f"# Standup — {dt.date.today().isoformat()}") + lines.append("") + lines.append("## Yesterday") + any_commit = False + for r in report["repos"]: + commits = r["commits"] + if not commits: + continue + any_commit = True + rname = os.path.basename(r["path"].rstrip("/")) or r["path"] + lines.append(f"### {rname}") + for c in commits: + lines.append(f"- {c['date']} `{c['hash'][:7]}` {c['subject']}") + if not any_commit: + lines.append("- _no commits in range_") + lines.append("") + lines.append("## Today (planned)") + lines.append("- TODO") + lines.append("") + lines.append("## Blockers") + lines.append("- TODO") + lines.append("") + return "\n".join(lines) + + +def _build_report( + repos: Iterable[str], + since_iso: str, + author: str | None, + until_iso: str | None = None, +) -> dict: + repo_reports: list[dict] = [] + for r in repos: + repo_reports.append({ + "path": r, + "commits": _collect_commits(r, since_iso, author, until_iso), + }) + return { + "since": since_iso, + "until": until_iso, + "author": author or "self", + "repos": repo_reports, + } + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="Generate standup notes from git activity.") + p.add_argument("--repos", default=None, help="Comma-separated repo paths (default: cwd).") + p.add_argument("--since", default=None, help="ISO date or 'yesterday' (default: last business day).") + p.add_argument("--until", default=None, + help="ISO date upper-bound (default: today, exclusive). Use 'none' to drop the bound.") + p.add_argument("--author", default=None, help="Email to filter by, or 'all' (default: self).") + p.add_argument("--format", choices=["json", "md"], default="json") + args = p.parse_args(argv) + + cwd = os.getcwd() + if args.repos: + repos = [os.path.abspath(r.strip()) for r in args.repos.split(",") if r.strip()] + else: + repos = [cwd] + + since_iso = _parse_since(args.since) + if args.until is None: + # default upper bound: today at 00:00, so "yesterday" never bleeds into today + until_iso: str | None = dt.date.today().isoformat() + elif args.until.lower() == "none": + until_iso = None + else: + until_iso = args.until + + author = args.author or _git_user_email(repos[0]) or None + if args.author == "all": + author = "all" + + report = _build_report(repos, since_iso, author, until_iso) + if args.format == "md": + sys.stdout.write(_render_markdown(report)) + else: + json.dump(report, sys.stdout, indent=2) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/test-gap/.claude-plugin/plugin.json b/plugins/test-gap/.claude-plugin/plugin.json new file mode 100644 index 0000000..596c07e --- /dev/null +++ b/plugins/test-gap/.claude-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "test-gap", + "version": "0.1.0", + "description": "/test-gap surfaces lines added or changed in the current git diff that are not covered by tests.", + "author": "pluginpool" +} diff --git a/plugins/test-gap/LICENSE b/plugins/test-gap/LICENSE new file mode 100644 index 0000000..266f59c --- /dev/null +++ b/plugins/test-gap/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 pluginpool contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/test-gap/README.md b/plugins/test-gap/README.md new file mode 100644 index 0000000..605bc16 --- /dev/null +++ b/plugins/test-gap/README.md @@ -0,0 +1,99 @@ +![hero](./assets/hero.svg) + +# test-gap + +**Surface the lines you just changed that have zero test coverage — before the PR review does.** + +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE) +[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/) +[![Claude Code Plugin](https://img.shields.io/badge/claude--code-plugin-7c3aed.svg)](https://docs.claude.com/en/docs/claude-code/overview) +[![Tests: 7 passing](https://img.shields.io/badge/tests-7%20passing-success.svg)](./tests) + +> **TL;DR:** `/test-gap` → markdown table of files where your diff added lines that nothing tests yet. + +## Why this exists + +Repo-wide coverage percentages are useless on a PR — what matters is whether the *lines you just touched* are covered. CI rarely tells you that, and "100% line coverage" isn't the target anyway. `test-gap` intersects your branch's diff with an existing coverage report and shows you the gap, sorted worst-first. + +## Install (Claude Code) + +```sh +git clone https://github.com/mturac/pluginpool-test-gap ~/.claude/plugins/test-gap +``` + +Restart Claude Code; the slash command `/test-gap` appears. + +## Quick start + +```sh +# After running your test suite with coverage: +python3 -m pytest --cov --cov-report=xml # produces coverage.xml +/test-gap # in Claude Code +``` + +Or directly: + +```sh +python3 scripts/gap.py --format md +python3 scripts/gap.py --base develop --report build/lcov.info +``` + +## Flags + +| Flag | Default | Description | +|---|---|---| +| `--base` | `main` (falls back to `master`) | Diff against this branch | +| `--report` | auto-detect | Path to `coverage.xml`, `lcov.info`, or `coverage.json` | +| `--format` | `json` | `json` or `md` | + +## Supported coverage formats + +| Format | Where it comes from | +|---|---| +| Cobertura `coverage.xml` | `pytest-cov`, `pytest --cov-report=xml`, `coverage xml` | +| `lcov.info` | `jest --coverage`, `c8`, Istanbul | +| `coverage.json` | `coverage json` | + +## Example output (markdown) + +``` +| file | changed lines | uncovered | uncovered lines | +|---|---|---|---| +| src/auth/refresh.py | 42 | 11 | 81-83, 102, 117-122 | +| src/util/parse.py | 15 | 7 | 22-28 | +``` + +## How it works + +1. Reads `git diff --unified=0 ..HEAD` and collects the added line numbers per file. +2. Parses the coverage report into a `{file: {covered_lines}}` map. +3. Intersects: any added line not in the covered set is reported as a gap. +4. Sorts the table by uncovered-count descending so the worst offenders surface first. + +## Limitations + +- Coverage is taken at face value — it doesn't know about branch coverage or test quality. +- Cobertura paths must match diff paths; configure your runner to emit project-relative paths. +- Empty diffs and no-test repos are handled gracefully (no output). + +## Examples + +Step-by-step walkthroughs with real input fixtures and the helper's actual output live in [`examples/`](./examples/README.md). Three or four scenarios per plugin — from the happy path to the edge cases the test suite guards. + +## Part of the pluginpool family + +Ten focused Claude Code plugins for everyday productivity: +[commit-narrator](https://github.com/mturac/pluginpool-commit-narrator) · +[pr-storyteller](https://github.com/mturac/pluginpool-pr-storyteller) · +[test-gap](https://github.com/mturac/pluginpool-test-gap) · +[deps-doctor](https://github.com/mturac/pluginpool-deps-doctor) · +[env-lint](https://github.com/mturac/pluginpool-env-lint) · +[secret-guard](https://github.com/mturac/pluginpool-secret-guard) · +[standup-gen](https://github.com/mturac/pluginpool-standup-gen) · +[todo-harvest](https://github.com/mturac/pluginpool-todo-harvest) · +[flaky-detector](https://github.com/mturac/pluginpool-flaky-detector) · +[changelog-forge](https://github.com/mturac/pluginpool-changelog-forge) + +## License + +MIT — see [`LICENSE`](./LICENSE). Contributions welcome. diff --git a/plugins/test-gap/commands/test-gap.md b/plugins/test-gap/commands/test-gap.md new file mode 100644 index 0000000..2bea205 --- /dev/null +++ b/plugins/test-gap/commands/test-gap.md @@ -0,0 +1,16 @@ +--- +description: Surface changed git diff lines that are not covered by tests +allowed-tools: Bash +--- + +Run `python3 scripts/gap.py "$@"` from the test-gap plugin root. + +Purpose: compare the current git diff against the selected base branch, parse the selected or auto-detected coverage report, and report changed lines that are not covered by tests. + +Inputs: optional `--base`, `--report`, and `--format` arguments supplied by the user. + +Boundaries: do not edit files, do not install packages, do not use the network, and do not hide failures. + +Output contract: return either the helper output or a concise failure summary with command, exit status, and next step. + +Verification contract: accept success only when the helper exits 0 and prints JSON or markdown matching the requested format. diff --git a/plugins/test-gap/scripts/gap.py b/plugins/test-gap/scripts/gap.py new file mode 100755 index 0000000..daa9cb8 --- /dev/null +++ b/plugins/test-gap/scripts/gap.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""Find changed git diff lines that coverage reports mark as uncovered.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class CoverageFile: + covered: set[int] + uncovered: set[int] + pct: float + + +HUNK_RE = re.compile(r"@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@") + + +def run(cmd: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + +def default_base() -> str: + for branch in ("main", "master"): + result = run(["git", "rev-parse", "--verify", branch]) + if result.returncode == 0: + return branch + return "main" + + +def git_diff(base: str) -> str: + result = run(["git", "diff", "--unified=0", f"{base}..HEAD"]) + if result.returncode != 0: + return "" + return result.stdout + + +def parse_diff(diff_text: str) -> dict[str, list[int]]: + changed: dict[str, set[int]] = {} + current_file: str | None = None + current_line: int | None = None + + for line in diff_text.splitlines(): + if line.startswith("+++ "): + path = line[4:].strip() + current_file = None if path == "/dev/null" else path.removeprefix("b/") + if current_file: + changed.setdefault(current_file, set()) + continue + + match = HUNK_RE.match(line) + if match: + current_line = int(match.group(1)) + continue + + if current_file is None or current_line is None: + continue + if line.startswith("+") and not line.startswith("+++"): + changed[current_file].add(current_line) + current_line += 1 + elif line.startswith("-") and not line.startswith("---"): + continue + elif line.startswith(" "): + current_line += 1 + + return {path: sorted(lines) for path, lines in changed.items()} + + +def pct(covered: set[int], uncovered: set[int]) -> float: + total = len(covered | uncovered) + return round((len(covered) / total) * 100, 2) if total else 100.0 + + +def parse_lcov(path: Path) -> dict[str, CoverageFile]: + files: dict[str, CoverageFile] = {} + current: str | None = None + covered: set[int] = set() + uncovered: set[int] = set() + + def flush() -> None: + if current is not None: + files[normalize(current)] = CoverageFile(set(covered), set(uncovered), pct(covered, uncovered)) + + for raw in path.read_text(encoding="utf-8").splitlines(): + if raw.startswith("SF:"): + flush() + current = raw[3:] + covered.clear() + uncovered.clear() + elif raw.startswith("DA:") and current is not None: + line_no, hits, *_ = raw[3:].split(",") + target = covered if int(float(hits)) > 0 else uncovered + target.add(int(line_no)) + elif raw == "end_of_record": + flush() + current = None + covered.clear() + uncovered.clear() + flush() + return files + + +def parse_cobertura(path: Path) -> dict[str, CoverageFile]: + root = ET.parse(path).getroot() + files: dict[str, CoverageFile] = {} + for class_node in root.findall(".//class"): + filename = class_node.get("filename") + if not filename: + continue + covered: set[int] = set() + uncovered: set[int] = set() + for line in class_node.findall(".//line"): + number = line.get("number") + hits = line.get("hits", "0") + if number is None: + continue + target = covered if int(float(hits)) > 0 else uncovered + target.add(int(number)) + files[normalize(filename)] = CoverageFile(covered, uncovered, pct(covered, uncovered)) + return files + + +def parse_coverage_json(path: Path) -> dict[str, CoverageFile]: + payload = json.loads(path.read_text(encoding="utf-8")) + files: dict[str, CoverageFile] = {} + for filename, data in payload.get("files", {}).items(): + covered = set(map(int, data.get("executed_lines", []))) + uncovered = set(map(int, data.get("missing_lines", []))) + summary = data.get("summary", {}) + percent = summary.get("percent_covered") + files[normalize(filename)] = CoverageFile(covered, uncovered, round(float(percent), 2) if percent is not None else pct(covered, uncovered)) + return files + + +def normalize(path: str) -> str: + return os.path.normpath(path).replace("\\", "/") + + +def detect_report(explicit: str | None) -> Path | None: + if explicit: + candidate = Path(explicit) + return candidate if candidate.exists() else None + for name in ("coverage.xml", "lcov.info", "coverage.json"): + candidate = Path(name) + if candidate.exists(): + return candidate + return None + + +def parse_report(path: Path | None) -> dict[str, CoverageFile]: + if path is None: + return {} + if path.name == "lcov.info": + return parse_lcov(path) + if path.suffix == ".xml": + return parse_cobertura(path) + if path.suffix == ".json": + return parse_coverage_json(path) + raise SystemExit(f"Unsupported coverage report: {path}") + + +_UNKNOWN_PCT = -1.0 # sentinel: no coverage info for this file + + +def coverage_for(path: str, coverage: dict[str, CoverageFile]) -> CoverageFile: + norm = normalize(path) + if norm in coverage: + return coverage[norm] + for candidate, data in coverage.items(): + if candidate.endswith("/" + norm) or norm.endswith("/" + candidate): + return data + return CoverageFile(set(), set(), _UNKNOWN_PCT) + + +def build_result(changed: dict[str, list[int]], coverage: dict[str, CoverageFile]) -> dict[str, list[dict[str, object]]]: + have_coverage = bool(coverage) + files: list[dict[str, object]] = [] + for path in sorted(changed): + data = coverage_for(path, coverage) + if data.pct == _UNKNOWN_PCT: + uncovered = list(changed[path]) # unknown → treat every changed line as a gap + cov_pct: object = None + else: + uncovered = [line for line in changed[path] if line in data.uncovered] + cov_pct = data.pct + files.append({ + "path": path, + "changed_lines": changed[path], + "uncovered_lines": uncovered, + "coverage_pct": cov_pct, + }) + return {"files": files, "coverage_loaded": have_coverage} + + +def markdown(result: dict[str, list[dict[str, object]]]) -> str: + files = sorted(result["files"], key=lambda item: len(item["uncovered_lines"]), reverse=True) + if not files: + return "No changed lines found." + rows = ["| File | Changed | Uncovered | Coverage |", "| --- | ---: | ---: | ---: |"] + for item in files: + cov = item["coverage_pct"] + cov_cell = "unknown" if cov is None else f"{cov:.2f}%" + rows.append( + f"| {item['path']} | {len(item['changed_lines'])} | " + f"{','.join(map(str, item['uncovered_lines'])) or '-'} | {cov_cell} |" + ) + if not result.get("coverage_loaded", True): + rows.append("") + rows.append("> **No coverage report found** — every changed line is reported as a gap. " + "Run your test suite with coverage (e.g. `pytest --cov --cov-report=xml`) first.") + return "\n".join(rows) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Report changed diff lines not covered by tests.") + parser.add_argument("--base", help="Base branch for git diff; defaults to main, then master.") + parser.add_argument("--report", help="Coverage report path.") + parser.add_argument("--format", choices=("json", "md"), default="json") + args = parser.parse_args(argv) + + base = args.base or default_base() + if args.report and not Path(args.report).exists(): + sys.stderr.write(f"error: coverage report not found at {args.report}\n") + return 2 + changed = parse_diff(git_diff(base)) + result = build_result(changed, parse_report(detect_report(args.report))) + if not result.get("coverage_loaded") and changed: + sys.stderr.write( + "warning: no coverage report detected (looked for coverage.xml, lcov.info, " + "coverage.json) — treating every changed line as a gap.\n" + ) + print(markdown(result) if args.format == "md" else json.dumps(result, indent=2, sort_keys=True)) + return 1 if changed and not result.get("coverage_loaded") else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/todo-harvest/.claude-plugin/plugin.json b/plugins/todo-harvest/.claude-plugin/plugin.json new file mode 100644 index 0000000..1da4518 --- /dev/null +++ b/plugins/todo-harvest/.claude-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "todo-harvest", + "version": "0.1.0", + "description": "Find TODO/FIXME/HACK comments with git-blame author and age.", + "author": "pluginpool" +} diff --git a/plugins/todo-harvest/LICENSE b/plugins/todo-harvest/LICENSE new file mode 100644 index 0000000..266f59c --- /dev/null +++ b/plugins/todo-harvest/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 pluginpool contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/todo-harvest/README.md b/plugins/todo-harvest/README.md new file mode 100644 index 0000000..6613b13 --- /dev/null +++ b/plugins/todo-harvest/README.md @@ -0,0 +1,93 @@ +![hero](./assets/hero.svg) + +# todo-harvest + +**Find the oldest, most-forgotten TODOs and put their authors on blast (constructively).** + +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE) +[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/) +[![Claude Code Plugin](https://img.shields.io/badge/claude--code-plugin-7c3aed.svg)](https://docs.claude.com/en/docs/claude-code/overview) +[![Tests: 9 passing](https://img.shields.io/badge/tests-9%20passing-success.svg)](./tests) + +> **TL;DR:** `/todo-harvest` → markdown table of every `TODO/FIXME/HACK` with `git blame` author + age in days, sorted oldest-first. + +## Why this exists + +`TODO` comments are how engineers say "future me will deal with this." Future me never does. Worse, future-me-the-new-hire doesn't even know who wrote them or whether they still apply. `todo-harvest` runs `git blame` for each match so you can triage: 800-day-old TODOs from a dev who left the company three years ago are deletable; 12-day-old ones from this sprint are real work. + +## Install (Claude Code) + +```sh +git clone https://github.com/mturac/pluginpool-todo-harvest ~/.claude/plugins/todo-harvest +``` + +Restart Claude Code; the slash command `/todo-harvest` appears. + +## Quick start + +```sh +/todo-harvest +``` + +Or directly: + +```sh +python3 scripts/harvest.py --format md +python3 scripts/harvest.py --min-age 180 --format md # only stuff older than 6 months +python3 scripts/harvest.py --markers TODO,FIXME --format json +``` + +## Flags + +| Flag | Default | Description | +|---|---|---| +| `--repo` | cwd | Repo path | +| `--markers` | `TODO,FIXME,HACK,XXX,NOTE` | Comma-separated marker words | +| `--min-age` | `0` | Only show markers ≥ N days old | +| `--format` | `json` | `json` or `md` | + +## Example output (markdown) + +``` +| age (d) | marker | file:line | author | note | +|---|---|---|---|---| +| 1247 | HACK | src/legacy/login.py:42 | Alice (left in 2022) | special-case the demo-account UA | +| 412 | FIXME | src/db.py:81 | Bob | this `n+1` lookup needs a join | +| 88 | TODO | src/auth.py:17 | Cara | wire to the new OAuth2 path | +``` + +## How it works + +1. Uses `git ls-files` so untracked + `.gitignore`d files are skipped. +2. Detects worktrees correctly (`.git` can be a file pointer, not a directory). +3. Skips binary files (null byte in first 1 KB). +4. Matches markers as whole words: `TODO`, `TODO:`, `# TODO …`. +5. Runs `git blame --porcelain -L N,N -- ` per match for the original author + author-time. + +## Limitations + +- One `git blame` call per match means it's slow on huge repos — use `--min-age` or `--markers` to narrow. +- "Age" is the age of the *current* line. Renames and reformats reset the clock. +- Unicode-safe (decodes with `errors="replace"`). + +## Examples + +Step-by-step walkthroughs with real input fixtures and the helper's actual output live in [`examples/`](./examples/README.md). Three or four scenarios per plugin — from the happy path to the edge cases the test suite guards. + +## Part of the pluginpool family + +Ten focused Claude Code plugins for everyday productivity: +[commit-narrator](https://github.com/mturac/pluginpool-commit-narrator) · +[pr-storyteller](https://github.com/mturac/pluginpool-pr-storyteller) · +[test-gap](https://github.com/mturac/pluginpool-test-gap) · +[deps-doctor](https://github.com/mturac/pluginpool-deps-doctor) · +[env-lint](https://github.com/mturac/pluginpool-env-lint) · +[secret-guard](https://github.com/mturac/pluginpool-secret-guard) · +[standup-gen](https://github.com/mturac/pluginpool-standup-gen) · +[todo-harvest](https://github.com/mturac/pluginpool-todo-harvest) · +[flaky-detector](https://github.com/mturac/pluginpool-flaky-detector) · +[changelog-forge](https://github.com/mturac/pluginpool-changelog-forge) + +## License + +MIT — see [`LICENSE`](./LICENSE). Contributions welcome. diff --git a/plugins/todo-harvest/commands/todo-harvest.md b/plugins/todo-harvest/commands/todo-harvest.md new file mode 100644 index 0000000..d99d559 --- /dev/null +++ b/plugins/todo-harvest/commands/todo-harvest.md @@ -0,0 +1,16 @@ +--- +description: List TODO/FIXME/HACK comments with author + age in days +allowed-tools: Bash +--- + +Role: act as a debt-triage assistant. Surface the oldest, highest-cost TODOs first. + +Run the helper: + +```bash +python3 scripts/harvest.py --format md +``` + +Useful flags: `--markers TODO,FIXME,HACK`, `--min-age 90`, `--format json`. + +The helper uses `git ls-files` (respecting .gitignore) and runs `git blame` per match for author + age. Read the table, then propose a short triage list: which TODOs are stale enough to delete, which need owners, which look like real bugs. Be specific — quote the file and line. diff --git a/plugins/todo-harvest/scripts/harvest.py b/plugins/todo-harvest/scripts/harvest.py new file mode 100755 index 0000000..8d7940a --- /dev/null +++ b/plugins/todo-harvest/scripts/harvest.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Harvest TODO/FIXME/HACK markers from a git repo with author + age.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import re +import subprocess +import sys +from typing import Iterable + + +DEFAULT_MARKERS = ("TODO", "FIXME", "HACK", "XXX", "NOTE") + + +def _run(args: list[str], cwd: str) -> str: + res = subprocess.run(args, cwd=cwd, capture_output=True, text=True, check=False) + return res.stdout + + +def _is_binary(path: str) -> bool: + try: + with open(path, "rb") as f: + chunk = f.read(1024) + return b"\x00" in chunk + except OSError: + return True + + +def _list_files(repo: str) -> list[str]: + out = _run(["git", "ls-files"], repo) + return [line for line in out.splitlines() if line] + + +def _marker_pattern(markers: Iterable[str]) -> re.Pattern: + escaped = [re.escape(m) for m in markers] + return re.compile(r"\b(" + "|".join(escaped) + r")\b[:\s](.*)$") + + +def _blame(repo: str, path: str, line: int) -> dict: + out = _run( + ["git", "blame", "--porcelain", "-L", f"{line},{line}", "--", path], + repo, + ) + author = "" + epoch = 0 + commit = "" + for ln in out.splitlines(): + if not commit and re.match(r"^[0-9a-f]{7,40} ", ln): + commit = ln.split(" ", 1)[0] + if ln.startswith("author "): + author = ln[len("author "):].strip() + elif ln.startswith("author-time "): + try: + epoch = int(ln[len("author-time "):].strip()) + except ValueError: + epoch = 0 + return {"author": author, "commit": commit, "author_time": epoch} + + +def _age_days(epoch: int, now: dt.datetime | None = None) -> int: + if epoch <= 0: + return -1 + now = now or dt.datetime.now(tz=dt.timezone.utc) + delta = now - dt.datetime.fromtimestamp(epoch, tz=dt.timezone.utc) + return max(0, delta.days) + + +def _is_git_repo(repo: str) -> bool: + """A directory is a git repo if `.git` is a dir (normal) OR a file (worktree). + Fall back to `git rev-parse --is-inside-work-tree` for edge cases like + GIT_DIR overrides or bare checkouts.""" + git_path = os.path.join(repo, ".git") + if os.path.exists(git_path): # file (worktree pointer) or directory + return True + res = subprocess.run( + ["git", "-C", repo, "rev-parse", "--is-inside-work-tree"], + capture_output=True, text=True, check=False, + ) + return res.returncode == 0 and res.stdout.strip() == "true" + + +def harvest(repo: str, markers: tuple[str, ...] = DEFAULT_MARKERS, min_age: int = 0) -> list[dict]: + if not _is_git_repo(repo): + return [] + pat = _marker_pattern(markers) + hits: list[dict] = [] + for rel in _list_files(repo): + abs_path = os.path.join(repo, rel) + if not os.path.isfile(abs_path) or _is_binary(abs_path): + continue + try: + with open(abs_path, "r", encoding="utf-8", errors="replace") as f: + for n, line in enumerate(f, 1): + m = pat.search(line) + if not m: + continue + marker, text = m.group(1), m.group(2).strip() + blame = _blame(repo, rel, n) + age = _age_days(blame["author_time"]) + if age >= 0 and age < min_age: + continue + hits.append( + { + "path": rel, + "line": n, + "marker": marker, + "text": text, + "author": blame["author"], + "age_days": age, + "commit": blame["commit"], + } + ) + except OSError: + continue + hits.sort(key=lambda h: (-h["age_days"], h["path"], h["line"])) + return hits + + +def _render_markdown(hits: list[dict]) -> str: + if not hits: + return "_No matching markers._\n" + out = ["| age (d) | marker | file:line | author | note |", "|---|---|---|---|---|"] + for h in hits: + note = h["text"].replace("|", "\\|") + out.append( + f"| {h['age_days']} | {h['marker']} | {h['path']}:{h['line']} | {h['author']} | {note} |" + ) + return "\n".join(out) + "\n" + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="Harvest TODO/FIXME/HACK markers with git blame.") + p.add_argument("--repo", default=os.getcwd()) + p.add_argument("--markers", default=",".join(DEFAULT_MARKERS), + help="Comma-separated marker words.") + p.add_argument("--min-age", type=int, default=0) + p.add_argument("--format", choices=["json", "md"], default="json") + args = p.parse_args(argv) + + markers = tuple(m.strip() for m in args.markers.split(",") if m.strip()) + hits = harvest(args.repo, markers, args.min_age) + if args.format == "md": + sys.stdout.write(_render_markdown(hits)) + else: + json.dump(hits, sys.stdout, indent=2) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())