diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58b99e2..f40086c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,9 @@ on: push: branches: [main] pull_request: + # The validate job installs the latest Claude Code, whose checks can tighten with no commit here. + schedule: + - cron: "0 3 * * 1" permissions: contents: read diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3eef2ce..89a9444 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,6 +29,8 @@ installed one. - A hook must never fail the session: errors go to stderr and the exit code stays 0. - Add or update tests in `tests/test_mailbox.py` with any change to the script. Tests set `ANIMUS_AGENTS_DIR` to a temporary folder, so they never touch the real `~/.agent-mailbox`. +- `tests/test_manifests.py` checks what `claude plugin validate` does not: every JSON manifest + parses, and each hook and monitor command runs a subcommand `scripts/mailbox.py` has. - Bump `version` in `.claude-plugin/plugin.json` when a change should reach installed copies. ## Commits diff --git a/tests/test_manifests.py b/tests/test_manifests.py new file mode 100644 index 0000000..b279223 --- /dev/null +++ b/tests/test_manifests.py @@ -0,0 +1,47 @@ +"""The wiring `claude plugin validate` does not check: every manifest parses, and each hook and monitor +command runs a subcommand scripts/mailbox.py dispatches on.""" +import json +import re +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SCRIPT = ROOT / "scripts" / "mailbox.py" +CALL = re.compile(r'"\$\{CLAUDE_PLUGIN_ROOT\}/(\S+?)" (\S+)') + + +def commands(node): + if isinstance(node, dict): + for key, value in node.items(): + if key == "command": + yield value + else: + yield from commands(value) + elif isinstance(node, list): + for value in node: + yield from commands(value) + + +class Manifests(unittest.TestCase): + def test_every_manifest_parses(self): + found = [p for p in ROOT.rglob("*.json") if ".git" not in p.parts] + self.assertGreaterEqual(len(found), 4) + for path in found: + with self.subTest(path=path.relative_to(ROOT)): + json.loads(path.read_text(encoding="utf-8")) + + def test_hooks_and_monitors_run_subcommands_the_script_has(self): + source = SCRIPT.read_text(encoding="utf-8") + dispatched = set(re.findall(r'command == "([\w-]+)"', source)) + self.assertIn("session-start", dispatched) + for manifest in ("hooks/hooks.json", "monitors/monitors.json"): + for command in commands(json.loads((ROOT / manifest).read_text(encoding="utf-8"))): + with self.subTest(manifest=manifest, command=command): + match = CALL.search(command) + self.assertIsNotNone(match, "runs no script from the plugin") + self.assertTrue((ROOT / match[1]).is_file(), match[1]) + self.assertIn(match[2], dispatched) + + +if __name__ == "__main__": + unittest.main()