From f63201c06614f29b33fc71d9360ef20b5ef78d83 Mon Sep 17 00:00:00 2001 From: neilgfoster <1370457+neilgfoster@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:48:55 +0100 Subject: [PATCH] refactor: adopt tredl two-tier plugin layout Separate the build/distribution repo (root) from the shippable plugin payload (plugin/), mirroring tredl's layout. - Move plugin.json, skills/, src/, hooks/ under plugin/ (git mv, history kept). ${CLAUDE_PLUGIN_ROOT} already resolves to the plugin root, so internal command paths are unchanged. - Add root .claude-plugin/marketplace.json (source: ./plugin) so the template is actually installable via the marketplace flow. - Add full dev scaffolding: pyproject.toml (ruff+pytest), .github CI/release workflows + PR template, tests/ (manifest resolution + client smoke), CHANGELOG, CONTRIBUTING. - Update README/CLAUDE.md for the new paths and verify-before-done. - Dedupe the duplicate speckit/tredl .gitignore block; add dev caches. Verified: ruff check + format clean, pytest 3 passed. Co-Authored-By: Claude Opus 4.8 --- .claude-plugin/marketplace.json | 14 ++++++ .github/pull_request_template.md | 26 +++++++++++ .github/workflows/ci.yml | 23 ++++++++++ .github/workflows/release.yml | 44 +++++++++++++++++++ .gitignore | 16 ++----- CHANGELOG.md | 16 +++++++ CLAUDE.md | 30 ++++++++++--- CONTRIBUTING.md | 43 ++++++++++++++++++ README.md | 38 ++++++++++++---- .../.claude-plugin}/plugin.json | 0 {hooks => plugin/hooks}/hooks.json | 0 .../skills}/example-subject-verb/SKILL.md | 0 {src => plugin/src}/example/__init__.py | 0 {src => plugin/src}/example/client.py | 16 +++++-- pyproject.toml | 17 +++++++ tests/test_client_smoke.py | 28 ++++++++++++ tests/test_plugin_manifest.py | 35 +++++++++++++++ 17 files changed, 315 insertions(+), 31 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md rename {.claude-plugin => plugin/.claude-plugin}/plugin.json (100%) rename {hooks => plugin/hooks}/hooks.json (100%) rename {skills => plugin/skills}/example-subject-verb/SKILL.md (100%) rename {src => plugin/src}/example/__init__.py (100%) rename {src => plugin/src}/example/client.py (92%) create mode 100644 pyproject.toml create mode 100644 tests/test_client_smoke.py create mode 100644 tests/test_plugin_manifest.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..8202348 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,14 @@ +{ + "name": "{{NAME}}", + "owner": { + "name": "Neil Foster", + "url": "https://github.com/neilgfoster" + }, + "plugins": [ + { + "name": "{{NAME}}", + "source": "./plugin", + "description": "{{DESCRIPTION}}" + } + ] +} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..9440dbb --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,26 @@ + + +## What and why + + + +## Conventions + +- [ ] Runtime stays **stdlib-only, zero-dependency, zero-backend** (`urllib`/`json`; ruff/pytest are + dev tooling only). +- [ ] No secrets/tokens in the repo — they live outside it in an XDG path (`0600`). +- [ ] Any new/changed skill follows `docs/AGENT-FRIENDLY.md` (description + CLI I/O are the contract). +- [ ] The template stays minimal — layout + exemplary patterns only, no speculative framework. + +## Verification + +```sh +ruff check . && ruff format --check . +python3 -m pytest -q +``` + +- [ ] `ruff check .` and `ruff format --check .` pass. +- [ ] `python3 -m pytest -q` passes. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f48a992 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dev tooling + run: pip install ruff pytest + - name: Lint + run: ruff check . + - name: Format check + run: ruff format --check . + - name: Test + run: python3 -m pytest -q diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e9bc81a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +name: release + +# Cut a GitHub Release from a vX.Y.Z tag. The version in +# plugin/.claude-plugin/plugin.json is the single source of truth; this workflow refuses to +# release if the tag and that version disagree, then publishes notes from CHANGELOG.md. + +on: + push: + tags: ["v*.*.*"] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Verify tag matches plugin.json version + run: | + tag="${GITHUB_REF_NAME#v}" + ver=$(python3 -c "import json; print(json.load(open('plugin/.claude-plugin/plugin.json'))['version'])") + if [ "$tag" != "$ver" ]; then + echo "tag $GITHUB_REF_NAME does not match plugin.json version $ver" >&2 + exit 1 + fi + + - name: Extract release notes from CHANGELOG + run: | + ver="${GITHUB_REF_NAME#v}" + awk -v v="$ver" ' + $0 ~ "^## \\[" v "\\]" {grab=1; next} + grab && /^## \[/ {exit} + grab {print} + ' CHANGELOG.md > RELEASE_NOTES.md + if [ ! -s RELEASE_NOTES.md ]; then + echo "No CHANGELOG section for $ver." > RELEASE_NOTES.md + fi + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes-file RELEASE_NOTES.md diff --git a/.gitignore b/.gitignore index 58afefb..765a44d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ __pycache__/ *.pyc .DS_Store +.pytest_cache/ +.ruff_cache/ +*.egg-info/ +.venv/ # Secrets/tokens NEVER live in the repo — they belong outside it (XDG, 0600). # These patterns are belt-and-braces only; the real guarantee is storing tokens @@ -9,18 +13,6 @@ __pycache__/ secrets/ .env -# --- Local spec-driven-development scaffolding (kept out of the repo) --- -# Spec Kit / SDD working artifacts stay local so the template's footprint stays minimal. -# Remove the relevant lines if you decide to commit these artifacts. -.tredl/ -.specify/ -specs/ -.claude/skills/speckit-*/ -.agents/skills/speckit-*/ -.github/agents/speckit* -.github/prompts/speckit* -# --- end --- - # --- tredl (solo trial) --- # tredl is installed transparently for one person: the Spec Kit and tredl artifacts below stay # local, so adopting tredl leaves (almost) no footprint on the team. When the value is proven, diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..31dc0a2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable changes to this plugin are recorded here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this plugin uses +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). Pre-1.0, breaking changes may land on a +minor bump and are called out explicitly. + +The `version` in `plugin/.claude-plugin/plugin.json` is the single source of truth; the release +workflow refuses to publish a tag that disagrees with it. + +## [Unreleased] + + diff --git a/CLAUDE.md b/CLAUDE.md index 6a4b639..854f992 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,21 +30,39 @@ Two situations you might be in: ## Where things are +**Two tiers.** The repo root builds/tests/distributes the plugin; everything under `plugin/` is the +shippable payload that installs. `${CLAUDE_PLUGIN_ROOT}` resolves to `plugin/`. + | Path | Purpose | |---|---| -| `.claude-plugin/plugin.json` | Plugin manifest (`{{NAME}}`/`{{DESCRIPTION}}` placeholders) | -| `skills/-/SKILL.md` | Agent-facing commands; one exemplary skill ships in the template | -| `src//client.py` | Stdlib kernel — importable AND runnable (`python3 -m .client …`) | +| `.claude-plugin/marketplace.json` | **Root** marketplace manifest; `source: ./plugin`, name must match the plugin's | +| `plugin/.claude-plugin/plugin.json` | Plugin manifest (`{{NAME}}`/`{{DESCRIPTION}}` placeholders) | +| `plugin/skills/-/SKILL.md` | Agent-facing commands; one exemplary skill ships in the template | +| `plugin/src//client.py` | Stdlib kernel — importable AND runnable (`python3 -m .client …`) | +| `plugin/hooks/hooks.json` | Optional PreToolUse example (delete if unused) | +| `pyproject.toml`, `tests/`, `.github/` | Dev tooling (ruff/pytest), manifest/kernel guards, CI+release | +| `CHANGELOG.md`, `CONTRIBUTING.md` | Keep-a-Changelog + contributor standards | | `docs/AGENT-FRIENDLY.md` | **Required reading** — the MCP/agent-tool design principles | | `README.md` | Human-facing overview + instantiation steps | ## How to instantiate a real plugin -1. Copy this repo; rename `src/example` → `src/` (update `APP` in `client.py`). -2. Rename `skills/example-subject-verb` → real `-` skills. -3. Fill `{{NAME}}` / `{{DESCRIPTION}}` in `.claude-plugin/plugin.json`. +1. Copy this repo; rename `plugin/src/example` → `plugin/src/` (update `APP` in `client.py` + and `known-first-party` in `pyproject.toml`). +2. Rename `plugin/skills/example-subject-verb` → real `-` skills. +3. Fill `{{NAME}}` / `{{DESCRIPTION}}` in `.claude-plugin/marketplace.json` and + `plugin/.claude-plugin/plugin.json` (marketplace entry name must equal the plugin name). 4. Build each skill against `docs/AGENT-FRIENDLY.md`. +## Verify before claiming done + +```sh +ruff check . && ruff format --check . +python3 -m pytest -q +``` + +`.github/workflows/ci.yml` runs exactly these; green CI is part of the Definition of Done. + For additional context about technologies to be used, project structure, shell commands, and other important information, read the current plan diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..21dd206 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,43 @@ +# Contributing + +This is a minimal plugin template, so contribution discipline is light. The few rules that earn their +keep: + +## Standards + +- **Python 3.11+, stdlib only at runtime.** `urllib` for HTTP, `json` for parsing. `ruff` and + `pytest` are dev tooling — they never ship in `plugin/`. No third-party runtime dependencies, no + backend/server process. The constraint is the point: portable, auditable, no install friction. +- **Two-tier layout.** `plugin/` is the shippable payload (its own `.claude-plugin/plugin.json` + + `skills/`, `src/`, `hooks/`); the repo root is the build/distribution repo + (`.claude-plugin/marketplace.json`, `pyproject.toml`, `tests/`, `.github/`, docs). Inside the + plugin, reference bundled files via `${CLAUDE_PLUGIN_ROOT}/...` — it resolves to `plugin/`. +- **Command naming: `-`.** Skills are named subject-then-verb (e.g. + `example-subject-verb`). Keep the convention. +- **Secrets live OUTSIDE the repo** — a user-level XDG path + (`${XDG_STATE_HOME:-~/.local/state}//`) with `0600` perms. Never committed, not even + encrypted. Do not add an in-repo secrets path or a git-crypt dependency. +- **Agent-friendly by design.** Every skill follows `docs/AGENT-FRIENDLY.md` — read it before adding + or changing a skill. The skill `description` and the CLI's inputs/outputs **are** the tool contract + an agent reads. +- **Keep the template minimal.** Layout + exemplary patterns only. Real abstractions are harvested + later from working plugins, never invented here speculatively. +- **Conventional Commit messages and PR titles.** + +## Verify before claiming done + +```sh +ruff check . && ruff format --check . +python3 -m pytest -q +``` + +CI (`.github/workflows/ci.yml`) runs exactly these; green CI is part of the Definition of Done. + +## Cutting a release + +SemVer, tag-driven. The version in `plugin/.claude-plugin/plugin.json` is the single source of truth. + +1. Bump `version` in `plugin/.claude-plugin/plugin.json`. +2. In `CHANGELOG.md`, move `## [Unreleased]` notes under a new `## [X.Y.Z] - YYYY-MM-DD` heading. +3. `ruff check . && ruff format --check . && python3 -m pytest -q`. +4. Merge, then `git tag vX.Y.Z && git push origin vX.Y.Z`. The release workflow publishes it. diff --git a/README.md b/README.md index 05b6a74..d4e2ca1 100644 --- a/README.md +++ b/README.md @@ -9,24 +9,44 @@ harvested later, from working plugins, not invented up front. ## Layout +Two tiers: the **repo root** builds/tests/distributes the plugin; **`plugin/`** is the shippable +payload that gets installed. + ``` -.claude-plugin/plugin.json # manifest ({{NAME}}/{{DESCRIPTION}} placeholders) -skills/-/SKILL.md # one exemplary skill demonstrating the conventions -src// # stdlib Python kernel (importable + runnable by skills) - client.py # urllib+json idiom + output-shaping patterns (stub) -hooks/hooks.json # optional PreToolUse example (delete if unused) -docs/AGENT-FRIENDLY.md # REQUIRED READING before adding a skill +.claude-plugin/marketplace.json # marketplace manifest — points at ./plugin ({{NAME}} placeholder) +plugin/ # THE PLUGIN PAYLOAD (this is what installs) + .claude-plugin/plugin.json # plugin manifest ({{NAME}}/{{DESCRIPTION}} placeholders) + skills/-/SKILL.md# one exemplary skill demonstrating the conventions + src// # stdlib Python kernel (importable + runnable by skills) + client.py # urllib+json idiom + output-shaping patterns (stub) + hooks/hooks.json # optional PreToolUse example (delete if unused) +pyproject.toml # ruff + pytest config (dev tooling only — never ships) +tests/ # guards the manifest install path + the runnable kernel +.github/workflows/{ci,release}.yml# lint+test on PR; tag-driven GitHub Release +CHANGELOG.md CONTRIBUTING.md # Keep-a-Changelog + contributor standards +docs/AGENT-FRIENDLY.md # REQUIRED READING before adding a skill ``` +Inside the plugin, reference bundled files via `${CLAUDE_PLUGIN_ROOT}/...` — it resolves to `plugin/`. + ## How to instantiate 1. Copy this repo to `~/source/neilgfoster/` and `git init`. -2. Rename `src/example` → `src/`; update `APP` in `client.py`. -3. Rename `skills/example-subject-verb` → real `-` skills. -4. Fill the `{{NAME}}` / `{{DESCRIPTION}}` placeholders in `.claude-plugin/plugin.json`. +2. Rename `plugin/src/example` → `plugin/src/`; update `APP` in `client.py` and + `known-first-party` in `pyproject.toml`. +3. Rename `plugin/skills/example-subject-verb` → real `-` skills. +4. Fill the `{{NAME}}` / `{{DESCRIPTION}}` placeholders in `.claude-plugin/marketplace.json` and + `plugin/.claude-plugin/plugin.json` (the marketplace entry name must equal the plugin name). 5. Build features spec-first (Spec-Driven Development): `/speckit-specify` → `clarify` → `plan` → `tasks` → `implement`. +## Verify before done + +```sh +ruff check . && ruff format --check . +python3 -m pytest -q +``` + ## Non-negotiable conventions - **stdlib only, zero dependencies, no backend.** `urllib` for HTTP, `json` for parsing. diff --git a/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json similarity index 100% rename from .claude-plugin/plugin.json rename to plugin/.claude-plugin/plugin.json diff --git a/hooks/hooks.json b/plugin/hooks/hooks.json similarity index 100% rename from hooks/hooks.json rename to plugin/hooks/hooks.json diff --git a/skills/example-subject-verb/SKILL.md b/plugin/skills/example-subject-verb/SKILL.md similarity index 100% rename from skills/example-subject-verb/SKILL.md rename to plugin/skills/example-subject-verb/SKILL.md diff --git a/src/example/__init__.py b/plugin/src/example/__init__.py similarity index 100% rename from src/example/__init__.py rename to plugin/src/example/__init__.py diff --git a/src/example/client.py b/plugin/src/example/client.py similarity index 92% rename from src/example/client.py rename to plugin/src/example/client.py index 83af7c5..40f2a22 100644 --- a/src/example/client.py +++ b/plugin/src/example/client.py @@ -12,6 +12,7 @@ Replace the stub `list` command with real operations. Keep the patterns. """ + import argparse import json import os @@ -40,10 +41,17 @@ "inputSchema": { "type": "object", "properties": { - "limit": {"type": "integer", "default": 25, - "description": "Max items to return (pagination)."}, - "format": {"type": "string", "enum": ["concise", "detailed"], "default": "concise", - "description": "concise = agent-legible summary; detailed = adds IDs."}, + "limit": { + "type": "integer", + "default": 25, + "description": "Max items to return (pagination).", + }, + "format": { + "type": "string", + "enum": ["concise", "detailed"], + "default": "concise", + "description": "concise = agent-legible summary; detailed = adds IDs.", + }, }, "required": [], }, diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fe48943 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +# Dev tooling only. The plugin payload under plugin/ stays stdlib-only at runtime; +# ruff and pytest never ship — they guard the build repo. Rename "example" to your +# plugin package when you instantiate (see known-first-party below). +[tool.ruff] +target-version = "py311" +line-length = 110 +src = ["plugin/src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B"] + +[tool.ruff.lint.isort] +known-first-party = ["example"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["plugin/src"] diff --git a/tests/test_client_smoke.py b/tests/test_client_smoke.py new file mode 100644 index 0000000..e9466fb --- /dev/null +++ b/tests/test_client_smoke.py @@ -0,0 +1,28 @@ +"""The bundled stdlib kernel must stay importable and runnable. + +`describe` is the discovery entry point (the zero-backend MCP tools/list equivalent); if it stops +emitting a valid catalog, agents can no longer enumerate the plugin's verbs. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +CLIENT = Path(__file__).resolve().parent.parent / "plugin" / "src" / "example" / "client.py" + + +def test_describe_emits_a_valid_tool_catalog(): + proc = subprocess.run( + [sys.executable, str(CLIENT), "describe"], + capture_output=True, + text=True, + check=False, + ) + assert proc.returncode == 0, f"describe failed: {proc.stderr}" + catalog = json.loads(proc.stdout) + assert isinstance(catalog.get("tools"), list) and catalog["tools"], "describe must list tools" + for tool in catalog["tools"]: + assert tool.get("name") and tool.get("description") and tool.get("inputSchema") diff --git a/tests/test_plugin_manifest.py b/tests/test_plugin_manifest.py new file mode 100644 index 0000000..c51aa13 --- /dev/null +++ b/tests/test_plugin_manifest.py @@ -0,0 +1,35 @@ +"""The marketplace manifest must resolve to a real, well-formed plugin. + +Guards the install path: `/plugin marketplace add neilgfoster/` then +`/plugin install @` only works if the root .claude-plugin/marketplace.json lists a +plugin whose `source` directory carries a valid .claude-plugin/plugin.json. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +MARKETPLACE = REPO_ROOT / ".claude-plugin" / "marketplace.json" + + +def test_marketplace_manifest_exists_and_is_valid_json(): + assert MARKETPLACE.is_file(), "root .claude-plugin/marketplace.json is required to install the plugin" + data = json.loads(MARKETPLACE.read_text(encoding="utf-8")) + assert data.get("name"), "marketplace needs a name" + assert isinstance(data.get("plugins"), list) and data["plugins"], "marketplace needs at least one plugin" + + +def test_each_plugin_source_resolves_to_a_plugin_manifest(): + data = json.loads(MARKETPLACE.read_text(encoding="utf-8")) + for entry in data["plugins"]: + source = entry.get("source") + assert source, f"plugin entry missing source: {entry}" + plugin_json = (REPO_ROOT / source / ".claude-plugin" / "plugin.json").resolve() + assert plugin_json.is_file(), f"source {source} has no .claude-plugin/plugin.json" + manifest = json.loads(plugin_json.read_text(encoding="utf-8")) + # The marketplace entry name must match the plugin's own name so `install @` works. + assert manifest.get("name") == entry.get("name"), ( + f"marketplace name {entry.get('name')!r} != plugin.json name {manifest.get('name')!r}" + )