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
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
13 changes: 8 additions & 5 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
},
Expand All @@ -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"
}
4 changes: 2 additions & 2 deletions .cursor-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
14 changes: 9 additions & 5 deletions .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
},
Expand All @@ -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"
}
4 changes: 3 additions & 1 deletion .github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -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/).

Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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

Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand Down
25 changes: 25 additions & 0 deletions hooks/claude-hooks.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
]
}
}
10 changes: 10 additions & 0 deletions scripts/check_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
117 changes: 103 additions & 14 deletions scripts/scaffold_hygiene.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading