From 11e100d9294648cb4ee59df7aced5aa6c5b76da2 Mon Sep 17 00:00:00 2001 From: "h.nejati" Date: Mon, 24 Aug 2026 17:16:45 +0330 Subject: [PATCH] feat: load ArchiPy rules and hooks in Claude Code Cursor and Claude Code use different hook JSON schemas; Claude also ignores .mdc rules. Ship a Claude-specific hooks file and inject matching rule bodies so both IDEs get the same architecture guidance. Co-authored-by: Cursor --- .claude-plugin/marketplace.json | 4 +- .claude-plugin/plugin.json | 13 +-- .cursor-plugin/marketplace.json | 4 +- .cursor-plugin/plugin.json | 14 ++-- .github/ISSUE_TEMPLATE/bug_report.md | 4 +- AGENTS.md | 4 +- CHANGELOG.md | 13 +++ CONTRIBUTING.md | 12 ++- README.md | 7 +- hooks/claude-hooks.json | 25 ++++++ scripts/check_catalog.py | 10 +++ scripts/scaffold_hygiene.py | 117 +++++++++++++++++++++++---- tests/test_scripts.py | 36 +++++++++ 13 files changed, 227 insertions(+), 36 deletions(-) create mode 100644 hooks/claude-hooks.json diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a23362d..cf46881 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -7,8 +7,8 @@ { "name": "archipy", "source": ".", - "description": "Rules, skills, and commands for building apps with ArchiPy", - "version": "0.7.0" + "description": "Cursor and Claude Code plugin: rules, skills, and commands for building apps with ArchiPy", + "version": "0.8.0" } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 458e3a9..552cdc3 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "archipy", - "version": "0.7.0", - "description": "Rules, skills, and commands for building apps with ArchiPy", + "version": "0.8.0", + "description": "Cursor and Claude Code plugin: rules, skills, and commands for building apps with ArchiPy", "author": { "name": "SyntaxArc" }, @@ -12,8 +12,11 @@ "archipy", "python", "clean-architecture", - "adapters", - "helpers" + "cursor", + "claude-code" ], - "logo": "assets/logo.jpg" + "logo": "assets/logo.jpg", + "skills": "./skills", + "commands": "./commands", + "hooks": "./hooks/claude-hooks.json" } diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index a23362d..cf46881 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -7,8 +7,8 @@ { "name": "archipy", "source": ".", - "description": "Rules, skills, and commands for building apps with ArchiPy", - "version": "0.7.0" + "description": "Cursor and Claude Code plugin: rules, skills, and commands for building apps with ArchiPy", + "version": "0.8.0" } ] } diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 458e3a9..856448e 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "archipy", - "version": "0.7.0", - "description": "Rules, skills, and commands for building apps with ArchiPy", + "version": "0.8.0", + "description": "Cursor and Claude Code plugin: rules, skills, and commands for building apps with ArchiPy", "author": { "name": "SyntaxArc" }, @@ -12,8 +12,12 @@ "archipy", "python", "clean-architecture", - "adapters", - "helpers" + "cursor", + "claude-code" ], - "logo": "assets/logo.jpg" + "logo": "assets/logo.jpg", + "rules": "./rules", + "skills": "./skills", + "commands": "./commands", + "hooks": "./hooks/hooks.json" } diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index ee9c712..e6be1fe 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -4,7 +4,9 @@ about: Report incorrect plugin guidance or broken scaffolds labels: bug --- -**Plugin version** (from `.cursor-plugin/plugin.json`): +**Plugin version** (from `.cursor-plugin/plugin.json` or `.claude-plugin/plugin.json`): + +**IDE** (Cursor / Claude Code): **What went wrong** diff --git a/AGENTS.md b/AGENTS.md index ea2a229..d18a65f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,6 @@ -# ArchiPy Cursor Plugin — Agent Instructions +# ArchiPy Plugin — Agent Instructions + +Works in **Cursor** and **Claude Code**. Consumer plugin for **apps that depend on** PyPI [`archipy`](https://pypi.org/project/archipy/). diff --git a/CHANGELOG.md b/CHANGELOG.md index 1516d8f..1e0a7a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.0] - 2026-08-24 + +### Added + +- Claude Code hook schema in `hooks/claude-hooks.json` (`SessionStart` / `PostToolUse`) +- Claude session/post-tool hooks inject `.mdc` rule bodies (Claude Code does not load Cursor rules) +- Explicit `skills` / `commands` / `hooks` paths in both plugin manifests; Cursor also sets `rules` + +### Changed + +- Plugin description and keywords cover Cursor and Claude Code +- Cursor keeps `hooks/hooks.json`; Claude manifest points at `hooks/claude-hooks.json` + ## [0.7.0] - 2026-07-28 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7993161..625a202 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,6 +49,8 @@ Keep Cursor and Claude manifests in sync: All four must share the same SemVer string. +`hooks` paths **must differ**: Cursor `./hooks/hooks.json`, Claude Code `./hooks/claude-hooks.json`. + ## Release checklist 1. Update `CHANGELOG.md` (Keep a Changelog). @@ -58,9 +60,13 @@ All four must share the same SemVer string. ## Hooks -Plugin hooks live in `hooks/hooks.json` and scripts under `scripts/`. They remind agents about import direction and warn -on top-level `adapters/` paths. Prefer `${CURSOR_PLUGIN_ROOT}` in hook commands so paths resolve from the plugin install -location. +Plugin hooks: + +- Cursor: `hooks/hooks.json` (`sessionStart`, `postToolUse`) via `${CURSOR_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}` +- Claude Code: `hooks/claude-hooks.json` (`SessionStart`, `PostToolUse`) via `${CLAUDE_PLUGIN_ROOT}` + +Claude Code does not load `.mdc` rules. `scripts/scaffold_hygiene.py SessionStart` / `PostToolUse` injects matching +rule bodies. Do not point both manifests at the same hooks file — schemas differ. ## Scope reminders diff --git a/README.md b/README.md index 0e139d9..95c462f 100644 --- a/README.md +++ b/README.md @@ -310,7 +310,7 @@ archipy-plugin/ ├── .cursor-plugin/ │ ├── plugin.json # Cursor plugin manifest │ └── marketplace.json # Cursor marketplace catalog -├── hooks/ # Plugin hooks (hooks.json) +├── hooks/ # Cursor hooks.json + Claude claude-hooks.json ├── scripts/ # Catalog checks + hook scripts ├── rules/ # .mdc rules ├── skills/ # SKILL.md directories (+ docs reference) @@ -323,8 +323,9 @@ archipy-plugin/ └── CHANGELOG.md ``` -Both Cursor and Claude Code discover `rules/`, `skills/`, `commands/`, and `hooks/` automatically when the manifest does -not override paths. +Cursor loads `rules/` (`.mdc`), `skills/`, `commands/`, and `hooks/hooks.json`. Claude Code loads `skills/`, `commands/`, +and `hooks/claude-hooks.json`. Claude Code does **not** load Cursor `.mdc` rules; the Claude session hook injects +always-on rule text, and the post-tool hook injects glob-matched rules. ## ArchiPy docs map diff --git a/hooks/claude-hooks.json b/hooks/claude-hooks.json new file mode 100644 index 0000000..ec700a3 --- /dev/null +++ b/hooks/claude-hooks.json @@ -0,0 +1,25 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/scaffold_hygiene.py\" SessionStart" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Write|Edit|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/scaffold_hygiene.py\" PostToolUse" + } + ] + } + ] + } +} diff --git a/scripts/check_catalog.py b/scripts/check_catalog.py index c48e603..6b86748 100755 --- a/scripts/check_catalog.py +++ b/scripts/check_catalog.py @@ -94,6 +94,16 @@ def check_manifest_parity() -> list[str]: logo_path = ROOT / str(logo) if not logo_path.is_file(): errors.append(f"{path.relative_to(ROOT)} logo missing: {logo}") + cursor = _load_json(ROOT / ".cursor-plugin" / "plugin.json") + claude = _load_json(ROOT / ".claude-plugin" / "plugin.json") + if cursor.get("hooks") != "./hooks/hooks.json": + errors.append("Cursor plugin.json hooks must be ./hooks/hooks.json") + if claude.get("hooks") != "./hooks/claude-hooks.json": + errors.append("Claude plugin.json hooks must be ./hooks/claude-hooks.json") + if not (ROOT / "hooks" / "hooks.json").is_file(): + errors.append("missing hooks/hooks.json") + if not (ROOT / "hooks" / "claude-hooks.json").is_file(): + errors.append("missing hooks/claude-hooks.json") return errors diff --git a/scripts/scaffold_hygiene.py b/scripts/scaffold_hygiene.py index d08ca06..b7c4d71 100755 --- a/scripts/scaffold_hygiene.py +++ b/scripts/scaffold_hygiene.py @@ -4,20 +4,36 @@ from __future__ import annotations import json +import os import re import sys -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import Any # Any adapters/*.py outside repositories/{domain}/adapters/ is forbidden for ArchiPy apps. ADAPTERS_FILE_RE = re.compile(r"(^|/)adapters/[^/]+\.py$") REPO_ADAPTERS_RE = re.compile(r"(^|/)repositories/[^/]+/adapters/") +FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n?", re.DOTALL) HARD_RULES = ( "ArchiPy apps: follow architecture-for-apps rule + skills/archipy-docs/reference.md. " "Domain adapters under repositories/{domain}/adapters/ only. Prefer /scaffold-* commands." ) +# Cursor event names (camelCase). Claude Code uses PascalCase and a different hooks.json schema. +CURSOR_SESSION = "sessionStart" +CURSOR_POST = "postToolUse" +CLAUDE_SESSION = "SessionStart" +CLAUDE_POST = "PostToolUse" + + +def _plugin_root() -> Path: + for key in ("CLAUDE_PLUGIN_ROOT", "CURSOR_PLUGIN_ROOT"): + raw = os.environ.get(key) + if raw: + return Path(raw) + return Path(__file__).resolve().parents[1] + def _read_stdin() -> dict[str, Any]: raw = sys.stdin.read() @@ -63,37 +79,110 @@ def _paths_from_payload(payload: dict[str, Any]) -> list[str]: return paths -def handle_session_start(_payload: dict[str, Any]) -> dict[str, Any]: +def _parse_rule(path: Path) -> tuple[dict[str, str], str]: + text = path.read_text(encoding="utf-8") + match = FRONTMATTER_RE.match(text) + meta: dict[str, str] = {} + body = text + if match: + for line in match.group(1).splitlines(): + if ":" not in line: + continue + key, value = line.split(":", 1) + meta[key.strip()] = value.strip() + body = text[match.end() :].lstrip("\n") + return meta, body + + +def _path_matches(file_path: str, glob_field: str) -> bool: + normalized = file_path.replace("\\", "/").lstrip("./") + candidate = PurePosixPath(normalized) + prefixed = PurePosixPath(f"_/{normalized}") + for pattern in (part.strip() for part in glob_field.split(",") if part.strip()): + variants = [pattern] + if pattern.startswith("**/"): + variants.append(pattern[3:]) + for variant in variants: + try: + if candidate.match(variant) or prefixed.match(variant): + return True + except ValueError: + continue + return False + + +def _always_apply_rules() -> list[str]: + chunks: list[str] = [] + rules_dir = _plugin_root() / "rules" + if not rules_dir.is_dir(): + return chunks + for rule in sorted(rules_dir.glob("*.mdc")): + meta, body = _parse_rule(rule) + if meta.get("alwaysApply", "").lower() == "true" and body.strip(): + chunks.append(body.strip()) + return chunks + + +def _matching_rules(file_paths: list[str]) -> list[str]: + rules_dir = _plugin_root() / "rules" + if not rules_dir.is_dir(): + return [] + chunks: list[str] = [] + seen: set[str] = set() + for rule in sorted(rules_dir.glob("*.mdc")): + meta, body = _parse_rule(rule) + globs = meta.get("globs", "") + if not globs or not body.strip(): + continue + if any(_path_matches(path, globs) for path in file_paths): + if rule.name not in seen: + seen.add(rule.name) + chunks.append(body.strip()) + return chunks + + +def handle_cursor_session_start(_payload: dict[str, Any]) -> dict[str, Any]: return {"additional_context": HARD_RULES} -def handle_post_tool_use(payload: dict[str, Any]) -> dict[str, Any]: - warnings: list[str] = [] - for path in _paths_from_payload(payload): +def handle_claude_session_start(_payload: dict[str, Any]) -> dict[str, Any]: + # Claude Code plugins do not load Cursor `.mdc` rules; inject always-on rule bodies. + parts = [HARD_RULES, *_always_apply_rules()] + return {"additional_context": "\n\n".join(parts)} + + +def handle_post_tool_use(payload: dict[str, Any], *, inject_rules: bool) -> dict[str, Any]: + paths = _paths_from_payload(payload) + parts: list[str] = [] + for path in paths: if _is_forbidden_adapters_path(path): - warnings.append( + parts.append( f"ArchiPy hygiene: `{Path(path).as_posix()}` is under adapters/ but not " "repositories/{domain}/adapters/. Move domain adapters there." ) - if not warnings: + if inject_rules: + parts.extend(_matching_rules(paths)) + if not parts: return {} - return {"additional_context": " ".join(warnings)} + return {"additional_context": "\n\n".join(parts)} def main() -> int: payload = _read_stdin() - # Cursor may pass hook event name via argv or payload fields. event = "" if len(sys.argv) > 1: event = sys.argv[1] event = event or str(payload.get("hook_event_name") or payload.get("event") or "") - if event in {"sessionStart", "session_start"}: - _emit(handle_session_start(payload)) - elif event in {"postToolUse", "post_tool_use"}: - _emit(handle_post_tool_use(payload)) + if event == CLAUDE_SESSION: + _emit(handle_claude_session_start(payload)) + elif event in {CURSOR_SESSION, "session_start"}: + _emit(handle_cursor_session_start(payload)) + elif event == CLAUDE_POST: + _emit(handle_post_tool_use(payload, inject_rules=True)) + elif event in {CURSOR_POST, "post_tool_use"}: + _emit(handle_post_tool_use(payload, inject_rules=False)) else: - # Fail open for unknown events / empty stdin. _emit({}) return 0 diff --git a/tests/test_scripts.py b/tests/test_scripts.py index d7ccb78..9356c11 100644 --- a/tests/test_scripts.py +++ b/tests/test_scripts.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os import subprocess import sys import unittest @@ -65,6 +66,41 @@ def test_warns_non_repo_adapters(self) -> None: payload = json.loads(result.stdout) self.assertIn("additional_context", payload) self.assertIn("repositories/{domain}/adapters", payload["additional_context"]) + self.assertNotIn("# Architecture for ArchiPy Apps", payload["additional_context"]) + + def test_claude_session_start_injects_architecture_rule(self) -> None: + env = os.environ.copy() + env["CLAUDE_PLUGIN_ROOT"] = str(ROOT) + result = subprocess.run( + [sys.executable, str(SCRIPTS / "scaffold_hygiene.py"), "SessionStart"], + cwd=ROOT, + input="{}", + capture_output=True, + text=True, + check=False, + env=env, + ) + self.assertEqual(result.returncode, 0, result.stderr) + payload = json.loads(result.stdout) + context = payload["additional_context"] + self.assertIn("# Architecture for ArchiPy Apps", context) + self.assertIn("services → logics", context) + + def test_claude_post_tool_use_injects_glob_rule(self) -> None: + env = os.environ.copy() + env["CLAUDE_PLUGIN_ROOT"] = str(ROOT) + result = subprocess.run( + [sys.executable, str(SCRIPTS / "scaffold_hygiene.py"), "PostToolUse"], + cwd=ROOT, + input=json.dumps({"tool_input": {"path": "logics/user/user_logic.py"}}), + capture_output=True, + text=True, + check=False, + env=env, + ) + self.assertEqual(result.returncode, 0, result.stderr) + payload = json.loads(result.stdout) + self.assertIn("Unit of Work", payload["additional_context"]) if __name__ == "__main__":