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
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ Update each hit. Keep release numbers and "checked" dates in `## Sources` sectio
- **Agent**: `agents/php-reviewer.md`. Report-only, no sub-agent dispatch. Its `tools:` include `Skill` so it can load `php-coding`.
- **Cursor rule**: `rules/php-context.mdc` (`globs: ["**/*.php"]`) mirrors the index table.
- **Claude hooks**: `hooks/hooks.json` wires `SessionStart` and `PostToolUse` (`matcher: "Write|Edit"`) to the shared scripts as `bash "${CLAUDE_PLUGIN_ROOT}/hooks/<script>.sh"`.
- **Cursor hooks**: `hooks/cursor-hooks.json` wires `sessionStart` and `afterFileEdit` to the same scripts with a workspace-relative command.
- **Shared hook scripts**: `hooks/session-start.sh` detects `composer.json` or a `*.php` file, prints one line, and names the library references whose packages appear in `composer.json` (and Laravel Boost for `laravel/framework`). `hooks/format-on-save.sh` reads the edited path from the host's stdin JSON (`tool_input.file_path` on Claude Code, `file_path` on Cursor) and runs `php-cs-fixer fix` on a `*.php` file when a project config and a fixer binary exist. Both always exit 0.
- **Cursor hooks**: `hooks/cursor-hooks.json` (`"version": 1`) wires `sessionStart` and `afterFileEdit`, each with a `timeout` in seconds. Commands are relative to the plugin install (`bash hooks/session-start.sh`), not `${CLAUDE_PLUGIN_ROOT}`.
- **Shared hook scripts**: `hooks/session-start.sh` detects `composer.json` or a `*.php` file, prints one line, and names the library references whose packages appear in `composer.json` (and Laravel Boost for `laravel/framework`). On Cursor it inspects `CURSOR_PROJECT_DIR` and prints JSON `additional_context`. `hooks/format-on-save.sh` reads the edited path from the host's stdin JSON (`tool_input.file_path` on Claude Code, `file_path` on Cursor `afterFileEdit`) and runs `php-cs-fixer fix` on a `*.php` file when a project config and a fixer binary exist. On Cursor it looks for that config in `CURSOR_PROJECT_DIR`, not the plugin install. The formatter uses `afterFileEdit`: Cursor documents no output for it, and the script only rewrites the file. Both always exit 0.
- **Reference lint config**: top-level `references/php-cs-fixer.php`, `references/phpstan.neon`, `references/rector.php` (Rector optional, default-off). This is a different directory from `skills/php-coding/references/`.
- **Eval suite**: `evals/<case>/` with `prompt.md` and `graders/`, run by `claude plugin eval`. Results go to `evals/results/`, which is gitignored.
- **Validation**: `scripts/validate.sh` wraps the stdlib-only `scripts/validate.py`. CI (`.github/workflows/validate.yml`) installs Python 3 and runs it on pushes to `main` and on pull requests.
Expand Down Expand Up @@ -131,7 +131,7 @@ When a dated version fact changes, follow [Refreshing a dated fact](#refreshing-
- **Quote `${CLAUDE_PLUGIN_ROOT}` in Claude hook commands.** A plugin path that contains a space splits into several words. `claude plugin validate .` warns on the unquoted form.
- **A Claude hook `timeout` is in seconds, not milliseconds.** The default for a command hook is 600. — <https://code.claude.com/docs/en/hooks>
- **Hook scripts get the edited path from stdin.** Claude Code sends it as `tool_input.file_path` and documents no `CLAUDE_FILE_PATH` variable. Keep the stdin parsing in `format-on-save.sh`.
- **The Cursor hook uses a workspace-relative command** (`bash hooks/session-start.sh`), *not* `${CLAUDE_PLUGIN_ROOT}`. Cursor's plugin docs, checked 2026-09-30, do not say how a plugin hook path resolves. Confirm both hooks fire in a PHP workspace other than this repo.
- **The Cursor hook command is relative to the plugin install** (`bash hooks/session-start.sh`), *not* `${CLAUDE_PLUGIN_ROOT}`. Cursor runs a plugin hook from its install, which `CURSOR_PLUGIN_ROOT` names, and sets `CURSOR_PROJECT_DIR` to the workspace root, checked 2026-10-27. So `session-start.sh` and `format-on-save.sh` move to `CURSOR_PROJECT_DIR` before they read the workspace, and do nothing without it: the install ships `references/*.php`. Confirm both hooks fire in a PHP workspace other than this repo. — <https://cursor.com/docs/agent/hooks>
- **An unquoted `: ` or ` #` in a frontmatter value is invalid YAML** (YAML 1.2.2, section 7.3.3). `scripts/validate.py` is the only check that catches it; `claude plugin validate` passed such a skill in a test on CLI 2.1.283. — <https://yaml.org/spec/1.2.2/#733-plain-style>
- **The fixer lags PER.** `@PER-CS` still means PER 3.0. Do not claim the fixer enforces a 3.1 rule; `style.md` lists them.
- **`AGENTS.md` and `.github/` are export-ignored** in `.gitattributes`. A shipped skill, reference, agent, rule, or hook must not depend on them.
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve

## [Unreleased]

### Added
- Validator: `scripts/validate.py` fails a `hooks/cursor-hooks.json` without `"version": 1` or with `${CLAUDE_PLUGIN_ROOT}`.

### Fixed
- Hooks: on Cursor, `session-start.sh` and `format-on-save.sh` read the workspace from `CURSOR_PROJECT_DIR`, not the plugin install, and stay silent without it.
- Hooks: on Cursor, `session-start.sh` returns its line as JSON `additional_context` and names `/php-coding`.
- Hooks: `hooks/cursor-hooks.json` declares `"version": 1` and a `timeout` per hook.

## [0.2.0] - 2026-10-19

### Changed
Expand Down
2 changes: 1 addition & 1 deletion docs/authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Each case is a directory with `prompt.md` and a `graders/` folder ([plugin evals

## Dual-host parity

Skills and agents are shared. The **Cursor** manifest (`.cursor-plugin/plugin.json`) declares each component path. **Claude** discovers the default folders automatically. Keep `name`, `version`, `description`, and `author` identical across the two manifests. The Claude hook command quotes the placeholder (`bash "${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh"`). The Cursor hook command is workspace-relative (`bash hooks/session-start.sh`), never `${CLAUDE_PLUGIN_ROOT}`.
Skills and agents are shared. The **Cursor** manifest (`.cursor-plugin/plugin.json`) declares each component path. **Claude** discovers the default folders automatically. Keep `name`, `version`, `description`, and `author` identical across the two manifests. The Claude hook command quotes the placeholder (`bash "${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh"`). The Cursor hook command is relative to the plugin install (`bash hooks/session-start.sh`), never `${CLAUDE_PLUGIN_ROOT}`. `hooks/cursor-hooks.json` has `"version": 1`. The formatter uses `afterFileEdit`, for which Cursor documents no output.

## Before committing

Expand Down
2 changes: 1 addition & 1 deletion docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,4 @@ Installing the plugin needs no PHP toolchain. Enforcement does. The pin is **PHP

`/php-lint-setup` writes the reference config. Install the binaries with Composer in the target repo, for example `friendsofphp/php-cs-fixer`, `phpstan/phpstan:^2`, and `phpunit/phpunit:^12.5`. Download PHP from <https://www.php.net/downloads.php>.

The format-on-save hook looks for `.php-cs-fixer.php` (or `.php-cs-fixer.dist.php`) in the working directory and for `vendor/bin/php-cs-fixer` or `php-cs-fixer` on `PATH`. It does not install them.
The format-on-save hook looks for `.php-cs-fixer.php` (or `.php-cs-fixer.dist.php`) and `vendor/bin/php-cs-fixer` in its working directory, then for `php-cs-fixer` on `PATH`. Claude Code runs it in the session's current directory. Cursor runs it in the plugin install, so the hook changes to `CURSOR_PROJECT_DIR` first. It does not install the fixer.
19 changes: 14 additions & 5 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,22 @@ Checked 2026-09-30 against php-cs-fixer 3.95.27 (`describe @PER-CS` prints `@PER

## Hook smoke tests

Both scripts always exit 0. Run them from the root of a PHP project, with `P` set to this repo's path:
Both scripts always exit 0. Run them from the root of a PHP project, with `P` set to this repo's path. Claude Code runs a hook in the project and reads plain stdout:

```bash
P=/path/to/php-coding-plugin
bash "$P/hooks/session-start.sh" # one standards line; nothing outside a PHP workspace
printf '{"tool_input":{"file_path":"src/Foo.php"}}' | bash "$P/hooks/format-on-save.sh" # Claude Code payload
printf '{"file_path":"/abs/path/src/Foo.php"}' | bash "$P/hooks/format-on-save.sh" # Cursor afterFileEdit payload
bash "$P/hooks/session-start.sh" # one standards line; nothing outside a PHP workspace
printf '{"tool_input":{"file_path":"src/Foo.php"}}' | bash "$P/hooks/format-on-save.sh" # Claude Code payload
```

Cursor runs a plugin hook in the plugin install, with `CURSOR_PLUGIN_ROOT` and `CURSOR_PROJECT_DIR` set. Replay that from the same project root:

```bash
W="$PWD"
(cd "$P" && CURSOR_PLUGIN_ROOT="$P" CURSOR_PROJECT_DIR="$W" bash hooks/session-start.sh) | python3 -m json.tool # {"additional_context": "..."}
printf '{"file_path":"%s/src/Foo.php"}' "$W" \
| (cd "$P" && CURSOR_PLUGIN_ROOT="$P" CURSOR_PROJECT_DIR="$W" bash hooks/format-on-save.sh) # Cursor afterFileEdit payload
(cd "$P" && CURSOR_PLUGIN_ROOT="$P" bash hooks/session-start.sh) # no CURSOR_PROJECT_DIR: prints nothing
```

With a `.php-cs-fixer.php` and a fixer binary present, the named file is reformatted. Without either, nothing happens.
Expand All @@ -45,6 +54,6 @@ Load a working copy (see [install.md](install.md)), then:
- **`php-reviewer`** — a review request loads `php-coding`, reads the references the diff maps to, returns severity-ranked findings, and does not edit or spawn agents.
- **`/php-lint-setup`** — writes the reference config and does not overwrite an existing file unprompted.
- **Cursor rule** — open a `.php` file and confirm `php-context.mdc` attaches.
- **Cursor hooks** — in a PHP workspace other than this repo, confirm the session-start line prints and a `.php` edit is reformatted. Cursor does not document how a plugin hook's relative command path resolves, so this is the only check that it works.
- **Cursor hooks** — in a PHP workspace other than this repo, confirm the session-start line reaches the agent as `additional_context` (it names `/php-coding`) and a `.php` edit is reformatted. Both hooks run in the plugin install and read the project from `CURSOR_PROJECT_DIR`.

After editing content, reinstall or restart the session.
7 changes: 5 additions & 2 deletions hooks/cursor-hooks.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
{
"version": 1,
"hooks": {
"sessionStart": [
{
"command": "bash hooks/session-start.sh"
"command": "bash hooks/session-start.sh",
"timeout": 10
}
],
"afterFileEdit": [
{
"command": "bash hooks/format-on-save.sh"
"command": "bash hooks/format-on-save.sh",
"timeout": 20
}
]
}
Expand Down
16 changes: 12 additions & 4 deletions hooks/format-on-save.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# PostToolUse / afterFileEdit hook (host-agnostic): format the just-edited PHP file.
# PostToolUse (Claude Code) / afterFileEdit (Cursor) hook: format the just-edited PHP file.
#
# Runs `php-cs-fixer fix` on that single file when a project config is present
# (`.php-cs-fixer.php` or `.php-cs-fixer.dist.php`) and a fixer binary is available.
Expand All @@ -8,14 +8,15 @@
# never block an edit. The fixer owns style; this hook does not pass a second ruleset.
#
# File-path resolution, in order:
# 1. $CLAUDE_FILE_PATH — set by Claude Code for Write/Edit hooks (fast path).
# 2. tool payload JSON on stdin — Claude (`tool_input.file_path`) or Cursor
# 1. $CLAUDE_FILE_PATH — explicit override for tests and manual runs; no host sets it.
# 2. tool payload JSON on stdin — Claude Code (`tool_input.file_path`) or Cursor
# `afterFileEdit` (`file_path`). Extracted without a jq/python dependency.
# A relative path resolves against the working directory: on Cursor, CURSOR_PROJECT_DIR.
set -u

f="${CLAUDE_FILE_PATH:-}"

# Fall back to the JSON the host pipes in on stdin (Cursor; newer Claude payloads).
# Otherwise read the JSON payload both hosts pipe in on stdin.
# Guard on a non-tty stdin so a manual run without a pipe doesn't block on `cat`.
if [ -z "$f" ] && [ ! -t 0 ]; then
payload="$(cat)"
Expand All @@ -25,6 +26,13 @@ if [ -z "$f" ] && [ ! -t 0 ]; then
| sed -E 's/.*"([^"]+)"$/\1/')"
fi

# Cursor plugin hooks run in the plugin install (https://cursor.com/docs/agent/hooks). The fixer
# config and vendor binary live in the workspace, which CURSOR_PROJECT_DIR names; without it
# there is no workspace to format against.
if [ -n "${CURSOR_PLUGIN_ROOT:-}" ]; then
[ -n "${CURSOR_PROJECT_DIR:-}" ] && cd "$CURSOR_PROJECT_DIR" 2>/dev/null || exit 0
fi

[ -n "$f" ] || exit 0
case "$f" in *.php) ;; *) exit 0 ;; esac
[ -f "$f" ] || exit 0
Expand Down
32 changes: 27 additions & 5 deletions hooks/session-start.sh
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
#!/usr/bin/env bash
# SessionStart hook (host-agnostic): print one PHP-standards context line when a PHP
# workspace is detected, naming the php-coding references that match composer.json.
# Always exits 0 so the assistant reads stdout and is never blocked.
# SessionStart (Claude Code) / sessionStart (Cursor) hook: print one PHP-standards context
# line when a PHP workspace is detected, naming the php-coding references that match
# composer.json. Always exits 0.
#
# Claude Code runs the hook in its current directory and reads plain stdout.
# Cursor runs a plugin hook from the plugin install and reads additional_context
# from JSON, so there the hook moves to CURSOR_PROJECT_DIR
# (https://cursor.com/docs/agent/hooks).
set -u

# CURSOR_PLUGIN_ROOT is set only for a Cursor plugin hook. Without CURSOR_PROJECT_DIR the hook
# stays silent: the plugin install ships references/*.php and would read as a PHP workspace.
cursor=""
if [ -n "${CURSOR_PLUGIN_ROOT:-}" ]; then
cursor=1
[ -n "${CURSOR_PROJECT_DIR:-}" ] && cd "$CURSOR_PROJECT_DIR" 2>/dev/null || exit 0
fi

is_php_workspace() {
[ -f composer.json ] && return 0
# Bounded search so session start stays fast; ignore vendor/ and .git/.
Expand All @@ -30,13 +43,22 @@ library_refs() {
}

if is_php_workspace; then
line="› PHP workspace detected: php-coding standards apply (PHP 8.4 pin, PER Coding Style 3.1, php-cs-fixer @PER-CS, PHPStan level 8). Load the php-coding skill and read the reference each change maps to."
if [ -n "$cursor" ]; then
line="› PHP workspace detected: php-coding standards apply (PHP 8.4 pin, PER Coding Style 3.1, php-cs-fixer @PER-CS, PHPStan level 8). Open /php-coding and read the reference each change maps to."
else
line="› PHP workspace detected: php-coding standards apply (PHP 8.4 pin, PER Coding Style 3.1, php-cs-fixer @PER-CS, PHPStan level 8). Load the php-coding skill and read the reference each change maps to."
fi
refs="$(library_refs)"
[ -n "$refs" ] && line="$line composer.json also maps to: $refs."
if [ -f composer.json ] && grep -q '"laravel/framework"' composer.json; then
line="$line Laravel framework code belongs to Laravel Boost."
fi
echo "$line"
if [ -n "$cursor" ]; then
# One JSON string: escape backslashes and double quotes.
printf '{"additional_context":"%s"}\n' "$(printf '%s' "$line" | sed 's/\\/\\\\/g; s/"/\\"/g')"
else
echo "$line"
fi
fi

exit 0
21 changes: 17 additions & 4 deletions scripts/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
* dual-host parity (name/version/description/author agree across manifests);
* every component path declared in a manifest exists inside the plugin dir;
* kebab-case directory/file names for skills, agents, commands, and rules;
* hook-config JSON validity when present;
* hook-config JSON validity when present; Cursor's file has ``"version": 1`` and
does not use ``${CLAUDE_PLUGIN_ROOT}``;
* skill reference files: linked one level deep from SKILL.md, no orphans, a Contents list
past 100 lines; the Cursor rule and agents name only references that exist;
* SKILL.md / agent / command frontmatter — required keys, and ``name`` matching the
Expand Down Expand Up @@ -220,7 +221,8 @@ def validate_manifest_paths(manifest: dict, label: str):

def validate_json_file(path: Path, label: str):
if path.is_file():
load_json(path, label)
return load_json(path, label)
return None


def main():
Expand Down Expand Up @@ -251,9 +253,20 @@ def main():
err(f"manifests disagree on '{field}': "
f"claude={claude.get(field)!r} cursor={cursor.get(field)!r}")

# Hook configs must be valid JSON when present.
# Hook configs must be valid JSON when present. Cursor requires "version": 1
# and does not set CLAUDE_PLUGIN_ROOT (https://cursor.com/docs/agent/hooks).
validate_json_file(ROOT / "hooks" / "hooks.json", "Claude hooks")
validate_json_file(ROOT / "hooks" / "cursor-hooks.json", "Cursor hooks")
cursor_hooks = validate_json_file(ROOT / "hooks" / "cursor-hooks.json", "Cursor hooks")
if cursor_hooks is not None and not isinstance(cursor_hooks, dict):
err("hooks/cursor-hooks.json: top level must be a JSON object")
elif cursor_hooks is not None:
version = cursor_hooks.get("version")
# type() rather than ==, since True == 1 and 1.0 == 1 in Python.
if type(version) is not int or version != 1:
err('hooks/cursor-hooks.json: needs "version": 1, which Cursor requires of a hooks file')
if "CLAUDE_PLUGIN_ROOT" in json.dumps(cursor_hooks):
err("hooks/cursor-hooks.json: uses CLAUDE_PLUGIN_ROOT, which Cursor does not set; "
"keep commands relative to the plugin install")

validate_skills()
validate_md_components("agents", require_name=True, is_agent=True)
Expand Down
Loading