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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions tests/test_manifests.py
Original file line number Diff line number Diff line change
@@ -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()
Loading