From e85465e7cc4b41068e2210876e4981fdd0f1085a Mon Sep 17 00:00:00 2001 From: Richard Knapp Date: Mon, 18 May 2026 18:24:17 +0200 Subject: [PATCH 1/2] fix(plugins): hooks.json must be {"hooks": {}}; add validator + CI guard The 4 vertical plugins (private-equity, financial-analysis, equity-research, wealth-management) had `hooks.json` written as `[]`, which the Claude Code plugin loader rejects with `expected: "record", path: ["hooks"], received: undefined`. Investment-banking already had the correct `{"hooks": {}}` shape. This corrects the 4 files, adds a hooks.json schema check to `scripts/check.py` (fails CI on any future regression of this shape), and wires `check.py` into a new `check` GitHub Actions workflow that runs on every PR + push to main. Supersedes the duplicate community PRs that have been queuing on this issue (#226, #225, #224, #221, #216, #206, #203, #200, #193, #192, #167, #163, #162, #146, #141, and others). Co-Authored-By: Claude Opus 4.7 --- .github/workflows/check.yml | 22 +++++++++++++++++++ .../.claude-plugin/plugin.json | 2 +- .../equity-research/hooks/hooks.json | 4 +++- .../.claude-plugin/plugin.json | 2 +- .../financial-analysis/hooks/hooks.json | 4 +++- .../private-equity/.claude-plugin/plugin.json | 2 +- .../private-equity/hooks/hooks.json | 4 +++- .../.claude-plugin/plugin.json | 2 +- .../wealth-management/hooks/hooks.json | 4 +++- scripts/check.py | 14 ++++++++++++ 10 files changed, 52 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/check.yml diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 00000000..9d50ce9f --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,22 @@ +name: check + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + manifests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.11' + - name: install deps + run: pip install pyyaml + - name: run check.py + run: python3 scripts/check.py diff --git a/plugins/vertical-plugins/equity-research/.claude-plugin/plugin.json b/plugins/vertical-plugins/equity-research/.claude-plugin/plugin.json index 7d0649ec..9cf5a1a7 100644 --- a/plugins/vertical-plugins/equity-research/.claude-plugin/plugin.json +++ b/plugins/vertical-plugins/equity-research/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "equity-research", - "version": "0.1.0", + "version": "0.1.1", "description": "Equity research tools: earnings analysis, initiating coverage reports, and research workflows", "author": { "name": "Anthropic FSI" diff --git a/plugins/vertical-plugins/equity-research/hooks/hooks.json b/plugins/vertical-plugins/equity-research/hooks/hooks.json index fe51488c..deffac97 100644 --- a/plugins/vertical-plugins/equity-research/hooks/hooks.json +++ b/plugins/vertical-plugins/equity-research/hooks/hooks.json @@ -1 +1,3 @@ -[] +{ + "hooks": {} +} diff --git a/plugins/vertical-plugins/financial-analysis/.claude-plugin/plugin.json b/plugins/vertical-plugins/financial-analysis/.claude-plugin/plugin.json index c3c39a33..8bb83a79 100644 --- a/plugins/vertical-plugins/financial-analysis/.claude-plugin/plugin.json +++ b/plugins/vertical-plugins/financial-analysis/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "financial-analysis", - "version": "0.1.0", + "version": "0.1.1", "description": "Core financial modeling and analysis tools: DCF, comps, LBO, 3-statement models, competitive analysis, and deck QC", "author": { "name": "Anthropic FSI" diff --git a/plugins/vertical-plugins/financial-analysis/hooks/hooks.json b/plugins/vertical-plugins/financial-analysis/hooks/hooks.json index fe51488c..deffac97 100644 --- a/plugins/vertical-plugins/financial-analysis/hooks/hooks.json +++ b/plugins/vertical-plugins/financial-analysis/hooks/hooks.json @@ -1 +1,3 @@ -[] +{ + "hooks": {} +} diff --git a/plugins/vertical-plugins/private-equity/.claude-plugin/plugin.json b/plugins/vertical-plugins/private-equity/.claude-plugin/plugin.json index 8972bac1..276b49e3 100644 --- a/plugins/vertical-plugins/private-equity/.claude-plugin/plugin.json +++ b/plugins/vertical-plugins/private-equity/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "private-equity", - "version": "0.1.0", + "version": "0.1.1", "description": "Private equity deal sourcing and workflow tools: company discovery, CRM integration, and founder outreach", "author": { "name": "Anthropic FSI" diff --git a/plugins/vertical-plugins/private-equity/hooks/hooks.json b/plugins/vertical-plugins/private-equity/hooks/hooks.json index fe51488c..deffac97 100644 --- a/plugins/vertical-plugins/private-equity/hooks/hooks.json +++ b/plugins/vertical-plugins/private-equity/hooks/hooks.json @@ -1 +1,3 @@ -[] +{ + "hooks": {} +} diff --git a/plugins/vertical-plugins/wealth-management/.claude-plugin/plugin.json b/plugins/vertical-plugins/wealth-management/.claude-plugin/plugin.json index e9c23274..117e5db6 100644 --- a/plugins/vertical-plugins/wealth-management/.claude-plugin/plugin.json +++ b/plugins/vertical-plugins/wealth-management/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "wealth-management", - "version": "0.1.0", + "version": "0.1.1", "description": "Wealth management and financial advisory tools: client reviews, financial planning, portfolio analysis, and client reporting", "author": { "name": "Anthropic FSI" diff --git a/plugins/vertical-plugins/wealth-management/hooks/hooks.json b/plugins/vertical-plugins/wealth-management/hooks/hooks.json index fe51488c..deffac97 100644 --- a/plugins/vertical-plugins/wealth-management/hooks/hooks.json +++ b/plugins/vertical-plugins/wealth-management/hooks/hooks.json @@ -1 +1,3 @@ -[] +{ + "hooks": {} +} diff --git a/scripts/check.py b/scripts/check.py index e8e93c7b..33c9e52b 100755 --- a/scripts/check.py +++ b/scripts/check.py @@ -88,6 +88,20 @@ def rel(p: Path) -> str: except json.JSONDecodeError as e: err(f"JSON parse: {rel(jf)}: {e}") +# --- 2b. hooks.json schema --- must be {"hooks": {...}}, never [] ---------- +for hjf in sorted(PLUGINS.glob("**/hooks/hooks.json")): + checked += 1 + try: + data = json.loads(hjf.read_text()) + except json.JSONDecodeError as e: + err(f"hooks.json parse: {rel(hjf)}: {e}") + continue + if not isinstance(data, dict) or "hooks" not in data: + err( + f'hooks.json: {rel(hjf)}: must be {{"hooks": {{...}}}}, ' + f"got {type(data).__name__}" + ) + # --- 3. agent.md frontmatter ----------------------------------------------- for md in sorted(PLUGINS.glob("agent-plugins/*/agents/*.md")): checked += 1 From 5827bc958cd1b85ef51322721c2c06a116c02114 Mon Sep 17 00:00:00 2001 From: Richard Knapp Date: Mon, 18 May 2026 21:30:32 +0200 Subject: [PATCH 2/2] test(check): add subprocess-based test suite + wire into CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `scripts/tests/{conftest.py,test_check.py}` covering: - hooks.json schema validator (Phase 1 / #231 regression coverage): array form, missing `hooks` key, malformed JSON - agent.md missing-frontmatter detection - system.file ref-resolution failure - bundled-skill drift detector (sync-agent-skills.py prompt) - marketplace `source` path resolution - missing required cookbook files (steering-examples.json) - clean-tree baseline Tests use a tmpdir fixture (`minimal_repo`) that builds a minimal-valid repo skeleton and copies `check.py` into it so the script's `Path(__file__).resolve().parents[1]` lands on the fixture. Each test mutates exactly one file and asserts exit code + specific error string. No production code refactor required. Extends `.github/workflows/check.yml` to install pytest and run the suite after `check.py` on every PR + push to main. Supersedes community PR #214 (which only covered `rel()` and `err()`). Stacked on #231 (which adds the hooks.json validator under test) — base will auto-rebase to `main` after #231 merges. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/check.yml | 4 +- scripts/tests/conftest.py | 122 ++++++++++++++++++++++++++++++++++++ scripts/tests/test_check.py | 108 +++++++++++++++++++++++++++++++ 3 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 scripts/tests/conftest.py create mode 100644 scripts/tests/test_check.py diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 9d50ce9f..500e5118 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -17,6 +17,8 @@ jobs: with: python-version: '3.11' - name: install deps - run: pip install pyyaml + run: pip install pyyaml pytest - name: run check.py run: python3 scripts/check.py + - name: run check.py test suite + run: python3 -m pytest scripts/tests/ -v diff --git a/scripts/tests/conftest.py b/scripts/tests/conftest.py new file mode 100644 index 00000000..afaea584 --- /dev/null +++ b/scripts/tests/conftest.py @@ -0,0 +1,122 @@ +"""Test fixtures for check.py. + +Builds a minimal-valid repo skeleton in a tmpdir and copies `check.py` into it +so the script's `Path(__file__).resolve().parents[1]` correctly identifies the +fixture as ROOT. Tests then mutate one file at a time and assert check.py's +exit code + stderr. +""" +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +REAL_CHECK_PY = Path(__file__).resolve().parents[1] / "check.py" + + +@pytest.fixture +def minimal_repo(tmp_path: Path) -> Path: + """Build a minimal-valid repo skeleton; return its root path.""" + root = tmp_path / "repo" + + # scripts/check.py — copy, not symlink, so Path(__file__).resolve() + # lands inside the fixture + (root / "scripts").mkdir(parents=True) + shutil.copy(REAL_CHECK_PY, root / "scripts" / "check.py") + + # .claude-plugin/marketplace.json + (root / ".claude-plugin").mkdir() + (root / ".claude-plugin" / "marketplace.json").write_text( + json.dumps( + { + "name": "test-marketplace", + "owner": {"name": "Test"}, + "plugins": [ + { + "name": "test-vertical", + "source": "./plugins/vertical-plugins/test-vertical", + "description": "Test vertical", + }, + { + "name": "test-agent", + "source": "./plugins/agent-plugins/test-agent", + "description": "Test agent", + }, + ], + } + ) + ) + + # vertical plugin + vp = root / "plugins" / "vertical-plugins" / "test-vertical" + (vp / ".claude-plugin").mkdir(parents=True) + (vp / ".claude-plugin" / "plugin.json").write_text( + json.dumps( + { + "name": "test-vertical", + "version": "0.1.0", + "description": "Test vertical", + "author": {"name": "Test"}, + } + ) + ) + (vp / "hooks").mkdir() + (vp / "hooks" / "hooks.json").write_text(json.dumps({"hooks": {}})) + (vp / "skills" / "shared-skill").mkdir(parents=True) + (vp / "skills" / "shared-skill" / "SKILL.md").write_text( + "---\nname: shared-skill\ndescription: Test\n---\n\nbody\n" + ) + + # agent plugin (bundles the shared-skill) + ap = root / "plugins" / "agent-plugins" / "test-agent" + (ap / ".claude-plugin").mkdir(parents=True) + (ap / ".claude-plugin" / "plugin.json").write_text( + json.dumps( + { + "name": "test-agent", + "version": "0.1.0", + "description": "Test agent", + "author": {"name": "Test"}, + } + ) + ) + (ap / "agents").mkdir() + (ap / "agents" / "test-agent.md").write_text( + "---\nname: test-agent\ndescription: Test agent\n---\n\nbody\n" + ) + # bundled copy of the skill — must match the source byte-for-byte + (ap / "skills" / "shared-skill").mkdir(parents=True) + (ap / "skills" / "shared-skill" / "SKILL.md").write_text( + "---\nname: shared-skill\ndescription: Test\n---\n\nbody\n" + ) + + # managed-agent cookbook + mac = root / "managed-agent-cookbooks" / "test-agent" + mac.mkdir(parents=True) + (mac / "README.md").write_text("# test-agent\n") + (mac / "steering-examples.json").write_text(json.dumps([{"event": "x"}])) + (mac / "agent.yaml").write_text( + "name: test-agent\n" + "system:\n" + " file: ../../plugins/agent-plugins/test-agent/agents/test-agent.md\n" + "skills:\n" + " - from_plugin: ../../plugins/agent-plugins/test-agent\n" + ) + + return root + + +@pytest.fixture +def run_check(): + """Return a callable that runs check.py inside the given repo root.""" + def _run(repo: Path) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(repo / "scripts" / "check.py")], + capture_output=True, + text=True, + ) + return _run diff --git a/scripts/tests/test_check.py b/scripts/tests/test_check.py new file mode 100644 index 00000000..01f2558c --- /dev/null +++ b/scripts/tests/test_check.py @@ -0,0 +1,108 @@ +"""End-to-end tests for scripts/check.py. + +Each test mutates one file in the minimal-valid fixture repo and asserts +check.py's exit code + the specific error message. Subprocess-based so +the script is exercised as it actually runs in CI. +""" +from __future__ import annotations + +import json +from pathlib import Path + +def test_clean_tree_passes(minimal_repo: Path, run_check) -> None: + result = run_check(minimal_repo) + assert result.returncode == 0, result.stderr + assert "OK" in result.stdout + + +# --- hooks.json validator (Phase 1 addition) ------------------------------- + +def test_hooks_json_as_array_fails(minimal_repo: Path, run_check) -> None: + hjf = minimal_repo / "plugins" / "vertical-plugins" / "test-vertical" / "hooks" / "hooks.json" + hjf.write_text("[]") + result = run_check(minimal_repo) + assert result.returncode == 1 + assert "must be" in result.stderr + assert "got list" in result.stderr + assert "hooks/hooks.json" in result.stderr + + +def test_hooks_json_missing_hooks_key_fails(minimal_repo: Path, run_check) -> None: + hjf = minimal_repo / "plugins" / "vertical-plugins" / "test-vertical" / "hooks" / "hooks.json" + hjf.write_text("{}") + result = run_check(minimal_repo) + assert result.returncode == 1 + assert "must be" in result.stderr + + +def test_hooks_json_malformed_fails(minimal_repo: Path, run_check) -> None: + hjf = minimal_repo / "plugins" / "vertical-plugins" / "test-vertical" / "hooks" / "hooks.json" + hjf.write_text("{not json") + result = run_check(minimal_repo) + assert result.returncode == 1 + assert "hooks.json parse" in result.stderr + + +# --- existing validators (regression coverage) ----------------------------- + +def test_missing_steering_examples_fails(minimal_repo: Path, run_check) -> None: + (minimal_repo / "managed-agent-cookbooks" / "test-agent" / "steering-examples.json").unlink() + result = run_check(minimal_repo) + assert result.returncode == 1 + assert "missing" in result.stderr + assert "steering-examples.json" in result.stderr + + +def test_agent_md_missing_frontmatter_fails(minimal_repo: Path, run_check) -> None: + md = minimal_repo / "plugins" / "agent-plugins" / "test-agent" / "agents" / "test-agent.md" + md.write_text("no frontmatter here\n") + result = run_check(minimal_repo) + assert result.returncode == 1 + assert "frontmatter" in result.stderr + + +def test_broken_system_file_ref_fails(minimal_repo: Path, run_check) -> None: + yml = minimal_repo / "managed-agent-cookbooks" / "test-agent" / "agent.yaml" + yml.write_text( + "name: test-agent\n" + "system:\n" + " file: ../../plugins/agent-plugins/test-agent/agents/does-not-exist.md\n" + ) + result = run_check(minimal_repo) + assert result.returncode == 1 + assert "system.file" in result.stderr + assert "not found" in result.stderr + + +def test_bundled_skill_drift_fails(minimal_repo: Path, run_check) -> None: + bundled = ( + minimal_repo + / "plugins" + / "agent-plugins" + / "test-agent" + / "skills" + / "shared-skill" + / "SKILL.md" + ) + bundled.write_text("drifted content\n") + result = run_check(minimal_repo) + assert result.returncode == 1 + assert "drifted" in result.stderr + assert "sync-agent-skills.py" in result.stderr + + +def test_marketplace_source_must_resolve(minimal_repo: Path, run_check) -> None: + mp = minimal_repo / ".claude-plugin" / "marketplace.json" + data = json.loads(mp.read_text()) + data["plugins"].append( + { + "name": "ghost", + "source": "./plugins/vertical-plugins/does-not-exist", + "description": "broken", + } + ) + mp.write_text(json.dumps(data)) + result = run_check(minimal_repo) + assert result.returncode == 1 + assert "marketplace" in result.stderr + assert "ghost" in result.stderr