diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..a813fc5 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "agent-scripts", + "interface": { + "displayName": "Agent Scripts" + }, + "plugins": [ + { + "name": "agent-scripts", + "source": { + "source": "local", + "path": "./" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Developer Tools" + } + ] +} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..2d57da6 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "name": "agent-scripts", + "owner": { + "name": "shaug" + }, + "metadata": { + "description": "Install the complete agent-scripts skill suite as one dependency-closed plugin." + }, + "plugins": [ + { + "name": "agent-scripts", + "source": "./", + "description": "Implementation, code-review, PR-lifecycle, and changeset workflows that install and run together." + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..46deecd --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,17 @@ +{ + "name": "agent-scripts", + "version": "0.1.0", + "description": "Install the complete agent-scripts implementation, review, PR-lifecycle, and changeset skill suite.", + "author": { + "name": "shaug" + }, + "homepage": "https://github.com/shaug/agent-scripts", + "repository": "https://github.com/shaug/agent-scripts", + "keywords": [ + "agent-skills", + "code-review", + "github", + "pull-requests" + ], + "skills": "./skills/" +} diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json new file mode 100644 index 0000000..d7603fb --- /dev/null +++ b/.codex-plugin/plugin.json @@ -0,0 +1,35 @@ +{ + "name": "agent-scripts", + "version": "0.1.0", + "description": "Install the complete agent-scripts implementation, review, PR-lifecycle, and changeset skill suite.", + "author": { + "name": "shaug", + "url": "https://github.com/shaug" + }, + "homepage": "https://github.com/shaug/agent-scripts", + "repository": "https://github.com/shaug/agent-scripts", + "keywords": [ + "agent-skills", + "code-review", + "github", + "pull-requests" + ], + "skills": "./skills/", + "interface": { + "displayName": "Agent Scripts", + "shortDescription": "Implementation and review workflows", + "longDescription": "Install the dependency-closed suite for ticket and epic implementation, code review, PR babysitting, and changeset carving.", + "developerName": "shaug", + "category": "Developer Tools", + "capabilities": [ + "Read", + "Write" + ], + "websiteURL": "https://github.com/shaug/agent-scripts", + "defaultPrompt": [ + "Implement one ticket through review and merge.", + "Review this code change with the full suite.", + "Babysit this pull request until it is ready." + ] + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7452f0..506c3cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,10 +52,13 @@ jobs: fi done + - name: Validate plugin packaging + run: python scripts/validate_plugins.py + - name: Run unit tests run: | found=0 - for tests in skills/*/scripts/tests review-suite/scripts/tests; do + for tests in scripts/tests skills/*/scripts/tests review-suite/scripts/tests; do if [ -d "$tests" ]; then found=1 echo "Running tests in $tests" @@ -63,5 +66,5 @@ jobs: fi done if [ "$found" -eq 0 ]; then - echo "No tests found under skills/*/scripts/tests or review-suite/scripts/tests" + echo "No tests found under scripts/tests, skills/*/scripts/tests, or review-suite/scripts/tests" fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d92ca1..937eaff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,9 @@ summary: Chronological history of repository and skill changes. ## 2026-07-21 — Completed carve-changesets and integrated ticket publication +- feat: package the workflow suite as a plugin - fix: fail closed on invalid carved handoffs + (`dc4f5c1f3e33c25ad6258f7365506bd33255ed82`) - feat: integrate carved ticket publication (`54c67f7cd7ace3269eee4fe628f974b090a4d699`) - refactor: derive the eval action vocabulary from expectations diff --git a/README.md b/README.md index 51a6958..a09349f 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,55 @@ A personal monorepo for agent skills and supporting scripts. +## Installation + +Install the plugin when you need any composed workflow. It packages all eight +skills together so stable-name dependencies such as `implement-ticket`, +`review-code-change`, and `babysit-pr` are available in the same fresh session. + +### Claude Code + +Add the repository marketplace, install the plugin, and start a fresh session: + +```text +/plugin marketplace add shaug/agent-scripts +/plugin install agent-scripts@agent-scripts +``` + +The equivalent non-interactive commands are: + +```bash +claude plugin marketplace add shaug/agent-scripts +claude plugin install agent-scripts@agent-scripts +``` + +### Codex + +Codex CLI 0.117 or newer can add the repository marketplace and install the same +plugin: + +```bash +codex plugin marketplace add shaug/agent-scripts +codex plugin add agent-scripts@agent-scripts +``` + +The repository marketplace also appears in the Codex plugin browser. Start a +fresh Codex task after installation so the complete skill set is loaded. + +### Individual skills + +Standalone-capable skills can still be installed independently: + +```bash +npx skills add shaug/agent-scripts +``` + +Codex users can also invoke `$skill-installer` with this repository. Prefer the +plugin for `implement-epic`, `implement-ticket`, `babysit-pr`, +`review-code-change`, or `carve-changesets`: installing one of those entrypoints +alone intentionally leaves required stable-name dependencies unavailable and +causes the workflow to fail closed. + ## Repository Layout - `skills/` — skill folders, each containing a `SKILL.md` and bundled resources diff --git a/justfile b/justfile index 90663fd..adf3ce2 100644 --- a/justfile +++ b/justfile @@ -21,7 +21,7 @@ sync-contracts: echo "Synced $dest"; \ done -test: +test: test-plugins @found=0; \ for tests in {{skills_dir}}/*/scripts/tests; do \ if [ -d "$tests" ]; then \ @@ -41,6 +41,9 @@ test: test-review-suite: python3 -m unittest discover -s review-suite/scripts/tests -p 'test_*.py' +test-plugins: + python3 -m unittest discover -s scripts/tests -p 'test_*.py' + test-babysit-pr: python3 -m unittest discover -s {{skills_dir}}/babysit-pr/scripts/tests -p 'test_*.py' @@ -71,6 +74,9 @@ eval-carve-changesets-executor executor: validate-skills: lint-skills +validate-plugins: + python3 scripts/validate_plugins.py + fmt-py: @if command -v ruff >/dev/null 2>&1; then \ ruff check --select I,RUF022 --fix {{py_targets}}; \ @@ -150,7 +156,7 @@ lint-skills: fi; \ done -lint: lint-py lint-md lint-skills +lint: lint-py lint-md lint-skills validate-plugins format: fmt-py fmt-md diff --git a/scripts/tests/test_validate_plugins.py b/scripts/tests/test_validate_plugins.py new file mode 100644 index 0000000..1550393 --- /dev/null +++ b/scripts/tests/test_validate_plugins.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import importlib.util +import json +import shutil +import tempfile +import unittest +from pathlib import Path + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location( + "validate_plugins", REPOSITORY_ROOT / "scripts" / "validate_plugins.py" +) +assert SPEC is not None and SPEC.loader is not None +validate_plugins = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(validate_plugins) + + +class ValidatePluginsTests(unittest.TestCase): + def copy_fixture(self, destination: Path) -> None: + for name in (".agents", ".claude-plugin", ".codex-plugin", "skills"): + shutil.copytree(REPOSITORY_ROOT / name, destination / name) + + def test_repository_plugin_package_is_complete(self) -> None: + validate_plugins.validate(REPOSITORY_ROOT) + + def test_manifest_versions_must_match(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.copy_fixture(root) + manifest_path = root / ".codex-plugin" / "plugin.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["version"] = "0.2.0" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + + with self.assertRaisesRegex( + validate_plugins.PluginValidationError, "versions must match" + ): + validate_plugins.validate(root) + + def test_required_bundled_resource_cannot_disappear(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.copy_fixture(root) + watcher = root / "skills" / "babysit-pr" / "scripts" / "gh_pr_watch.py" + watcher.unlink() + + with self.assertRaisesRegex( + validate_plugins.PluginValidationError, "missing the babysit-pr watcher" + ): + validate_plugins.validate(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/validate_plugins.py b/scripts/validate_plugins.py new file mode 100644 index 0000000..8ae1aae --- /dev/null +++ b/scripts/validate_plugins.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Validate the repository's two plugin packages and marketplace catalogs.""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path +from typing import Any + +PLUGIN_NAME = "agent-scripts" +SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") +REQUIRED_SKILLS = { + "babysit-pr", + "carve-changesets", + "implement-epic", + "implement-ticket", + "review-code-change", + "review-code-simplicity", + "review-correctness", + "review-solution-simplicity", +} +REVIEW_SKILLS = { + "review-code-change", + "review-code-simplicity", + "review-correctness", + "review-solution-simplicity", +} +REVIEW_BUNDLE_FILES = { + "CONTRACT.md", + "review-packet.schema.json", + "review-result.schema.json", + "validate.py", +} + + +class PluginValidationError(ValueError): + """Raised when plugin packaging is incomplete or internally inconsistent.""" + + +def _require(condition: bool, message: str) -> None: + if not condition: + raise PluginValidationError(message) + + +def _load_object(path: Path) -> dict[str, Any]: + _require(path.is_file(), f"missing {path}") + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise PluginValidationError(f"invalid JSON in {path}: {error}") from error + _require(isinstance(value, dict), f"{path} must contain a JSON object") + return value + + +def _single_marketplace_entry( + marketplace: dict[str, Any], path: Path +) -> dict[str, Any]: + _require( + marketplace.get("name") == PLUGIN_NAME, f"wrong marketplace name in {path}" + ) + plugins = marketplace.get("plugins") + _require( + isinstance(plugins, list) and len(plugins) == 1, + f"{path} must expose one plugin", + ) + entry = plugins[0] + _require(isinstance(entry, dict), f"{path} plugin entry must be an object") + _require(entry.get("name") == PLUGIN_NAME, f"wrong plugin entry name in {path}") + return entry + + +def validate(root: Path) -> None: + root = root.resolve() + claude_manifest_path = root / ".claude-plugin" / "plugin.json" + codex_manifest_path = root / ".codex-plugin" / "plugin.json" + claude_manifest = _load_object(claude_manifest_path) + codex_manifest = _load_object(codex_manifest_path) + + for path, manifest in ( + (claude_manifest_path, claude_manifest), + (codex_manifest_path, codex_manifest), + ): + _require(manifest.get("name") == PLUGIN_NAME, f"wrong plugin name in {path}") + _require( + isinstance(manifest.get("description"), str) + and manifest["description"].strip(), + f"missing plugin description in {path}", + ) + _require(manifest.get("skills") == "./skills/", f"wrong skills path in {path}") + version = manifest.get("version") + _require( + isinstance(version, str) and SEMVER.fullmatch(version), + f"invalid version in {path}", + ) + + _require( + claude_manifest["version"] == codex_manifest["version"], + "Claude and Codex plugin versions must match", + ) + + claude_marketplace_path = root / ".claude-plugin" / "marketplace.json" + claude_entry = _single_marketplace_entry( + _load_object(claude_marketplace_path), claude_marketplace_path + ) + _require( + claude_entry.get("source") == "./", + "Claude plugin source must be the repository root", + ) + + codex_marketplace_path = root / ".agents" / "plugins" / "marketplace.json" + codex_entry = _single_marketplace_entry( + _load_object(codex_marketplace_path), codex_marketplace_path + ) + _require( + codex_entry.get("source") == {"source": "local", "path": "./"}, + "Codex plugin source must be the repository root", + ) + _require( + codex_entry.get("policy") + == {"installation": "AVAILABLE", "authentication": "ON_INSTALL"}, + "Codex marketplace policy must keep the plugin installable", + ) + _require( + isinstance(codex_entry.get("category"), str), + "Codex plugin category is required", + ) + + installed_skills = { + path.parent.name + for path in (root / "skills").glob("*/SKILL.md") + if path.is_file() + } + missing_skills = sorted(REQUIRED_SKILLS - installed_skills) + _require( + not missing_skills, + f"plugin is missing required skills: {', '.join(missing_skills)}", + ) + + for skill in REQUIRED_SKILLS: + _require( + (root / "skills" / skill / "agents" / "openai.yaml").is_file(), + f"missing OpenAI adapter metadata for {skill}", + ) + + _require( + (root / "skills" / "babysit-pr" / "scripts" / "gh_pr_watch.py").is_file(), + "plugin is missing the babysit-pr watcher", + ) + for skill in REVIEW_SKILLS: + bundle = root / "skills" / skill / "references" / "review-suite" + missing_bundle_files = sorted( + name for name in REVIEW_BUNDLE_FILES if not (bundle / name).is_file() + ) + _require( + not missing_bundle_files, + f"{skill} review-suite bundle is missing: {', '.join(missing_bundle_files)}", + ) + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + try: + validate(root) + except PluginValidationError as error: + print(f"plugin validation failed: {error}", file=sys.stderr) + return 1 + print("Validated Claude and Codex plugin packaging for agent-scripts") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())