Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 16 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<!-- BEGIN GENERATED PLUGIN TABLE -->

| 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. |
<!-- END GENERATED PLUGIN TABLE -->

## Installation

Expand Down Expand Up @@ -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
Expand Down
97 changes: 97 additions & 0 deletions scripts/generate_plugin_table.py
Original file line number Diff line number Diff line change
@@ -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 = "<!-- BEGIN GENERATED PLUGIN TABLE -->"
END_MARKER = "<!-- END GENERATED PLUGIN TABLE -->"


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