diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 00000000..027aca44 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,17 @@ +{ + "name": "shopware-ai-coding-tools", + "plugins": [ + { + "name": "dev-tooling", + "source": { + "source": "local", + "path": "./plugins/dev-tooling" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Developer Tools" + } + ] +} diff --git a/.claude/skills/plugin-updating/SKILL.md b/.claude/skills/plugin-updating/SKILL.md index 2c91c8bf..50f57e24 100644 --- a/.claude/skills/plugin-updating/SKILL.md +++ b/.claude/skills/plugin-updating/SKILL.md @@ -63,7 +63,7 @@ PATCH — Everything else For detailed triggers, public API definitions, and edge cases see `references/version-bump-reasoning.md`. -### Step 3: Bump plugin.json and all SKILL.md versions +### Step 3: Bump plugin.json, the Codex manifest and all SKILL.md versions Run the bundled helper script: @@ -71,7 +71,7 @@ Run the bundled helper script: bash "${CLAUDE_SKILL_DIR}/scripts/bump-plugin-version.sh" ``` -It updates `plugins//.claude-plugin/plugin.json` (surgical text replacement that preserves formatting) and rewrites the `version:` field in every `plugins//skills/*/SKILL.md` frontmatter. Do not edit these files by hand. +It updates `plugins//.claude-plugin/plugin.json` (surgical text replacement that preserves formatting) and, when the plugin has a `.codex-plugin/plugin.json`, that file's `version` to the same value — failing if the two differ afterwards — and rewrites the `version:` field in every `plugins//skills/*/SKILL.md` frontmatter. Do not edit these files by hand. ### Step 4: Update CHANGELOG.md diff --git a/.claude/skills/plugin-updating/scripts/bump-plugin-version.sh b/.claude/skills/plugin-updating/scripts/bump-plugin-version.sh index b2be60a4..9234ff0a 100755 --- a/.claude/skills/plugin-updating/scripts/bump-plugin-version.sh +++ b/.claude/skills/plugin-updating/scripts/bump-plugin-version.sh @@ -1,11 +1,12 @@ #!/usr/bin/env bash # -# Bumps a plugin's version in plugin.json and all SKILL.md frontmatters. +# Bumps a plugin's version in plugin.json, its Codex manifest (when present) and all SKILL.md frontmatters. # # Usage: bump-plugin-version.sh # # Updates in-place: # plugins//.claude-plugin/plugin.json (top-level "version" field) +# plugins//.codex-plugin/plugin.json (top-level "version" field, only when the file exists) # plugins//skills/*/SKILL.md (frontmatter "version" field) # # Does NOT touch CHANGELOG.md (needs semantic content). @@ -60,18 +61,41 @@ fi rel() { printf '%s' "${1#"$REPO_ROOT"/}"; } -# 1. plugin.json — surgical text replacement to preserve existing formatting -tmp="$(mktemp)" -awk -v v="$NEW_VERSION" ' - !done && /^[[:space:]]*"version"[[:space:]]*:/ { - sub(/:[[:space:]]*"[^"]*"/, ": \"" v "\"") - done = 1 - } - { print } -' "$PLUGIN_JSON" > "$tmp" -mv "$tmp" "$PLUGIN_JSON" +# 1. Manifests — surgical text replacement to preserve existing formatting +rewrite_manifest_version() { + local manifest="$1" out + out="$(mktemp)" + awk -v v="$NEW_VERSION" ' + !done && /^[[:space:]]*"version"[[:space:]]*:/ { + sub(/:[[:space:]]*"[^"]*"/, ": \"" v "\"") + done = 1 + } + { print } + ' "$manifest" > "$out" + mv "$out" "$manifest" +} + +check_manifest_version() { + local manifest="$1" got + got="$(jq -r .version "$manifest")" + if [ "$got" != "$NEW_VERSION" ]; then + echo "Error: $(rel "$manifest") reads version '$got', expected '$NEW_VERSION'." >&2 + exit 1 + fi +} + +rewrite_manifest_version "$PLUGIN_JSON" +check_manifest_version "$PLUGIN_JSON" printf ' %s (%s -> %s)\n' "$(rel "$PLUGIN_JSON")" "$OLD_VERSION" "$NEW_VERSION" +# The Codex manifest carries the same version, and only some plugins have one +CODEX_JSON="$PLUGIN_DIR/.codex-plugin/plugin.json" +if [ -f "$CODEX_JSON" ]; then + rewrite_manifest_version "$CODEX_JSON" + check_manifest_version "$CODEX_JSON" + printf ' %s (-> %s)\n' "$(rel "$CODEX_JSON")" "$NEW_VERSION" +fi + # 2. SKILL.md frontmatters SKILLS_DIR="$PLUGIN_DIR/skills" missing_version=() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72c8c84a..58cf69c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,8 @@ on: paths: - 'plugins/**' - 'plugin-tests/**' + - '.agents/**' + - '.claude-plugin/**' - '.mcp-sdk.lock' - 'pyproject.toml' - 'uv.lock' @@ -16,6 +18,8 @@ on: paths: - 'plugins/**' - 'plugin-tests/**' + - '.agents/**' + - '.claude-plugin/**' - '.mcp-sdk.lock' - 'pyproject.toml' - 'uv.lock' diff --git a/README.md b/README.md index d2919104..08768c7b 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,13 @@ Add the marketplace, then install the plugins you need: Restart Claude Code after installing plugins that include MCP servers. Once setup is complete, you can uninstall `plugin-setup` to free up description budget (`/plugin uninstall plugin-setup@shopware-ai-coding-tools`). +On OpenAI Codex, add the same marketplace and install `dev-tooling` — the only plugin here with a Codex manifest: + +```bash +codex plugin marketplace add shopwareLabs/ai-coding-tools +codex plugin add dev-tooling@shopware-ai-coding-tools +``` + ## 🛠️ Recommended Setup These plugins work best alongside a few Claude Code tweaks. Turn on `ENABLE_TOOL_SEARCH=1` for deferred MCP tool loading. Add complementary marketplaces like Anthropic's `superpowers` and `it-bens/ai-tools` (for `llm-author` and `redundant-read-blocker`). Pre-approve common tools in `settings.json`. @@ -41,10 +48,13 @@ These plugins work best alongside a few Claude Code tweaks. Turn on `ENABLE_TOOL ### dev-tooling -Three MCP servers for PHP and JavaScript operations plus an optional phpactor LSP for active PHP code discovery. Supports native, Docker, Docker Compose, Vagrant, and DDEV environments. +Three MCP servers for PHP and JavaScript operations plus an optional phpactor LSP for active PHP code discovery. Supports native, Docker, Docker Compose, Vagrant, and DDEV environments. Runs on Claude Code and on OpenAI Codex, where the servers start without a project root and bind one on demand; the LSP, the runner agent, and the worktree reminder hook are Claude Code only. ```bash /plugin install dev-tooling@shopware-ai-coding-tools +# Codex +codex plugin marketplace add shopwareLabs/ai-coding-tools +codex plugin add dev-tooling@shopware-ai-coding-tools ``` - **PHP:** PHPStan static analysis, ECS code style, PHPUnit test runner with coverage gap analysis, Symfony Console, Rector @@ -52,7 +62,7 @@ Three MCP servers for PHP and JavaScript operations plus an optional phpactor LS - **Storefront JS:** ESLint, Stylelint, Jest, Webpack builds - **PHP LSP (optional):** document symbols, hover, go-to-definition, and references via [phpactor](https://github.com/phpactor/phpactor) -After installing, also install `plugin-setup@shopware-ai-coding-tools`, then ask Claude to help you set up the plugin — the `dev-tooling-setting-up` skill walks you through configuration. Prerequisites: `jq`, restart after install. For LSP: `phpactor` binary available on the host (native) or inside the container (docker/docker-compose/vagrant/ddev). +After installing, also install `plugin-setup@shopware-ai-coding-tools`, then ask Claude to help you set up the plugin — the `dev-tooling-setting-up` skill walks you through configuration. Prerequisites: `bash` 4.1+ (macOS's stock `/bin/bash` is 3.2), `jq`, and a restart after install. For LSP: `phpactor` binary available on the host (native) or inside the container (docker/docker-compose/vagrant/ddev). On Codex, trust the plugin hooks when Codex asks: the SessionStart directive then names the session directory, and the model binds each server by calling `set_project_root` itself. A server binds only to a project that already holds its configuration file (`.mcp-php-tooling.json` for `php-tooling`, `.mcp-js-tooling.json` for the two JS servers) unless `MCP_PHP_TOOLING_CONFIG` or `MCP_JS_TOOLING_CONFIG` names an existing file, and `plugin-setup` has no Codex manifest, so write that file by hand. Exporting `PROJECT_ROOT` before starting Codex is the alternative; see the [plugin README](./plugins/dev-tooling/README.md) for its caveat. See [full documentation](./plugins/dev-tooling/README.md) for configuration and tool reference. diff --git a/plugin-tests/dev-tooling/codex_launch.bats b/plugin-tests/dev-tooling/codex_launch.bats new file mode 100644 index 00000000..1caa25a0 --- /dev/null +++ b/plugin-tests/dev-tooling/codex_launch.bats @@ -0,0 +1,686 @@ +#!/usr/bin/env bats +# bats file_tags=dev-tooling,launch +bats_require_minimum_version 1.11.0 + +# Starts the real servers the way each host does. Codex starts a plugin MCP +# server with its working directory at the plugin root, a cleared environment, +# and the command relative to that root; the server then has no project root +# until set_project_root binds one. Claude Code starts it in the project. + +load 'test_helper/common_setup' + +SOURCE_PLUGIN_DIR="${REPO_ROOT}/plugins/dev-tooling" + +INITIALIZE='{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"bats","version":"1"}}}' + +setup() { + # The servers' environment wrappers build docker, vagrant and ddev commands; + # none of these tests may run one, so a real binary on PATH is refused. + container_cli_refuse_real + + # A copy laid out as an installed plugin, so the plugin root the servers + # measure against is the copy's and their logs stay out of the checkout. + PLUGIN="${BATS_TEST_TMPDIR}/plugin" + mkdir -p "${PLUGIN}" + cp -R "${SOURCE_PLUGIN_DIR}/shared" "${SOURCE_PLUGIN_DIR}/mcp-server-php" \ + "${SOURCE_PLUGIN_DIR}/mcp-server-js-admin" "${SOURCE_PLUGIN_DIR}/mcp-server-js-storefront" "${PLUGIN}/" + rm -f "${PLUGIN}"/mcp-server-*/server.log + PLUGIN=$(cd "${PLUGIN}" && pwd -P) + + PROJECT="${BATS_TEST_TMPDIR}/project" + mkdir -p "${PROJECT}/src/Administration/Resources/app/administration" \ + "${PROJECT}/src/Storefront/Resources/app/storefront" + printf '{"environment":"native"}\n' > "${PROJECT}/.mcp-php-tooling.json" + printf '{"environment":"native"}\n' > "${PROJECT}/.mcp-js-tooling.json" + PROJECT=$(cd "${PROJECT}" && pwd -P) + + # composer and npm stand-ins record where they ran and with what, so a + # call that ran against the project — or ran at all — is visible. + STUB_BIN="${BATS_TEST_TMPDIR}/bin" + CALLS_LOG="${BATS_TEST_TMPDIR}/calls.log" + mkdir -p "${STUB_BIN}" + _write_stub composer + _write_stub npm + _write_stub ddev + + SERVER_TMP="${BATS_TEST_TMPDIR}/server-tmp" + mkdir -p "${SERVER_TMP}" + SERVER_ENV=() +} + +_write_stub() { + cat > "${STUB_BIN}/$1" <> '${CALLS_LOG}' +STUB + chmod +x "${STUB_BIN}/$1" +} + +# One tools/call request line. +# Args: $1 = id, $2 = tool name, $3 = arguments JSON (default {}) +_call() { + local args="${3:-}" + [[ -n "${args}" ]] || args='{}' + jq -nc --argjson id "$1" --arg name "$2" --argjson args "${args}" \ + '{jsonrpc: "2.0", id: $id, method: "tools/call", params: {name: $name, arguments: $args}}' +} + +# The set_project_root request binding $2. +# Args: $1 = id, $2 = project_root +_bind() { + _call "$1" set_project_root "$(jq -nc --arg root "$2" '{project_root: $root}')" +} + +# Runs one server over the given requests, preceded by initialize, with a +# cleared environment carrying HOME, PATH and TMPDIR plus SERVER_ENV. +# Args: $1 = working directory, $2 = command as the host passes it, +# $3... = request lines +# Globals: sets RESPONSES, the file holding one response per line +_serve() { + local cwd="$1" command="$2" + shift 2 + local requests="${BATS_TEST_TMPDIR}/requests.jsonl" + printf '%s\n' "${INITIALIZE}" "$@" > "${requests}" + RESPONSES="${BATS_TEST_TMPDIR}/responses.jsonl" + ( cd "${cwd}" && exec env -i HOME="${HOME}" PATH="${STUB_BIN}:${PATH}" TMPDIR="${SERVER_TMP}" \ + ${SERVER_ENV[@]+"${SERVER_ENV[@]}"} "${command}" ) \ + < "${requests}" > "${RESPONSES}" 2> "${BATS_TEST_TMPDIR}/server-stderr" || true +} + +# Starts the server as Codex does: plugin root as cwd, command relative to it. +# Args: $1 = server directory suffix (php, js-admin, js-storefront), $2... = requests +_codex_serve() { + local server="$1" + shift + _serve "${PLUGIN}" "./mcp-server-${server}/server.sh" "$@" +} + +# Sets $output to the text of the response to request $1, prefixed "ERROR: " +# when the result is a tool error. +_result() { + run jq -r --argjson id "$1" \ + 'select(.id == $id) | (if .result.isError then "ERROR: " else "" end) + .result.content[0].text' "${RESPONSES}" +} + +_assert_lists_tools() { + local server="$1" name="$2" + _codex_serve "${server}" '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' + + run jq -r 'select(.id == 0) | .result.serverInfo.name' "${RESPONSES}" + assert_output "${name}" + run jq -r 'select(.id == 1) | .result.tools[].name' "${RESPONSES}" + assert_line "set_project_root" +} + +_assert_unbound_refusal() { + local server="$1" name="$2" tool="$3" + _codex_serve "${server}" "$(_call 1 "${tool}")" + + _result 1 + assert_output --partial "ERROR: Error executing ${tool}: No project root is set." + assert_output --partial "Call \`set_project_root\` on the \`${name}\` MCP server with the absolute path of your project" + assert [ ! -e "${CALLS_LOG}" ] +} + +_assert_bound_call_runs_in() { + local server="$1" tool="$2" expected_call="$3" + _codex_serve "${server}" "$(_bind 1 "${PROJECT}")" "$(_call 2 "${tool}")" + + _result 2 + assert_output --partial "Project root: ${PROJECT} (launch)" + run cat "${CALLS_LOG}" + assert_output "${expected_call}" +} + +_assert_bind_refused() { + local root="$1" reason="$2" + _codex_serve php "$(_bind 1 "${root}")" + + _result 1 + assert_output --partial "ERROR: Error executing set_project_root: " + assert_output --partial "${reason}" +} + +# The refusal a bind settled on is not one the server keeps: a refused root +# binds nothing, so the next ordinary call still refuses and a directory that +# does load binds afterwards. +# Args: $1 = directory to bind, $2 = expected refusal reason +_assert_bind_refused_leaves_unbound() { + local root="$1" reason="$2" + _codex_serve php "$(_bind 1 "${root}")" "$(_call 2 phpstan_analyze)" "$(_bind 3 "${PROJECT}")" + + _result 1 + assert_output --partial "ERROR: Error executing set_project_root: Refusing to bind \"${root}\": " + assert_output --partial "${reason}" + _result 2 + assert_output --partial "ERROR: Error executing phpstan_analyze: No project root is set." + _result 3 + assert_output --partial "Project root bound: ${PROJECT} (configuration: ${PROJECT}/.mcp-php-tooling.json; environment: native)." + assert [ ! -e "${CALLS_LOG}" ] +} + +# A project directory holding one configuration file with the given content. +# Args: $1 = directory name, $2 = .mcp-php-tooling.json content +# Stdout: the directory +_project_with_config() { + local dir="${BATS_TEST_TMPDIR}/$1" + mkdir -p "${dir}" + printf '%s\n' "$2" > "${dir}/.mcp-php-tooling.json" + printf '%s\n' "${dir}" +} + +@test "php-tooling started in its plugin directory answers initialize and lists its tools" { + _assert_lists_tools php php-tooling +} + +@test "js-admin-tooling started in its plugin directory answers initialize and lists its tools" { + _assert_lists_tools js-admin js-admin-tooling +} + +@test "js-storefront-tooling started in its plugin directory answers initialize and lists its tools" { + _assert_lists_tools js-storefront js-storefront-tooling +} + +@test "a bound php-tooling runs a tool in the project it was bound to" { + _assert_bound_call_runs_in php phpstan_analyze "composer|${PROJECT}|phpstan -- --error-format=json" +} + +@test "a bound js-admin-tooling runs a tool in the project's administration package" { + _assert_bound_call_runs_in js-admin tsc_check \ + "npm|${PROJECT}/src/Administration/Resources/app/administration|run lint:types" +} + +@test "a bound js-storefront-tooling runs a tool in the project's storefront package" { + _assert_bound_call_runs_in js-storefront webpack_build \ + "npm|${PROJECT}/src/Storefront/Resources/app/storefront|run production" +} + +# ddev finds its project from the directory it is run in, and a launch-root +# call enters none, so a bound server has to be standing in the project. +@test "a project bound under ddev runs its commands from the project directory" { + printf '{"environment":"ddev"}\n' > "${PROJECT}/.mcp-php-tooling.json" + + _codex_serve php "$(_bind 1 "${PROJECT}")" "$(_call 2 phpstan_analyze)" + + run cat "${CALLS_LOG}" + assert_output "ddev|${PROJECT}|composer phpstan -- --error-format=json" +} + +# The compose resolver lives in a module a bound server loads at startup; a +# configured workdir answers without asking docker. +@test "a project bound under docker-compose resolves its configured container workdir" { + printf '{"environment":"docker-compose","docker-compose":{"workdir":"/var/www/html"}}\n' \ + > "${PROJECT}/.mcp-php-tooling.json" + + _codex_serve php "$(_bind 1 "${PROJECT}")" "$(_call 2 cwd)" + + _result 2 + assert_line "Working directory, no scope applied: /var/www/html" +} + +@test "binding a project reports the configuration and environment it loaded" { + _codex_serve php "$(_bind 1 "${PROJECT}")" + + _result 1 + assert_output --partial "Project root bound: ${PROJECT} (configuration: ${PROJECT}/.mcp-php-tooling.json; environment: native)." +} + +# The bind is the server's launch root from then on, so another directory is +# held to the rules any launch root applies to a sticky root. +@test "a different directory after the bind is held to the worktree rules" { + local other="${BATS_TEST_TMPDIR}/other" + mkdir -p "${other}" + _codex_serve php "$(_bind 1 "${PROJECT}")" "$(_bind 2 "${other}")" + + _result 2 + assert_output --partial "ERROR: Error executing set_project_root: Refusing to run against \"${other}\": it holds no \".git\" file" +} + +# Accepting a worktree needs the bound root's common git directory, which the +# bind records for later calls the way startup records it for a bound server. +@test "a linked worktree of the bound project can become the sticky root" { + git -C "${PROJECT}" init -q + git -C "${PROJECT}" -c user.email=test@example.com -c user.name=Test -c commit.gpgsign=false commit -q --allow-empty -m seed + local worktree="${PROJECT}/.claude/worktrees/feature" + git -C "${PROJECT}" worktree add -q "${worktree}" -b feature + worktree_gitdir_relative "${worktree}" + + _codex_serve php "$(_bind 1 "${PROJECT}")" "$(_bind 2 "${worktree}")" + + _result 2 + assert_output --partial "Sticky project root set. Effective project root: ${worktree} (sticky)." +} + +# A repository with one linked worktree under the project, relinked relatively. +# Globals: sets WORKTREE +_project_with_worktree() { + git -C "${PROJECT}" init -q + git -C "${PROJECT}" -c user.email=test@example.com -c user.name=Test -c commit.gpgsign=false commit -q --allow-empty -m seed + WORKTREE="${PROJECT}/.claude/worktrees/feature" + git -C "${PROJECT}" worktree add -q "${WORKTREE}" -b feature + worktree_gitdir_relative "${WORKTREE}" +} + +# Naming the launch root again hands the server back to it: whatever the +# sticky worktree was, the root every later call resolves to is the launch one. +@test "naming the bound project after sticking a worktree makes the project the effective root" { + _project_with_worktree + + _codex_serve php "$(_bind 1 "${PROJECT}")" "$(_bind 2 "${WORKTREE}")" "$(_bind 3 "${PROJECT}")" "$(_call 4 cwd)" + + _result 3 + assert_output "Sticky project root cleared. Effective project root: ${PROJECT} (launch)." + _result 4 + assert_line "Effective project root: ${PROJECT} (launch)" + assert_line "Sticky project root: none" +} + +@test "naming the launch project after sticking a worktree makes the project the effective root on a server started in it" { + _project_with_worktree + + _serve "${PROJECT}" "${PLUGIN}/mcp-server-php/server.sh" \ + "$(_bind 1 "${WORKTREE}")" "$(_bind 2 "${PROJECT}")" "$(_call 3 cwd)" + + _result 2 + assert_output "Sticky project root cleared. Effective project root: ${PROJECT} (launch)." + _result 3 + assert_line "Effective project root: ${PROJECT} (launch)" + assert_line "Sticky project root: none" +} + +# The bind keeps the path as the caller spelled it unless it is made physical, +# and a later call naming the same tree by its physical path is then compared +# as a different root and refused as a foreign worktree. +# Args: $1 = the spelling to bind +_assert_bind_spelling_matches_canonical() { + _codex_serve php "$(_bind 1 "$1")" "$(_call 2 phpstan_analyze "$(jq -nc --arg root "${PROJECT}" '{project_root: $root}')")" + + _result 1 + assert_output --partial "Project root bound: ${PROJECT} " + _result 2 + assert_output --partial "Project root: ${PROJECT} (call)" + run cat "${CALLS_LOG}" + assert_output "composer|${PROJECT}|phpstan -- --error-format=json" +} + +@test "a project bound with a trailing slash is the launch root for a call naming it without one" { + _assert_bind_spelling_matches_canonical "${PROJECT}/" +} + +@test "a project bound through a symlink is the launch root for a call naming its physical path" { + ln -s "${PROJECT}" "${BATS_TEST_TMPDIR}/project-link" + + _assert_bind_spelling_matches_canonical "${BATS_TEST_TMPDIR}/project-link" +} + +# A relative override resolves against the process's directory. A server +# started in a project reads it there; one started in the plugin directory has +# to read it against the project the bind names. +@test "the bind resolves a relative MCP_PHP_TOOLING_CONFIG against the bound project" { + printf '{"environment":"native"}\n' > "${PROJECT}/override.json" + SERVER_ENV=("MCP_PHP_TOOLING_CONFIG=override.json") + + _codex_serve php "$(_bind 1 "${PROJECT}")" "$(_call 2 phpstan_analyze)" + + _result 1 + assert_output --partial "Project root bound: ${PROJECT} (configuration: ${PROJECT}/override.json; environment: native)." + run cat "${CALLS_LOG}" + assert_output "composer|${PROJECT}|phpstan -- --error-format=json" +} + +@test "the bind resolves a relative MCP_JS_TOOLING_CONFIG against the bound project" { + printf '{"environment":"native"}\n' > "${PROJECT}/override.json" + SERVER_ENV=("MCP_JS_TOOLING_CONFIG=override.json") + + _codex_serve js-admin "$(_bind 1 "${PROJECT}")" + + _result 1 + assert_output --partial "Project root bound: ${PROJECT} (configuration: ${PROJECT}/override.json; environment: native)." +} + +# The bind makes the override absolute inside its own call; a later call starts +# from the host's environment again. Read from the worktree it enters, a +# relative value names no file, and the project's setting is dropped from the +# command with nothing said. +_bind_relative_override_with_phpstan_config() { + printf '{"environment":"native","phpstan":{"config":"custom.neon"}}\n' > "${PROJECT}/override.json" + SERVER_ENV=("MCP_PHP_TOOLING_CONFIG=override.json") +} + +@test "a relative MCP_PHP_TOOLING_CONFIG still applies to a call after the bind that names a linked worktree" { + _project_with_worktree + mkdir -p "${WORKTREE}/vendor" + touch "${WORKTREE}/vendor/autoload.php" + _bind_relative_override_with_phpstan_config + + _codex_serve php "$(_bind 1 "${PROJECT}")" \ + "$(_call 2 phpstan_analyze "$(jq -nc --arg root "${WORKTREE}" '{project_root: $root}')")" + + run cat "${CALLS_LOG}" + assert_output "composer|${WORKTREE}|phpstan -- --configuration=custom.neon --error-format=json" +} + +@test "cwd reports the bound project's absolute path for a relative MCP_PHP_TOOLING_CONFIG" { + _bind_relative_override_with_phpstan_config + + _codex_serve php "$(_bind 1 "${PROJECT}")" "$(_call 2 cwd)" + + _result 2 + assert_line "Configuration in use: ${PROJECT}/override.json (from MCP_PHP_TOOLING_CONFIG; discovery is skipped)" +} + +# The clear path measures the root physically, as the bind does, so a spelling +# of the launch root that is not the stored string clears the sticky root too. +@test "naming the bound project with a trailing slash after sticking a worktree makes the project the effective root" { + _project_with_worktree + + _codex_serve php "$(_bind 1 "${PROJECT}")" "$(_bind 2 "${WORKTREE}")" "$(_bind 3 "${PROJECT}/")" "$(_call 4 cwd)" + + _result 3 + assert_output "Sticky project root cleared. Effective project root: ${PROJECT} (launch)." + _result 4 + assert_line "Sticky project root: none" +} + +# Every discovered file is merged into a temp file the bind hands to the +# process; a later call still reads it, here through the default scope only +# the second file declares. +@test "a bound project's merged configuration stays in force for later calls" { + mkdir -p "${PROJECT}/.claude" "${PROJECT}/custom/plugins/Launch" + printf '{"default_scope":"launch","scopes":{"launch":{"cwd":"custom/plugins/Launch"}}}\n' \ + > "${PROJECT}/.claude/.mcp-php-tooling.json" + + _codex_serve php "$(_bind 1 "${PROJECT}")" "$(_call 2 phpstan_analyze)" + + run cat "${CALLS_LOG}" + assert_output "composer|${PROJECT}/custom/plugins/Launch|phpstan -- --error-format=json" +} + +@test "a bound project's merged configuration is removed when the server exits" { + mkdir -p "${PROJECT}/.claude" + printf '{"default_scope":"shopware"}\n' > "${PROJECT}/.claude/.mcp-php-tooling.json" + + _codex_serve php "$(_bind 1 "${PROJECT}")" + + # The path comes from the server's log rather than from TMPDIR: macOS + # mktemp without a template ignores TMPDIR. A missing line is the + # precondition failing — no merge happened. + run sed -n 's/^.*\[INFO\] Merged config created: //p' "${PLUGIN}/mcp-server-php/server.log" + assert_output --regexp '^/.+' + assert [ ! -e "${output}" ] +} + +@test "the bind honors a configuration file named by MCP_PHP_TOOLING_CONFIG" { + local bare="${BATS_TEST_TMPDIR}/bare-project" + mkdir -p "${bare}" + bare=$(cd "${bare}" && pwd -P) + local override="${BATS_TEST_TMPDIR}/override.json" + printf '{"environment":"native"}\n' > "${override}" + SERVER_ENV=("MCP_PHP_TOOLING_CONFIG=${override}") + + _codex_serve php "$(_bind 1 "${bare}")" + + _result 1 + assert_output --partial "Project root bound: ${bare} (configuration: ${override}; environment: native)." +} + +@test "php-tooling refuses an ordinary tool call before a project is bound" { + _assert_unbound_refusal php php-tooling phpstan_analyze +} + +# The refusal comes before the call's own project_root is read, so naming a +# directory — a linked worktree, the main checkout — cannot stand in for the +# bind. A server a host started without PROJECT_ROOT and the model has not +# bound yet is what a first call from a worktree session reaches. +@test "an unbound server refuses a call naming a worktree or the main checkout as project_root" { + git -C "${PROJECT}" init -q + git -C "${PROJECT}" -c user.email=test@example.com -c user.name=Test -c commit.gpgsign=false commit -q --allow-empty -m seed + local worktree="${PROJECT}/.claude/worktrees/feature" + git -C "${PROJECT}" worktree add -q "${worktree}" -b feature + worktree_gitdir_relative "${worktree}" + + local phpstan_worktree phpstan_main prepare_worktree + phpstan_worktree=$(_call 1 phpstan_analyze "$(jq -nc --arg root "${worktree}" '{project_root: $root}')") + phpstan_main=$(_call 2 phpstan_analyze "$(jq -nc --arg root "${PROJECT}" '{project_root: $root}')") + prepare_worktree=$(_call 3 worktree_prepare "$(jq -nc --arg root "${worktree}" '{project_root: $root}')") + _codex_serve php "${phpstan_worktree}" "${phpstan_main}" "${prepare_worktree}" + + local id tool + for id in 1 2 3; do + tool=phpstan_analyze + [[ "${id}" -eq 3 ]] && tool=worktree_prepare + _result "${id}" + assert_output --partial "ERROR: Error executing ${tool}: No project root is set." + assert_output --partial "Call \`set_project_root\` on the \`php-tooling\` MCP server with the absolute path of your project" + done + assert [ ! -e "${CALLS_LOG}" ] +} + +@test "js-admin-tooling refuses an ordinary tool call before a project is bound" { + _assert_unbound_refusal js-admin js-admin-tooling tsc_check +} + +@test "js-storefront-tooling refuses an ordinary tool call before a project is bound" { + _assert_unbound_refusal js-storefront js-storefront-tooling webpack_build +} + +# Every tool the server declares, enumerated from its tools.json so a tool added +# later is held to the refusal without a test edit. The call carries a string +# for each required parameter, because the argument check runs before the tool. +# The count guards the enumeration: an empty list would pass every assertion. +# Args: $1 = server directory suffix, $2 = the server's name +_assert_every_tool_refuses_unbound() { + local server="$1" name="$2" + # Read through `run` so a jq error fails the test instead of truncating the list. + run jq -r '.tools[] | . as $tool + | select(.name != "set_project_root" and .name != "cwd") + | [.name, (reduce ($tool.inputSchema.required // [])[] as $key ({}; + .[$key] = (if $tool.inputSchema.properties[$key].type == "string" then "x" + else error("required parameter \($key) of \($tool.name) is not a string") end)) | tojson)] + | @tsv' "${PLUGIN}/mcp-server-${server}/tools.json" + assert_success + local -a rows=() + mapfile -t rows <<< "${output}" + assert [ "${#rows[@]}" -gt 0 ] + + local -a requests=() + local index tool arguments + for index in "${!rows[@]}"; do + IFS=$'\t' read -r tool arguments <<< "${rows[index]}" + requests+=("$(_call "$((index + 1))" "${tool}" "${arguments}")") + done + _codex_serve "${server}" "${requests[@]}" + + for index in "${!rows[@]}"; do + IFS=$'\t' read -r tool arguments <<< "${rows[index]}" + _result "$((index + 1))" + assert_output --partial "ERROR: Error executing ${tool}: No project root is set." + assert_output --partial "Call \`set_project_root\` on the \`${name}\` MCP server with the absolute path of your project" + done + assert [ ! -e "${CALLS_LOG}" ] +} + +@test "every php-tooling tool refuses before a project is bound" { + _assert_every_tool_refuses_unbound php php-tooling +} + +@test "every js-admin-tooling tool refuses before a project is bound" { + _assert_every_tool_refuses_unbound js-admin js-admin-tooling +} + +@test "every js-storefront-tooling tool refuses before a project is bound" { + _assert_every_tool_refuses_unbound js-storefront js-storefront-tooling +} + +# A configuration override names a file, not a project: the plugin directory +# must not become the root the tools run in. +@test "a configuration override does not bind a server started in its plugin directory" { + local override="${BATS_TEST_TMPDIR}/override.json" + printf '{"environment":"native"}\n' > "${override}" + SERVER_ENV=("MCP_PHP_TOOLING_CONFIG=${override}") + + _codex_serve php "$(_call 1 phpstan_analyze)" + + _result 1 + assert_output --partial "ERROR: Error executing phpstan_analyze: No project root is set." + assert [ ! -e "${CALLS_LOG}" ] +} + +# A host that does name a root can name one inside the plugin directory, which +# is not a project either: the plugin's own files must not become the root. +@test "a PROJECT_ROOT inside the plugin directory starts the server unbound" { + SERVER_ENV=("PROJECT_ROOT=${PLUGIN}/mcp-server-php") + + _codex_serve php "$(_call 1 phpstan_analyze)" + + _result 1 + assert_output --partial "ERROR: Error executing phpstan_analyze: No project root is set." + assert_output --partial "Call \`set_project_root\` on the \`php-tooling\` MCP server with the absolute path of your project" + assert [ ! -e "${CALLS_LOG}" ] +} + +@test "cwd before a project is bound reports no root and names set_project_root" { + _codex_serve php "$(_call 1 cwd)" + + _result 1 + assert_line "Effective project root: none (unbound)" + assert_line "Effective project root resolves: no" + assert_output --partial "Call \`set_project_root\` on the \`php-tooling\` MCP server" +} + +# An explicit "" is a caller error on a bound and an unbound server alike, not +# a missing project root. +@test "set_project_root refuses an empty project_root on an unbound server as an empty string" { + _codex_serve php "$(_bind 1 "")" + + _result 1 + assert_output "ERROR: Error executing set_project_root: Refusing to set the project root: \"project_root\" was supplied as an empty string. Pass the absolute path of your project to bind this server." +} + +@test "set_project_root refuses an empty project_root on a server started in the project as an empty string" { + _serve "${PROJECT}" "${PLUGIN}/mcp-server-php/server.sh" "$(_bind 1 "")" + + _result 1 + assert_output "ERROR: Error executing set_project_root: Refusing to set the project root: \"project_root\" was supplied as an empty string. Omit the parameter entirely to clear the sticky project root." +} + +@test "set_project_root refuses an empty project_root on a server bound through set_project_root as an empty string" { + _codex_serve php "$(_bind 1 "${PROJECT}")" "$(_bind 2 "")" + + _result 2 + assert_output "ERROR: Error executing set_project_root: Refusing to set the project root: \"project_root\" was supplied as an empty string. Omit the parameter entirely to clear the sticky project root." +} + +@test "set_project_root refuses a relative path" { + _assert_bind_refused "project" "the project root must be an absolute path" +} + +@test "set_project_root refuses a directory that does not exist" { + _assert_bind_refused "${BATS_TEST_TMPDIR}/missing" "Refusing to bind \"${BATS_TEST_TMPDIR}/missing\": it does not exist, or it is not a directory." +} + +@test "set_project_root refuses the plugin directory itself" { + _assert_bind_refused "${PLUGIN}" "it is this server's own plugin directory ${PLUGIN}, or inside it" +} + +@test "set_project_root refuses a directory inside the plugin directory" { + _assert_bind_refused "${PLUGIN}/mcp-server-php" "it is this server's own plugin directory ${PLUGIN}, or inside it" +} + +@test "set_project_root refuses a directory holding no configuration file" { + local bare="${BATS_TEST_TMPDIR}/bare-project" + mkdir -p "${bare}" + _assert_bind_refused "${bare}" "it holds no configuration file for this server — one of .mcp-php-tooling.json," +} + +# load_config finds the file and succeeds whatever it holds, so the shape of +# its content is what the bind has to refuse. +@test "set_project_root refuses a configuration that is not valid JSON" { + local broken + broken=$(_project_with_config broken-project 'not json at all') + + _assert_bind_refused_leaves_unbound "${broken}" 'does not parse as a JSON object' +} + +# Valid JSON of the wrong type reads as no keys at all through a reader that +# swallows the failure, so the type is checked before anything is read. +@test "set_project_root refuses a configuration that is a JSON array" { + local broken + broken=$(_project_with_config array-config-project '[1,2,3]') + + _assert_bind_refused_leaves_unbound "${broken}" 'does not parse as a JSON object' +} + +@test "set_project_root refuses a configuration whose default scope is not declared" { + local broken + broken=$(_project_with_config unscoped-project '{"environment":"native","default_scope":"nope"}') + + _assert_bind_refused_leaves_unbound "${broken}" 'is not usable — Config error: default_scope "nope" is not declared in scopes.' +} + +@test "set_project_root refuses a configuration declaring no environment" { + local broken + broken=$(_project_with_config environmentless-project '{"foo":1}') + + _assert_bind_refused_leaves_unbound "${broken}" 'declares no "environment" field' +} + +@test "a refused bind leaves the server unbound" { + local bare="${BATS_TEST_TMPDIR}/bare-project" + mkdir -p "${bare}" + + _codex_serve php "$(_bind 1 "${bare}")" "$(_call 2 phpstan_analyze)" + + _result 2 + assert_output --partial "ERROR: Error executing phpstan_analyze: No project root is set." +} + +# The bound launch is the Claude Code path and must behave as it always has. +@test "a server started in a project directory runs a tool there without a bind" { + _serve "${PROJECT}" "${PLUGIN}/mcp-server-php/server.sh" "$(_call 1 phpstan_analyze)" + + _result 1 + assert_output --partial "Project root: ${PROJECT} (launch)" + run cat "${CALLS_LOG}" + assert_output "composer|${PROJECT}|phpstan -- --error-format=json" +} + +# A server a host starts in the project reads a relative override against that +# project at startup; a call that enters a worktree afterwards is somewhere else. +@test "a relative MCP_PHP_TOOLING_CONFIG on a server started in the project still applies to a call naming a linked worktree" { + _project_with_worktree + mkdir -p "${WORKTREE}/vendor" + touch "${WORKTREE}/vendor/autoload.php" + _bind_relative_override_with_phpstan_config + + _serve "${PROJECT}" "${PLUGIN}/mcp-server-php/server.sh" \ + "$(_call 1 phpstan_analyze "$(jq -nc --arg root "${WORKTREE}" '{project_root: $root}')")" + + run cat "${CALLS_LOG}" + assert_output "composer|${WORKTREE}|phpstan -- --configuration=custom.neon --error-format=json" +} + +# Args: $1 = server directory suffix (js-admin, js-storefront), $2 = tool, +# $3 = the npm arguments the stub records +# The override declares a scope the project's own configuration does not, so a +# call naming it reaches the stub only when the override file was found. +_assert_js_relative_override_applies_to_worktree() { + _project_with_worktree + mkdir -p "${WORKTREE}/custom/node_modules" + printf '{"environment":"native","scopes":{"custom":{"cwd":"custom"}}}\n' > "${PROJECT}/override.json" + SERVER_ENV=("MCP_JS_TOOLING_CONFIG=override.json") + + _serve "${PROJECT}" "${PLUGIN}/mcp-server-$1/server.sh" \ + "$(_call 1 "$2" "$(jq -nc --arg root "${WORKTREE}" '{project_root: $root, scope: "custom"}')")" + + run cat "${CALLS_LOG}" + assert_output "npm|${WORKTREE}/custom|$3" +} + +@test "a relative MCP_JS_TOOLING_CONFIG on a js-admin-tooling server started in the project still applies to a call naming a linked worktree" { + _assert_js_relative_override_applies_to_worktree js-admin tsc_check "run lint:types" +} + +@test "a relative MCP_JS_TOOLING_CONFIG on a js-storefront-tooling server started in the project still applies to a call naming a linked worktree" { + _assert_js_relative_override_applies_to_worktree js-storefront webpack_build "run production" +} diff --git a/plugin-tests/dev-tooling/codex_manifest.bats b/plugin-tests/dev-tooling/codex_manifest.bats new file mode 100644 index 00000000..59670c13 --- /dev/null +++ b/plugin-tests/dev-tooling/codex_manifest.bats @@ -0,0 +1,245 @@ +#!/usr/bin/env bats +# bats file_tags=dev-tooling,codex +bats_require_minimum_version 1.11.0 + +# The Codex packaging of dev-tooling: .codex-plugin/plugin.json, codex.mcp.json +# and the repo marketplace at .agents/plugins/marketplace.json. Codex resolves a +# plugin MCP server's relative cwd against the plugin root and passes the command +# to the OS unchanged, so a "./..." command resolves only together with cwd ".". + +load 'test_helper/common_setup' + +PLUGIN_DIR="${REPO_ROOT}/plugins/dev-tooling" +CODEX_MANIFEST="${PLUGIN_DIR}/.codex-plugin/plugin.json" +CLAUDE_MANIFEST="${PLUGIN_DIR}/.claude-plugin/plugin.json" +CODEX_MCP="${PLUGIN_DIR}/codex.mcp.json" +CLAUDE_MCP="${PLUGIN_DIR}/.mcp.json" +CODEX_MARKETPLACE="${REPO_ROOT}/.agents/plugins/marketplace.json" +CLAUDE_MARKETPLACE="${REPO_ROOT}/.claude-plugin/marketplace.json" +DIRECTIVES="${PLUGIN_DIR}/hooks/prompts/mcp-tool-directives.md" +CODEX_DIRECTIVES="${PLUGIN_DIR}/hooks/prompts/mcp-tool-directives-codex.md" + +# Args: $1 = path of the file +_assert_json_object() { + run jq -e 'type == "object"' "$1" + assert_success + assert_output "true" +} + +# Args: $1 = server key in codex.mcp.json +_assert_server_launch() { + local command cwd + command=$(jq -r --arg k "$1" '.mcpServers[$k].command' "${CODEX_MCP}") + cwd=$(jq -r --arg k "$1" '.mcpServers[$k].cwd' "${CODEX_MCP}") + assert_equal "${cwd}" "." + assert_equal "${command#./}" "mcp-server-${1%-tooling}/server.sh" + assert [ -x "${PLUGIN_DIR}/${command#./}" ] +} + +# The override variable is derived from the server's own CONFIG_PREFIX by the +# rule config.sh applies, so a re-prefixed server cannot keep a stale name here +# that Codex would silently skip. +# Args: $1 = server key in codex.mcp.json +_assert_server_forwards_config_vars() { + local command prefix config_var + command=$(jq -r --arg k "$1" '.mcpServers[$k].command' "${CODEX_MCP}") + prefix=$(sed -n 's/^CONFIG_PREFIX="\([^"]*\)"$/\1/p' "${PLUGIN_DIR}/${command#./}") + assert [ -n "${prefix}" ] + config_var="MCP_$(printf '%s' "${prefix}" | tr '[:lower:]-' '[:upper:]_')_CONFIG" + run jq -c --arg k "$1" --arg v "${config_var}" \ + '[.mcpServers[$k].env_vars[] | select(. == "PROJECT_ROOT" or . == $v)] | sort' "${CODEX_MCP}" + assert_output "$(jq -cn --arg v "${config_var}" '[$v, "PROJECT_ROOT"] | sort')" +} + +# bats test_tags=static +@test "the codex manifest parses as a JSON object" { _assert_json_object "${CODEX_MANIFEST}"; } +# bats test_tags=static +@test "codex.mcp.json parses as a JSON object" { _assert_json_object "${CODEX_MCP}"; } +# bats test_tags=static +@test "the codex marketplace parses as a JSON object" { _assert_json_object "${CODEX_MARKETPLACE}"; } + +# bats test_tags=static +@test "the codex manifest version equals the claude manifest version" { + run jq -r '.version' "${CODEX_MANIFEST}" + assert_output "$(jq -r '.version' "${CLAUDE_MANIFEST}")" +} + +# The description and the keywords are the fields the two hosts differ on: the +# LSP is Claude Code only, and the Claude manifest advertises it in both. +# bats test_tags=static +@test "the codex manifest copies the claude manifest's shared metadata" { + local fields='{name, author, license, homepage, repository}' + run jq -cS "${fields}" "${CODEX_MANIFEST}" + assert_output "$(jq -cS "${fields}" "${CLAUDE_MANIFEST}")" +} + +# bats test_tags=static +@test "the codex manifest keywords are the claude manifest keywords without the LSP ones" { + run jq -c '.keywords' "${CODEX_MANIFEST}" + assert_output "$(jq -c '.keywords - ["lsp", "language-server"]' "${CLAUDE_MANIFEST}")" +} + +# bats test_tags=static +@test "the codex manifest description does not advertise the LSP" { + run jq -r '.description | test("\\blsp\\b|phpactor|language server"; "i")' "${CODEX_MANIFEST}" + assert_output "false" +} + +# Without a hooks field Codex loads hooks/hooks.json itself; a hooks field +# would replace that file rather than add to it. +# bats test_tags=static +@test "the codex manifest declares no hooks field" { + run jq -r 'has("hooks")' "${CODEX_MANIFEST}" + assert_output "false" +} + +# bats test_tags=static +@test "the codex manifest points mcpServers at an existing ./ file under the plugin root" { + local path + path=$(jq -r '.mcpServers' "${CODEX_MANIFEST}") + assert_equal "${path:0:2}" "./" + assert [ -f "${PLUGIN_DIR}/${path#./}" ] +} + +# bats test_tags=static +@test "codex.mcp.json declares the same server keys as .mcp.json" { + run jq -c '.mcpServers | keys' "${CODEX_MCP}" + assert_output "$(jq -c '.mcpServers | keys' "${CLAUDE_MCP}")" +} + +# bats test_tags=static +@test "php-tooling launches its executable server script relative to the plugin root" { _assert_server_launch "php-tooling"; } +# bats test_tags=static +@test "js-admin-tooling launches its executable server script relative to the plugin root" { _assert_server_launch "js-admin-tooling"; } +# bats test_tags=static +@test "js-storefront-tooling launches its executable server script relative to the plugin root" { _assert_server_launch "js-storefront-tooling"; } + +# bats test_tags=static +@test "php-tooling forwards PROJECT_ROOT and its config override variable" { _assert_server_forwards_config_vars "php-tooling"; } +# bats test_tags=static +@test "js-admin-tooling forwards PROJECT_ROOT and its config override variable" { _assert_server_forwards_config_vars "js-admin-tooling"; } +# bats test_tags=static +@test "js-storefront-tooling forwards PROJECT_ROOT and its config override variable" { _assert_server_forwards_config_vars "js-storefront-tooling"; } + +# A variable added to one server's list and not the others leaves that one +# server without it under Codex, which forwards only what is listed. +# bats test_tags=static +@test "every codex server forwards the same variables apart from its config override" { + run jq -c '[.mcpServers[].env_vars | map(select(test("^MCP_.*_TOOLING_CONFIG$") | not))] | unique | length' "${CODEX_MCP}" + assert_output "1" +} + +# Codex forwards only the names a server lists, so a name dropped from one entry +# leaves that server running without the setting the environment wrappers read. +# The per-server override variable is asserted by the tests above. +# bats test_tags=static +@test "every codex server forwards the logging, container, vagrant and SSH variables" { + local required='["PROJECT_ROOT","MCP_LOG_STDERR","MCP_EXTRA_LOG_FILE","DOCKER_HOST","DOCKER_CONTEXT","DOCKER_CONFIG","DOCKER_CERT_PATH","DOCKER_TLS_VERIFY","COMPOSE_PROJECT_NAME","COMPOSE_FILE","COMPOSE_PROFILES","COMPOSE_PATH_SEPARATOR","VAGRANT_CWD","VAGRANT_HOME","VAGRANT_DOTFILE_PATH","VAGRANT_VAGRANTFILE","VAGRANT_DEFAULT_PROVIDER","SSH_AUTH_SOCK"]' + run jq -r '.mcpServers | length' "${CODEX_MCP}" + assert_output "3" + run jq -r --argjson required "${required}" \ + '.mcpServers | to_entries[] | .key as $k | ($required - .value.env_vars)[] | "\($k) lacks \(.)"' "${CODEX_MCP}" + assert_output "" +} + +# bats test_tags=static +@test "the codex marketplace carries the claude marketplace's name" { + run jq -r '.name' "${CODEX_MARKETPLACE}" + assert_output "$(jq -r '.name' "${CLAUDE_MARKETPLACE}")" +} + +# bats test_tags=static +@test "the codex marketplace lists dev-tooling only, as an available local plugin" { + run jq -c '[.plugins[] | {name, source, installation: .policy.installation}]' "${CODEX_MARKETPLACE}" + assert_output '[{"name":"dev-tooling","source":{"source":"local","path":"./plugins/dev-tooling"},"installation":"AVAILABLE"}]' +} + +# bats test_tags=static +@test "the codex marketplace entry's path holds a codex manifest" { + local path + path=$(jq -r '.plugins[0].source.path' "${CODEX_MARKETPLACE}") + assert [ -f "${REPO_ROOT}/${path#./}/.codex-plugin/plugin.json" ] +} + +# A bare tool name is ambiguous to a host that exposes the three servers' tools +# side by side, so every mention below the per-server listings names its server. +# Args: $1 = path of the directive prompt +_assert_mentions_name_their_server() { + local tools prose mentions + tools=$(jq -r '.tools[].name' "${PLUGIN_DIR}"/mcp-server-*/tools.json | sort -u | paste -sd '|' -) + prose=$(grep -v '^On the `' "$1") + mentions=$(grep -oE "\`(${tools})\`( on (the \`[a-z-]+\`|each dev-tooling|the dev-tooling) MCP server)?" <<< "${prose}") + assert [ -n "${mentions}" ] + run grep -v ' MCP server$' <<< "${mentions}" + assert_output "" +} + +# Args: $1 = path of the directive prompt +_assert_named_servers_provide_their_tool() { + local tools prose pairs + tools=$(jq -r '.tools[].name' "${PLUGIN_DIR}"/mcp-server-*/tools.json | sort -u | paste -sd '|' -) + prose=$(grep -v '^On the `' "$1") + # shellcheck disable=SC2016 # the backticks in the sed script are literal Markdown, not command substitution + pairs=$(grep -oE "\`(${tools})\` on the \`[a-z-]+\` MCP server" <<< "${prose}" \ + | sed -E 's/^`([^`]*)` on the `([^`]*)` MCP server$/\2 \1/' | sort -u) + assert [ -n "${pairs}" ] + run bash -c 'while read -r server tool; do + jq -e --arg t "${tool}" "any(.tools[]; .name == \$t)" "$1/mcp-server-${server%-tooling}/tools.json" >/dev/null 2>&1 \ + || printf "%s on %s\n" "${tool}" "${server}" + done <<< "$2"' _ "${PLUGIN_DIR}" "${pairs}" + assert_output "" +} + +# The per-server listing lines are the one place a bare tool name is allowed, so +# each must list exactly the tools its server's tools.json declares. +# Args: $1 = path of the directive prompt, $2 = server (php, js-admin, js-storefront), +# $3 = server name as the listing writes it +_assert_listing_matches_tools_json() { + local listed declared + # shellcheck disable=SC2016 # the backticks are literal Markdown, not command substitution + listed=$(grep "^On the \`$3\` MCP server:" "$1" | grep -oE '`[a-z_]+`' | tr -d '`' | sort) + declared=$(jq -r '.tools[].name' "${PLUGIN_DIR}/mcp-server-$2/tools.json" | sort) + assert [ -n "${listed}" ] + assert_equal "${listed}" "${declared}" +} + +# bats test_tags=directives +@test "every tool mention in the directive prose names its server" { _assert_mentions_name_their_server "${DIRECTIVES}"; } +# bats test_tags=directives +@test "every tool mention in the codex directive prose names its server" { _assert_mentions_name_their_server "${CODEX_DIRECTIVES}"; } + +# bats test_tags=directives +@test "every directive mention that names one server names a server providing that tool" { _assert_named_servers_provide_their_tool "${DIRECTIVES}"; } +# bats test_tags=directives +@test "every codex directive mention that names one server names a server providing that tool" { _assert_named_servers_provide_their_tool "${CODEX_DIRECTIVES}"; } + +# A ludtwig tool on js-storefront-tooling refuses a missing vendor/ with "Call worktree_prepare on the php-tooling server". +# bats test_tags=directives +@test "the Claude directives send a ludtwig vendor/ refusal to php-tooling instead of the server that refused" { + # shellcheck disable=SC2016 # literal backticks in the prompt text + run grep -F 'a ludtwig refusal on a missing vendor/ names `php-tooling`' "${DIRECTIVES}" + assert_success + run grep -F 'MCP server that refused' "${DIRECTIVES}" + assert_failure +} + +# bats test_tags=directives +@test "the directive listings match each server's tools.json" { + _assert_listing_matches_tools_json "${DIRECTIVES}" php php-tooling + _assert_listing_matches_tools_json "${DIRECTIVES}" js-admin js-admin-tooling + _assert_listing_matches_tools_json "${DIRECTIVES}" js-storefront js-storefront-tooling +} +# bats test_tags=directives +@test "the codex directive listings match each server's tools.json" { + _assert_listing_matches_tools_json "${CODEX_DIRECTIVES}" php php-tooling + _assert_listing_matches_tools_json "${CODEX_DIRECTIVES}" js-admin js-admin-tooling + _assert_listing_matches_tools_json "${CODEX_DIRECTIVES}" js-storefront js-storefront-tooling +} + +# Codex has no plugin agents and no worktree-switching tools. +# bats test_tags=directives +@test "the codex directives name no Claude Code-only agent or tool" { + assert [ -s "${CODEX_DIRECTIVES}" ] + run grep -nE 'dev-tooling-runner|subagent|EnterWorktree|ExitWorktree' "${CODEX_DIRECTIVES}" + assert_failure 1 +} diff --git a/plugin-tests/dev-tooling/js_admin_tools.bats b/plugin-tests/dev-tooling/js_admin_tools.bats index 61a037a5..d9c0cbb1 100644 --- a/plugin-tests/dev-tooling/js_admin_tools.bats +++ b/plugin-tests/dev-tooling/js_admin_tools.bats @@ -19,7 +19,32 @@ CONFIG_PREFIX="js-tooling" assert_success } -js_admin_hook_blocks() { assert_hook_blocks "check-js-admin-tools.sh" "$1" "$2"; } +# The block message names the tool and its server; neither host's mcp__ form. +js_admin_hook_blocks() { + assert_hook_blocks "check-js-admin-tools.sh" "$1" "$2" + assert_output --partial "on the \`js-admin-tooling\` MCP server" + refute_output --partial "mcp__" +} + +# bats test_tags=cwd +@test "reads the config from the hook input cwd when CLAUDE_PROJECT_DIR is unset" { + unset CLAUDE_PROJECT_DIR + local project + project=$(make_cwd_project "js-tooling" "ddev") + run_hook_with_cwd "check-js-admin-tools.sh" "npm run lint" "$project" + assert_failure 2 + assert_output --partial "'ddev' environment" +} + +# bats test_tags=cwd +@test "CLAUDE_PROJECT_DIR wins over the hook input cwd when both are set" { + local project + project=$(make_cwd_project "js-tooling" "ddev") + run_hook_with_cwd "check-js-admin-tools.sh" "npm run lint" "$project" + assert_failure 2 + assert_output --partial "'native' environment" + refute_output --partial "'ddev' environment" +} # bats test_tags=blocking bats_test_function --description "blocks npm run lint:scss → suggests stylelint_check" \ diff --git a/plugin-tests/dev-tooling/js_storefront_tools.bats b/plugin-tests/dev-tooling/js_storefront_tools.bats index 0da57592..6a650d7f 100644 --- a/plugin-tests/dev-tooling/js_storefront_tools.bats +++ b/plugin-tests/dev-tooling/js_storefront_tools.bats @@ -19,7 +19,32 @@ CONFIG_PREFIX="js-tooling" assert_output --partial "eslint_check" } -js_storefront_hook_blocks() { assert_hook_blocks "check-js-storefront-tools.sh" "$1" "$2"; } +# The block message names the tool and its server; neither host's mcp__ form. +js_storefront_hook_blocks() { + assert_hook_blocks "check-js-storefront-tools.sh" "$1" "$2" + assert_output --partial "on the \`js-storefront-tooling\` MCP server" + refute_output --partial "mcp__" +} + +# bats test_tags=cwd +@test "reads the config from the hook input cwd when CLAUDE_PROJECT_DIR is unset" { + unset CLAUDE_PROJECT_DIR + local project + project=$(make_cwd_project "js-tooling" "ddev") + run_hook_with_cwd "check-js-storefront-tools.sh" "cd src/Storefront && npm run lint:js" "$project" + assert_failure 2 + assert_output --partial "'ddev' environment" +} + +# bats test_tags=cwd +@test "CLAUDE_PROJECT_DIR wins over the hook input cwd when both are set" { + local project + project=$(make_cwd_project "js-tooling" "ddev") + run_hook_with_cwd "check-js-storefront-tools.sh" "cd src/Storefront && npm run lint:js" "$project" + assert_failure 2 + assert_output --partial "'native' environment" + refute_output --partial "'ddev' environment" +} # bats test_tags=blocking bats_test_function --description "blocks npm run lint:scss → suggests stylelint_check" \ diff --git a/plugin-tests/dev-tooling/php_tools.bats b/plugin-tests/dev-tooling/php_tools.bats index 4d571a38..814816bd 100644 --- a/plugin-tests/dev-tooling/php_tools.bats +++ b/plugin-tests/dev-tooling/php_tools.bats @@ -6,29 +6,64 @@ load 'test_helper/common_setup' CONFIG_PREFIX="php-tooling" -php_hook_blocks() { assert_hook_blocks "check-php-tools.sh" "$1" "$2"; } +# The block message names the tool and its server; neither host's mcp__ form. +php_hook_blocks() { + assert_hook_blocks "check-php-tools.sh" "$1" "$2" + assert_output --partial "on the \`php-tooling\` MCP server" + refute_output --partial "mcp__" +} # bats test_tags=blocking bats_test_function --description "blocks vendor/bin/phpstan → suggests phpstan_analyze" \ -- php_hook_blocks "vendor/bin/phpstan analyze src/" "phpstan_analyze" bats_test_function --description "blocks vendor/bin/ecs → suggests ecs_check/ecs_fix" \ - -- php_hook_blocks "vendor/bin/ecs check src/" "ecs_check or ecs_fix" + -- php_hook_blocks "vendor/bin/ecs check src/" "\`ecs_check\` or \`ecs_fix\`" bats_test_function --description "blocks vendor/bin/phpunit → suggests phpunit_run" \ -- php_hook_blocks "vendor/bin/phpunit tests/" "phpunit_run" bats_test_function --description "blocks bin/console → suggests console_run/console_list" \ - -- php_hook_blocks "bin/console cache:clear" "console_run or console_list" + -- php_hook_blocks "bin/console cache:clear" "\`console_run\` or \`console_list\`" bats_test_function --description "blocks && compound command → suggests phpstan_analyze" \ -- php_hook_blocks "git pull && vendor/bin/phpstan analyze" "phpstan_analyze" bats_test_function --description "blocks composer phpstan → suggests phpstan_analyze" \ -- php_hook_blocks "composer phpstan -- src/" "phpstan_analyze" bats_test_function --description "blocks composer ecs → suggests ecs_check/ecs_fix" \ - -- php_hook_blocks "composer ecs -- src/" "ecs_check or ecs_fix" + -- php_hook_blocks "composer ecs -- src/" "\`ecs_check\` or \`ecs_fix\`" bats_test_function --description "blocks vendor/bin/rector → suggests rector_fix/rector_check" \ - -- php_hook_blocks "vendor/bin/rector process src/" "rector_fix or rector_check" + -- php_hook_blocks "vendor/bin/rector process src/" "\`rector_fix\` or \`rector_check\`" bats_test_function --description "blocks composer rector → suggests rector_fix/rector_check" \ - -- php_hook_blocks "composer rector -- --dry-run" "rector_fix or rector_check" + -- php_hook_blocks "composer rector -- --dry-run" "\`rector_fix\` or \`rector_check\`" bats_test_function --description "blocks php bin/console → suggests console_run/console_list" \ - -- php_hook_blocks "php bin/console cache:clear" "console_run or console_list" + -- php_hook_blocks "php bin/console cache:clear" "\`console_run\` or \`console_list\`" + +# bats test_tags=cwd +@test "reads the config from the hook input cwd when CLAUDE_PROJECT_DIR is unset" { + unset CLAUDE_PROJECT_DIR + local project + project=$(make_cwd_project "php-tooling" "ddev") + run_hook_with_cwd "check-php-tools.sh" "vendor/bin/phpstan analyze" "$project" + assert_failure 2 + assert_output --partial "'ddev' environment" +} + +# bats test_tags=cwd +@test "an empty CLAUDE_PROJECT_DIR falls through to the hook input cwd" { + export CLAUDE_PROJECT_DIR="" + local project + project=$(make_cwd_project "php-tooling" "ddev") + run_hook_with_cwd "check-php-tools.sh" "vendor/bin/phpstan analyze" "$project" + assert_failure 2 + assert_output --partial "'ddev' environment" +} + +# bats test_tags=cwd +@test "CLAUDE_PROJECT_DIR wins over the hook input cwd when both are set" { + local project + project=$(make_cwd_project "php-tooling" "ddev") + run_hook_with_cwd "check-php-tools.sh" "vendor/bin/phpstan analyze" "$project" + assert_failure 2 + assert_output --partial "'native' environment" + refute_output --partial "'ddev' environment" +} # bats test_tags=allow @test "allows unrelated commands" { diff --git a/plugin-tests/dev-tooling/phpstan_baseline.bats b/plugin-tests/dev-tooling/phpstan_baseline.bats index f4fc47d7..55382aca 100644 --- a/plugin-tests/dev-tooling/phpstan_baseline.bats +++ b/plugin-tests/dev-tooling/phpstan_baseline.bats @@ -191,3 +191,53 @@ create_neon_baseline() { assert_success echo "$output" | jq -e '.hookSpecificOutput.additionalContext | contains("src/Foo.php")' } + +# bats test_tags=cwd +@test "CLAUDE_PROJECT_DIR wins over cwd from input when both are set" { + create_php_baseline "src/Foo.php" + local other="${BATS_TEST_TMPDIR}/other-project" + mkdir -p "$other" + local input + input=$(printf '{"tool_input": {"paths": ["src/Foo.php"]}, "cwd": "%s"}' "$other") + run_baseline_hook "$input" + assert_success + echo "$output" | jq -e '.hookSpecificOutput.additionalContext | contains("src/Foo.php")' +} + +# bats test_tags=cwd +@test "silent when CLAUDE_PROJECT_DIR is unset and input carries no cwd" { + create_php_baseline "src/Foo.php" + unset CLAUDE_PROJECT_DIR + run bash -c 'printf "%s" "$1" | bash "$2"' _ '{"tool_input": {"paths": ["src/Foo.php"]}}' "$BASELINE_SCRIPT" + assert_success + assert_output "" +} + +# bats test_tags=output +@test "warning names no mcp__ tool form" { + create_php_baseline "src/Foo.php" + run_baseline_hook "$(make_post_input '["src/Foo.php"]')" + assert_success + refute_output --partial "mcp__" +} + +# ============================================================================ +# PostToolUse matcher +# ============================================================================ + +# Claude Code names plugin MCP tools mcp__plugin____; +# Codex sanitizes the server segment to mcp__php_tooling__. +# bats test_tags=config +@test "hooks.json phpstan matcher matches the Claude and Codex tool names only" { + local matcher + matcher=$(jq -r '.hooks.PostToolUse[] | select(.hooks[0].command | contains("check-phpstan-baseline.sh")) | .matcher' \ + "${REPO_ROOT}/plugins/dev-tooling/hooks/hooks.json") + [[ -n "$matcher" ]] + + [[ "mcp__plugin_dev-tooling_php-tooling__phpstan_analyze" =~ $matcher ]] + [[ "mcp__php_tooling__phpstan_analyze" =~ $matcher ]] + [[ ! "mcp__php-tooling__phpstan_analyze" =~ $matcher ]] + [[ ! "mcp__plugin_dev-tooling_php-tooling__phpstan_analyze_x" =~ $matcher ]] + [[ ! "mcp__php_tooling__phpunit_run" =~ $matcher ]] + [[ ! "xmcp__php_tooling__phpstan_analyze" =~ $matcher ]] +} diff --git a/plugin-tests/dev-tooling/session_start.bats b/plugin-tests/dev-tooling/session_start.bats index 013fc906..5de8a677 100644 --- a/plugin-tests/dev-tooling/session_start.bats +++ b/plugin-tests/dev-tooling/session_start.bats @@ -62,3 +62,180 @@ run_session_start() { assert_success echo "$output" | jq -e '.hookSpecificOutput.additionalContext | length > 0' } + +# ============================================================================ +# Project directory: CLAUDE_PROJECT_DIR, else the hook input's cwd +# ============================================================================ + +# Run the hook with the given JSON on stdin. +run_session_start_with_input() { + run bash -c 'printf "%s" "$1" | bash "$2"' _ "$1" "$SESSION_SCRIPT" +} + +# bats test_tags=output +@test "directives name no mcp__ tool form" { + run_session_start + assert_success + refute_output --partial "mcp__" +} + +# The bind hint is not gated on enforcement, so with .cwd present the output is +# not empty; what enforcement read from .cwd controls is the directives. +# The sentence only the Codex prompt carries is the one refuted: with +# CLAUDE_PROJECT_DIR unset the Codex prompt is the one selected, so it is what +# appears if the .cwd read fails and enforcement defaults to on. +# bats test_tags=cwd +@test "reads enforcement from the hook input cwd when CLAUDE_PROJECT_DIR is unset" { + unset CLAUDE_PROJECT_DIR + local project="${BATS_TEST_TMPDIR}/cwd-project" + mkdir -p "$project" + echo '{"enforce_mcp_tools": false}' > "${project}/.mcp-php-tooling.json" + echo '{"enforce_mcp_tools": false}' > "${project}/.mcp-js-tooling.json" + run_session_start_with_input "$(jq -cn --arg cwd "$project" '{cwd: $cwd}')" + assert_success + run jq -r '.hookSpecificOutput.additionalContext' <<< "$output" + assert_success + refute_output --partial "A server started without a project root refuses every tool" +} + +# Codex names the session directory only in .cwd and starts the servers unbound +# in the plugin directory; the hint names the root that binds them. +# bats test_tags=cwd +@test "names the hook input cwd as the root to bind when CLAUDE_PROJECT_DIR is unset" { + unset CLAUDE_PROJECT_DIR + local project="${BATS_TEST_TMPDIR}/cwd-project" + mkdir -p "$project" + run_session_start_with_input "$(jq -cn --arg cwd "$project" '{cwd: $cwd}')" + assert_success + run jq -r '.hookSpecificOutput.additionalContext' <<< "$output" + assert_success + assert_output --partial "reports that no project root is set, call \`set_project_root\` on that server with \`${project}\` and retry." +} + +# bats test_tags=cwd +@test "adds the bind hint even when enforcement read from the hook input cwd is off" { + unset CLAUDE_PROJECT_DIR + local project="${BATS_TEST_TMPDIR}/cwd-project" + mkdir -p "$project" + echo '{"enforce_mcp_tools": false}' > "${project}/.mcp-php-tooling.json" + echo '{"enforce_mcp_tools": false}' > "${project}/.mcp-js-tooling.json" + run_session_start_with_input "$(jq -cn --arg cwd "$project" '{cwd: $cwd}')" + assert_success + run jq -r '.hookSpecificOutput.additionalContext' <<< "$output" + assert_success + assert_output --partial "call \`set_project_root\` on that server with \`${project}\`" +} + +# A host that sets CLAUDE_PROJECT_DIR starts the servers bound, so the hint +# would only send the session to a tool it has no reason to call. +# bats test_tags=cwd +@test "omits the bind hint when CLAUDE_PROJECT_DIR is set" { + local project="${BATS_TEST_TMPDIR}/cwd-project" + mkdir -p "$project" + run_session_start_with_input "$(jq -cn --arg cwd "$project" '{cwd: $cwd}')" + assert_success + refute_output --partial "no project root is set" +} + +# bats test_tags=cwd +@test "renders scopes from the hook input cwd when CLAUDE_PROJECT_DIR is unset" { + unset CLAUDE_PROJECT_DIR + local project="${BATS_TEST_TMPDIR}/cwd-project" + mkdir -p "$project" + echo '{"scopes": {"plugin-z": {"cwd": "custom/plugins/Z"}}}' > "${project}/.mcp-php-tooling.json" + run_session_start_with_input "$(jq -cn --arg cwd "$project" '{cwd: $cwd}')" + assert_success + assert_output --partial "php-tooling scopes" + assert_output --partial "plugin-z" +} + +# bats test_tags=cwd +@test "CLAUDE_PROJECT_DIR wins over the hook input cwd when both are set" { + local project="${BATS_TEST_TMPDIR}/cwd-project" + mkdir -p "$project" + echo '{"scopes": {"plugin-x": {"cwd": "custom/plugins/X"}}}' > "${BATS_TEST_TMPDIR}/.mcp-php-tooling.json" + echo '{"scopes": {"plugin-z": {"cwd": "custom/plugins/Z"}}}' > "${project}/.mcp-php-tooling.json" + run_session_start_with_input "$(jq -cn --arg cwd "$project" '{cwd: $cwd}')" + assert_success + assert_output --partial "plugin-x" + refute_output --partial "plugin-z" +} + +# ============================================================================ +# Directive prompt by host +# ============================================================================ + +# Codex has no plugin subagents and no worktree-switching tools, so the Claude +# prompt would send the model to a runner and a tool that do not exist there. +# bats test_tags=cwd +@test "emits the Codex directives, free of Claude-only guidance, when CLAUDE_PROJECT_DIR is unset" { + unset CLAUDE_PROJECT_DIR + local project="${BATS_TEST_TMPDIR}/cwd-project" + mkdir -p "$project" + run_session_start_with_input "$(jq -cn --arg cwd "$project" '{cwd: $cwd}')" + assert_success + run jq -r '.hookSpecificOutput.additionalContext' <<< "$output" + assert_success + assert_output --partial "The final section of this context, when present, names the project directory" + refute_output --partial "dev-tooling-runner" + refute_output --partial "EnterWorktree" + refute_output --partial "ExitWorktree" + refute_output --partial "subagent" +} + +# With no scopes declared and no bind hint, the context is the Claude prompt file +# and nothing else. +# bats test_tags=cwd +@test "emits exactly the Claude directives, without Codex-only text, when CLAUDE_PROJECT_DIR is set" { + local project="${BATS_TEST_TMPDIR}/cwd-project" + mkdir -p "$project" + run_session_start_with_input "$(jq -cn --arg cwd "$project" '{cwd: $cwd}')" + assert_success + run jq -r '.hookSpecificOutput.additionalContext' <<< "$output" + assert_success + assert_output "$(cat "${REPO_ROOT}/plugins/dev-tooling/hooks/prompts/mcp-tool-directives.md")" + refute_output --partial "The final section of this context, when present, names the project directory" +} + +# The Codex prompt opens the context and is followed by the bind hint it points at. +# bats test_tags=cwd +@test "the Codex directives open the context and are followed by the bind hint" { + unset CLAUDE_PROJECT_DIR + local project="${BATS_TEST_TMPDIR}/cwd-project" + mkdir -p "$project" + run_session_start_with_input "$(jq -cn --arg cwd "$project" '{cwd: $cwd}')" + assert_success + local hook_output="$output" + run jq -r '.hookSpecificOutput.additionalContext | split("\n") | .[0]' <<< "$hook_output" + assert_success + assert_output --partial "through the dev-tooling MCP servers, not the shell" + run jq -r '.hookSpecificOutput.additionalContext | split("\n\n") | last' <<< "$hook_output" + assert_success + assert_output --partial "call \`set_project_root\` on that server with \`${project}\` and retry." +} + +# A host whose stdin is empty or is not JSON names no directory. Directives +# still go out, without the bind section that needs one. +# bats test_tags=cwd +@test "emits the Codex directives and no bind hint when the hook input is empty" { + unset CLAUDE_PROJECT_DIR + run_session_start_with_input "" + assert_success + run jq -r '.hookSpecificOutput.additionalContext' <<< "$output" + assert_success + assert_output --partial "A server started without a project root refuses every tool" + refute_output --partial "reports that no project root is set" +} + +# bats test_tags=cwd +@test "emits the Codex directives and no bind hint when the hook input is not JSON" { + unset CLAUDE_PROJECT_DIR + # The parse error jq reports goes to stderr; the hook's stdout is the JSON. + # shellcheck disable=SC2016 # the script is single-quoted so the inner bash, not this shell, expands $1 + run --separate-stderr bash -c 'printf "%s" "not json" | bash "$1"' _ "$SESSION_SCRIPT" + assert_success + run jq -r '.hookSpecificOutput.additionalContext' <<< "$output" + assert_success + assert_output --partial "A server started without a project root refuses every tool" + refute_output --partial "reports that no project root is set" +} diff --git a/plugin-tests/dev-tooling/test_helper/common_setup.bash b/plugin-tests/dev-tooling/test_helper/common_setup.bash index 8c16078c..310c3fb4 100644 --- a/plugin-tests/dev-tooling/test_helper/common_setup.bash +++ b/plugin-tests/dev-tooling/test_helper/common_setup.bash @@ -24,6 +24,26 @@ teardown() { unset CLAUDE_PROJECT_DIR } +# Run a PreToolUse hook script with the hook input's cwd set. Codex sets no +# CLAUDE_PROJECT_DIR, so the cwd is how it names the project. +# Args: $1 = script name, $2 = bash command, $3 = cwd +run_hook_with_cwd() { + local payload + payload=$(jq -cn --arg cmd "$2" --arg cwd "$3" '{tool_input: {command: $cmd}, cwd: $cwd}') + run bash -c 'printf "%s" "$1" | bash "$2"' _ "$payload" "${SCRIPTS_DIR}/$1" +} + +# Create a project directory holding an MCP config of the given environment, +# distinct from the one setup_config writes at BATS_TEST_TMPDIR. +# Args: $1 = config prefix, $2 = environment +# Stdout: the project directory +make_cwd_project() { + local dir="${BATS_TEST_TMPDIR}/cwd-project" + mkdir -p "$dir" + jq -n --arg env "$2" '{environment: $env}' > "${dir}/.mcp-${1}.json" + printf '%s\n' "$dir" +} + # The path $2 relative to the directory $1. Both must be absolute and # canonical. No external tool computes this portably — BSD realpath has no # --relative-to — and the fixtures below have both sides already. diff --git a/plugin-tests/dev-tooling/worktree_conformance.bats b/plugin-tests/dev-tooling/worktree_conformance.bats index b5d5c5ea..bd367d69 100644 --- a/plugin-tests/dev-tooling/worktree_conformance.bats +++ b/plugin-tests/dev-tooling/worktree_conformance.bats @@ -91,6 +91,7 @@ LINT_ENV="native" LINT_WORKDIR="\${PROJECT_ROOT}" source "\${PLUGIN_DIR}/shared/worktree.sh" worktree_state_init +worktree_launch_common_init trap worktree_state_cleanup EXIT # A lib that fails to source would otherwise drop its tools from the # enumeration below and leave every remaining tool reporting OK, so the scan diff --git a/plugin-tests/dev-tooling/worktree_js_tools.bats b/plugin-tests/dev-tooling/worktree_js_tools.bats index c7ceaa5c..ae6ef39a 100644 --- a/plugin-tests/dev-tooling/worktree_js_tools.bats +++ b/plugin-tests/dev-tooling/worktree_js_tools.bats @@ -67,6 +67,7 @@ LINT_ENV="native" LINT_WORKDIR="\${PROJECT_ROOT}" source "\${PLUGIN_DIR}/shared/worktree.sh" worktree_state_init +worktree_launch_common_init trap worktree_state_cleanup EXIT HEADER diff --git a/plugin-tests/dev-tooling/worktree_php_tools.bats b/plugin-tests/dev-tooling/worktree_php_tools.bats index 27f8e580..a50940e9 100644 --- a/plugin-tests/dev-tooling/worktree_php_tools.bats +++ b/plugin-tests/dev-tooling/worktree_php_tools.bats @@ -48,6 +48,7 @@ setup() { # shellcheck source=/dev/null source "${PLUGIN_DIR}/shared/worktree.sh" worktree_state_init + worktree_launch_common_init # Captures both the shell's cwd (proves the resolver's cd reached this # call) and LINT_WORKDIR (proves the resolver also rebound the variable # every real command-wrapping path reads). A regression that keeps the cd diff --git a/plugin-tests/dev-tooling/worktree_resolution.bats b/plugin-tests/dev-tooling/worktree_resolution.bats index 58367ae9..0b055fc7 100644 --- a/plugin-tests/dev-tooling/worktree_resolution.bats +++ b/plugin-tests/dev-tooling/worktree_resolution.bats @@ -62,6 +62,7 @@ setup() { # shellcheck source=/dev/null source "${PLUGIN_DIR}/shared/worktree.sh" worktree_state_init + worktree_launch_common_init # Before any test body runs: the real exec_command reaches a container CLI # under a container environment. A test that means to exercise the probe # calls stub_worktree_probe to replace this. @@ -571,6 +572,7 @@ LINT_ENV="native" LINT_WORKDIR="${PROJECT_ROOT}" source "${PLUGIN_DIR}/shared/worktree.sh" worktree_state_init +worktree_launch_common_init ( worktree_resolve_root "{\"project_root\":\"${WORKTREE_A}\"}" >/dev/null 2>&1 ) @@ -631,6 +633,7 @@ LINT_ENV="native" LINT_WORKDIR="${PROJECT_ROOT}" source "${PLUGIN_DIR}/shared/worktree.sh" worktree_state_init +worktree_launch_common_init _exists() { ls -1d "$1" 2>/dev/null | wc -l | tr -d ' '; } @@ -706,6 +709,7 @@ LINT_ENV="native" LINT_WORKDIR="${PROJECT_ROOT}" source "${PLUGIN_DIR}/shared/worktree.sh" worktree_state_init +worktree_launch_common_init _exists() { ls -1d "$1" 2>/dev/null | wc -l | tr -d ' '; } REPORT_FILE="${TMPDIR}/setter-report" diff --git a/plugin-tests/dev-tooling/worktree_state.bats b/plugin-tests/dev-tooling/worktree_state.bats index 67367d27..1c838da4 100644 --- a/plugin-tests/dev-tooling/worktree_state.bats +++ b/plugin-tests/dev-tooling/worktree_state.bats @@ -56,6 +56,7 @@ setup() { # shellcheck source=/dev/null source "${PLUGIN_DIR}/shared/worktree.sh" worktree_state_init + worktree_launch_common_init # Before any test body runs: the real exec_command reaches a container CLI # under a container environment. A test that means to exercise the probe # calls stub_worktree_probe to replace this. diff --git a/plugin-tests/shopware-env/hook_enforcement.bats b/plugin-tests/shopware-env/hook_enforcement.bats index b1b5d554..f33db3c0 100644 --- a/plugin-tests/shopware-env/hook_enforcement.bats +++ b/plugin-tests/shopware-env/hook_enforcement.bats @@ -36,11 +36,11 @@ bats_test_function --description "blocks bin/console plugin:activate → suggest # frontend build chain commands bats_test_function --description "blocks bin/console bundle:dump → suggests frontend_build_*" \ - -- lifecycle_hook_blocks "bin/console bundle:dump" "frontend_build_admin or frontend_build_storefront" + -- lifecycle_hook_blocks "bin/console bundle:dump" "\`frontend_build_admin\` or \`frontend_build_storefront\`" bats_test_function --description "blocks bin/console theme:compile → suggests frontend_build_storefront" \ -- lifecycle_hook_blocks "bin/console theme:compile" "frontend_build_storefront" bats_test_function --description "blocks bin/console assets:install → suggests frontend_build_*" \ - -- lifecycle_hook_blocks "bin/console assets:install" "frontend_build_admin or frontend_build_storefront" + -- lifecycle_hook_blocks "bin/console assets:install" "\`frontend_build_admin\` or \`frontend_build_storefront\`" @test "allows unrelated commands without block message" { run_hook "check-lifecycle-tools.sh" "git status" @@ -57,3 +57,20 @@ bats_test_function --description "blocks bin/console assets:install → suggests assert_success refute_output --partial "install_dependencies" } + +@test "block message names the tool and server without a host mcp__ prefix" { + run_hook "check-lifecycle-tools.sh" "composer install" + assert_failure 2 + assert_output --partial "Use \`install_dependencies\` on the \`lifecycle-tooling\` MCP server instead!" + refute_output --partial "mcp__" +} + +# bats test_tags=cwd +@test "reads the config from the hook input cwd when CLAUDE_PROJECT_DIR is unset" { + unset CLAUDE_PROJECT_DIR + local project + project=$(make_cwd_project "php-tooling" "ddev") + run_hook_with_cwd "check-lifecycle-tools.sh" "composer install" "$project" + assert_failure 2 + assert_output --partial "'ddev' environment" +} diff --git a/plugin-tests/shopware-env/test_helper/common_setup.bash b/plugin-tests/shopware-env/test_helper/common_setup.bash index 39618357..a88d90f5 100644 --- a/plugin-tests/shopware-env/test_helper/common_setup.bash +++ b/plugin-tests/shopware-env/test_helper/common_setup.bash @@ -13,6 +13,26 @@ setup_config() { printf '%s\n' "$content" > "${BATS_TEST_TMPDIR}/.mcp-${prefix}.json" } +# Run a PreToolUse hook script with the hook input's cwd set. Codex sets no +# CLAUDE_PROJECT_DIR, so the cwd is how it names the project. +# Args: $1 = script name, $2 = bash command, $3 = cwd +run_hook_with_cwd() { + local payload + payload=$(jq -cn --arg cmd "$2" --arg cwd "$3" '{tool_input: {command: $cmd}, cwd: $cwd}') + run bash -c 'printf "%s" "$1" | bash "$2"' _ "$payload" "${SCRIPTS_DIR}/$1" +} + +# Create a project directory holding an MCP config of the given environment, +# distinct from the one setup_config writes at BATS_TEST_TMPDIR. +# Args: $1 = config prefix, $2 = environment +# Stdout: the project directory +make_cwd_project() { + local dir="${BATS_TEST_TMPDIR}/cwd-project" + mkdir -p "$dir" + jq -n --arg env "$2" '{environment: $env}' > "${dir}/.mcp-${1}.json" + printf '%s\n' "$dir" +} + # Setup for MCP lifecycle tool tests. # Stubs log/exec_command, sources environment.sh + resolve_env.sh, then # sources the given tool library. diff --git a/plugins/dev-tooling/.claude-plugin/plugin.json b/plugins/dev-tooling/.claude-plugin/plugin.json index 26054066..2ab9e6c5 100644 --- a/plugins/dev-tooling/.claude-plugin/plugin.json +++ b/plugins/dev-tooling/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dev-tooling", - "version": "4.0.0", + "version": "4.1.0", "description": "Three MCP servers for PHP and JavaScript operations plus an optional PHP language server. PHP (php-tooling): PHPStan, ECS, Rector, PHPUnit, PHPUnit coverage gap analysis, Symfony Console. Administration (js-admin-tooling): ESLint, Stylelint, Prettier, Jest, TypeScript, Vite builds. Storefront (js-storefront-tooling): ESLint, Stylelint, Jest, Webpack builds. LSP (opt-in): phpactor for PHP, routed through a Python URI-rewriting proxy for containerized environments. Includes SessionStart hook that injects MCP tool directives and PreToolUse hooks that enforce MCP tool usage by blocking bash commands. Supports native, Docker, Docker Compose, Vagrant and DDEV environments.", "author": { "name": "Shopware Labs" diff --git a/plugins/dev-tooling/.codex-plugin/plugin.json b/plugins/dev-tooling/.codex-plugin/plugin.json new file mode 100644 index 00000000..7e7aadca --- /dev/null +++ b/plugins/dev-tooling/.codex-plugin/plugin.json @@ -0,0 +1,41 @@ +{ + "name": "dev-tooling", + "version": "4.1.0", + "description": "Three MCP servers for PHP and JavaScript operations. PHP (php-tooling): PHPStan, ECS, Rector, PHPUnit, PHPUnit coverage gap analysis, Symfony Console. Administration (js-admin-tooling): ESLint, Stylelint, Prettier, Jest, TypeScript, Vite builds. Storefront (js-storefront-tooling): ESLint, Stylelint, Jest, Webpack builds. Includes SessionStart hook that injects MCP tool directives and PreToolUse hooks that enforce MCP tool usage by blocking bash commands. Supports native, Docker, Docker Compose, Vagrant and DDEV environments.", + "author": { + "name": "Shopware Labs" + }, + "license": "MIT", + "keywords": [ + "php", + "javascript", + "phpstan", + "ecs", + "rector", + "phpunit", + "eslint", + "stylelint", + "prettier", + "jest", + "typescript", + "vite", + "webpack", + "mcp", + "hooks", + "static-analysis", + "testing", + "code-quality", + "linting", + "shopware", + "twig" + ], + "homepage": "https://github.com/shopwareLabs/ai-coding-tools/tree/main/plugins/dev-tooling", + "repository": "https://github.com/shopwareLabs/ai-coding-tools", + "mcpServers": "./codex.mcp.json", + "interface": { + "displayName": "Dev Tooling", + "shortDescription": "PHPStan, ECS, PHPUnit, ESLint, Stylelint, Jest and more for Shopware via MCP servers", + "developerName": "Shopware Labs", + "category": "Developer Tools" + } +} diff --git a/plugins/dev-tooling/AGENTS.md b/plugins/dev-tooling/AGENTS.md index af4abe8b..ce623c85 100644 --- a/plugins/dev-tooling/AGENTS.md +++ b/plugins/dev-tooling/AGENTS.md @@ -14,9 +14,11 @@ plugins/dev-tooling/ ├── AGENTS.md # LLM navigation guide (this file) ├── CLAUDE.md # Points to AGENTS.md ├── CHANGELOG.md # Version history -├── .claude-plugin/ # Plugin manifest (plugin.json: name, version, metadata) -├── .mcp.json # MCP server registration (php-tooling, js-admin-tooling, js-storefront-tooling) -├── .lsp.json # LSP server configuration (phpactor PHP LSP) +├── .claude-plugin/ # Claude Code plugin manifest (plugin.json: name, version, metadata) +├── .codex-plugin/ # Codex plugin manifest (plugin.json: same name and version; mcpServers points at codex.mcp.json) +├── .mcp.json # MCP server registration for Claude Code (php-tooling, js-admin-tooling, js-storefront-tooling) +├── codex.mcp.json # MCP server registration for Codex: same three servers, each launched with `cwd` "." relative to the plugin root +├── .lsp.json # LSP server configuration (phpactor PHP LSP; Claude Code only) │ ├── agents/ # AGENTS (dev-tooling check/fix executor) │ └── dev-tooling-runner.md # Lean runner; given targets + checks/fixes, runs them and returns a pass/fail report (haiku) @@ -24,11 +26,12 @@ plugins/dev-tooling/ ├── hooks/ # HOOKS (MCP tool enforcement) │ ├── hooks.json # Hook configuration (SessionStart + PreToolUse + PostToolUse) │ ├── prompts/ -│ │ ├── mcp-tool-directives.md # SessionStart prompt: MCP tool listing and usage rules +│ │ ├── mcp-tool-directives.md # SessionStart prompt for Claude Code: MCP tool listing and usage rules +│ │ ├── mcp-tool-directives-codex.md # SessionStart prompt for Codex: the same tool listing, without the runner agent and EnterWorktree/ExitWorktree guidance │ │ ├── lsp-directives-header.md # SessionStart prompt: LSP preamble, emitted when an LSP is enabled │ │ └── lsp-directives-php.md # SessionStart prompt: phpactor tool listing and usage rules │ └── scripts/ -│ ├── session-start.sh # SessionStart hook: reads prompt file, checks enforcement, outputs JSON +│ ├── session-start.sh # SessionStart hook: picks the prompt file by host, checks enforcement, outputs JSON │ ├── lsp-directives.sh # SessionStart hook: emits the LSP directives when .lsp-php-tooling.json enables one │ ├── check-php-tools.sh # Blocks PHPStan, ECS, PHPUnit, Rector, bin/console bash commands │ ├── check-js-admin-tools.sh # Blocks Administration npm/npx commands (ESLint, Stylelint, Prettier, Jest, TSC, Vite) @@ -36,11 +39,12 @@ plugins/dev-tooling/ │ ├── check-phpstan-baseline.sh # PostToolUse hook: warns when analyzed paths appear in phpstan-baseline.neon │ ├── worktree-directives.sh # PostToolUse hook on EnterWorktree|ExitWorktree: set_project_root reminder; on enter also the repair command for an absolute gitdir pointer and the worktree.baseRef warning │ └── lib/ -│ └── common.sh # Shared: parse_hook_input(), load_mcp_config(), block_tool() +│ └── common.sh # Shared (template-synced from templates/hooks-shared/common.sh): parse_hook_input(), load_mcp_config(), resolve_project_dir(), block_tool() │ ├── shared/ # SHARED FRAMEWORK (language-agnostic) │ ├── mcpserver_core.sh # JSON-RPC 2.0 protocol handler + validate_tool_arguments() │ ├── config.sh # Config discovery & merging (parameterized via CONFIG_PREFIX) +│ ├── launch.sh # Launch mode: bound, or unbound when the host started the server in this plugin's own directory (dev-tooling-owned, not templated) │ ├── environment.sh # Environment detection, PHP & JS command wrapping, argument quoting, path guards, environment-side path mapping, noise filtering │ ├── worktree.sh # Per-call root resolution and worktree validation: worktree_enter(), set_project_root, cwd (dev-tooling-owned, not templated) │ ├── server_run.sh # Shared server tail: run_mcp_server() call and why it is not isolated in a subshell (dev-tooling-owned, not templated) @@ -101,28 +105,29 @@ plugins/dev-tooling/ ## 🧱 Component Overview This plugin provides: -- **Three MCP Servers** via `.mcp.json`: +- **Three MCP Servers**, registered in `.mcp.json` for Claude Code and in `codex.mcp.json` for Codex — the same three servers, but the Codex file launches each one with `cwd` `.`, so it starts unbound against the plugin directory: - `php-tooling` - PHP linting/testing tools - `js-admin-tooling` - Administration JavaScript tools (Vue 3/Vite) - `js-storefront-tooling` - Storefront JavaScript tools (vanilla JS/Webpack): `eslint_check`, `eslint_fix`, `stylelint_check`, `stylelint_fix`, `jest_run`, `vitest_run`, `ludtwig_check`, `ludtwig_fix`, `webpack_build`, `worktree_prepare` -- **PHP LSP (phpactor, opt-in)** via `.lsp.json`: +- **Codex packaging**: `.codex-plugin/plugin.json` (same name, version and other shared metadata as the Claude Code manifest, but a description and keywords without the Claude Code only components; `mcpServers` pointing at `codex.mcp.json` and no `hooks` field, so Codex loads `hooks/hooks.json` itself) plus, at the repository root, `.agents/plugins/marketplace.json`, which lists `dev-tooling` as an available local plugin for `codex plugin marketplace add`. Codex reads that marketplace before `.claude-plugin/marketplace.json` and resolves a plugin's `.codex-plugin/plugin.json` before `.claude-plugin/plugin.json`. `codex.mcp.json` repeats the three servers with a relative `command` and `cwd` `.`, because Codex does not expand `${CLAUDE_PLUGIN_ROOT}`, and a Codex MCP server receives only a small default environment (`HOME`, `PATH`, `SHELL`, `USER`, `TMPDIR` and a few more) plus the variables its entry declares — so each entry lists what to forward: `PROJECT_ROOT`, the server's `MCP_*_TOOLING_CONFIG` override, `MCP_LOG_STDERR`, `MCP_EXTRA_LOG_FILE`, the `DOCKER_*` set, the docker compose selectors `COMPOSE_PROJECT_NAME`, `COMPOSE_FILE`, `COMPOSE_PROFILES` and `COMPOSE_PATH_SEPARATOR`, the Vagrant settings `VAGRANT_CWD`, `VAGRANT_HOME`, `VAGRANT_DOTFILE_PATH`, `VAGRANT_VAGRANTFILE` and `VAGRANT_DEFAULT_PROVIDER`, and `SSH_AUTH_SOCK`. +- **PHP LSP (phpactor, opt-in)** via `.lsp.json` (Claude Code only — Codex reads no `.lsp.json`): - Active PHP code discovery: document symbols, hover, go-to-definition, references - Runs natively on the host or inside a container (docker, docker-compose, vagrant, ddev) via the URI-rewriting proxy - Enabled via `.lsp-php-tooling.json` with `enabled: true`; falls back to the null stub otherwise - Requires the `phpactor` binary available where the LSP runs (host or container) -- **Subagent** via `agents/`: +- **Subagent** via `agents/` (Claude Code only — the Codex plugin manifest has no agents field): - `dev-tooling-runner` — executor for dev-tooling checks (and rule-driven fixes); run it to keep verbose output out of the conversation and get back a lean pass/fail report (runs on haiku); see [Agents](#agents) - **SessionStart Hooks** via `hooks/hooks.json`: - - `session-start.sh` injects MCP tool directives into conversation context at session start; prompt maintained in `hooks/prompts/mcp-tool-directives.md`; outputs JSON `additionalContext` format; also steers the active session to delegate heavy dev-tool runs to `dev-tooling-runner` - - `lsp-directives.sh` injects LSP usage directives, only when `.lsp-php-tooling.json` enables a language server + - `session-start.sh` injects MCP tool directives into conversation context at session start; prompt maintained in `hooks/prompts/mcp-tool-directives.md` when `CLAUDE_PROJECT_DIR` is set and in `hooks/prompts/mcp-tool-directives-codex.md` when it is not; outputs JSON `additionalContext` format; the Claude Code prompt also steers the active session to delegate heavy dev-tool runs to `dev-tooling-runner`, the Codex prompt does not. When `enforce_mcp_tools` is off but the host names the session directory only in the hook input's `.cwd` (Codex sets no `CLAUDE_PROJECT_DIR`), it still injects a short hint naming the directory and telling the model to call `set_project_root` on a server that reports no project root + - `lsp-directives.sh` injects LSP usage directives, only when `.lsp-php-tooling.json` enables a language server (Claude Code only) - **PreToolUse Hooks** via `hooks/hooks.json`: - - Blocks bash commands that should use MCP tools instead + - Blocks bash commands that should use MCP tools instead; the block message names the tool and its server, e.g. ``Use `phpstan_analyze` on the `php-tooling` MCP server instead!``, so it reads the same on Claude Code and Codex, which spell the tool name differently - PHP hook: blocks PHPStan, ECS, PHPUnit, Rector, bin/console - Admin JS hook: blocks ESLint, Stylelint, Prettier, Jest, TSC, lint_all/lint_twig, Vite commands - Storefront JS hook: blocks ESLint, Stylelint, Jest, Vitest, ludtwig, Webpack commands - **PostToolUse Hooks** via `hooks/hooks.json`: - - `check-phpstan-baseline.sh` warns when a targeted `phpstan_analyze` run covers paths listed in `phpstan-baseline.neon` (or `.php`) - - `worktree-directives.sh` fires on `EnterWorktree`/`ExitWorktree` and reminds the session to call `set_project_root` on all three servers; on enter it additionally names the repair command when the worktree's `.git` file carries an absolute gitdir pointer, and warns that a native worktree branches from the default remote branch unless `worktree.baseRef` is `head` + - `check-phpstan-baseline.sh` warns when a targeted `phpstan_analyze` run covers paths listed in `phpstan-baseline.neon` (or `.php`). Its matcher carries both host spellings of the tool name — `mcp__plugin_dev-tooling_php-tooling__phpstan_analyze` on Claude Code and `mcp__php_tooling__phpstan_analyze` on Codex, which replaces the dashes in the server name + - `worktree-directives.sh` fires on `EnterWorktree`/`ExitWorktree` and reminds the session to call `set_project_root` on all three servers; on enter it also names the repair command when the worktree's `.git` file carries an absolute gitdir pointer, and warns that a native worktree branches from the default remote branch unless `worktree.baseRef` is `head` (Claude Code only — those tools do not exist on Codex) - Both ignore `enforce_mcp_tools` and always run - `session-start.sh` and every PreToolUse hook are configurable via `enforce_mcp_tools: false` in config files; `lsp-directives.sh` and both PostToolUse hooks read no such flag - **Shared Framework** in `shared/` - reusable across all servers @@ -149,6 +154,7 @@ All MCP servers source shared framework files: ```bash source "${SHARED_DIR}/mcpserver_core.sh" # JSON-RPC protocol source "${SHARED_DIR}/config.sh" # Config discovery +source "${SHARED_DIR}/launch.sh" # Launch mode: bound, or unbound in the plugin directory source "${SHARED_DIR}/environment.sh" # Command execution ``` @@ -222,12 +228,12 @@ Both handle environment-specific execution (native/docker/docker-compose/vagrant | Add PHP tool | `mcp-server-php/lib/.sh` | `mcp-server-php/tools.json` | `tool_*()`, `exec_command()` | | Add Admin JS tool | `mcp-server-js-admin/lib/.sh` | `mcp-server-js-admin/tools.json` | `tool_*()`, `exec_npm_command()` | | Add Storefront JS tool | `mcp-server-js-storefront/lib/.sh` | `mcp-server-js-storefront/tools.json` | `tool_*()`, `exec_npm_command()` | -| Edit SessionStart prompt | `hooks/prompts/mcp-tool-directives.md` | `hooks/scripts/session-start.sh` | Plain markdown, read by script | +| Edit SessionStart prompt | `hooks/prompts/mcp-tool-directives.md` (Claude Code), `hooks/prompts/mcp-tool-directives-codex.md` (Codex) | `hooks/scripts/session-start.sh` | Plain markdown, read by script; the prompt is picked by whether `CLAUDE_PROJECT_DIR` is set | | Edit dev-tooling runner agent | `agents/dev-tooling-runner.md` | - | `tools`/`disallowedTools` (no Edit/Write, no console_*/unit_setup/worktree_prepare/set_project_root), check/fix-kind→tool table, report template | | Add blocked PHP command | `hooks/scripts/check-php-tools.sh` | - | `block_tool()`, grep pattern | | Add blocked Admin JS command | `hooks/scripts/check-js-admin-tools.sh` | - | `block_tool()`, `is_admin_context()` | | Add blocked Storefront JS command | `hooks/scripts/check-js-storefront-tools.sh` | - | `block_tool()`, `is_storefront_context()` | -| Modify shared hook logic | `hooks/scripts/lib/common.sh` | - | `parse_hook_input()`, `load_mcp_config()`, `block_tool()` | +| Modify shared hook logic | `templates/hooks-shared/common.sh` (edit template, sync per `.claude/rules/template-sync.md`) | - | `parse_hook_input()`, `load_mcp_config()`, `resolve_project_dir()`, `block_tool(server, description, tool...)` | | Disable hook enforcement | `.mcp-*-tooling.json` | - | `enforce_mcp_tools: false` | | Adjust hook timeout | `hooks/hooks.json` | - | `timeout` field (default: 5s) | | Add config location | `templates/mcp-shared/config.sh` (edit template, sync per `.claude/rules/template-sync.md`) | - | `CONFIG_LOCATIONS` array | @@ -236,7 +242,9 @@ Both handle environment-specific execution (native/docker/docker-compose/vagrant | Add noise filter pattern | `templates/mcp-shared/environment.sh` (edit template, sync per `.claude/rules/template-sync.md`) | - | `ENV_NOISE_PATTERNS` array, `_filter_env_noise()` | | Modify protocol | upstream `shopwareLabs/bash-mcp-sdk` (release pinned in `.mcp-sdk.lock`; never edit `shared/mcpserver_core.sh` in place) | - | `process_request()`, `handle_*()` | | Update tool schemas | `mcp-server-*/tools.json` | - | JSON Schema Draft 7 | -| Register new server | `.mcp.json` | - | `mcpServers` object | +| Register new server | `.mcp.json` for Claude Code; `codex.mcp.json` for Codex | - | `mcpServers` object; the Codex file needs a relative `command` and `cwd` `.` | +| Package the plugin for Codex | `.codex-plugin/plugin.json` | `codex.mcp.json`, repo `.agents/plugins/marketplace.json` | name, version and shared metadata equal to `.claude-plugin/plugin.json`, description and keywords differing (equality of the rest asserted by `codex_manifest.bats`, which also asserts the description and keywords omit the LSP), no `hooks` field | +| Change launch or bind behavior | `shared/launch.sh` | `shared/worktree.sh` (`worktree_launch_hydrate`, `_worktree_launch_bind`) | `LAUNCH_MODE`, the `launch` record in the state file, `worktree_state_cleanup` removing the merged config | ## ✏️ When to Modify What @@ -275,17 +283,17 @@ Both handle environment-specific execution (native/docker/docker-compose/vagrant ## 🔗 Integration with Other Plugins -MCP tool names follow pattern: `mcp____` +On Claude Code, a plugin-provided MCP server's tools are named `mcp__plugin____`. On Codex they keep the plain MCP form `mcp____`, with every character outside `[A-Za-z0-9_]` in the server name replaced by `_` — so `php-tooling` becomes `php_tooling`. ```yaml # PHP tools -tools: mcp__php-tooling__phpstan_analyze, mcp__php-tooling__ecs_check +tools: mcp__plugin_dev-tooling_php-tooling__phpstan_analyze, mcp__plugin_dev-tooling_php-tooling__ecs_check # Admin JS tools -tools: mcp__js-admin-tooling__eslint_check, mcp__js-admin-tooling__jest_run +tools: mcp__plugin_dev-tooling_js-admin-tooling__eslint_check, mcp__plugin_dev-tooling_js-admin-tooling__jest_run # Storefront JS tools -tools: mcp__js-storefront-tooling__eslint_check, mcp__js-storefront-tooling__webpack_build +tools: mcp__plugin_dev-tooling_js-storefront-tooling__eslint_check, mcp__plugin_dev-tooling_js-storefront-tooling__webpack_build ``` ## 🧪 Testing @@ -320,6 +328,8 @@ This plugin's own suites are in `plugin-tests/dev-tooling/`: | `worktree_php_tools.bats` | `phpunit_coverage_gaps`: clover paths relative to the mapped workdir, refused read | | `worktree_js_tools.bats` | `project_root` reaching the JS package directory, which the conformance scan does not capture | | `worktree_conformance.bats` | Every enumerated `tool_*` function runs in the named worktree and runs nothing against a refused one, with the enumeration reconciled against `tools.json` | +| `codex_launch.bats` | Launch mode and the bind: a server started in its plugin directory lists tools and refuses every other call, `set_project_root` binds it (exercised under `native`, `docker-compose` and `ddev`) and refuses a bad root, `cwd` reports both states, and a server started in a project directory runs a tool there with no bind | +| `codex_manifest.bats` | Codex packaging: `.codex-plugin/plugin.json` version and shared-metadata equality with the Claude Code manifest and a description and keywords free of the LSP, `codex.mcp.json` server launch and forwarded variables, the `.agents/plugins/marketplace.json` entry, and that both directive prompts (Claude Code and Codex) list each server's `tools.json` tools and name a server at every tool mention; the Codex prompt carries no runner-agent or `EnterWorktree`/`ExitWorktree` guidance | Three non-suite entries sit alongside them: `test_helper/common_setup.bash` — the shared helpers `setup_config()`/`setup()`, `setup_php_mcp_env()` (which stubs `log()` and `exec_command()` before sourcing a tool library), the worktree git fixtures `worktree_gitdir_relative()` and `_absolute_path_relative_to()`, and the probe stubs `stub_worktree_probe()` / `worktree_test_guard_probe()`; `fixtures/coverage/` — Clover XML samples for `mcp_tool_phpunit_coverage.bats`; and `lsp_proxy/` — the Python `pytest` suite for `shared/lsp_proxy.py`, run outside BATS. diff --git a/plugins/dev-tooling/CHANGELOG.md b/plugins/dev-tooling/CHANGELOG.md index ea9ea42d..10e80d81 100644 --- a/plugins/dev-tooling/CHANGELOG.md +++ b/plugins/dev-tooling/CHANGELOG.md @@ -5,6 +5,24 @@ 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.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.1.0] - 2026-10-07 + +### Added +- **The plugin installs on OpenAI Codex.** The repository now carries a Codex marketplace at `.agents/plugins/marketplace.json` listing `dev-tooling` alone, and the plugin carries a Codex manifest at `.codex-plugin/plugin.json` beside its Claude Code one: same name, version and metadata apart from a description and keywords that leave out the LSP, `mcpServers` pointing at the new `codex.mcp.json`, and no `hooks` field, so Codex keeps discovering `hooks/hooks.json` by itself. `codex.mcp.json` registers the same three servers with a command relative to the plugin root and `cwd` `.`, and lists the environment variables each entry forwards — `PROJECT_ROOT`, the server's `MCP_*_TOOLING_CONFIG` override, `MCP_LOG_STDERR`, `MCP_EXTRA_LOG_FILE`, the `DOCKER_*` family, the `COMPOSE_PROJECT_NAME`, `COMPOSE_FILE`, `COMPOSE_PROFILES` and `COMPOSE_PATH_SEPARATOR` selectors of docker compose, the `VAGRANT_CWD`, `VAGRANT_HOME`, `VAGRANT_DOTFILE_PATH`, `VAGRANT_VAGRANTFILE` and `VAGRANT_DEFAULT_PROVIDER` settings of Vagrant, and `SSH_AUTH_SOCK` — because Codex hands an MCP server its small default environment (`HOME`, `PATH`, `SHELL`, `USER`, `TMPDIR` and a few more) plus only what the entry declares. Add the marketplace with `codex plugin marketplace add shopwareLabs/ai-coding-tools` and install with `codex plugin add dev-tooling@shopware-ai-coding-tools`. +- **A server that starts without a project root can be bound at runtime.** The Codex manifest launches each server in the plugin's own directory (`cwd` `.` resolves against the plugin root), so there is no project root to derive a configuration from: the server answers `initialize` and `tools/list`, and every tool but `set_project_root` and `cwd` refuses until one is bound. The first `set_project_root` call carrying a path now binds it — the configuration is discovered, the environment derived, and the root recorded so every later call enters it — and from then on the server behaves as one started in that root, accepting a linked git worktree of it for later sticky changes. The binding is refused with a message naming the reason when the path is relative, missing, inside the plugin directory, holds no configuration file, or holds one that does not parse, declares no environment, or breaks the scope rules (a `default_scope` other than `shopware` that `scopes` does not declare, or a scope named `shopware`). A `cwd` call on an unbound server reports the root as `none (unbound)` and names the bind. Exporting `PROJECT_ROOT` with the project path before starting the host still binds at startup. +- **SessionStart directives for Codex.** A host that sets no `CLAUDE_PROJECT_DIR` receives `hooks/prompts/mcp-tool-directives-codex.md` instead of `hooks/prompts/mcp-tool-directives.md`, which sends the model to the `dev-tooling-runner` subagent and to `set_project_root` after `EnterWorktree`/`ExitWorktree` — an agent and two tools Codex does not have. The Codex prompt lists the same tools per server, keeps the worktree, Jest-versus-Vitest storefront and same-server-sequential rules, and points at the project-root section that follows it. When the hook event's `.cwd` names the session directory, that section names the directory and the `set_project_root` call that binds a server reporting no project root. It is emitted even when `enforce_mcp_tools` is `false`, because an unbound server refuses every tool whatever that flag says. A host that sets `CLAUDE_PROJECT_DIR` keeps the Claude Code prompt and gets no project-root section. +- **The hooks resolve the session's project directory from `CLAUDE_PROJECT_DIR` or, when that is unset, the hook event's `.cwd`.** Codex sets no `CLAUDE_PROJECT_DIR`, so the tool redirects, the scopes sections and the PHPStan baseline warning need the second source there. `resolve_project_dir` in `hooks/scripts/lib/common.sh` reads both, and the three callers that need a project directory — `load_mcp_config`, `session-start.sh` and `check-phpstan-baseline.sh` — share it; each reads stdin once and passes the event on. + +### Changed +- **`set_project_root` with the launch root clears the sticky root.** On a server started with a project root, naming that root was refused as a main checkout that is not a linked worktree. It now clears the sticky root, as omitting `project_root` does, and the three tool descriptions say so. +- **The block messages name the tool and its server instead of an MCP tool name.** They previously embedded `mcp____`, a form neither host uses — Claude Code prefixes a plugin's tools with `mcp__plugin__`, and Codex replaces the `-` in the server name with `_`. `block_tool` now takes the server name and the tool names and emits `Use \`\` on the \`\` MCP server instead!`, so one message reads correctly on both. +- **The SessionStart directives name the server of every tool they mention.** A bare tool name is ambiguous where three servers expose tools side by side, so each mention in `hooks/prompts/mcp-tool-directives.md` now either names the server providing the tool or, for the tools all three servers share, says it applies to each dev-tooling server. + +### Fixed +- **The Claude SessionStart directive no longer sends a ludtwig refusal to the wrong server.** It said to call `worktree_prepare` on the server that refused, but a ludtwig tool on `js-storefront-tooling` refuses a missing `vendor/` with "Call worktree_prepare on the php-tooling server". It now says to call it on the server the refusal names and notes the ludtwig case. +- **The PHPStan baseline hook never fired for a Claude Code plugin install.** Its matcher was `mcp__php-tooling__phpstan_analyze`, and Claude Code compares a plain matcher string exactly against the name it gives a plugin's tools, `mcp__plugin_dev-tooling_php-tooling__phpstan_analyze` — so the PostToolUse warning was dead on an installed plugin. The anchored matcher now accepts that name and the one Codex gives the same tool, `mcp__php_tooling__phpstan_analyze`. +- **A relative `MCP_PHP_TOOLING_CONFIG` or `MCP_JS_TOOLING_CONFIG` was lost on a call targeting a linked worktree.** A server started in the project kept the override as written, and the call enters the worktree before it reads the configuration, where a relative path names no file — so `phpstan_analyze` ran without the project's `--configuration`, with nothing said. The override is now made absolute against the directory the server started in, at startup. + ## [4.0.0] - 2026-09-14 ### Added diff --git a/plugins/dev-tooling/README.md b/plugins/dev-tooling/README.md index 12fb355e..e9d70a18 100644 --- a/plugins/dev-tooling/README.md +++ b/plugins/dev-tooling/README.md @@ -1,6 +1,6 @@ # Dev Tooling -PHP and JavaScript tooling for Shopware 6 exposed through three MCP servers, plus an optional PHP language server (phpactor) for active code discovery. Wraps the toolchain you already run on the command line: PHPStan, ECS, PHPUnit, Rector, Symfony Console, ESLint, Stylelint, Prettier, Jest, Vitest, ludtwig, TypeScript, and the Vite and Webpack builds. Works against native installs, Docker, Docker Compose, Vagrant, and DDEV, with the environment auto-detected from your config. +PHP and JavaScript tooling for Shopware 6 exposed through three MCP servers, plus an optional PHP language server (phpactor) for active code discovery. Wraps the toolchain you already run on the command line: PHPStan, ECS, PHPUnit, Rector, Symfony Console, ESLint, Stylelint, Prettier, Jest, Vitest, ludtwig, TypeScript, and the Vite and Webpack builds. Works against native installs, Docker, Docker Compose, Vagrant, and DDEV, with the environment auto-detected from your config. Runs on Claude Code and on OpenAI Codex; the LSP, the runner agent, and the worktree reminder hook are Claude Code only. ## 🧩 Features @@ -53,7 +53,7 @@ Optional Language Server Protocol integration for active PHP code discovery thro ## ⚡ Quick Start -### Installation +### Installation (Claude Code) ```bash /plugin install dev-tooling@shopware-ai-coding-tools @@ -62,6 +62,28 @@ Optional Language Server Protocol integration for active PHP code discovery thro > [!IMPORTANT] > Restart Claude Code after installation so the three MCP servers come up. +### Installation (Codex) + +The repository carries a Codex marketplace at [`.agents/plugins/marketplace.json`](../../.agents/plugins/marketplace.json), which lists this plugin and nothing else, and a Codex plugin manifest at [`.codex-plugin/plugin.json`](./.codex-plugin/plugin.json) beside the Claude Code one: + +```bash +codex plugin marketplace add shopwareLabs/ai-coding-tools +codex plugin add dev-tooling@shopware-ai-coding-tools +``` + +`codex.mcp.json` starts each server with `cwd: "."`, which Codex resolves against the plugin root — so the three servers come up in the plugin's own directory with no project root, and every tool but `set_project_root` and `cwd` refuses until one is bound. The recommended way to bind is on demand: trust the plugin hooks, and the SessionStart directive names the session directory and tells the model to call `set_project_root` on the server that refused. An untrusted SessionStart hook does not run, and then only the refusal text remains — it names the call but not the path. Exporting `PROJECT_ROOT` with the project path before starting Codex binds at startup instead. [🌳 Worktree Support](#-worktree-support) has the details. + +> [!WARNING] +> Codex's long-lived app-server process inherits `PROJECT_ROOT` from the environment it was started in and keeps it until that process restarts. Later sessions it serves, including other projects and the IDE extension, then start bound to that path. + +> [!IMPORTANT] +> A bind succeeds only for a project that already holds the server's configuration file: `.mcp-php-tooling.json` for `php-tooling`, `.mcp-js-tooling.json` for `js-admin-tooling` and `js-storefront-tooling`, at the project's top or in one of the supported tool directories. `MCP_PHP_TOOLING_CONFIG` or `MCP_JS_TOOLING_CONFIG` can name an existing file instead, and a relative path there is read against the project being bound. The file has to parse as a JSON object, declare `environment`, and keep to the scope rules: a `default_scope` other than `shopware` has to be declared in `scopes`, and no scope may be named `shopware`. Write it by hand, as [docs/configuration.md](./docs/configuration.md) describes: the Interactive Setup section below runs through `plugin-setup`, which has no Codex manifest, and the Verification section below is written for Claude Code's `/mcp` list. + +Codex runs plugin hooks only once you trust them. Its startup review and the `/hooks` command both record trust in your Codex user config, and a non-interactive `codex exec` runs the trusted hooks without asking, or all of them with `--dangerously-bypass-hook-trust`. An untrusted hook is skipped, so MCP tool enforcement and the PHPStan baseline warning stay off until you trust them. + +> [!NOTE] +> Three parts are Claude Code only: the phpactor LSP bridge (`.lsp.json`), the `dev-tooling-runner` subagent (Codex has no plugin agents), and the `EnterWorktree`/`ExitWorktree` reminder hook (those tools are Claude Code's). + ### Interactive Setup Install the `plugin-setup` plugin, then ask Claude to help you set up dev-tooling: @@ -92,7 +114,9 @@ The [full reference](./docs/reference.md) has parameter tables and examples for ## 🌳 Worktree Support -Every tool except `cwd`, on all three servers, takes an optional `project_root` to run one call against a linked git worktree of the root the server was launched in. `set_project_root` and `cwd` manage that state rather than targeting one call with it — `set_project_root` takes a `project_root` and sticks it for every later call on that server process (each server is a separate process holding its own sticky value), and omitting the argument clears it; `cwd` takes no parameters and reports what a server currently resolves to. +Every tool except `cwd`, on all three servers, takes an optional `project_root` to run one call against a linked git worktree of the server's project root. `set_project_root` and `cwd` manage that state rather than targeting one call with it — `set_project_root` takes a `project_root` and sticks it for every later call on that server process (each server is a separate process holding its own sticky value), and omitting the argument, or naming the launch root itself, clears it; `cwd` takes no parameters and reports what a server currently resolves to. + +A server started in its own plugin directory — what this plugin's Codex manifest produces, since it launches each server with `cwd` `.` — has no project root yet, so it has no launch root to fall back on and every tool but `set_project_root` and `cwd` refuses until one is bound. There the first `set_project_root` call with a path binds the server: it discovers the configuration, derives the environment, and records the root every later call enters, so from then on the server behaves as one started in that root — and later calls may stick a linked git worktree of it. Omitting the argument while unbound is refused rather than clearing anything. To skip the bind, export `PROJECT_ROOT` with the project path before starting the host, which binds at startup; on Codex the app-server process keeps that value, so later sessions it serves start bound to the same path until it restarts. > [!NOTE] > Worktree targeting works in every environment. Under `docker`, `docker-compose`, `vagrant`, and `ddev` the worktree has to sit inside the launch project root — that root is the only tree the container or VM mounts — so a worktree created elsewhere on the host is refused. An in-root worktree is reached at its own position below the environment's working directory: `docker.workdir`, `vagrant.workdir`, or `ddev.workdir`, and for `docker-compose` a configured `docker-compose.workdir`, or else the destination of the longest matching bind mount. diff --git a/plugins/dev-tooling/SETUP.md b/plugins/dev-tooling/SETUP.md index 6624dd9e..c6a5ccbb 100644 --- a/plugins/dev-tooling/SETUP.md +++ b/plugins/dev-tooling/SETUP.md @@ -385,19 +385,19 @@ Validation runs in two stages. Stage 1 checks config-file existence and shape be Run each applicable check below. Skip any whose config file was not created. If Phase 4 created or modified scopes this session, defer all of Stage 2 until after restart. #### PHP Tooling -- Use the `mcp__php-tooling__phpstan_analyze` tool to analyze any PHP file in the project (e.g., `src/Kernel.php` or any file that exists) +- Use the `mcp__plugin_dev-tooling_php-tooling__phpstan_analyze` tool to analyze any PHP file in the project (e.g., `src/Kernel.php` or any file that exists) - **Pass**: PHPStan output with analysis results (errors or "No errors") - **Fail**: Connection error, "missing config file" error, or "container not found" error - Common failure causes: wrong container name, container not running, PHP not installed #### JS Admin Tooling (only if .mcp-js-tooling.json was created) -- Use the `mcp__js-admin-tooling__eslint_check` tool on any JS or Vue file in `src/Administration/Resources/app/administration/` +- Use the `mcp__plugin_dev-tooling_js-admin-tooling__eslint_check` tool on any JS or Vue file in `src/Administration/Resources/app/administration/` - **Pass**: ESLint output with results - **Fail**: Connection error or "command not found" error - Common failure causes: node_modules not installed, wrong container #### JS Storefront Tooling (only if .mcp-js-tooling.json was created) -- Use the `mcp__js-storefront-tooling__eslint_check` tool on any JS file in `src/Storefront/Resources/app/storefront/` +- Use the `mcp__plugin_dev-tooling_js-storefront-tooling__eslint_check` tool on any JS file in `src/Storefront/Resources/app/storefront/` - **Pass**: ESLint output with results - **Fail**: Connection error or "command not found" error diff --git a/plugins/dev-tooling/codex.mcp.json b/plugins/dev-tooling/codex.mcp.json new file mode 100644 index 00000000..2df7892c --- /dev/null +++ b/plugins/dev-tooling/codex.mcp.json @@ -0,0 +1,82 @@ +{ + "mcpServers": { + "php-tooling": { + "command": "./mcp-server-php/server.sh", + "args": [], + "cwd": ".", + "env_vars": [ + "PROJECT_ROOT", + "MCP_PHP_TOOLING_CONFIG", + "MCP_LOG_STDERR", + "MCP_EXTRA_LOG_FILE", + "DOCKER_HOST", + "DOCKER_CONTEXT", + "DOCKER_CONFIG", + "DOCKER_CERT_PATH", + "DOCKER_TLS_VERIFY", + "COMPOSE_PROJECT_NAME", + "COMPOSE_FILE", + "COMPOSE_PROFILES", + "COMPOSE_PATH_SEPARATOR", + "VAGRANT_CWD", + "VAGRANT_HOME", + "VAGRANT_DOTFILE_PATH", + "VAGRANT_VAGRANTFILE", + "VAGRANT_DEFAULT_PROVIDER", + "SSH_AUTH_SOCK" + ] + }, + "js-admin-tooling": { + "command": "./mcp-server-js-admin/server.sh", + "args": [], + "cwd": ".", + "env_vars": [ + "PROJECT_ROOT", + "MCP_JS_TOOLING_CONFIG", + "MCP_LOG_STDERR", + "MCP_EXTRA_LOG_FILE", + "DOCKER_HOST", + "DOCKER_CONTEXT", + "DOCKER_CONFIG", + "DOCKER_CERT_PATH", + "DOCKER_TLS_VERIFY", + "COMPOSE_PROJECT_NAME", + "COMPOSE_FILE", + "COMPOSE_PROFILES", + "COMPOSE_PATH_SEPARATOR", + "VAGRANT_CWD", + "VAGRANT_HOME", + "VAGRANT_DOTFILE_PATH", + "VAGRANT_VAGRANTFILE", + "VAGRANT_DEFAULT_PROVIDER", + "SSH_AUTH_SOCK" + ] + }, + "js-storefront-tooling": { + "command": "./mcp-server-js-storefront/server.sh", + "args": [], + "cwd": ".", + "env_vars": [ + "PROJECT_ROOT", + "MCP_JS_TOOLING_CONFIG", + "MCP_LOG_STDERR", + "MCP_EXTRA_LOG_FILE", + "DOCKER_HOST", + "DOCKER_CONTEXT", + "DOCKER_CONFIG", + "DOCKER_CERT_PATH", + "DOCKER_TLS_VERIFY", + "COMPOSE_PROJECT_NAME", + "COMPOSE_FILE", + "COMPOSE_PROFILES", + "COMPOSE_PATH_SEPARATOR", + "VAGRANT_CWD", + "VAGRANT_HOME", + "VAGRANT_DOTFILE_PATH", + "VAGRANT_VAGRANTFILE", + "VAGRANT_DEFAULT_PROVIDER", + "SSH_AUTH_SOCK" + ] + } + } +} diff --git a/plugins/dev-tooling/docs/configuration.md b/plugins/dev-tooling/docs/configuration.md index 031d20d8..c206928b 100644 --- a/plugins/dev-tooling/docs/configuration.md +++ b/plugins/dev-tooling/docs/configuration.md @@ -96,7 +96,7 @@ A common pattern is to check a shared `.mcp-php-tooling.json` into git and keep ## 🌳 Worktree Configuration -A tool call may target a linked git worktree of the launch root by passing `project_root`, or by sticking one first with `set_project_root`. Targeting works in every environment: `native`, `docker`, `docker-compose`, `vagrant`, and `ddev`. +A tool call may target a linked git worktree of the launch root by passing `project_root`, or by sticking one first with `set_project_root`. Targeting works in every environment: `native`, `docker`, `docker-compose`, `vagrant`, and `ddev`. A server that this plugin's Codex manifest starts in the plugin's own directory (`cwd` `.` resolves against the plugin root) has no launch root until `set_project_root` binds one, which discovers that root's configuration at bind time; see [reference.md](./reference.md#-worktree-support). Under the four container environments the worktree has to sit inside the launch project root, because that root is the only tree the container or VM mounts and a worktree outside it has no path inside. The worktree is reached at its own position below the environment's working directory (`docker.workdir`, `vagrant.workdir`, `ddev.workdir`), and under `docker-compose` below a configured `docker-compose.workdir`, or below the destination of the longest matching bind mount when none is set. Once the mapping exists, `cwd` and the messages raised after it name both the host path and the environment-side path; the refusals that precede the mapping name the host path alone, since none exists yet. @@ -111,7 +111,7 @@ After validation the tool probes that the environment reaches the mapped path, t **Configuration read timing** differs between the launch root and a worktree target: -- The launch root's configuration is discovered and bound once, when the server starts, and its environment scalars — the environment, the container name, and the environment-side working directory — are derived then. Individual values are still read out of that file on each use, but a mid-session edit to any of those three does not reach a worktree-targeted call until the server restarts: validation, the mapping and the existence probe all answer from the startup binding, so a running server keeps running against the environment it started with. +- The launch root's configuration is discovered and bound once — when the server starts, or when `set_project_root` binds a server that started without a project root — and its environment scalars — the environment, the container name, and the environment-side working directory — are derived then. Individual values are still read out of that file on each use, but a mid-session edit to any of those three does not reach a worktree-targeted call until the server restarts: validation, the mapping and the existence probe all answer from the startup binding, so a running server keeps running against the environment it started with. - A worktree's own configuration is discovered fresh on every call that targets it. A call targeting the launch root never picks up a worktree's file. **Configuration selection for a worktree**: a worktree created under `.claude/worktrees/` holds tracked files only, and the tooling config files (`.mcp-php-tooling.json`, `.mcp-js-tooling.json`) are user-local and untracked — so a worktree ordinarily carries no configuration of its own. When it has none, the launch root's configuration applies unchanged: a worktree of the same checkout runs the same toolchain. When it has one, that configuration is discovered and applied for calls targeting that worktree, following the same discovery priority as the launch root — including the `environment` field it declares, so a worktree with its own configuration runs under that configuration's environment for that call, while a later call against the launch root still runs under the environment the server started with. diff --git a/plugins/dev-tooling/docs/lsp.md b/plugins/dev-tooling/docs/lsp.md index ae38b88f..a834fc62 100644 --- a/plugins/dev-tooling/docs/lsp.md +++ b/plugins/dev-tooling/docs/lsp.md @@ -6,6 +6,8 @@ Containerized environments get a little extra machinery. A stdlib-only Python pr > [!NOTE] > Claude Code only issues LSP tool calls when `ENABLE_LSP_TOOL=1` is set in its environment (typically under `env` in `~/.claude/settings.json`). Without the flag, LSP diagnostics still surface passively in context, but the agent can't invoke LSP operations directly. +> +> The bridge itself is Claude Code only: Codex reads no `.lsp.json`, so nothing on this page applies there and a `.lsp-php-tooling.json` has no effect. ## 📦 Installation diff --git a/plugins/dev-tooling/docs/mcp-enforcement.md b/plugins/dev-tooling/docs/mcp-enforcement.md index 8784cf3b..419182c4 100644 --- a/plugins/dev-tooling/docs/mcp-enforcement.md +++ b/plugins/dev-tooling/docs/mcp-enforcement.md @@ -10,13 +10,15 @@ MCP is a synchronous request-response protocol. A long-running watcher like `npm Three hook events carry scripts between them: SessionStart, PreToolUse, and PostToolUse. -**SessionStart** runs two. The first injects a directive at the top of every conversation that lists the available MCP tools and tells Claude to prefer them over bash; the prompt lives in `hooks/prompts/mcp-tool-directives.md` if you want to read or tweak it. The second emits the LSP directives, and only when `.lsp-php-tooling.json` enables a language server. +**SessionStart** runs two. The first injects a directive at the top of every conversation that lists the available MCP tools and tells Claude to prefer them over bash; the prompt lives in `hooks/prompts/mcp-tool-directives.md` if you want to read or tweak it. On a host that names the session directory only in the hook input rather than in `CLAUDE_PROJECT_DIR` — Codex — the directive comes from `hooks/prompts/mcp-tool-directives-codex.md` instead, which leaves out the runner-agent and `EnterWorktree`/`ExitWorktree` guidance Codex has no counterpart for, and the hook also appends a short hint naming that directory and the `set_project_root` call that binds a server which started without a project root. The second emits the LSP directives, and only when `.lsp-php-tooling.json` enables a language server (Claude Code only). -**PreToolUse** runs one script per server, and they are the safety net: they intercept bash commands that map to a known MCP tool and point Claude at the replacement, so even if the SessionStart directive got ignored or compacted away, the bad call gets caught before it runs. +**PreToolUse** runs one script per server, and they are the safety net: they intercept bash commands that map to a known MCP tool and point Claude at the replacement, so even if the SessionStart directive got ignored or compacted away, the bad call gets caught before it runs. Each block message names the tool and its server — ``Use `phpstan_analyze` on the `php-tooling` MCP server instead!`` — which reads the same on Claude Code and on Codex, where the full tool name differs. -**PostToolUse** runs two. One watches `phpstan_analyze`: when it runs against specific files, it cross-references `phpstan-baseline.neon` (or `.php`) and surfaces a warning if any of the analyzed paths appear in the baseline, which usually means a baseline entry has gone stale. Full-project PHPStan runs skip the check because PHPStan validates the baseline natively there. The other watches `EnterWorktree` and `ExitWorktree` and reminds the session to call `set_project_root` on all three servers, since each holds its own sticky root. +**PostToolUse** runs two. One watches `phpstan_analyze`: when it runs against specific files, it cross-references `phpstan-baseline.neon` (or `.php`) and surfaces a warning if any of the analyzed paths appear in the baseline, which usually means a baseline entry has gone stale. Full-project PHPStan runs skip the check because PHPStan validates the baseline natively there. Its matcher carries both host spellings of the tool name, so it fires for a Claude Code plugin install and for a Codex install alike. The other watches `EnterWorktree` and `ExitWorktree` — both Claude Code tools — and reminds the session to call `set_project_root` on all three servers, since each holds its own sticky root. -Within SessionStart, `session-start.sh` honors `enforce_mcp_tools` and turns off when it's `false`; `lsp-directives.sh` ignores the flag and runs whenever `.lsp-php-tooling.json` enables a language server. Every PreToolUse script honors the flag. Both PostToolUse scripts, the baseline check and the worktree-directives reminder, ignore the flag and always run. +Within SessionStart, `session-start.sh` honors `enforce_mcp_tools` and turns off when it's `false`; the project-root hint above is not gated on the flag, because a server started without a project root refuses every tool whatever the flag says. `lsp-directives.sh` ignores the flag and runs whenever `.lsp-php-tooling.json` enables a language server. Every PreToolUse script honors the flag. Both PostToolUse scripts, the baseline check and the worktree-directives reminder, ignore the flag and always run. + +On Codex, hooks run only once you trust them: its startup review and the `/hooks` command each record trust in your Codex user config, an untrusted hook is skipped, and a non-interactive `codex exec` runs the trusted hooks without asking or all of them with `--dangerously-bypass-hook-trust`. All of them are plugin hooks, so none runs there until you trust it. ### Disabling Enforcement @@ -28,13 +30,13 @@ Flip the switch per config file (`.mcp-php-tooling.json` or `.mcp-js-tooling.jso ### Blocked PHP Commands -| Bash Command | MCP Tool | +| Bash Command | MCP Tool on `php-tooling` | |-------------------------------------------------------------|--------------------------------------------------| -| `vendor/bin/phpstan`, `composer phpstan` | `mcp__php-tooling__phpstan_analyze` | -| `vendor/bin/ecs`, `vendor/bin/php-cs-fixer`, `composer ecs` | `mcp__php-tooling__ecs_check` / `ecs_fix` | -| `vendor/bin/phpunit`, `composer phpunit` | `mcp__php-tooling__phpunit_run` | -| `bin/console`, `php bin/console` | `mcp__php-tooling__console_run` / `console_list` | -| `vendor/bin/rector`, `composer rector` | `mcp__php-tooling__rector_fix` / `rector_check` | +| `vendor/bin/phpstan`, `composer phpstan` | `phpstan_analyze` | +| `vendor/bin/ecs`, `vendor/bin/php-cs-fixer`, `composer ecs` | `ecs_check` / `ecs_fix` | +| `vendor/bin/phpunit`, `composer phpunit` | `phpunit_run` | +| `bin/console`, `php bin/console` | `console_run` / `console_list` | +| `vendor/bin/rector`, `composer rector` | `rector_fix` / `rector_check` | ### Blocked JavaScript Commands @@ -77,12 +79,14 @@ Commands that aren't blocked: `npm install`, `composer install`, watch-mode scri ## 🔗 Plugin Integration -Other plugins can pull these tools into their own skills or agents by referencing them in frontmatter. The MCP tool name is `mcp____`: +Other plugins can pull these tools into their own skills or agents by referencing them in frontmatter. On Claude Code a plugin-provided server's tools are named `mcp__plugin____`, so the PHP tools here are `mcp__plugin_dev-tooling_php-tooling__`: ```markdown --- -tools: mcp__php-tooling__phpstan_analyze, mcp__php-tooling__ecs_check, mcp__js-admin-tooling__eslint_check +tools: mcp__plugin_dev-tooling_php-tooling__phpstan_analyze, mcp__plugin_dev-tooling_php-tooling__ecs_check, mcp__plugin_dev-tooling_js-admin-tooling__eslint_check --- ``` +On Codex the name keeps the plain MCP form and replaces every character outside `[A-Za-z0-9_]` in the server name with `_`, so those three become `mcp__php_tooling__phpstan_analyze`, `mcp__php_tooling__ecs_check`, and `mcp__js_admin_tooling__eslint_check`. + The `test-writing` plugin in this marketplace is a working example. diff --git a/plugins/dev-tooling/docs/reference.md b/plugins/dev-tooling/docs/reference.md index b760e817..4dc0237c 100644 --- a/plugins/dev-tooling/docs/reference.md +++ b/plugins/dev-tooling/docs/reference.md @@ -138,20 +138,24 @@ Use rector_fix with only_suffix "Controller" ### `set_project_root` -Sets or clears the sticky project root for this server process. With a `project_root`, validates it as a linked git worktree of the launch root and sticks it — every later call on this server targets it until `set_project_root` is called again with no argument. Without one, clears the sticky root unconditionally (no validation of the value discarded) and returns the server to its launch root. +Sets or clears the sticky project root for this server process, and binds a server that started without one. + +- A server started in its own plugin directory — what this plugin's Codex manifest produces, since it launches each server with `cwd` `.` — has no project root, and the first call carrying a `project_root` binds it instead of sticking it. The root has to be a directory outside the plugin directory that holds this server's `.mcp-php-tooling.json`, at its top or in one of the supported tool directories, unless `MCP_PHP_TOOLING_CONFIG` names the configuration file (a relative value is read against the root being bound); on success the server loads that configuration, derives the environment, and records that root with symlinks resolved, which every later call enters before it runs. After the bind, a tool call may target the launch root itself, spelled as recorded, which is accepted unchanged, or a linked git worktree of it; any other directory is refused. A call with no `project_root` while unbound is refused: there is no project root to return to. +- A server that has a project root — started in one, or bound as above — validates a `project_root` as a linked git worktree of that root and sticks it; every later call on this server targets it until `set_project_root` is called again with no argument. Without one, clears the sticky root unconditionally (no validation of the value discarded) and returns the server to its project root; naming the launch root itself does the same. ``` +Use php-tooling set_project_root with project_root "/path/to/project" Use php-tooling set_project_root with project_root "/path/to/worktree" Use php-tooling set_project_root ``` -| Parameter | Type | Description | -|----------------|--------|-------------------------------------------------------------------------------------------| -| `project_root` | string | Absolute path to a linked git worktree of the launch root. Omit to clear the sticky root. | +| Parameter | Type | Description | +|----------------|--------|--------------------------------------------------------------------------------------------| +| `project_root` | string | Absolute path to bind, or a linked worktree of the launch root. Once bound, omit to clear. | ### `cwd` -Reports this server's resolution state: the effective project root, its source (`launch` or `sticky` — `cwd` takes no `project_root`, so it never reports the `call` source the other tools' banners can show), whether it currently resolves, the resolved working directory, the environment, and the configuration file(s) in use. No parameters, validates nothing, never fails — including when a sticky root names a directory that no longer exists. +Reports this server's resolution state: the effective project root, its source (`launch` or `sticky` — `cwd` takes no `project_root`, so it never reports the `call` source the other tools' banners can show), whether it currently resolves, the resolved working directory, the environment, and the configuration file(s) in use. A server with no project root yet reports the root as `none (unbound)`, names the plugin directory it started in and the `set_project_root` call that binds it, and reports the configuration as pending until a bind loads one. No parameters, validates nothing, never fails — including when a sticky root names a directory that no longer exists. "Resolves" reports resolution and mapping, not reachability: the existence probe runs on tool calls, not here, so a root `cwd` reports as resolving can still be refused by the next tool call's probe. @@ -307,11 +311,11 @@ Vite build for Administration (Vue 3). ### `set_project_root` -Sets or clears the sticky project root for this server process. Same behavior as the PHP server's `set_project_root` above — a separate process, a separate sticky value. +Sets or clears the sticky project root for this server process, and binds a server that started without one. Same behavior as the PHP server's `set_project_root` above — a separate process, a separate sticky value. A bind requires a directory holding this server's own `.mcp-js-tooling.json`, at its top or in one of the supported tool directories, unless `MCP_JS_TOOLING_CONFIG` names the configuration file; after the bind, a call may target that root itself or stick a linked git worktree of it. -| Parameter | Type | Description | -|----------------|--------|-------------------------------------------------------------------------------------------| -| `project_root` | string | Absolute path to a linked git worktree of the launch root. Omit to clear the sticky root. | +| Parameter | Type | Description | +|----------------|--------|--------------------------------------------------------------------------------------------| +| `project_root` | string | Absolute path to bind, or a linked worktree of the launch root. Once bound, omit to clear. | ### `cwd` @@ -426,11 +430,11 @@ Webpack build for Storefront (vanilla JS). ### `set_project_root` -Sets or clears the sticky project root for this server process. Same behavior as the PHP server's `set_project_root` above — a separate process, a separate sticky value. +Sets or clears the sticky project root for this server process, and binds a server that started without one. Same behavior as the PHP server's `set_project_root` above — a separate process, a separate sticky value. A bind requires a directory holding this server's own `.mcp-js-tooling.json`, at its top or in one of the supported tool directories, unless `MCP_JS_TOOLING_CONFIG` names the configuration file; after the bind, a call may target that root itself or stick a linked git worktree of it. -| Parameter | Type | Description | -|----------------|--------|-------------------------------------------------------------------------------------------| -| `project_root` | string | Absolute path to a linked git worktree of the launch root. Omit to clear the sticky root. | +| Parameter | Type | Description | +|----------------|--------|--------------------------------------------------------------------------------------------| +| `project_root` | string | Absolute path to bind, or a linked worktree of the launch root. Once bound, omit to clear. | ### `cwd` @@ -438,7 +442,9 @@ Reports this server's resolution state. No parameters. Same shape as the PHP ser ## 🌳 Worktree Support -Every tool except `cwd`, on all three servers, accepts `project_root`. Pass it to run one call against a linked git worktree of the root the server was launched in, instead of the launch root itself. Targeting works in every environment: `native`, `docker`, `docker-compose`, `vagrant`, and `ddev`. +Every tool except `cwd`, on all three servers, accepts `project_root`. Pass it to run one call against a linked git worktree of the server's project root, instead of that root itself. Targeting works in every environment: `native`, `docker`, `docker-compose`, `vagrant`, and `ddev`. + +**Binding a server that has no project root.** How the host starts a server decides whether it has one: Claude Code starts it in the session's project directory, while this plugin's Codex manifest starts it in the plugin's own directory (`cwd` `.` resolves against the plugin root), where it comes up unbound and refuses every tool except `set_project_root` and `cwd`. On such a server the first `set_project_root` call carrying a path binds it — the configuration, the environment and the working directory are derived from that root, and it stays the server's root for the rest of the process, so a later call may target that root itself or stick a linked git worktree of it. On Codex the recommended route is this on-demand bind: with the plugin hooks trusted, the SessionStart directive names the session directory and the call that binds it, so the model can do it without being asked (an untrusted SessionStart hook does not run, leaving only the refusal text, which names the call but not the path). Exporting `PROJECT_ROOT` with the project path before starting Codex binds at startup instead, but Codex's long-lived app-server process keeps the value, so later sessions it serves, including other projects and the IDE extension, start bound to that path until the process restarts. Throughout this section, "launch root" means the root the server is bound to, by either route. What the environment adds: diff --git a/plugins/dev-tooling/hooks/hooks.json b/plugins/dev-tooling/hooks/hooks.json index a98b5da1..2d12ed19 100644 --- a/plugins/dev-tooling/hooks/hooks.json +++ b/plugins/dev-tooling/hooks/hooks.json @@ -41,7 +41,7 @@ ], "PostToolUse": [ { - "matcher": "mcp__php-tooling__phpstan_analyze", + "matcher": "^(mcp__plugin_dev-tooling_php-tooling__phpstan_analyze|mcp__php_tooling__phpstan_analyze)$", "hooks": [ { "type": "command", diff --git a/plugins/dev-tooling/hooks/prompts/mcp-tool-directives-codex.md b/plugins/dev-tooling/hooks/prompts/mcp-tool-directives-codex.md new file mode 100644 index 00000000..982fa9ea --- /dev/null +++ b/plugins/dev-tooling/hooks/prompts/mcp-tool-directives-codex.md @@ -0,0 +1,13 @@ +Run PHP and JavaScript checks, fixes, tests and builds through the dev-tooling MCP servers, not the shell; the shell equivalents are blocked. The servers detect the development environment (native/docker/vagrant/ddev) and apply the project's configuration. + +On the `php-tooling` MCP server: `phpstan_analyze`, `ecs_check`, `ecs_fix`, `phpunit_run`, `phpunit_coverage_gaps`, `console_run`, `console_list`, `rector_fix`, `rector_check`, `worktree_prepare`, `set_project_root`, `cwd` +On the `js-admin-tooling` MCP server: `eslint_check`/`eslint_fix`, `stylelint_check`/`stylelint_fix`, `prettier_check`/`prettier_fix`, `jest_run`, `tsc_check`, `lint_all`, `lint_twig`, `unit_setup`, `vite_build`, `worktree_prepare`, `set_project_root`, `cwd` +On the `js-storefront-tooling` MCP server: `eslint_check`/`eslint_fix`, `stylelint_check`/`stylelint_fix`, `jest_run`, `vitest_run`, `ludtwig_check`/`ludtwig_fix`, `webpack_build`, `worktree_prepare`, `set_project_root`, `cwd` + +A server started without a project root refuses every tool until one is bound, except the two that manage it: `set_project_root` on each dev-tooling MCP server and `cwd` on each dev-tooling MCP server. The root a server is bound to, by that call or at startup, is its launch root. The final section of this context, when present, names the project directory and the call that binds a server; without it, bind to the session's working directory. + +Every tool except `cwd` on each dev-tooling MCP server takes an optional project_root to target a linked git worktree of the launch root, in every environment; refusals name what to fix and how. To work in a worktree across calls, call `set_project_root` on each dev-tooling MCP server you use with the worktree path, or with none to clear; each server is a separate process with its own sticky value, and `cwd` on each dev-tooling MCP server reports that server's current state. A fresh worktree lacks vendor/ and node_modules: call `worktree_prepare` on the dev-tooling MCP server the refusal names (a ludtwig refusal on a missing vendor/ names `php-tooling`) instead of provisioning by hand, and do not symlink vendor/ from the launch tree (composer's autoloaders resolve through the symlink to the launch tree's classes). + +Storefront tests are split across two runners: `jest_run` on the `js-storefront-tooling` MCP server covers the app/storefront package suite, `vitest_run` on the `js-storefront-tooling` MCP server covers the component suite under src/Storefront/Resources/views/components/. `jest_run` on the `js-storefront-tooling` MCP server rejects views/components patterns. + +Call tools on the same server one at a time. Tools on different servers can run in parallel (e.g. `phpunit_run` on the `php-tooling` MCP server + `jest_run` on the `js-storefront-tooling` MCP server). diff --git a/plugins/dev-tooling/hooks/prompts/mcp-tool-directives.md b/plugins/dev-tooling/hooks/prompts/mcp-tool-directives.md index 849be4bd..b00d9771 100644 --- a/plugins/dev-tooling/hooks/prompts/mcp-tool-directives.md +++ b/plugins/dev-tooling/hooks/prompts/mcp-tool-directives.md @@ -2,14 +2,14 @@ ALWAYS use MCP dev tools for PHP and JavaScript operations — NEVER run these v MCP tools auto-detect the development environment (native/docker/vagrant/ddev) and apply project configuration. -php-tooling: phpstan_analyze, ecs_check, ecs_fix, phpunit_run, phpunit_coverage_gaps, console_run, console_list, rector_fix, rector_check, worktree_prepare, set_project_root, cwd -js-admin-tooling: eslint_check/fix, stylelint_check/fix, prettier_check/fix, jest_run, tsc_check, lint_all, lint_twig, unit_setup, vite_build, worktree_prepare, set_project_root, cwd -js-storefront-tooling: eslint_check/fix, stylelint_check/fix, jest_run, vitest_run, ludtwig_check/fix, webpack_build, worktree_prepare, set_project_root, cwd +On the `php-tooling` MCP server: `phpstan_analyze`, `ecs_check`, `ecs_fix`, `phpunit_run`, `phpunit_coverage_gaps`, `console_run`, `console_list`, `rector_fix`, `rector_check`, `worktree_prepare`, `set_project_root`, `cwd` +On the `js-admin-tooling` MCP server: `eslint_check`/`eslint_fix`, `stylelint_check`/`stylelint_fix`, `prettier_check`/`prettier_fix`, `jest_run`, `tsc_check`, `lint_all`, `lint_twig`, `unit_setup`, `vite_build`, `worktree_prepare`, `set_project_root`, `cwd` +On the `js-storefront-tooling` MCP server: `eslint_check`/`eslint_fix`, `stylelint_check`/`stylelint_fix`, `jest_run`, `vitest_run`, `ludtwig_check`/`ludtwig_fix`, `webpack_build`, `worktree_prepare`, `set_project_root`, `cwd` -Every tool except cwd takes an optional project_root to target a linked git worktree of the launch root, in every environment; refusals name what to fix and how. After EnterWorktree/ExitWorktree, call set_project_root (with the worktree path, or with none to clear) on all three servers — each is a separate process with its own sticky value; cwd reports a server's current state. A fresh worktree lacks vendor/ and node_modules — call worktree_prepare on the refusing server instead of provisioning by hand, and NEVER symlink vendor/ from the launch tree (composer's autoloaders resolve through the symlink to the launch tree's classes). +Every tool except `cwd` on each dev-tooling MCP server takes an optional project_root to target a linked git worktree of the launch root, in every environment; refusals name what to fix and how. After EnterWorktree/ExitWorktree, call `set_project_root` on each dev-tooling MCP server (with the worktree path, or with none to clear) — each is a separate process with its own sticky value; `cwd` on each dev-tooling MCP server reports that server's current state. A fresh worktree lacks vendor/ and node_modules — call `worktree_prepare` on the dev-tooling MCP server the refusal names (a ludtwig refusal on a missing vendor/ names `php-tooling`) instead of provisioning by hand, and NEVER symlink vendor/ from the launch tree (composer's autoloaders resolve through the symlink to the launch tree's classes). -Storefront tests are split across two runners: jest_run covers the app/storefront package suite, vitest_run covers the component suite under src/Storefront/Resources/views/components/. jest_run rejects views/components patterns. +Storefront tests are split across two runners: `jest_run` on the `js-storefront-tooling` MCP server covers the app/storefront package suite, `vitest_run` on the `js-storefront-tooling` MCP server covers the component suite under src/Storefront/Resources/views/components/. `jest_run` on the `js-storefront-tooling` MCP server rejects views/components patterns. -Call tools on the SAME server sequentially — never in parallel. Tools on DIFFERENT servers CAN run in parallel (e.g. phpunit_run + js-storefront jest_run). +Call tools on the SAME server sequentially — never in parallel. Tools on DIFFERENT servers CAN run in parallel (e.g. `phpunit_run` on the `php-tooling` MCP server + `jest_run` on the `js-storefront-tooling` MCP server). -When a dev-tooling run (PHPStan, ECS, PHPUnit, Rector, ESLint, Stylelint, Prettier, TypeScript, Jest, Vitest, ludtwig, or a Vite/Webpack build) would produce more than a trivial single check — especially during a large implementation task — delegate it to the `dev-tooling-runner` subagent so the verbose output stays out of this conversation. You decide what to check: hand it the explicit paths and any affected tests, plus the kinds of checks to run and any mechanical fixes (e.g. ecs_fix) to apply. It executes and returns a condensed pass/fail report. For a quick single-file check you may still call the MCP tool inline. +When a dev-tooling run (PHPStan, ECS, PHPUnit, Rector, ESLint, Stylelint, Prettier, TypeScript, Jest, Vitest, ludtwig, or a Vite/Webpack build) would produce more than a trivial single check — especially during a large implementation task — delegate it to the `dev-tooling-runner` subagent so the verbose output stays out of this conversation. You decide what to check: hand it the explicit paths and any affected tests, plus the kinds of checks to run and any mechanical fixes (e.g. `ecs_fix` on the `php-tooling` MCP server) to apply. It executes and returns a condensed pass/fail report. For a quick single-file check you may still call the MCP tool inline. diff --git a/plugins/dev-tooling/hooks/scripts/check-js-admin-tools.sh b/plugins/dev-tooling/hooks/scripts/check-js-admin-tools.sh index 2da034d3..4d982087 100755 --- a/plugins/dev-tooling/hooks/scripts/check-js-admin-tools.sh +++ b/plugins/dev-tooling/hooks/scripts/check-js-admin-tools.sh @@ -49,23 +49,23 @@ fi # ============================================================================ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+lint(\s|--|$)'; then - block_tool "mcp__js-admin-tooling__eslint_check" \ - "Use eslint_check for linting or eslint_fix to auto-fix." + block_tool "js-admin-tooling" \ + "Use eslint_check for linting or eslint_fix to auto-fix." "eslint_check" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+lint:fix(\s|$)'; then - block_tool "mcp__js-admin-tooling__eslint_fix" \ - "Use eslint_fix to auto-fix ESLint violations." + block_tool "js-admin-tooling" \ + "Use eslint_fix to auto-fix ESLint violations." "eslint_fix" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+lint:debugging(\s|--|$)'; then - block_tool "mcp__js-admin-tooling__eslint_check" \ - "Use eslint_check with paths for linting, or eslint_fix with paths to auto-fix." + block_tool "js-admin-tooling" \ + "Use eslint_check with paths for linting, or eslint_fix with paths to auto-fix." "eslint_check" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npx\s+eslint(\s|$)'; then - block_tool "mcp__js-admin-tooling__eslint_check" \ - "Use eslint_check for linting or eslint_fix to auto-fix." + block_tool "js-admin-tooling" \ + "Use eslint_check for linting or eslint_fix to auto-fix." "eslint_check" fi # ============================================================================ @@ -73,23 +73,23 @@ fi # ============================================================================ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+lint:scss(\s|--|$)'; then - block_tool "mcp__js-admin-tooling__stylelint_check" \ - "Use stylelint_check for SCSS/CSS linting." + block_tool "js-admin-tooling" \ + "Use stylelint_check for SCSS/CSS linting." "stylelint_check" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+lint:scss-fix(\s|$)'; then - block_tool "mcp__js-admin-tooling__stylelint_fix" \ - "Use stylelint_fix to auto-fix Stylelint violations." + block_tool "js-admin-tooling" \ + "Use stylelint_fix to auto-fix Stylelint violations." "stylelint_fix" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+stylelint:base(\s|--|$)'; then - block_tool "mcp__js-admin-tooling__stylelint_check" \ - "Use stylelint_check with paths for SCSS/CSS linting, or stylelint_fix with paths to auto-fix." + block_tool "js-admin-tooling" \ + "Use stylelint_check with paths for SCSS/CSS linting, or stylelint_fix with paths to auto-fix." "stylelint_check" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npx\s+stylelint(\s|$)'; then - block_tool "mcp__js-admin-tooling__stylelint_check" \ - "Use stylelint_check for SCSS/CSS linting or stylelint_fix to auto-fix." + block_tool "js-admin-tooling" \ + "Use stylelint_check for SCSS/CSS linting or stylelint_fix to auto-fix." "stylelint_check" fi # ============================================================================ @@ -97,23 +97,23 @@ fi # ============================================================================ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+format(\s|$)'; then - block_tool "mcp__js-admin-tooling__prettier_check" \ - "Use prettier_check to verify formatting." + block_tool "js-admin-tooling" \ + "Use prettier_check to verify formatting." "prettier_check" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+format:fix(\s|$)'; then - block_tool "mcp__js-admin-tooling__prettier_fix" \ - "Use prettier_fix to auto-format files." + block_tool "js-admin-tooling" \ + "Use prettier_fix to auto-format files." "prettier_fix" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+prettier:base(\s|--|$)'; then - block_tool "mcp__js-admin-tooling__prettier_check" \ - "Use prettier_check with paths to verify formatting, or prettier_fix with paths to auto-format." + block_tool "js-admin-tooling" \ + "Use prettier_check with paths to verify formatting, or prettier_fix with paths to auto-format." "prettier_check" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npx\s+prettier(\s|$)'; then - block_tool "mcp__js-admin-tooling__prettier_check" \ - "Use prettier_check to verify formatting or prettier_fix to auto-format." + block_tool "js-admin-tooling" \ + "Use prettier_check to verify formatting or prettier_fix to auto-format." "prettier_check" fi # ============================================================================ @@ -121,18 +121,18 @@ fi # ============================================================================ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+unit(\s|--|$)'; then - block_tool "mcp__js-admin-tooling__jest_run" \ - "Use jest_run with testPathPattern, testNamePattern, coverage options." + block_tool "js-admin-tooling" \ + "Use jest_run with testPathPattern, testNamePattern, coverage options." "jest_run" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+jest:base(\s|--|$)'; then - block_tool "mcp__js-admin-tooling__jest_run" \ - "Use jest_run with testPathPatterns, testNamePattern, coverage, ci options." + block_tool "js-admin-tooling" \ + "Use jest_run with testPathPatterns, testNamePattern, coverage, ci options." "jest_run" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npx\s+jest(\s|$)'; then - block_tool "mcp__js-admin-tooling__jest_run" \ - "Use jest_run with testPathPattern, testNamePattern, coverage options." + block_tool "js-admin-tooling" \ + "Use jest_run with testPathPattern, testNamePattern, coverage options." "jest_run" fi # ============================================================================ @@ -140,13 +140,13 @@ fi # ============================================================================ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+lint:types(\s|$)'; then - block_tool "mcp__js-admin-tooling__tsc_check" \ - "Use tsc_check for TypeScript type checking." + block_tool "js-admin-tooling" \ + "Use tsc_check for TypeScript type checking." "tsc_check" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npx\s+tsc(\s|$)'; then - block_tool "mcp__js-admin-tooling__tsc_check" \ - "Use tsc_check for TypeScript type checking." + block_tool "js-admin-tooling" \ + "Use tsc_check for TypeScript type checking." "tsc_check" fi # ============================================================================ @@ -154,13 +154,13 @@ fi # ============================================================================ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+lint:all(\s|$)'; then - block_tool "mcp__js-admin-tooling__lint_all" \ - "Use lint_all to run all lint checks (TypeScript, ESLint, Stylelint, Prettier)." + block_tool "js-admin-tooling" \ + "Use lint_all to run all lint checks (TypeScript, ESLint, Stylelint, Prettier)." "lint_all" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+lint:twig(\s|$)'; then - block_tool "mcp__js-admin-tooling__lint_twig" \ - "Use lint_twig for Twig template linting." + block_tool "js-admin-tooling" \ + "Use lint_twig for Twig template linting." "lint_twig" fi # ============================================================================ @@ -168,8 +168,8 @@ fi # ============================================================================ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+build(\s|--|$)'; then - block_tool "mcp__js-admin-tooling__vite_build" \ - "Use vite_build with mode (development/production) option." + block_tool "js-admin-tooling" \ + "Use vite_build with mode (development/production) option." "vite_build" fi exit 0 diff --git a/plugins/dev-tooling/hooks/scripts/check-js-storefront-tools.sh b/plugins/dev-tooling/hooks/scripts/check-js-storefront-tools.sh index 586360b4..4c4a0011 100755 --- a/plugins/dev-tooling/hooks/scripts/check-js-storefront-tools.sh +++ b/plugins/dev-tooling/hooks/scripts/check-js-storefront-tools.sh @@ -56,62 +56,62 @@ fi # Storefront-specific ESLint (lint:js) if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+lint:js(\s|--|$)'; then - block_tool "mcp__js-storefront-tooling__eslint_check" \ - "Use eslint_check for linting." + block_tool "js-storefront-tooling" \ + "Use eslint_check for linting." "eslint_check" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+lint:js:fix(\s|$)'; then - block_tool "mcp__js-storefront-tooling__eslint_fix" \ - "Use eslint_fix to auto-fix ESLint violations." + block_tool "js-storefront-tooling" \ + "Use eslint_fix to auto-fix ESLint violations." "eslint_fix" fi # lint:js and lint:js:fix each chain the two per-tree scripts below, which are # reachable on their own too. if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+lint:js:app(\s|--|$)'; then - block_tool "mcp__js-storefront-tooling__eslint_check" \ - "Use eslint_check with paths for linting, or eslint_fix with paths to auto-fix." + block_tool "js-storefront-tooling" \ + "Use eslint_check with paths for linting, or eslint_fix with paths to auto-fix." "eslint_check" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+lint:js:components(\s|--|$)'; then - block_tool "mcp__js-storefront-tooling__eslint_check" \ - "Use eslint_check with paths under views/components/ for linting, or eslint_fix to auto-fix." + block_tool "js-storefront-tooling" \ + "Use eslint_check with paths under views/components/ for linting, or eslint_fix to auto-fix." "eslint_check" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+lint:js:app:fix(\s|$)'; then - block_tool "mcp__js-storefront-tooling__eslint_fix" \ - "Use eslint_fix with paths to auto-fix ESLint violations." + block_tool "js-storefront-tooling" \ + "Use eslint_fix with paths to auto-fix ESLint violations." "eslint_fix" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+lint:js:components:fix(\s|$)'; then - block_tool "mcp__js-storefront-tooling__eslint_fix" \ - "Use eslint_fix with paths under views/components/ to auto-fix ESLint violations." + block_tool "js-storefront-tooling" \ + "Use eslint_fix with paths under views/components/ to auto-fix ESLint violations." "eslint_fix" fi # Generic lint in Storefront context if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+lint(\s|--|$)'; then - block_tool "mcp__js-storefront-tooling__eslint_check" \ - "Use eslint_check for linting or eslint_fix to auto-fix." + block_tool "js-storefront-tooling" \ + "Use eslint_check for linting or eslint_fix to auto-fix." "eslint_check" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+lint:fix(\s|$)'; then - block_tool "mcp__js-storefront-tooling__eslint_fix" \ - "Use eslint_fix to auto-fix ESLint violations." + block_tool "js-storefront-tooling" \ + "Use eslint_fix to auto-fix ESLint violations." "eslint_fix" fi # Target-less base scripts the MCP tools route path-scoped runs at if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+eslint:app(\s|--|$)'; then - block_tool "mcp__js-storefront-tooling__eslint_check" \ - "Use eslint_check with paths for linting, or eslint_fix with paths to auto-fix." + block_tool "js-storefront-tooling" \ + "Use eslint_check with paths for linting, or eslint_fix with paths to auto-fix." "eslint_check" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+eslint:components(\s|--|$)'; then - block_tool "mcp__js-storefront-tooling__eslint_check" \ - "Use eslint_check with paths under views/components/ for linting, or eslint_fix to auto-fix." + block_tool "js-storefront-tooling" \ + "Use eslint_check with paths under views/components/ for linting, or eslint_fix to auto-fix." "eslint_check" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npx\s+eslint(\s|$)'; then - block_tool "mcp__js-storefront-tooling__eslint_check" \ - "Use eslint_check for linting or eslint_fix to auto-fix." + block_tool "js-storefront-tooling" \ + "Use eslint_check for linting or eslint_fix to auto-fix." "eslint_check" fi # ============================================================================ @@ -119,23 +119,23 @@ fi # ============================================================================ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+lint:scss(\s|--|$)'; then - block_tool "mcp__js-storefront-tooling__stylelint_check" \ - "Use stylelint_check for SCSS/CSS linting." + block_tool "js-storefront-tooling" \ + "Use stylelint_check for SCSS/CSS linting." "stylelint_check" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+lint:scss-fix(\s|$)'; then - block_tool "mcp__js-storefront-tooling__stylelint_fix" \ - "Use stylelint_fix to auto-fix Stylelint violations." + block_tool "js-storefront-tooling" \ + "Use stylelint_fix to auto-fix Stylelint violations." "stylelint_fix" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+stylelint:app(\s|--|$)'; then - block_tool "mcp__js-storefront-tooling__stylelint_check" \ - "Use stylelint_check with paths for SCSS/CSS linting, or stylelint_fix with paths to auto-fix." + block_tool "js-storefront-tooling" \ + "Use stylelint_check with paths for SCSS/CSS linting, or stylelint_fix with paths to auto-fix." "stylelint_check" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npx\s+stylelint(\s|$)'; then - block_tool "mcp__js-storefront-tooling__stylelint_check" \ - "Use stylelint_check for SCSS/CSS linting or stylelint_fix to auto-fix." + block_tool "js-storefront-tooling" \ + "Use stylelint_check for SCSS/CSS linting or stylelint_fix to auto-fix." "stylelint_check" fi # ============================================================================ @@ -143,21 +143,21 @@ fi # ============================================================================ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+unit(\s|--|$)'; then - block_tool "mcp__js-storefront-tooling__jest_run" \ - "Use jest_run with testPathPatterns, testNamePattern, coverage options." + block_tool "js-storefront-tooling" \ + "Use jest_run with testPathPatterns, testNamePattern, coverage options." "jest_run" fi # jest:base is declared by both packages, so it reaches this block only when # the command names the Storefront tree; a bare invocation stays with the Admin # hook's unknown-context fallback. if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+jest:base(\s|--|$)'; then - block_tool "mcp__js-storefront-tooling__jest_run" \ - "Use jest_run with testPathPatterns, testNamePattern, coverage, ci options." + block_tool "js-storefront-tooling" \ + "Use jest_run with testPathPatterns, testNamePattern, coverage, ci options." "jest_run" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npx\s+jest(\s|$)'; then - block_tool "mcp__js-storefront-tooling__jest_run" \ - "Use jest_run with testPathPatterns, testNamePattern, coverage options." + block_tool "js-storefront-tooling" \ + "Use jest_run with testPathPatterns, testNamePattern, coverage options." "jest_run" fi # ============================================================================ @@ -165,18 +165,18 @@ fi # ============================================================================ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+unit:components(:watch|:coverage)?(\s|--|$)'; then - block_tool "mcp__js-storefront-tooling__vitest_run" \ - "Use vitest_run with paths, testNamePattern, coverage options." + block_tool "js-storefront-tooling" \ + "Use vitest_run with paths, testNamePattern, coverage options." "vitest_run" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npx\s+vitest(\s|$)'; then - block_tool "mcp__js-storefront-tooling__vitest_run" \ - "Use vitest_run with paths, testNamePattern, coverage options." + block_tool "js-storefront-tooling" \ + "Use vitest_run with paths, testNamePattern, coverage options." "vitest_run" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*composer\s+storefront:components:unit(\s|$)'; then - block_tool "mcp__js-storefront-tooling__vitest_run" \ - "Use vitest_run with paths, testNamePattern, coverage options." + block_tool "js-storefront-tooling" \ + "Use vitest_run with paths, testNamePattern, coverage options." "vitest_run" fi # ============================================================================ @@ -184,13 +184,13 @@ fi # ============================================================================ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*composer\s+ludtwig:storefront:fix(\s|$)'; then - block_tool "mcp__js-storefront-tooling__ludtwig_fix" \ - "Use ludtwig_fix to auto-fix Twig template violations." + block_tool "js-storefront-tooling" \ + "Use ludtwig_fix to auto-fix Twig template violations." "ludtwig_fix" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*composer\s+ludtwig:storefront(\s|$)'; then - block_tool "mcp__js-storefront-tooling__ludtwig_check" \ - "Use ludtwig_check for Twig template linting or ludtwig_fix to auto-fix." + block_tool "js-storefront-tooling" \ + "Use ludtwig_check for Twig template linting or ludtwig_fix to auto-fix." "ludtwig_check" fi # A plain-whitespace leading boundary (matching the context detector's) would @@ -203,8 +203,8 @@ fi # as ludtwig takes its own options — the likeliest real invocation is the one # with the separator, and `npx -y` / `pnpm dlx --` are the same shape. if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*ludtwig(\s|$)|(^|;|&&|\|)\s*(composer\s+exec|npm\s+exec|npx|pnpm\s+exec|pnpm\s+dlx|bunx|yarn\s+exec|yarn\s+run)(\s+(--?[A-Za-z0-9][-A-Za-z0-9]*|--))*\s+ludtwig(\s|$)'; then - block_tool "mcp__js-storefront-tooling__ludtwig_check" \ - "Use ludtwig_check for Twig template linting or ludtwig_fix to auto-fix." + block_tool "js-storefront-tooling" \ + "Use ludtwig_check for Twig template linting or ludtwig_fix to auto-fix." "ludtwig_check" fi # ============================================================================ @@ -212,8 +212,8 @@ fi # ============================================================================ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+(production|development)(\s|$)'; then - block_tool "mcp__js-storefront-tooling__webpack_build" \ - "Use webpack_build with mode (development/production) option." + block_tool "js-storefront-tooling" \ + "Use webpack_build with mode (development/production) option." "webpack_build" fi exit 0 diff --git a/plugins/dev-tooling/hooks/scripts/check-php-tools.sh b/plugins/dev-tooling/hooks/scripts/check-php-tools.sh index d2df1f18..5c7d9322 100755 --- a/plugins/dev-tooling/hooks/scripts/check-php-tools.sh +++ b/plugins/dev-tooling/hooks/scripts/check-php-tools.sh @@ -16,68 +16,68 @@ parse_hook_input load_mcp_config "php-tooling" # ============================================================================ -# PHPStan - Use mcp__php-tooling__phpstan_analyze +# PHPStan - Use phpstan_analyze # ============================================================================ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*(php\s+)?\.?/?vendor/bin/phpstan(\s|$)'; then - block_tool "mcp__php-tooling__phpstan_analyze" \ - "Use phpstan_analyze for static analysis with configurable level (0-9) and paths." + block_tool "php-tooling" \ + "Use phpstan_analyze for static analysis with configurable level (0-9) and paths." "phpstan_analyze" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*composer\s+phpstan(\s|$)'; then - block_tool "mcp__php-tooling__phpstan_analyze" \ - "Use phpstan_analyze for static analysis with configurable level (0-9) and paths." + block_tool "php-tooling" \ + "Use phpstan_analyze for static analysis with configurable level (0-9) and paths." "phpstan_analyze" fi # ============================================================================ -# ECS / PHP-CS-Fixer - Use mcp__php-tooling__ecs_check or ecs_fix +# ECS / PHP-CS-Fixer - Use ecs_check or ecs_fix # ============================================================================ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*(php\s+)?\.?/?vendor/bin/(ecs|php-cs-fixer)(\s|$)'; then - block_tool "mcp__php-tooling__ecs_check or ecs_fix" \ - "Use ecs_check for dry-run validation or ecs_fix to apply fixes." + block_tool "php-tooling" \ + "Use ecs_check for dry-run validation or ecs_fix to apply fixes." "ecs_check" "ecs_fix" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*composer\s+ecs(-fix)?(\s|$)'; then - block_tool "mcp__php-tooling__ecs_check or ecs_fix" \ - "Use ecs_check for dry-run validation or ecs_fix to apply fixes." + block_tool "php-tooling" \ + "Use ecs_check for dry-run validation or ecs_fix to apply fixes." "ecs_check" "ecs_fix" fi # ============================================================================ -# PHPUnit - Use mcp__php-tooling__phpunit_run +# PHPUnit - Use phpunit_run # ============================================================================ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*(php\s+)?\.?/?vendor/bin/phpunit(\s|$)'; then - block_tool "mcp__php-tooling__phpunit_run" \ - "Use phpunit_run with testsuite, paths, filter, coverage, stop_on_failure options." + block_tool "php-tooling" \ + "Use phpunit_run with testsuite, paths, filter, coverage, stop_on_failure options." "phpunit_run" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*composer\s+phpunit(\s|$)'; then - block_tool "mcp__php-tooling__phpunit_run" \ - "Use phpunit_run with testsuite, paths, filter, coverage, stop_on_failure options." + block_tool "php-tooling" \ + "Use phpunit_run with testsuite, paths, filter, coverage, stop_on_failure options." "phpunit_run" fi # ============================================================================ -# Symfony Console - Use mcp__php-tooling__console_run or console_list +# Symfony Console - Use console_run or console_list # ============================================================================ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*(php\s+)?\.?/?bin/console(\s|$)'; then - block_tool "mcp__php-tooling__console_run or console_list" \ - "Use console_run to execute commands or console_list to list available commands." + block_tool "php-tooling" \ + "Use console_run to execute commands or console_list to list available commands." "console_run" "console_list" fi # ============================================================================ -# Rector - Use mcp__php-tooling__rector_fix or rector_check +# Rector - Use rector_fix or rector_check # ============================================================================ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*(php\s+)?\.?/?vendor/bin/rector(\s|$)'; then - block_tool "mcp__php-tooling__rector_fix or rector_check" \ - "Use rector_fix to apply refactorings or rector_check for dry-run preview." + block_tool "php-tooling" \ + "Use rector_fix to apply refactorings or rector_check for dry-run preview." "rector_fix" "rector_check" fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*composer\s+rector(\s|$)'; then - block_tool "mcp__php-tooling__rector_fix or rector_check" \ - "Use rector_fix to apply refactorings or rector_check for dry-run preview." + block_tool "php-tooling" \ + "Use rector_fix to apply refactorings or rector_check for dry-run preview." "rector_fix" "rector_check" fi exit 0 diff --git a/plugins/dev-tooling/hooks/scripts/check-phpstan-baseline.sh b/plugins/dev-tooling/hooks/scripts/check-phpstan-baseline.sh index 6a4ed47d..7f92c819 100755 --- a/plugins/dev-tooling/hooks/scripts/check-phpstan-baseline.sh +++ b/plugins/dev-tooling/hooks/scripts/check-phpstan-baseline.sh @@ -11,6 +11,9 @@ set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/lib/common.sh" + INPUT=$(cat) # Extract paths array from tool_input (single jq call) @@ -28,10 +31,7 @@ if [[ -z "$PATHS_LIST" ]]; then fi # Detect project directory -PROJECT_DIR="${CLAUDE_PROJECT_DIR:-}" -if [[ -z "$PROJECT_DIR" ]]; then - PROJECT_DIR=$(printf '%s' "$INPUT" | jq -r '.cwd // empty') -fi +PROJECT_DIR=$(resolve_project_dir "$INPUT") if [[ -z "$PROJECT_DIR" ]]; then exit 0 fi diff --git a/plugins/dev-tooling/hooks/scripts/lib/common.sh b/plugins/dev-tooling/hooks/scripts/lib/common.sh index c748153e..565fffb5 100644 --- a/plugins/dev-tooling/hooks/scripts/lib/common.sh +++ b/plugins/dev-tooling/hooks/scripts/lib/common.sh @@ -9,27 +9,40 @@ # parse_hook_input # load_mcp_config "php-tooling" # or "js-tooling" # # ... pattern matching ... -# block_tool "mcp__php-tooling__phpstan_analyze" "Description" +# block_tool "php-tooling" "Description" "phpstan_analyze" # Global variables set by this library: +# HOOK_INPUT - The hook-event JSON read from stdin # COMMAND - The bash command being checked # CONFIG_FILE - Path to loaded config file (or empty) # ENVIRONMENT - Environment from config (native/docker/docker-compose/vagrant/ddev) # ENFORCE_MCP_TOOLS - Whether to enforce MCP tools (true/false) +# Resolve the session's project directory: CLAUDE_PROJECT_DIR when set and +# non-empty, else the hook input's .cwd (Codex sets no CLAUDE_PROJECT_DIR; both +# name the session directory). Stdin is read once by the caller and passed in. +# Args: $1 = hook-event JSON +# Stdout: the directory, or nothing when neither source names one +resolve_project_dir() { + if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then + printf '%s\n' "$CLAUDE_PROJECT_DIR" + return 0 + fi + printf '%s' "$1" | jq -r 'if (.cwd | type) == "string" then .cwd else empty end' +} + # Parse hook input from stdin -# Sets: COMMAND (global) +# Sets: HOOK_INPUT, COMMAND (globals) # Exits 0 if command is empty parse_hook_input() { - local input - input=$(cat) - COMMAND=$(echo "$input" | jq -r '.tool_input.command // empty') + HOOK_INPUT=$(cat) + COMMAND=$(echo "$HOOK_INPUT" | jq -r '.tool_input.command // empty') if [[ -z "$COMMAND" ]]; then exit 0 fi } -# Load MCP config from project directory +# Load MCP config from project directory (resolve_project_dir on HOOK_INPUT) # Args: $1 = config prefix (e.g., "php-tooling", "js-tooling") # Sets: CONFIG_FILE, ENVIRONMENT, ENFORCE_MCP_TOOLS (globals) # Exits 0 if enforcement is disabled @@ -39,11 +52,14 @@ load_mcp_config() { ENVIRONMENT="" ENFORCE_MCP_TOOLS="true" - if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then + local project_dir + project_dir=$(resolve_project_dir "$HOOK_INPUT") + + if [[ -n "$project_dir" ]]; then # Check config locations in priority order for location in ".claude/.mcp-${config_prefix}.json" ".mcp-${config_prefix}.json"; do - if [[ -f "${CLAUDE_PROJECT_DIR}/${location}" ]]; then - CONFIG_FILE="${CLAUDE_PROJECT_DIR}/${location}" + if [[ -f "${project_dir}/${location}" ]]; then + CONFIG_FILE="${project_dir}/${location}" break fi done @@ -67,15 +83,24 @@ load_mcp_config() { } # Block a tool with formatted message -# Args: $1 = full MCP tool name (e.g., "mcp__php-tooling__phpstan_analyze") +# Tool names carry no host prefix: Claude Code and Codex each build their own +# mcp__ form, so the message names the tool and its server instead. +# Args: $1 = MCP server name (e.g., "php-tooling") # $2 = description of what to use instead +# $3... = tool names, one or more (e.g., "ecs_check" "ecs_fix") # Outputs to stderr and exits with code 2 block_tool() { - local tool="$1" + local server="$1" local description="$2" + shift 2 + + local tools="" name + for name in "$@"; do + tools+="${tools:+ or }\`${name}\`" + done { - echo "🤖 Down, model! Use the ${tool} instead!" + echo "🤖 Down, model! Use ${tools} on the \`${server}\` MCP server instead!" echo "" echo "Bad command detected: ${COMMAND}" echo "" diff --git a/plugins/dev-tooling/hooks/scripts/session-start.sh b/plugins/dev-tooling/hooks/scripts/session-start.sh index 22efd107..abe18d00 100755 --- a/plugins/dev-tooling/hooks/scripts/session-start.sh +++ b/plugins/dev-tooling/hooks/scripts/session-start.sh @@ -2,21 +2,33 @@ # SessionStart hook: inject MCP dev tool usage directives + scopes metadata. set -euo pipefail -# Claude Code writes hook-event JSON to stdin for every hook, including -# SessionStart; draining it avoids blocking the harness's write on a payload -# larger than the pipe buffer. -cat > /dev/null - HOOK_DIR="$(cd "$(dirname "$0")/.." && pwd)" -PROMPT_FILE="${HOOK_DIR}/prompts/mcp-tool-directives.md" +# Claude Code sets CLAUDE_PROJECT_DIR for plugin hooks and Codex does not; Codex +# has no plugin subagents or worktree-switching tools, so it gets its own prompt. +if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then + PROMPT_FILE="${HOOK_DIR}/prompts/mcp-tool-directives.md" +else + PROMPT_FILE="${HOOK_DIR}/prompts/mcp-tool-directives-codex.md" +fi + +# shellcheck source=lib/common.sh +source "${HOOK_DIR}/scripts/lib/common.sh" + +# The host writes hook-event JSON to stdin for every hook, including +# SessionStart; reading it fully also avoids blocking the host's write on a +# payload larger than the pipe buffer. Its .cwd is the project-dir fallback. +# Input that is not JSON names no directory; the directives still go out, so a +# resolve failure leaves PROJECT_DIR empty instead of aborting under set -e. +HOOK_INPUT=$(cat) +PROJECT_DIR=$(resolve_project_dir "$HOOK_INPUT") || PROJECT_DIR="" is_enforced() { local config_prefix="$1" - [[ -z "${CLAUDE_PROJECT_DIR:-}" ]] && return 0 + [[ -z "${PROJECT_DIR}" ]] && return 0 local config_file="" for location in ".claude/.mcp-${config_prefix}.json" ".mcp-${config_prefix}.json"; do - if [[ -f "${CLAUDE_PROJECT_DIR}/${location}" ]]; then - config_file="${CLAUDE_PROJECT_DIR}/${location}" + if [[ -f "${PROJECT_DIR}/${location}" ]]; then + config_file="${PROJECT_DIR}/${location}" break fi done @@ -32,13 +44,13 @@ is_enforced() { # no scopes are present. _render_scopes_section() { local prefix="$1" - [[ -z "${CLAUDE_PROJECT_DIR:-}" ]] && return 0 + [[ -z "${PROJECT_DIR}" ]] && return 0 command -v jq &>/dev/null || return 0 local config_file="" for location in ".claude/.mcp-${prefix}.json" ".mcp-${prefix}.json"; do - if [[ -f "${CLAUDE_PROJECT_DIR}/${location}" ]]; then - config_file="${CLAUDE_PROJECT_DIR}/${location}" + if [[ -f "${PROJECT_DIR}/${location}" ]]; then + config_file="${PROJECT_DIR}/${location}" break fi done @@ -66,21 +78,39 @@ scope is the default. EOF } -is_enforced "php-tooling" || is_enforced "js-tooling" || exit 0 +# No CLAUDE_PROJECT_DIR means a host that names the session directory only in +# .cwd, as Codex does; it also starts the servers in the plugin's own directory, +# unbound, so the session is told which root binds them. Not gated on +# enforce_mcp_tools: an unbound server refuses every tool whatever that says. +bind_hint="" +if [[ -z "${CLAUDE_PROJECT_DIR:-}" && -n "${PROJECT_DIR}" ]]; then + bind_hint=$(printf '%s\n\n%s' "## Project root for the dev-tooling MCP servers" \ + "If a tool of the \`php-tooling\`, \`js-admin-tooling\` or \`js-storefront-tooling\` MCP server reports that no project root is set, call \`set_project_root\` on that server with \`${PROJECT_DIR}\` and retry.") +fi + +enforced=1 +is_enforced "php-tooling" || is_enforced "js-tooling" || enforced=0 +[[ "${enforced}" -eq 0 && -z "${bind_hint}" ]] && exit 0 context="" -if [[ -f "$PROMPT_FILE" ]]; then - context=$(cat "$PROMPT_FILE") -fi +if [[ "${enforced}" -eq 1 ]]; then + if [[ -f "$PROMPT_FILE" ]]; then + context=$(cat "$PROMPT_FILE") + fi + + # Append scopes sections (one per config prefix that declares scopes). + scopes_block="" + for prefix in php-tooling js-tooling; do + section=$(_render_scopes_section "${prefix}") + [[ -n "${section}" ]] && scopes_block+="${section}" + done -# Append scopes sections (one per config prefix that declares scopes). -scopes_block="" -for prefix in php-tooling js-tooling; do - section=$(_render_scopes_section "${prefix}") - [[ -n "${section}" ]] && scopes_block+="${section}" -done + [[ -n "${scopes_block}" ]] && context+="${scopes_block}" +fi -[[ -n "${scopes_block}" ]] && context+="${scopes_block}" +if [[ -n "${bind_hint}" ]]; then + context+="${context:+$'\n\n'}${bind_hint}" +fi json_context=$(printf '%s' "${context}" | jq -Rs '.') cat <}" -log "INFO" "======================================" +if launch_is_bound; then + detect_environment "${PROJECT_ROOT}" + _configure_extra_log_file "$(_get_config_value '.log_file' '')" + + log "INFO" "======================================" + log "INFO" "Admin Tooling MCP Server starting" + log "INFO" "Script dir: ${SCRIPT_DIR}" + log "INFO" "Project root: ${PROJECT_ROOT}" + log "INFO" "Config file: ${LINT_CONFIG_FILE}" + log "INFO" "Environment: ${LINT_ENV}" + log "INFO" "Working dir: ${LINT_WORKDIR}" + log "INFO" "Extra log: ${MCP_EXTRA_LOG_FILE:-}" + log "INFO" "======================================" +fi source "${SHARED_DIR}/server_run.sh" diff --git a/plugins/dev-tooling/mcp-server-js-admin/tools.json b/plugins/dev-tooling/mcp-server-js-admin/tools.json index 397bfca4..acea56aa 100644 --- a/plugins/dev-tooling/mcp-server-js-admin/tools.json +++ b/plugins/dev-tooling/mcp-server-js-admin/tools.json @@ -329,13 +329,13 @@ }, { "name": "set_project_root", - "description": "Set the project root every later call on this server targets, or clear it. Pass a linked git worktree of the root the server was launched in to move the server onto that tree; omit project_root to return it to the launch root. Each of php-tooling, js-admin-tooling and js-storefront-tooling is a separate process and keeps its own setting, so call this on each server you use.", + "description": "Set the project root every later call on this server targets, or clear it. A server started in a project directory is bound to it as its launch root: pass a linked git worktree of that root to move the server onto that tree, and omit project_root, or pass the launch root itself, to return it to the launch root. A server started in its own plugin directory, which is where Codex starts plugin MCP servers, has no project root, and every tool except cwd refuses until this is called with the absolute path of the project; that binds the server to it as its launch root for the rest of the process, and calling it again with that path clears any sticky worktree, the same as omitting project_root. Each of php-tooling, js-admin-tooling and js-storefront-tooling is a separate process and keeps its own setting, so call this on each server you use.", "inputSchema": { "type": "object", "properties": { "project_root": { "type": "string", - "description": "Absolute path to a linked git worktree of the root this server was launched in. Omit to clear the setting and return the server to its launch root; the value being discarded is not validated, so this also recovers from a worktree that has been removed. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + "description": "Absolute path. On a server with no project root yet: the project to bind, which must hold this server's .mcp-js-tooling.json at its top or in a supported tool directory such as .claude/, unless MCP_JS_TOOLING_CONFIG names the configuration file. Otherwise: a linked git worktree of the root this server was launched in. Omit it, or pass the launch root itself, to clear the setting and return the server to its launch root; the value being discarded is not validated, so this also recovers from a worktree that has been removed. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." } }, "additionalProperties": false diff --git a/plugins/dev-tooling/mcp-server-js-storefront/server.sh b/plugins/dev-tooling/mcp-server-js-storefront/server.sh index 1f874ff7..d18e4316 100755 --- a/plugins/dev-tooling/mcp-server-js-storefront/server.sh +++ b/plugins/dev-tooling/mcp-server-js-storefront/server.sh @@ -16,7 +16,8 @@ # - ludtwig_check: Run ludtwig on Storefront Twig templates # - ludtwig_fix: Auto-fix ludtwig violations # - webpack_build: Build with Webpack -# - set_project_root: Set or clear the sticky project root +# - set_project_root: Bind the project root of an unbound server, or set or +# clear the sticky project root # - cwd: Report the project root, working directory and config in use # # Supports environments: native, docker, docker-compose, vagrant, ddev @@ -55,20 +56,30 @@ source "${SHARED_DIR}/mcpserver_core.sh" # Source config module and load configuration source "${SHARED_DIR}/config.sh" -if ! load_config "${PROJECT_ROOT}"; then - exit 1 +# A launch in this plugin's own directory starts unbound and skips every +# project-dependent startup step below; set_project_root performs them later. +source "${SHARED_DIR}/launch.sh" +if launch_is_bound; then + if ! load_config "${PROJECT_ROOT}"; then + exit 1 + fi fi # Source remaining modules source "${SHARED_DIR}/environment.sh" source "${SHARED_DIR}/scope.sh" -if ! scope_validate; then - exit 1 +if launch_is_bound; then + if ! scope_validate; then + exit 1 + fi fi source "${SHARED_DIR}/worktree.sh" if ! worktree_state_init; then exit 1 fi +if launch_is_bound; then + worktree_launch_common_init +fi # One EXIT trap doing both jobs. A second `trap ... EXIT` would replace the one # config.sh installs at source time, leaving the merged-config temp file behind. @@ -84,17 +95,19 @@ source "${SCRIPT_DIR}/lib/prepare.sh" trap 'log "ERROR" "Unexpected error on line ${LINENO}"' ERR -detect_environment "${PROJECT_ROOT}" -_configure_extra_log_file "$(_get_config_value '.log_file' '')" - -log "INFO" "======================================" -log "INFO" "Storefront Tooling MCP Server starting" -log "INFO" "Script dir: ${SCRIPT_DIR}" -log "INFO" "Project root: ${PROJECT_ROOT}" -log "INFO" "Config file: ${LINT_CONFIG_FILE}" -log "INFO" "Environment: ${LINT_ENV}" -log "INFO" "Working dir: ${LINT_WORKDIR}" -log "INFO" "Extra log: ${MCP_EXTRA_LOG_FILE:-}" -log "INFO" "======================================" +if launch_is_bound; then + detect_environment "${PROJECT_ROOT}" + _configure_extra_log_file "$(_get_config_value '.log_file' '')" + + log "INFO" "======================================" + log "INFO" "Storefront Tooling MCP Server starting" + log "INFO" "Script dir: ${SCRIPT_DIR}" + log "INFO" "Project root: ${PROJECT_ROOT}" + log "INFO" "Config file: ${LINT_CONFIG_FILE}" + log "INFO" "Environment: ${LINT_ENV}" + log "INFO" "Working dir: ${LINT_WORKDIR}" + log "INFO" "Extra log: ${MCP_EXTRA_LOG_FILE:-}" + log "INFO" "======================================" +fi source "${SHARED_DIR}/server_run.sh" diff --git a/plugins/dev-tooling/mcp-server-js-storefront/tools.json b/plugins/dev-tooling/mcp-server-js-storefront/tools.json index b259aa9f..3209869b 100644 --- a/plugins/dev-tooling/mcp-server-js-storefront/tools.json +++ b/plugins/dev-tooling/mcp-server-js-storefront/tools.json @@ -274,13 +274,13 @@ }, { "name": "set_project_root", - "description": "Set the project root every later call on this server targets, or clear it. Pass a linked git worktree of the root the server was launched in to move the server onto that tree; omit project_root to return it to the launch root. Each of php-tooling, js-admin-tooling and js-storefront-tooling is a separate process and keeps its own setting, so call this on each server you use.", + "description": "Set the project root every later call on this server targets, or clear it. A server started in a project directory is bound to it as its launch root: pass a linked git worktree of that root to move the server onto that tree, and omit project_root, or pass the launch root itself, to return it to the launch root. A server started in its own plugin directory, which is where Codex starts plugin MCP servers, has no project root, and every tool except cwd refuses until this is called with the absolute path of the project; that binds the server to it as its launch root for the rest of the process, and calling it again with that path clears any sticky worktree, the same as omitting project_root. Each of php-tooling, js-admin-tooling and js-storefront-tooling is a separate process and keeps its own setting, so call this on each server you use.", "inputSchema": { "type": "object", "properties": { "project_root": { "type": "string", - "description": "Absolute path to a linked git worktree of the root this server was launched in. Omit to clear the setting and return the server to its launch root; the value being discarded is not validated, so this also recovers from a worktree that has been removed. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + "description": "Absolute path. On a server with no project root yet: the project to bind, which must hold this server's .mcp-js-tooling.json at its top or in a supported tool directory such as .claude/, unless MCP_JS_TOOLING_CONFIG names the configuration file. Otherwise: a linked git worktree of the root this server was launched in. Omit it, or pass the launch root itself, to clear the setting and return the server to its launch root; the value being discarded is not validated, so this also recovers from a worktree that has been removed. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." } }, "additionalProperties": false diff --git a/plugins/dev-tooling/mcp-server-php/server.sh b/plugins/dev-tooling/mcp-server-php/server.sh index b0fdbc4a..e9d87c21 100755 --- a/plugins/dev-tooling/mcp-server-php/server.sh +++ b/plugins/dev-tooling/mcp-server-php/server.sh @@ -13,7 +13,8 @@ # - console_list: List available bin/console commands # - rector_check: Preview Rector transformations (dry-run) # - rector_fix: Apply Rector transformations -# - set_project_root: Set or clear the sticky project root +# - set_project_root: Bind the project root of an unbound server, or set or +# clear the sticky project root # - cwd: Report the project root, working directory and config in use # # Supports environments: native, docker, docker-compose, vagrant, ddev @@ -46,19 +47,29 @@ export SCRIPT_DIR SHARED_DIR MCP_CONFIG_FILE MCP_TOOLS_LIST_FILE MCP_LOG_FILE PR source "${SHARED_DIR}/mcpserver_core.sh" source "${SHARED_DIR}/config.sh" -if ! load_config "${PROJECT_ROOT}"; then - exit 1 +# A launch in this plugin's own directory starts unbound and skips every +# project-dependent startup step below; set_project_root performs them later. +source "${SHARED_DIR}/launch.sh" +if launch_is_bound; then + if ! load_config "${PROJECT_ROOT}"; then + exit 1 + fi fi source "${SHARED_DIR}/environment.sh" source "${SHARED_DIR}/scope.sh" -if ! scope_validate; then - exit 1 +if launch_is_bound; then + if ! scope_validate; then + exit 1 + fi fi source "${SHARED_DIR}/worktree.sh" if ! worktree_state_init; then exit 1 fi +if launch_is_bound; then + worktree_launch_common_init +fi # One EXIT trap doing both jobs. A second `trap ... EXIT` would replace the one # config.sh installs at source time, leaving the merged-config temp file behind. @@ -74,17 +85,19 @@ source "${SCRIPT_DIR}/lib/prepare.sh" trap 'log "ERROR" "Unexpected error on line ${LINENO}"' ERR -detect_environment "${PROJECT_ROOT}" -_configure_extra_log_file "$(_get_config_value '.log_file' '')" +if launch_is_bound; then + detect_environment "${PROJECT_ROOT}" + _configure_extra_log_file "$(_get_config_value '.log_file' '')" -log "INFO" "======================================" -log "INFO" "PHP Linting MCP Server starting" -log "INFO" "Script dir: ${SCRIPT_DIR}" -log "INFO" "Project root: ${PROJECT_ROOT}" -log "INFO" "Config file: ${LINT_CONFIG_FILE}" -log "INFO" "Environment: ${LINT_ENV}" -log "INFO" "Working dir: ${LINT_WORKDIR}" -log "INFO" "Extra log: ${MCP_EXTRA_LOG_FILE:-}" -log "INFO" "======================================" + log "INFO" "======================================" + log "INFO" "PHP Linting MCP Server starting" + log "INFO" "Script dir: ${SCRIPT_DIR}" + log "INFO" "Project root: ${PROJECT_ROOT}" + log "INFO" "Config file: ${LINT_CONFIG_FILE}" + log "INFO" "Environment: ${LINT_ENV}" + log "INFO" "Working dir: ${LINT_WORKDIR}" + log "INFO" "Extra log: ${MCP_EXTRA_LOG_FILE:-}" + log "INFO" "======================================" +fi source "${SHARED_DIR}/server_run.sh" diff --git a/plugins/dev-tooling/mcp-server-php/tools.json b/plugins/dev-tooling/mcp-server-php/tools.json index d242aca8..b9745b43 100644 --- a/plugins/dev-tooling/mcp-server-php/tools.json +++ b/plugins/dev-tooling/mcp-server-php/tools.json @@ -353,13 +353,13 @@ }, { "name": "set_project_root", - "description": "Set the project root every later call on this server targets, or clear it. Pass a linked git worktree of the root the server was launched in to move the server onto that tree; omit project_root to return it to the launch root. Each of php-tooling, js-admin-tooling and js-storefront-tooling is a separate process and keeps its own setting, so call this on each server you use.", + "description": "Set the project root every later call on this server targets, or clear it. A server started in a project directory is bound to it as its launch root: pass a linked git worktree of that root to move the server onto that tree, and omit project_root, or pass the launch root itself, to return it to the launch root. A server started in its own plugin directory, which is where Codex starts plugin MCP servers, has no project root, and every tool except cwd refuses until this is called with the absolute path of the project; that binds the server to it as its launch root for the rest of the process, and calling it again with that path clears any sticky worktree, the same as omitting project_root. Each of php-tooling, js-admin-tooling and js-storefront-tooling is a separate process and keeps its own setting, so call this on each server you use.", "inputSchema": { "type": "object", "properties": { "project_root": { "type": "string", - "description": "Absolute path to a linked git worktree of the root this server was launched in. Omit to clear the setting and return the server to its launch root; the value being discarded is not validated, so this also recovers from a worktree that has been removed. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + "description": "Absolute path. On a server with no project root yet: the project to bind, which must hold this server's .mcp-php-tooling.json at its top or in a supported tool directory such as .claude/, unless MCP_PHP_TOOLING_CONFIG names the configuration file. Otherwise: a linked git worktree of the root this server was launched in. Omit it, or pass the launch root itself, to clear the setting and return the server to its launch root; the value being discarded is not validated, so this also recovers from a worktree that has been removed. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." } }, "additionalProperties": false diff --git a/plugins/dev-tooling/shared/launch.sh b/plugins/dev-tooling/shared/launch.sh new file mode 100644 index 00000000..31fcde15 --- /dev/null +++ b/plugins/dev-tooling/shared/launch.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Launch mode of a dev-tooling MCP server: bound to the project root it was +# started in, or unbound because the host started it in this plugin's own +# directory. Claude Code starts a server in the session's project directory. +# Codex starts every plugin MCP server in the plugin directory with no project +# named anywhere, so a server whose launch root is this plugin starts unbound: +# initialize and tools/list work, and every tool refuses until set_project_root +# binds a project (worktree.sh). +# +# Owned by dev-tooling. Not templated — do not add it to the mapping in +# .claude/rules/template-sync.md. +# +# Requires: PROJECT_ROOT and MCP_CONFIG_FILE set by server.sh; log() from +# mcpserver_core.sh. Sourced after config.sh and before load_config. +# +# Public: +# launch_is_bound - true unless the server started unbound +# launch_path_in_plugin_root - true when a canonical path is the +# plugin root or inside it +# launch_anchor_config_override - makes a relative MCP__CONFIG +# absolute against a directory +# +# Globals set at source time: +# LAUNCH_MODE - "bound" or "unbound" +# LAUNCH_PLUGIN_ROOT - this plugin's root, canonical +# LAUNCH_SERVER_NAME - the server's name from its config.json; unbound only +# PROJECT_ROOT, LINT_CONFIG_FILE - exported empty when unbound + +set -euo pipefail +shopt -s inherit_errexit 2>/dev/null || true # Bash 4.4+ + +LAUNCH_PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +LAUNCH_SERVER_NAME="" +LAUNCH_MODE="bound" + +# launch_path_in_plugin_root +# Args: $1 = a path already resolved with `pwd -P` +# Returns: 0 when it is the plugin root or sits under it, 1 otherwise +launch_path_in_plugin_root() { + local path="$1" + [[ "${path}" == "${LAUNCH_PLUGIN_ROOT}" || "${path}" == "${LAUNCH_PLUGIN_ROOT}/"* ]] +} + +# launch_anchor_config_override +# Makes a relative config-variable override absolute against . A +# relative value only names a file from the directory the server read it in, +# and a call that enters a worktree, or the dispatch subshell of a later call +# that starts from the host's environment again, is somewhere else — the +# project's configuration would be dropped without a word. The caller picks the +# directory the value was meant against and repeats the call wherever the +# environment is rebuilt. +# Args: $1 = the directory, physical form +# Globals: reads and assigns the variable CONFIG_ENV_VAR names +launch_anchor_config_override() { + local name="${CONFIG_ENV_VAR:-}" + if [[ -z "${name}" ]]; then + return 0 + fi + local value="${!name:-}" + if [[ -n "${value}" && "${value}" != /* ]]; then + printf -v "${name}" '%s' "$1/${value}" + fi +} + +# launch_is_bound +# Returns: 0 when the server started bound to a project root, 1 when unbound +launch_is_bound() { + [[ "${LAUNCH_MODE}" == "bound" ]] +} + +# A root that does not resolve stays bound, so startup refuses it exactly as +# before. MCP__CONFIG is deliberately not consulted: it names a +# configuration file, not a project, and honoring it here would make the +# plugin directory the root every tool runs in. The bind honors it instead. +_launch_canonical_root="" +_launch_canonical_root=$(cd "${PROJECT_ROOT}" >/dev/null 2>&1 && pwd -P) || _launch_canonical_root="" +if [[ -n "${_launch_canonical_root}" ]] && launch_path_in_plugin_root "${_launch_canonical_root}"; then + LAUNCH_MODE="unbound" + + # The name the refusal tells the caller to call set_project_root on. + LAUNCH_SERVER_NAME=$(jq -r '.serverInfo.name // empty' "${MCP_CONFIG_FILE}" 2>/dev/null) || LAUNCH_SERVER_NAME="" + if [[ -z "${LAUNCH_SERVER_NAME}" ]]; then + log "ERROR" "Cannot read .serverInfo.name from ${MCP_CONFIG_FILE}" + exit 1 + fi + + PROJECT_ROOT="" + LINT_CONFIG_FILE="" + export PROJECT_ROOT LINT_CONFIG_FILE + + log "INFO" "${LAUNCH_SERVER_NAME} starting without a project root: launched in its plugin directory ${LAUNCH_PLUGIN_ROOT}. Every tool refuses until set_project_root binds one." +else + # A bound server reads a relative override against its own directory, as it + # always has; made absolute here, every later call reads the same file. + launch_anchor_config_override "$(pwd -P)" +fi +unset _launch_canonical_root diff --git a/plugins/dev-tooling/shared/worktree.sh b/plugins/dev-tooling/shared/worktree.sh index 2b61e322..8118e41a 100644 --- a/plugins/dev-tooling/shared/worktree.sh +++ b/plugins/dev-tooling/shared/worktree.sh @@ -16,10 +16,16 @@ # server.sh; log(), load_config(), _discover_configs(), # _get_config_value(), get_workdir(), get_js_workdir() and # scope_validate() from the shared modules; JS_CONTEXT set by the JS servers only. +# A server that started unbound (launch.sh) also needs LAUNCH_MODE, +# LAUNCH_PLUGIN_ROOT, LAUNCH_SERVER_NAME, +# launch_path_in_plugin_root() and launch_anchor_config_override(); +# one sourced without launch.sh is bound. # # Public: # worktree_state_init - create the state file; once, from server.sh +# worktree_launch_common_init - record the launch root's common git dir # worktree_state_cleanup - remove it; from server.sh's EXIT trap +# worktree_launch_hydrate - give an unbound server its bound launch globals # worktree_enter - resolve the effective root and banner it # worktree_resolve_root - the resolution half of worktree_enter # worktree_root_banner - the banner half of worktree_enter @@ -87,6 +93,10 @@ WORKTREE_LAUNCH_COMMON="" # stdout: reading it via command substitution would subshell away the selected config and owned temp path that call also sets. WORKTREE_VALIDATION_MESSAGE="" +# Why the last worktree_launch_hydrate call failed; a global for the same +# reason, since hydration assigns the launch globals in the caller's shell. +WORKTREE_LAUNCH_MESSAGE="" + # ============================================================================= # State file # ============================================================================= @@ -116,23 +126,43 @@ worktree_state_init() { DEV_TOOLING_STATE_FILE="${path}" export DEV_TOOLING_STATE_FILE - # A launch root that is not a git repository leaves this empty rather than - # failing the server: only a worktree-targeted call needs the value. + return 0 +} + +# worktree_launch_common_init +# Records the launch root's common git directory, which every worktree-targeted +# call compares against. Kept out of worktree_state_init because an unbound +# server has no launch root yet: git run against its empty PROJECT_ROOT reads +# the repository the process sits in, which is the plugin's own checkout. The +# bind runs this once it has a root. +# A launch root that is not a git repository leaves this empty rather than +# failing the server: only a worktree-targeted call needs the value. +# Globals: reads PROJECT_ROOT; sets WORKTREE_LAUNCH_COMMON +# Returns: 0 always +worktree_launch_common_init() { WORKTREE_LAUNCH_COMMON="" local launch_pair if launch_pair=$(_worktree_git_dir_pair "${PROJECT_ROOT}"); then { IFS= read -r _; IFS= read -r WORKTREE_LAUNCH_COMMON; } <<< "${launch_pair}" fi - return 0 } # worktree_state_cleanup -# Removes the state file. Called from server.sh's EXIT trap. -# Globals: reads DEV_TOOLING_STATE_FILE +# Removes the state file, and the merged configuration a bind handed to the +# process, which outlives the call that created it. Called from server.sh's +# EXIT trap. +# Globals: reads DEV_TOOLING_STATE_FILE, LAUNCH_MODE # Returns: 0 always, so it cannot displace the trap's other work worktree_state_cleanup() { if [[ -n "${DEV_TOOLING_STATE_FILE:-}" ]]; then + if [[ "${LAUNCH_MODE:-}" == "unbound" ]]; then + local owned="" + owned=$(jq -r '.launch.config_temp // empty' "${DEV_TOOLING_STATE_FILE}" 2>/dev/null) || owned="" + if [[ -n "${owned}" ]]; then + rm -f -- "${owned}" + fi + fi rm -f -- "${DEV_TOOLING_STATE_FILE}" fi return 0 @@ -1058,6 +1088,241 @@ _worktree_validate_root() { return 0 } +# ============================================================================= +# Unbound launch +# +# A server launch.sh started unbound has no project root until set_project_root +# binds one. Every request runs in its own dispatch subshell and nothing runs +# between requests, so the bind cannot assign the server's own globals: it +# writes them as the "launch" record in the state file, and every tool entry +# reads them back through worktree_launch_hydrate. +# ============================================================================= + +# worktree_launch_hydrate +# Gives an unbound server the launch globals a bound server sets at startup, +# read back from the launch record, and enters the root: a launch-root call +# never changes directory, the container wrappers skip the directory a native +# command enters, and ddev and vagrant discover their project from the current +# directory — all of which a bound server satisfies by being started there. +# A bound server returns at once and reads nothing. +# Call it directly, never through a command substitution, which would discard +# every global it sets. +# Globals: reads LAUNCH_MODE, LAUNCH_PLUGIN_ROOT, LAUNCH_SERVER_NAME and +# DEV_TOOLING_STATE_FILE; sets WORKTREE_LAUNCH_MESSAGE on failure. From +# a record it sets PROJECT_ROOT and LINT_CONFIG_FILE (exported, as +# server.sh and config.sh export them), MCP_EXTRA_LOG_FILE (exported +# when set, as _configure_extra_log_file does), LINT_ENV, LINT_WORKDIR, +# DOCKER_CONTAINER, COMPOSE_SERVICE, COMPOSE_WORKDIR_OVERRIDE, +# COMPOSE_FILE_OVERRIDE, WORKTREE_LAUNCH_COMMON, +# WORKTREE_LAUNCH_CONFIG_FILE, WORKTREE_SELECTED_CONFIG_FILE and +# WORKTREE_EFFECTIVE_ROOT, makes a relative config-variable override +# absolute against PROJECT_ROOT, and changes the working directory +# Returns: 0 when bound or hydrated, 2 when unbound with no readable launch +# record, 1 when a record was read and cannot be applied +worktree_launch_hydrate() { + WORKTREE_LAUNCH_MESSAGE="" + + if [[ "${LAUNCH_MODE:-}" != "unbound" ]]; then + return 0 + fi + + # NUL-separated so no value can split another, and a field that is not a + # string fails the whole read rather than shifting the ones after it. + local -a fields=() + local field + if [[ -n "${DEV_TOOLING_STATE_FILE:-}" && -f "${DEV_TOOLING_STATE_FILE}" ]]; then + while IFS= read -r -d '' field; do + fields+=("${field}") + done < <(jq -j ' + if type != "object" then error("state") + elif has("launch") | not then "none\u0000" + else .launch + | [.project_root, .config_file, .environment, .workdir, + .docker_container, .compose_service, .compose_workdir_override, + .compose_file_override, .extra_log_file, .launch_common] + | if all(type == "string") then ["record"] + . | map(. + "\u0000") | add + else error("launch") end + end' "${DEV_TOOLING_STATE_FILE}" 2>/dev/null) + fi + + local unbound="No project root is set. This MCP server started in its own plugin directory (${LAUNCH_PLUGIN_ROOT:-}), which is where Codex starts plugin MCP servers. Call \`set_project_root\` on the \`${LAUNCH_SERVER_NAME:-}\` MCP server with the absolute path of your project, then retry." + + if [[ "${#fields[@]}" -eq 1 && "${fields[0]}" == "none" ]]; then + WORKTREE_LAUNCH_MESSAGE="${unbound}" + return 2 + fi + + if [[ "${#fields[@]}" -ne 11 || "${fields[0]}" != "record" ]]; then + WORKTREE_LAUNCH_MESSAGE="${unbound} The state file ${DEV_TOOLING_STATE_FILE:-} holds no readable launch record, so a project root bound earlier, if any, is lost." + return 2 + fi + + PROJECT_ROOT="${fields[1]}" + LINT_CONFIG_FILE="${fields[2]}" + LINT_ENV="${fields[3]}" + LINT_WORKDIR="${fields[4]}" + DOCKER_CONTAINER="${fields[5]}" + COMPOSE_SERVICE="${fields[6]}" + COMPOSE_WORKDIR_OVERRIDE="${fields[7]}" + COMPOSE_FILE_OVERRIDE="${fields[8]}" + MCP_EXTRA_LOG_FILE="${fields[9]}" + WORKTREE_LAUNCH_COMMON="${fields[10]}" + export PROJECT_ROOT LINT_CONFIG_FILE + if [[ -n "${MCP_EXTRA_LOG_FILE}" ]]; then + export MCP_EXTRA_LOG_FILE + fi + + # The bind's own rewrite died with its subshell; this call's environment is the host's. + launch_anchor_config_override "${PROJECT_ROOT}" + + WORKTREE_LAUNCH_CONFIG_FILE="${LINT_CONFIG_FILE}" + WORKTREE_SELECTED_CONFIG_FILE="${LINT_CONFIG_FILE}" + WORKTREE_EFFECTIVE_ROOT="${PROJECT_ROOT}" + + # A bound server loaded the compose module during startup derivation; the + # bind's derivation loaded it into a subshell that is gone. + if [[ "${LINT_ENV}" == "docker-compose" ]] && ! _ensure_compose_module >/dev/null; then + WORKTREE_LAUNCH_MESSAGE="Refusing to run: docker-compose.sh was not found next to environment.sh, so the docker-compose environment of \"${PROJECT_ROOT}\" cannot be used." + return 1 + fi + + if ! cd "${PROJECT_ROOT}" >/dev/null 2>&1; then + WORKTREE_LAUNCH_MESSAGE="Refusing to run: the project root \"${PROJECT_ROOT}\" set_project_root bound this server to could not be entered; it may have been removed. Restart the server to bind another." + return 1 + fi + + return 0 +} + +# _worktree_launch_bind +# Binds an unbound server to : the project-dependent startup a +# bound server runs, against this root, with the result written as the launch +# record. Nothing beyond startup is asked of the root — no git repository and +# no probe — because a launch root is asked neither. +# Runs in the dispatch subshell, so every global it assigns dies with the call; +# the record is what outlives it. +# Args: $1 = the project root, as the caller spelled it; the record and the +# launch root hold its physical (`pwd -P`) form +# Globals: reads LAUNCH_PLUGIN_ROOT, CONFIG_ENV_VAR, CONFIG_LOCATIONS and +# _FOUND_CONFIGS; sets, for this call, PROJECT_ROOT, LINT_CONFIG_FILE, +# LINT_ENV, the environment scalar family, MCP_EXTRA_LOG_FILE, +# WORKTREE_EFFECTIVE_ROOT, WORKTREE_LAUNCH_COMMON and +# WORKTREE_OWNED_TEMP_FILE +# Stdout: the confirmation, or the message naming the refusal +# Returns: 0 when bound, 1 otherwise +_worktree_launch_bind() { + local root="$1" + + if ! _worktree_assert_path_shape "${root}"; then + printf '%s\n' "${WORKTREE_VALIDATION_MESSAGE}" + return 1 + fi + + if [[ ! -d "${root}" ]]; then + printf '%s\n' "Refusing to bind \"${root}\": it does not exist, or it is not a directory." + return 1 + fi + + local canonical="" + if ! canonical=$(cd "${root}" >/dev/null 2>&1 && pwd -P); then + printf '%s\n' "Refusing to bind \"${root}\": it could not be entered." + return 1 + fi + + if launch_path_in_plugin_root "${canonical}"; then + printf '%s\n' "Refusing to bind \"${root}\": it is this server's own plugin directory ${LAUNCH_PLUGIN_ROOT}, or inside it. Pass the absolute path of your project." + return 1 + fi + + # More than one discovered file is merged into a temp file that belongs to + # this call until the record below takes it over. + trap 'worktree_release_owned_temp' EXIT + + # Stored physically: the root a later call names to mean this tree is + # compared with PROJECT_ROOT as a string. + PROJECT_ROOT="${canonical}" + WORKTREE_EFFECTIVE_ROOT="${canonical}" + + # A relative override resolves against the process's directory: the project + # on a server started bound, but the plugin directory here. + launch_anchor_config_override "${canonical}" + local env_value + env_value=$(_worktree_config_env_value) + if ! load_config "${canonical}"; then + if [[ -n "${env_value}" ]]; then + printf '%s\n' "Refusing to bind \"${root}\": ${CONFIG_ENV_VAR} names the configuration file ${env_value}, which does not exist." + else + local locations + locations=$(printf '%s, ' "${CONFIG_LOCATIONS[@]}") + printf '%s\n' "Refusing to bind \"${root}\": it holds no configuration file for this server — one of ${locations%, } under it. Create one, then call set_project_root again." + fi + return 1 + fi + + local config_label="${env_value}" + if [[ -z "${env_value}" ]]; then + config_label="${_FOUND_CONFIGS[*]}" + if [[ "${#_FOUND_CONFIGS[@]}" -gt 1 ]]; then + WORKTREE_OWNED_TEMP_FILE="${LINT_CONFIG_FILE}" + fi + fi + + # The same checks startup makes before it binds: load_config's readers + # swallow a parse failure, scope_validate and detect_environment refuse. + if ! jq -e 'type == "object"' "${LINT_CONFIG_FILE}" >/dev/null 2>&1; then + worktree_release_owned_temp + printf '%s\n' "Refusing to bind \"${root}\": its configuration (${config_label}) does not parse as a JSON object, so nothing can be read from it." + return 1 + fi + + local scope_error rc=0 + scope_error=$(scope_validate 2>&1 >/dev/null) || rc=$? + if [[ "${rc}" -ne 0 ]]; then + worktree_release_owned_temp + printf '%s\n' "Refusing to bind \"${root}\": its configuration (${config_label}) is not usable — ${scope_error}" + return 1 + fi + + local environment + environment=$(_get_config_value '.environment' '') + if [[ -z "${environment}" ]]; then + worktree_release_owned_temp + printf '%s\n' "Refusing to bind \"${root}\": its configuration (${config_label}) declares no \"environment\" field, so the environment its tools run under cannot be established." + return 1 + fi + + if ! _worktree_run_derivation "${LINT_CONFIG_FILE}" "${environment}"; then + worktree_release_owned_temp + printf '%s\n' "${WORKTREE_DERIVATION_MESSAGE}" + return 1 + fi + LINT_ENV="${environment}" + + _configure_extra_log_file "$(_get_config_value '.log_file' '')" + worktree_launch_common_init + + local write_error="" + # shellcheck disable=SC2016 # the jq filter is single-quoted so jq, not the shell, reads its $variables + if ! write_error=$(_worktree_state_update '.launch = {project_root: $root, config_file: $config, environment: $environment, workdir: $workdir, docker_container: $container, compose_service: $service, compose_workdir_override: $compose_workdir, compose_file_override: $compose_file, extra_log_file: $extra_log, launch_common: $common, config_temp: $temp}' \ + --arg root "${canonical}" --arg config "${LINT_CONFIG_FILE}" --arg environment "${LINT_ENV}" \ + --arg workdir "${LINT_WORKDIR}" --arg container "${DOCKER_CONTAINER}" \ + --arg service "${COMPOSE_SERVICE}" --arg compose_workdir "${COMPOSE_WORKDIR_OVERRIDE}" \ + --arg compose_file "${COMPOSE_FILE_OVERRIDE}" --arg extra_log "${MCP_EXTRA_LOG_FILE:-}" \ + --arg common "${WORKTREE_LAUNCH_COMMON}" --arg temp "${WORKTREE_OWNED_TEMP_FILE}"); then + worktree_release_owned_temp + printf '%s\n' "${write_error}" + return 1 + fi + + # The record owns the merged file now; worktree_state_cleanup removes it + # when the process exits. + WORKTREE_OWNED_TEMP_FILE="" + + log "INFO" "Bound to project root ${canonical}: config ${LINT_CONFIG_FILE}, environment ${LINT_ENV}, working dir ${LINT_WORKDIR}" + printf '%s\n' "Project root bound: ${canonical} (configuration: ${config_label}; environment: ${LINT_ENV}). It is this server's launch project root for the rest of the process and every later call runs against it; from here on set_project_root accepts a linked git worktree of it." + return 0 +} + # ============================================================================= # Per-call resolution # ============================================================================= @@ -1065,14 +1330,20 @@ _worktree_validate_root() { # worktree_resolve_root # The resolution half of worktree_enter: the call's own project_root argument, # then sticky_root from the state file, then the launch root in PROJECT_ROOT. +# An unbound server is hydrated first and refuses every call until it is bound. # Args: $1 = the tool call's arguments JSON # Globals: sets WORKTREE_EFFECTIVE_ROOT, WORKTREE_ROOT_SOURCE and LINT_WORKDIR; -# reads PROJECT_ROOT +# reads PROJECT_ROOT; the launch globals worktree_launch_hydrate sets # Stdout: nothing on success, one sentence naming the failure otherwise # Returns: 0 when the call may proceed, 1 otherwise worktree_resolve_root() { local args="${1:-}" + if ! worktree_launch_hydrate; then + printf '%s\n' "${WORKTREE_LAUNCH_MESSAGE}" + return 1 + fi + # One jq answers both questions — presence and value — because this runs on # every call's hot path. The marker letter carries presence: "//" alone # cannot, since it yields the empty string for an absent key AND for an @@ -1710,14 +1981,28 @@ worktree_root_banner() { # never showed — which is the one failure mode this tool must not have, because # the sticky value outlives the call that wrote it and the session has no way # to tell it went wrong. +# +# On an unbound server with no launch record it binds the server instead, once: +# see _worktree_launch_bind. A bound record is permanent for the process, so +# any other root is held to the worktree rules above, the same as on a server +# started bound. On either, naming the launch root itself clears the sticky +# root, like omitting the argument. # Args: $1 = the tool call's arguments JSON -# Globals: reads PROJECT_ROOT; sets WORKTREE_EFFECTIVE_ROOT, WORKTREE_ROOT_SOURCE -# and the environment scalar family for this call only +# Globals: reads PROJECT_ROOT and LAUNCH_MODE; sets WORKTREE_EFFECTIVE_ROOT, +# WORKTREE_ROOT_SOURCE and the environment scalar family for this call +# only # Stdout: the resulting effective root, or the message naming the failure # Returns: 0 on success, 1 otherwise tool_set_project_root() { local args="${1:-}" + local launch_rc=0 + worktree_launch_hydrate || launch_rc=$? + if [[ "${launch_rc}" -eq 1 ]]; then + printf '%s\n' "${WORKTREE_LAUNCH_MESSAGE}" + return 1 + fi + # One jq answers both questions — presence and value — the same marked # read worktree_resolve_root performs: absent means "clear", an explicit # "" is a caller error, and "//" alone cannot tell the two apart. @@ -1728,12 +2013,41 @@ tool_set_project_root() { return 1 fi + # Refused before the bind-or-clear split, so a bound and an unbound server + # name the same caller error for it. Omitting the parameter clears a sticky + # root and is refused on an unbound server, so the remediation differs. + if [[ "${marked}" == "P" ]]; then + local empty_remedy="Omit the parameter entirely to clear the sticky project root." + if [[ "${launch_rc}" -eq 2 ]]; then + empty_remedy="Pass the absolute path of your project to bind this server." + fi + printf '%s\n' "Refusing to set the project root: \"project_root\" was supplied as an empty string. ${empty_remedy}" + return 1 + fi + + # Unbound, there is no launch root to clear back to; only a root binds. + if [[ "${launch_rc}" -eq 2 ]]; then + if [[ "${marked}" != P?* ]]; then + printf '%s\n' "${WORKTREE_LAUNCH_MESSAGE}" + return 1 + fi + _worktree_launch_bind "${marked#P}" + return + fi + local write_error root="" if [[ "${marked}" == P* ]]; then root="${marked#P}" - if [[ -z "${root}" ]]; then - printf '%s\n' "Refusing to set the project root: \"project_root\" was supplied as an empty string. Omit the parameter entirely to clear the sticky project root." - return 1 + fi + + # The launch root is the state clearing leads back to, and validation would + # refuse it as a main checkout, so naming it clears the sticky root. + if [[ -n "${root}" && "${root}" == /* ]]; then + local canonical_root="" canonical_launch="" + canonical_root=$(cd "${root}" >/dev/null 2>&1 && pwd -P) || canonical_root="" + canonical_launch=$(cd "${PROJECT_ROOT}" >/dev/null 2>&1 && pwd -P) || canonical_launch="" + if [[ -n "${canonical_root}" && "${canonical_root}" == "${canonical_launch}" ]]; then + root="" fi fi @@ -1794,12 +2108,33 @@ tool_set_project_root() { # Reports where this server resolves to. It validates nothing and never fails # on a sticky root that no longer resolves, for the same reason # tool_set_project_root does not: this is the tool a session calls to see what -# state the server is in. +# state the server is in. An unbound server reports that it has no root, under +# the same labels, and names set_project_root. # Args: $1 = the tool call's arguments JSON, which carries no parameters # Stdout: the effective root, its source, whether it resolves, the resolved # working directory, the environment, and the configuration in use # Returns: 0 always tool_cwd() { + local launch_rc=0 + worktree_launch_hydrate || launch_rc=$? + if [[ "${launch_rc}" -eq 2 ]]; then + local pending env_value + env_value=$(_worktree_config_env_value) + pending="none — set_project_root loads it from the project root it binds" + if [[ -n "${env_value}" ]]; then + pending="none yet — ${env_value} (from ${CONFIG_ENV_VAR:-}) applies once set_project_root binds a project root" + fi + printf '%s\n' "Effective project root: none (unbound)" + printf '%s\n' "Launch project root: none — this server started in its own plugin directory, ${LAUNCH_PLUGIN_ROOT:-}" + printf '%s\n' "Sticky project root: none" + printf '%s\n' "Effective project root resolves: no" + printf '%s\n' " ${WORKTREE_LAUNCH_MESSAGE}" + printf '%s\n' "Working directory, no scope applied: unresolved — the effective project root does not resolve" + printf '%s\n' "Environment: none" + printf '%s\n' "Configuration in use: ${pending}" + return 0 + fi + local launch_config="${LINT_CONFIG_FILE:-}" local sticky="" sticky_error="" @@ -1821,6 +2156,12 @@ tool_cwd() { # call actually runs in whichever root it targets. local environment="${LINT_ENV:-}" local resolves="yes" resolve_detail="" + if [[ "${launch_rc}" -ne 0 ]]; then + # A record whose root cannot be entered: the root it names is reported, + # and nothing under it resolves. + resolves="no" + resolve_detail="${WORKTREE_LAUNCH_MESSAGE}" + fi WORKTREE_ENV_WORKDIR="" if [[ "${effective}" != "${PROJECT_ROOT}" ]]; then # Validation and the derivation below both create call-owned temp files diff --git a/plugins/plugin-setup/.claude-plugin/plugin.json b/plugins/plugin-setup/.claude-plugin/plugin.json index 51819742..af8a2c5c 100644 --- a/plugins/plugin-setup/.claude-plugin/plugin.json +++ b/plugins/plugin-setup/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "plugin-setup", - "version": "1.0.2", + "version": "1.0.3", "description": "Interactive setup skills for plugins that ship a SETUP.md. Bundles one setting-up skill per supported plugin: dev-tooling, gh-tooling, and chunkhound-integration.", "author": { "name": "Shopware Labs" diff --git a/plugins/plugin-setup/CHANGELOG.md b/plugins/plugin-setup/CHANGELOG.md index 47b0c9ea..52d7e188 100644 --- a/plugins/plugin-setup/CHANGELOG.md +++ b/plugins/plugin-setup/CHANGELOG.md @@ -5,6 +5,11 @@ 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.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.3] - 2026-10-07 + +### Changed +- **The `dev-tooling-setting-up` skill's bundled reference copy of dev-tooling's `SETUP.md` is refreshed.** Its post-restart validation steps now name the MCP tools the way Claude Code names them for an installed plugin (`mcp__plugin_dev-tooling___` instead of `mcp____`), matching dev-tooling 4.1.0. No workflow change. + ## [1.0.2] - 2026-07-13 ### Removed diff --git a/plugins/plugin-setup/skills/chunkhound-integration-setting-up/SKILL.md b/plugins/plugin-setup/skills/chunkhound-integration-setting-up/SKILL.md index 923be7bd..fca8f601 100644 --- a/plugins/plugin-setup/skills/chunkhound-integration-setting-up/SKILL.md +++ b/plugins/plugin-setup/skills/chunkhound-integration-setting-up/SKILL.md @@ -1,6 +1,6 @@ --- name: chunkhound-integration-setting-up -version: 1.0.2 +version: 1.0.3 description: Use this skill when the user just installed the chunkhound-integration plugin and needs to configure it, asks how to set up semantic code search or ChunkHound — phrases like "help me set up chunkhound", "configure semantic search", "set up code research" — or when ChunkHound MCP tools fail with config or connection errors. Walks through prerequisite checks (chunkhound CLI, embedding provider — VoyageAI or OpenAI), creates .chunkhound.json with the chosen provider, runs the initial index, and validates the MCP server connection. model: sonnet allowed-tools: Bash, Read, Write, Glob, AskUserQuestion diff --git a/plugins/plugin-setup/skills/dev-tooling-setting-up/SKILL.md b/plugins/plugin-setup/skills/dev-tooling-setting-up/SKILL.md index 0cfd3955..a9c38e1f 100644 --- a/plugins/plugin-setup/skills/dev-tooling-setting-up/SKILL.md +++ b/plugins/plugin-setup/skills/dev-tooling-setting-up/SKILL.md @@ -1,6 +1,6 @@ --- name: dev-tooling-setting-up -version: 1.0.2 +version: 1.0.3 description: Use this skill when the user just installed the dev-tooling plugin and needs to configure it for a Shopware project, asks "help me set up dev-tooling" or for guidance on configuring the PHP and JavaScript tool chains, or when dev-tooling MCP tools fail with missing-config errors. Also use when reconfiguring the environment type (native, Docker, Docker Compose, Vagrant, DDEV) for the PHP and JS MCP servers. Checks prerequisites (jq), creates .mcp-php-tooling.json and .mcp-js-tooling.json, validates that the three MCP servers (PHP — PHPStan, ECS, Rector, PHPUnit; Administration JS; Storefront JS) connect, and walks through optional scope setup including the phpactor LSP. model: sonnet allowed-tools: Bash, Read, Write, Glob, AskUserQuestion diff --git a/plugins/plugin-setup/skills/dev-tooling-setting-up/references/plugin-setup.md b/plugins/plugin-setup/skills/dev-tooling-setting-up/references/plugin-setup.md index 6624dd9e..c6a5ccbb 100644 --- a/plugins/plugin-setup/skills/dev-tooling-setting-up/references/plugin-setup.md +++ b/plugins/plugin-setup/skills/dev-tooling-setting-up/references/plugin-setup.md @@ -385,19 +385,19 @@ Validation runs in two stages. Stage 1 checks config-file existence and shape be Run each applicable check below. Skip any whose config file was not created. If Phase 4 created or modified scopes this session, defer all of Stage 2 until after restart. #### PHP Tooling -- Use the `mcp__php-tooling__phpstan_analyze` tool to analyze any PHP file in the project (e.g., `src/Kernel.php` or any file that exists) +- Use the `mcp__plugin_dev-tooling_php-tooling__phpstan_analyze` tool to analyze any PHP file in the project (e.g., `src/Kernel.php` or any file that exists) - **Pass**: PHPStan output with analysis results (errors or "No errors") - **Fail**: Connection error, "missing config file" error, or "container not found" error - Common failure causes: wrong container name, container not running, PHP not installed #### JS Admin Tooling (only if .mcp-js-tooling.json was created) -- Use the `mcp__js-admin-tooling__eslint_check` tool on any JS or Vue file in `src/Administration/Resources/app/administration/` +- Use the `mcp__plugin_dev-tooling_js-admin-tooling__eslint_check` tool on any JS or Vue file in `src/Administration/Resources/app/administration/` - **Pass**: ESLint output with results - **Fail**: Connection error or "command not found" error - Common failure causes: node_modules not installed, wrong container #### JS Storefront Tooling (only if .mcp-js-tooling.json was created) -- Use the `mcp__js-storefront-tooling__eslint_check` tool on any JS file in `src/Storefront/Resources/app/storefront/` +- Use the `mcp__plugin_dev-tooling_js-storefront-tooling__eslint_check` tool on any JS file in `src/Storefront/Resources/app/storefront/` - **Pass**: ESLint output with results - **Fail**: Connection error or "command not found" error diff --git a/plugins/shopware-env/.claude-plugin/plugin.json b/plugins/shopware-env/.claude-plugin/plugin.json index a0049278..666c3c7a 100644 --- a/plugins/shopware-env/.claude-plugin/plugin.json +++ b/plugins/shopware-env/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "shopware-env", - "version": "1.4.0", + "version": "1.4.1", "description": "Bootstrap and maintain Shopware development environments. Lifecycle MCP tools for dependencies, database, frontend builds, and plugin management.", "author": { "name": "Martin Bens", "email": "m.bens@shopware.com" }, "license": "MIT", diff --git a/plugins/shopware-env/AGENTS.md b/plugins/shopware-env/AGENTS.md index 5379cc08..583507ba 100644 --- a/plugins/shopware-env/AGENTS.md +++ b/plugins/shopware-env/AGENTS.md @@ -114,12 +114,12 @@ SessionStart: PreToolUse (Bash matcher): check-lifecycle-tools.sh → parse_hook_input() (from lib/common.sh) - → load_mcp_config("php-tooling") — reads enforce_mcp_tools flag + → load_mcp_config("php-tooling") — resolves the project dir (CLAUDE_PROJECT_DIR, else the hook input's .cwd) and reads the enforce_mcp_tools flag → pattern-matches COMMAND against blocked bash patterns - → block_tool() outputs JSON decision + message (exit 2) or exit 0 + → block_tool() writes a plain-text message to stderr and exits 2 when a pattern matches; the script exits 0 when none does ``` -Both hooks share `lib/common.sh` which provides `parse_hook_input()`, `load_mcp_config()`, and `block_tool()`. +Both hooks share `lib/common.sh` which provides `parse_hook_input()`, `load_mcp_config()`, and `block_tool()`. `block_tool()` takes the server name, the description, and one or more bare tool names, and the message names each tool with its server (``Use `plugin_setup` on the `lifecycle-tooling` MCP server instead!``) rather than a host-specific `mcp__…` name. ### Shared Templates Boundary diff --git a/plugins/shopware-env/CHANGELOG.md b/plugins/shopware-env/CHANGELOG.md index 7cd77383..8f8760e1 100644 --- a/plugins/shopware-env/CHANGELOG.md +++ b/plugins/shopware-env/CHANGELOG.md @@ -5,6 +5,12 @@ 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.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.4.1] - 2026-10-07 + +### Changed +- **The hook's block messages name the tool and its server instead of an MCP tool name.** `block_tool()` in the template-synced `hooks/scripts/lib/common.sh` took one full tool name and embedded it in the message; the name it was given, `mcp__lifecycle-tooling__plugin_setup`, is not the form Claude Code gives a plugin's tools, which it prefixes with `mcp__plugin__`. The function now takes the server name and the tool names, and each call in `hooks/scripts/check-lifecycle-tools.sh` passes them, so the message reads ``Use `plugin_setup` on the `lifecycle-tooling` MCP server instead!``. +- **The hook reads the project's `.mcp-php-tooling.json` from the hook event's `.cwd` when `CLAUDE_PROJECT_DIR` is unset.** `load_mcp_config` looked for `.claude/.mcp-php-tooling.json` and `.mcp-php-tooling.json` only under `CLAUDE_PROJECT_DIR`. It now takes `CLAUDE_PROJECT_DIR` when it is set, so that lookup is unchanged, and the hook event's `.cwd` otherwise. + ## [1.4.0] - 2026-09-16 ### Changed diff --git a/plugins/shopware-env/hooks/scripts/check-lifecycle-tools.sh b/plugins/shopware-env/hooks/scripts/check-lifecycle-tools.sh index f5a1a665..b674c5b8 100755 --- a/plugins/shopware-env/hooks/scripts/check-lifecycle-tools.sh +++ b/plugins/shopware-env/hooks/scripts/check-lifecycle-tools.sh @@ -17,44 +17,45 @@ load_mcp_config "php-tooling" # Composer install/update - Use install_dependencies if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*composer\s+(install|update)(\s|$)'; then - block_tool "mcp__lifecycle-tooling__install_dependencies" \ - "Use install_dependencies with composer/administration/storefront flags." + block_tool "lifecycle-tooling" \ + "Use install_dependencies with composer/administration/storefront flags." "install_dependencies" fi # npm install/ci - Use install_dependencies if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+(install|ci)(\s|$)'; then - block_tool "mcp__lifecycle-tooling__install_dependencies" \ - "Use install_dependencies with administration/storefront flags." + block_tool "lifecycle-tooling" \ + "Use install_dependencies with administration/storefront flags." "install_dependencies" fi # system:install / system:setup - Use database_install if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*(php\s+)?\.?/?bin/console\s+system:(install|setup)(\s|$)'; then - block_tool "mcp__lifecycle-tooling__database_install" \ - "Use database_install for first-time setup or database_reset to wipe and rebuild." + block_tool "lifecycle-tooling" \ + "Use database_install for first-time setup or database_reset to wipe and rebuild." "database_install" fi # plugin:create - Use plugin_create if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*(php\s+)?\.?/?bin/console\s+plugin:create(\s|$)'; then - block_tool "mcp__lifecycle-tooling__plugin_create" \ - "Use plugin_create with plugin_name and plugin_namespace arguments." + block_tool "lifecycle-tooling" \ + "Use plugin_create with plugin_name and plugin_namespace arguments." "plugin_create" fi # plugin:install / plugin:refresh / plugin:activate - Use plugin_setup if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*(php\s+)?\.?/?bin/console\s+plugin:(install|refresh|activate)(\s|$)'; then - block_tool "mcp__lifecycle-tooling__plugin_setup" \ - "Use plugin_setup with plugin_name argument." + block_tool "lifecycle-tooling" \ + "Use plugin_setup with plugin_name argument." "plugin_setup" fi # bundle:dump / assets:install / feature:dump / framework:schema:dump - Use frontend_build_* if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*(php\s+)?\.?/?bin/console\s+(bundle:dump|assets:install|feature:dump|framework:schema:dump)(\s|$)'; then - block_tool "mcp__lifecycle-tooling__frontend_build_admin or frontend_build_storefront" \ - "Use frontend_build_admin or frontend_build_storefront for complete build chains." + block_tool "lifecycle-tooling" \ + "Use frontend_build_admin or frontend_build_storefront for complete build chains." \ + "frontend_build_admin" "frontend_build_storefront" fi # theme:compile - Use frontend_build_storefront if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*(php\s+)?\.?/?bin/console\s+theme:compile(\s|$)'; then - block_tool "mcp__lifecycle-tooling__frontend_build_storefront" \ - "Use frontend_build_storefront for the complete storefront build chain." + block_tool "lifecycle-tooling" \ + "Use frontend_build_storefront for the complete storefront build chain." "frontend_build_storefront" fi exit 0 diff --git a/plugins/shopware-env/hooks/scripts/lib/common.sh b/plugins/shopware-env/hooks/scripts/lib/common.sh index c748153e..565fffb5 100644 --- a/plugins/shopware-env/hooks/scripts/lib/common.sh +++ b/plugins/shopware-env/hooks/scripts/lib/common.sh @@ -9,27 +9,40 @@ # parse_hook_input # load_mcp_config "php-tooling" # or "js-tooling" # # ... pattern matching ... -# block_tool "mcp__php-tooling__phpstan_analyze" "Description" +# block_tool "php-tooling" "Description" "phpstan_analyze" # Global variables set by this library: +# HOOK_INPUT - The hook-event JSON read from stdin # COMMAND - The bash command being checked # CONFIG_FILE - Path to loaded config file (or empty) # ENVIRONMENT - Environment from config (native/docker/docker-compose/vagrant/ddev) # ENFORCE_MCP_TOOLS - Whether to enforce MCP tools (true/false) +# Resolve the session's project directory: CLAUDE_PROJECT_DIR when set and +# non-empty, else the hook input's .cwd (Codex sets no CLAUDE_PROJECT_DIR; both +# name the session directory). Stdin is read once by the caller and passed in. +# Args: $1 = hook-event JSON +# Stdout: the directory, or nothing when neither source names one +resolve_project_dir() { + if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then + printf '%s\n' "$CLAUDE_PROJECT_DIR" + return 0 + fi + printf '%s' "$1" | jq -r 'if (.cwd | type) == "string" then .cwd else empty end' +} + # Parse hook input from stdin -# Sets: COMMAND (global) +# Sets: HOOK_INPUT, COMMAND (globals) # Exits 0 if command is empty parse_hook_input() { - local input - input=$(cat) - COMMAND=$(echo "$input" | jq -r '.tool_input.command // empty') + HOOK_INPUT=$(cat) + COMMAND=$(echo "$HOOK_INPUT" | jq -r '.tool_input.command // empty') if [[ -z "$COMMAND" ]]; then exit 0 fi } -# Load MCP config from project directory +# Load MCP config from project directory (resolve_project_dir on HOOK_INPUT) # Args: $1 = config prefix (e.g., "php-tooling", "js-tooling") # Sets: CONFIG_FILE, ENVIRONMENT, ENFORCE_MCP_TOOLS (globals) # Exits 0 if enforcement is disabled @@ -39,11 +52,14 @@ load_mcp_config() { ENVIRONMENT="" ENFORCE_MCP_TOOLS="true" - if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then + local project_dir + project_dir=$(resolve_project_dir "$HOOK_INPUT") + + if [[ -n "$project_dir" ]]; then # Check config locations in priority order for location in ".claude/.mcp-${config_prefix}.json" ".mcp-${config_prefix}.json"; do - if [[ -f "${CLAUDE_PROJECT_DIR}/${location}" ]]; then - CONFIG_FILE="${CLAUDE_PROJECT_DIR}/${location}" + if [[ -f "${project_dir}/${location}" ]]; then + CONFIG_FILE="${project_dir}/${location}" break fi done @@ -67,15 +83,24 @@ load_mcp_config() { } # Block a tool with formatted message -# Args: $1 = full MCP tool name (e.g., "mcp__php-tooling__phpstan_analyze") +# Tool names carry no host prefix: Claude Code and Codex each build their own +# mcp__ form, so the message names the tool and its server instead. +# Args: $1 = MCP server name (e.g., "php-tooling") # $2 = description of what to use instead +# $3... = tool names, one or more (e.g., "ecs_check" "ecs_fix") # Outputs to stderr and exits with code 2 block_tool() { - local tool="$1" + local server="$1" local description="$2" + shift 2 + + local tools="" name + for name in "$@"; do + tools+="${tools:+ or }\`${name}\`" + done { - echo "🤖 Down, model! Use the ${tool} instead!" + echo "🤖 Down, model! Use ${tools} on the \`${server}\` MCP server instead!" echo "" echo "Bad command detected: ${COMMAND}" echo "" diff --git a/plugins/shopware-env/skills/dev-environment-bootstrapping/SKILL.md b/plugins/shopware-env/skills/dev-environment-bootstrapping/SKILL.md index 1637c3ac..934212b6 100644 --- a/plugins/shopware-env/skills/dev-environment-bootstrapping/SKILL.md +++ b/plugins/shopware-env/skills/dev-environment-bootstrapping/SKILL.md @@ -1,6 +1,6 @@ --- name: dev-environment-bootstrapping -version: 1.4.0 +version: 1.4.1 model: sonnet description: Use this skill when the user asks to bootstrap, set up, create, or initialize a Shopware development environment from scratch — phrases like "set up a Shopware dev environment", "clone and install Shopware", "initialize a Shopware plugin project", "bootstrap Shopware and a new plugin called X", "get a fresh Shopware instance running". Orchestrates the full first-run flow — detects the current state of a Shopware checkout, proposes a numbered action plan, confirms with the user, then executes dependency installation, database setup, plugin activation, and frontend builds via the lifecycle-tooling MCP server. Hands off to dev-tooling setup when the environment is running. allowed-tools: AskUserQuestion, Bash, Read, Glob, Write, mcp__plugin_shopware-env_lifecycle-tooling__install_dependencies, mcp__plugin_shopware-env_lifecycle-tooling__database_install, mcp__plugin_shopware-env_lifecycle-tooling__database_reset, mcp__plugin_shopware-env_lifecycle-tooling__testdb_prepare, mcp__plugin_shopware-env_lifecycle-tooling__frontend_build_admin, mcp__plugin_shopware-env_lifecycle-tooling__frontend_build_storefront, mcp__plugin_shopware-env_lifecycle-tooling__plugin_create, mcp__plugin_shopware-env_lifecycle-tooling__plugin_setup diff --git a/templates/hooks-shared/common.sh b/templates/hooks-shared/common.sh index c748153e..565fffb5 100644 --- a/templates/hooks-shared/common.sh +++ b/templates/hooks-shared/common.sh @@ -9,27 +9,40 @@ # parse_hook_input # load_mcp_config "php-tooling" # or "js-tooling" # # ... pattern matching ... -# block_tool "mcp__php-tooling__phpstan_analyze" "Description" +# block_tool "php-tooling" "Description" "phpstan_analyze" # Global variables set by this library: +# HOOK_INPUT - The hook-event JSON read from stdin # COMMAND - The bash command being checked # CONFIG_FILE - Path to loaded config file (or empty) # ENVIRONMENT - Environment from config (native/docker/docker-compose/vagrant/ddev) # ENFORCE_MCP_TOOLS - Whether to enforce MCP tools (true/false) +# Resolve the session's project directory: CLAUDE_PROJECT_DIR when set and +# non-empty, else the hook input's .cwd (Codex sets no CLAUDE_PROJECT_DIR; both +# name the session directory). Stdin is read once by the caller and passed in. +# Args: $1 = hook-event JSON +# Stdout: the directory, or nothing when neither source names one +resolve_project_dir() { + if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then + printf '%s\n' "$CLAUDE_PROJECT_DIR" + return 0 + fi + printf '%s' "$1" | jq -r 'if (.cwd | type) == "string" then .cwd else empty end' +} + # Parse hook input from stdin -# Sets: COMMAND (global) +# Sets: HOOK_INPUT, COMMAND (globals) # Exits 0 if command is empty parse_hook_input() { - local input - input=$(cat) - COMMAND=$(echo "$input" | jq -r '.tool_input.command // empty') + HOOK_INPUT=$(cat) + COMMAND=$(echo "$HOOK_INPUT" | jq -r '.tool_input.command // empty') if [[ -z "$COMMAND" ]]; then exit 0 fi } -# Load MCP config from project directory +# Load MCP config from project directory (resolve_project_dir on HOOK_INPUT) # Args: $1 = config prefix (e.g., "php-tooling", "js-tooling") # Sets: CONFIG_FILE, ENVIRONMENT, ENFORCE_MCP_TOOLS (globals) # Exits 0 if enforcement is disabled @@ -39,11 +52,14 @@ load_mcp_config() { ENVIRONMENT="" ENFORCE_MCP_TOOLS="true" - if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then + local project_dir + project_dir=$(resolve_project_dir "$HOOK_INPUT") + + if [[ -n "$project_dir" ]]; then # Check config locations in priority order for location in ".claude/.mcp-${config_prefix}.json" ".mcp-${config_prefix}.json"; do - if [[ -f "${CLAUDE_PROJECT_DIR}/${location}" ]]; then - CONFIG_FILE="${CLAUDE_PROJECT_DIR}/${location}" + if [[ -f "${project_dir}/${location}" ]]; then + CONFIG_FILE="${project_dir}/${location}" break fi done @@ -67,15 +83,24 @@ load_mcp_config() { } # Block a tool with formatted message -# Args: $1 = full MCP tool name (e.g., "mcp__php-tooling__phpstan_analyze") +# Tool names carry no host prefix: Claude Code and Codex each build their own +# mcp__ form, so the message names the tool and its server instead. +# Args: $1 = MCP server name (e.g., "php-tooling") # $2 = description of what to use instead +# $3... = tool names, one or more (e.g., "ecs_check" "ecs_fix") # Outputs to stderr and exits with code 2 block_tool() { - local tool="$1" + local server="$1" local description="$2" + shift 2 + + local tools="" name + for name in "$@"; do + tools+="${tools:+ or }\`${name}\`" + done { - echo "🤖 Down, model! Use the ${tool} instead!" + echo "🤖 Down, model! Use ${tools} on the \`${server}\` MCP server instead!" echo "" echo "Bad command detected: ${COMMAND}" echo ""