diff --git a/Makefile b/Makefile index fac484e..4ec1cc1 100644 --- a/Makefile +++ b/Makefile @@ -20,13 +20,18 @@ lint-fix: ## Apply skillsaw autofixes docs: ## Generate plugin/skill documentation to docs/index.html uvx skillsaw==$(SKILLSAW_VERSION) docs --format html -o docs/index.html --title "stbenjam's skills" +.PHONY: plugin-table +plugin-table: ## Generate the README plugin table from the marketplace catalogs + @python3 scripts/generate_plugin_table.py + .PHONY: sync-skills sync-skills: ## Refresh root skills/ symlinks from plugin skills @python3 scripts/sync_skills.py .PHONY: update -update: ## Regenerate documentation and refresh root skill symlinks +update: ## Regenerate documentation, the README plugin table, and root skill symlinks @$(MAKE) docs + @$(MAKE) plugin-table @$(MAKE) sync-skills .PHONY: new-plugin diff --git a/README.md b/README.md index fd33106..42e3c20 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,17 @@ Shared skills and plugins for Claude Code and Codex by stbenjam. ## Plugins -- **[books](plugins/books/)**: Book library workflows -- **[loops](plugins/loops/)**: Autonomous workflows that shepherd work to completion, such as driving a pull request to a mergeable state -- **[openclaw](plugins/openclaw/)**: A grab-bag of miscellaneous OpenClaw skills (pollen forecasts, Orangetheory lookups, narrated video reels) with no unifying theme -- **[reviews](plugins/reviews/)**: Multi-agent panel code review with specialist reviewers and runtime reproducers -- **[rules](plugins/rules/)**: Context-specific rules for safe Git operations and rigorous test-failure investigation -- **[steering](plugins/steering/)**: Compact skills for changing direction, explaining decisions, asking for clarity, tightening prose, executing decisively, and raising frontend quality + + +| Plugin | Description | +| --- | --- | +| [books](plugins/books/) | Search and analyze a Calibre library or Goodreads export | +| [loops](plugins/loops/) | Autonomous loops that shepherd work to completion, such as driving a PR to a mergeable state | +| [openclaw](plugins/openclaw/) | A grab-bag of miscellaneous OpenClaw skills (pollen forecasts, Orangetheory lookups, narrated video reels) with no unifying theme. | +| [reviews](plugins/reviews/) | Multi-agent panel code review with specialist reviewers and runtime reproducers | +| [rules](plugins/rules/) | Context-specific rules for safe Git operations and rigorous test-failure investigation. | +| [steering](plugins/steering/) | Compact conversation-steering skills for changing direction, explaining decisions, asking for clarity, tightening prose, executing decisively, and raising frontend quality. | + ## Installation @@ -73,17 +78,19 @@ Run `make update` after adding or removing plugin skills to refresh links and do ## Development -Lint plugins and skills with [skillsaw](https://skillsaw.org/) in strict mode: +Run `make update` to regenerate the documentation, this plugin table, and the +root `skills/` symlinks. Then lint plugins and skills with +[skillsaw](https://skillsaw.org/) in strict mode: ```bash +make update make lint ``` -Apply autofixes or regenerate the static catalog documentation with: +Apply autofixes with: ```bash make lint-fix -make update ``` ## License diff --git a/scripts/generate_plugin_table.py b/scripts/generate_plugin_table.py new file mode 100644 index 0000000..0a5baff --- /dev/null +++ b/scripts/generate_plugin_table.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Generate the README plugin table from the marketplace catalogs.""" + +from __future__ import annotations + +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +README = ROOT / "README.md" +CLAUDE_MARKETPLACE = ROOT / ".claude-plugin/marketplace.json" +CODEX_MARKETPLACE = ROOT / ".agents/plugins/marketplace.json" +START_MARKER = "" +END_MARKER = "" + + +def load_json(path: Path) -> dict: + with path.open() as file: + data = json.load(file) + if not isinstance(data, dict): + raise ValueError(f"expected an object in {path}") + return data + + +def load_claude_plugins() -> dict[str, tuple[str, str]]: + data = load_json(CLAUDE_MARKETPLACE) + plugins = data.get("plugins") + if not isinstance(plugins, list): + raise ValueError(f"expected a plugins list in {CLAUDE_MARKETPLACE}") + + discovered: dict[str, tuple[str, str]] = {} + for plugin in plugins: + if not isinstance(plugin, dict): + raise ValueError(f"invalid plugin entry in {CLAUDE_MARKETPLACE}") + name = plugin.get("name") + source = plugin.get("source") + description = plugin.get("description") + if not all(isinstance(value, str) for value in (name, source, description)): + raise ValueError(f"plugin entries need name, source, and description: {plugin}") + if not source.startswith("./"): + raise ValueError(f"plugin source must be relative: {source}") + if name in discovered: + raise ValueError(f"duplicate plugin name: {name}") + plugin_path = ROOT / source[2:] + if not plugin_path.is_dir(): + raise ValueError(f"plugin source does not exist: {source}") + discovered[name] = (source[2:], description) + return discovered + + +def load_codex_plugin_names() -> set[str]: + data = load_json(CODEX_MARKETPLACE) + plugins = data.get("plugins") + if not isinstance(plugins, list): + raise ValueError(f"expected a plugins list in {CODEX_MARKETPLACE}") + names: set[str] = set() + for plugin in plugins: + if not isinstance(plugin, dict) or not isinstance(plugin.get("name"), str): + raise ValueError(f"invalid plugin entry in {CODEX_MARKETPLACE}") + names.add(plugin["name"]) + return names + + +def markdown_table(plugins: dict[str, tuple[str, str]]) -> str: + lines = [START_MARKER, "", "| Plugin | Description |", "| --- | --- |"] + for name, (source, description) in sorted(plugins.items()): + escaped_description = " ".join(description.split()).replace("|", "\\|") + lines.append(f"| [{name}]({source}/) | {escaped_description} |") + lines.append(END_MARKER) + return "\n".join(lines) + + +def update_readme(table: str) -> None: + contents = README.read_text() + if contents.count(START_MARKER) != 1 or contents.count(END_MARKER) != 1: + raise ValueError("README must contain exactly one plugin table marker pair") + start = contents.index(START_MARKER) + end = contents.index(END_MARKER, start) + len(END_MARKER) + updated = contents[:start] + table + contents[end:] + if updated != contents: + README.write_text(updated) + print(f"updated {README.relative_to(ROOT)}") + else: + print(f"{README.relative_to(ROOT)} is up to date") + + +def main() -> None: + plugins = load_claude_plugins() + codex_names = load_codex_plugin_names() + if set(plugins) != codex_names: + raise ValueError("Claude and Codex marketplace plugin lists differ") + update_readme(markdown_table(plugins)) + + +if __name__ == "__main__": + main()