From 52df1ae0a9cedad0f0df0c6cf6d8ea55d29b70ba Mon Sep 17 00:00:00 2001 From: Sal <63654791+sasdeployer@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:56:04 -0400 Subject: [PATCH] Harden loading for every host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep pass of every manifest, hook, and MCP file against each host's primary documentation (Claude Code plugins reference, Cursor plugins + hooks reference, Codex/OpenAI plugins + hooks, Agent Plugins 1.0, Agent Skills spec, Devin CLI plugins, VS Code agent plugins, Copilot CLI plugin + hooks), then verified against Claude Code by real install and a --debug session. Behavior (found by installing, not by reading): - hooks/hooks.json carried Cursor's flat schema at the default filename. Claude Code reads that file regardless of the manifest pointer and emitted `[WARN] hooks.afterFileEdit: unknown hook event` on every session start for every user. Codex defaults to the same file with the same nested schema Claude Code uses. The shared filename now carries the nested schema (PostToolUse, matcher Write|Edit|MultiEdit|NotebookEdit|apply_patch, ${CLAUDE_PLUGIN_ROOT}); Cursor — the one host whose docs say the manifest pointer replaces default discovery — gets hooks/cursor.json. WARN count in a live session: 0. - .claude-plugin/plugin.json: `"commands": []`. commands/ is deprecated in Claude Code and each skill is already slash-invocable, so the same-named wrappers produced `Skills (4)` with duplicate names. Now `Skills (2)`. Dropped the `hooks` and `mcpServers` pointers — Claude Code reads the default locations regardless, and a second MCP declaration is at best redundant. - .devin-plugin/plugin.json: removed `agentSubagents`, which is not a documented Devin key; Devin reads agents/ by convention. - .cursor-plugin/plugin.json: every path ./-prefixed, hooks → ./hooks/cursor.json. - .codex-plugin/plugin.json: explicit hooks → ./hooks/hooks.json. - Hook script finds the edited file in Codex apply_patch payloads (path is inside patch text, resolved against cwd) and Copilot toolArgs, in addition to Claude Code and Cursor. All four shapes tested; exit 0 on garbage. Verified: Claude Code accepts type streamable-http and normalises to HTTP. Claude Code suppresses a plugin MCP server that duplicates a manually configured one by URL — which is why a developer machine never shows the plugin's copy, and is not a load failure. Patch 0004: ship-it-nexlayer allowed-tools split into one Tool(pattern) per token per the Agent Skills spec. Depends on 0003's context; filename order guarantees application order. Validator: encodes every rule above — hook schemas per file, per-host wiring, ./ paths, Devin's documented key set, Agent Plugins transport values, Agent Skills frontmatter shape. All 11 new checks confirmed to fire by breaking each on a git-archive copy. Docs: PLATFORMS.md rewritten hooks section, loading-rules table, per-host notes for Copilot (VS Code namespace holds agents/hooks/commands/rules; Copilot CLI hook schema recorded, not shipped — no plugin-root variable documented), Devin (root hooks.json, Windsurf-style rules), transport type, Windows behaviour. CHANGELOG folded. Not verified here: live Cursor, Devin, VS Code, Grok installs; Windows; sync-from-mcp.sh --check against a claudecode-mcp-go clone. Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/plugin.json | 3 +- .codex-plugin/plugin.json | 16 ++- .cursor-plugin/plugin.json | 16 +-- .devin-plugin/plugin.json | 13 +- .gitignore | 3 + CHANGELOG.md | 6 +- README.md | 6 +- docs/PLATFORMS.md | 38 ++++-- hooks/claude-code.json | 15 --- hooks/cursor.json | 10 ++ hooks/hooks.json | 12 +- hooks/nexlayer-yaml-check.py | 36 ++++- ...-allowed-tools-one-pattern-per-token.patch | 11 ++ patches/README.md | 11 ++ scripts/validate.py | 125 +++++++++++++----- skills/ship-it-nexlayer/SKILL.md | 2 +- 16 files changed, 240 insertions(+), 83 deletions(-) delete mode 100644 hooks/claude-code.json create mode 100644 hooks/cursor.json create mode 100644 patches/0004-allowed-tools-one-pattern-per-token.patch diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index f52dcd9..f04cc66 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -20,6 +20,5 @@ "mcp", "infrastructure" ], - "mcpServers": "./mcp.json", - "hooks": "./hooks/claude-code.json" + "commands": [] } diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index f8d0de0..1fce128 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -10,16 +10,28 @@ "homepage": "https://nexlayer.com", "repository": "https://github.com/Nexlayer/nexlayer-plugin", "license": "MIT", - "keywords": ["deploy", "cloud", "hosting", "containers", "ai", "mcp", "infrastructure"], + "keywords": [ + "deploy", + "cloud", + "hosting", + "containers", + "ai", + "mcp", + "infrastructure" + ], "skills": "./skills/", "mcpServers": "./.mcp.json", + "hooks": "./hooks/hooks.json", "interface": { "displayName": "Nexlayer", "shortDescription": "Ship containerized apps to a live URL", "longDescription": "Nexlayer turns a repo into a running production app: generate the Dockerfile, build and push the image, write and validate nexlayer.yaml, deploy, and hand back a live URL. Includes debugging for what you shipped — logs, events, shells, and live database queries.", "developerName": "Nexlayer", "category": "Developer Tools", - "capabilities": ["Read", "Write"], + "capabilities": [ + "Read", + "Write" + ], "websiteURL": "https://nexlayer.com", "privacyPolicyURL": "https://nexlayer.com/legal/privacy", "termsOfServiceURL": "https://nexlayer.com/legal/terms", diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index f11c97b..33564c4 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "nexlayer", "displayName": "Nexlayer", "version": "1.0.0", - "description": "Deploy any containerized application to Nexlayer and get a live URL \u2014 without leaving your editor.", + "description": "Deploy any containerized application to Nexlayer and get a live URL — without leaving your editor.", "author": { "name": "Nexlayer", "email": "support@nexlayer.com" @@ -10,7 +10,7 @@ "homepage": "https://nexlayer.com", "repository": "https://github.com/Nexlayer/nexlayer-plugin", "license": "MIT", - "logo": "assets/logo.svg", + "logo": "./assets/logo.svg", "keywords": [ "deploy", "cloud", @@ -20,10 +20,10 @@ "mcp", "infrastructure" ], - "skills": "skills", - "commands": "commands", - "agents": "agents", - "rules": "rules", - "mcpServers": "mcp.json", - "hooks": "hooks/hooks.json" + "skills": "./skills/", + "commands": "./commands/", + "agents": "./agents/", + "rules": "./rules/", + "mcpServers": "./mcp.json", + "hooks": "./hooks/cursor.json" } diff --git a/.devin-plugin/plugin.json b/.devin-plugin/plugin.json index f25d807..51bfed7 100644 --- a/.devin-plugin/plugin.json +++ b/.devin-plugin/plugin.json @@ -10,8 +10,15 @@ "homepage": "https://nexlayer.com", "repository": "https://github.com/Nexlayer/nexlayer-plugin", "license": "MIT", - "keywords": ["deploy", "cloud", "hosting", "containers", "ai", "mcp", "infrastructure"], + "keywords": [ + "deploy", + "cloud", + "hosting", + "containers", + "ai", + "mcp", + "infrastructure" + ], "skills": "./skills/", - "mcpServers": "./mcp.json", - "agentSubagents": "./agents/" + "mcpServers": "./mcp.json" } diff --git a/.gitignore b/.gitignore index 029049f..d8cd540 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ __pycache__/ *.pfx credentials.json service-account*.json + +# Local project memory — maintainer notes, not published +CLAUDE.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c77f96a..d151641 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,13 +12,17 @@ First release. - Codex support: `.codex-plugin/plugin.json` with listing metadata and `.agents/plugins/marketplace.json`, so `codex plugin marketplace add Nexlayer/nexlayer-plugin` works - `.devin-plugin/plugin.json` — Devin CLI shipped plugins; it also honors the root Agent Plugins manifest - `com.github.copilot/agents/nexlayer-deploy.agent.md` so VS Code and Copilot get the subagent, generated by `scripts/gen-host-components.py` and drift-checked -- `hooks/nexlayer-yaml-check.py`: advisory `nexlayer.yaml` check on file edit, covering the five hard constraints the server-side validator returns VALID for, plus the `version: 2.0` gate, `.pod` in browser-facing vars, loopback addresses, volume-size units, and the Postgres `PGDATA` trap. Wired for Cursor (`hooks/hooks.json`, `afterFileEdit`) and Claude Code (`hooks/claude-code.json`, `PostToolUse`) +- `hooks/nexlayer-yaml-check.py`: advisory `nexlayer.yaml` check on file edit, covering the five hard constraints the server-side validator returns VALID for, plus the `version: 2.0` gate, `.pod` in browser-facing vars, loopback addresses, volume-size units, and the Postgres `PGDATA` trap. `hooks/hooks.json` is the nested Claude Code + Codex schema (`PostToolUse`, matcher covers `Write|Edit|MultiEdit|NotebookEdit|apply_patch`); `hooks/cursor.json` is Cursor's flat schema. Finds the edited file in Claude Code, Cursor, Codex `apply_patch`, and Copilot payloads - `.mcp.json` alongside `mcp.json` — Claude Code discovers MCP servers only from the dot-prefixed name; the manifest `mcpServers` key is ignored. Verified by real install +- `.claude-plugin/plugin.json` carries no `hooks` or `mcpServers` pointer (Claude Code reads the default locations regardless) and sets `"commands": []`, because `commands/` is deprecated there and duplicated the skills' own slash names +- Every path-valued manifest key is `./`-prefixed; `.devin-plugin/plugin.json` drops the undocumented `agentSubagents` key +- `validate.py` encodes every host-loading rule found by real installs — hook file schemas and wiring per host, `.mcp.json`/`mcp.json` agreement, Agent Plugins transport values, `./` paths, Devin's documented key set, Agent Skills frontmatter shape — each confirmed to fire by breaking it - `assets/logo.png` (512×512) for hosts whose listings want a raster logo - `scripts/sync-from-mcp.sh` — resync or drift-check skills and the tool list against the MCP repo - `scripts/validate.py` — spec schemas, skill frontmatter, links, MCP tool names, manifest agreement, hook scripts - `patches/` layer: `sync-from-mcp.sh` reapplies patches with `git apply` after every sync and exits 3 if one goes stale, so a local fix can neither rot nor be silently reverted - `0001` — `references/MCP-SETUP.md`: dashboard is `app.nexlayer.com` (there is no `app.nexlayer.io`), transport is `http` not the deprecated `sse`, Cursor config path is `~/.cursor/mcp.json`, and Claude Code leads with `npx @nexlayer/mcp-install` — all matching nexlayer.com/docs/mcp. Upstream: claudecode-mcp-go#45 - `0002` — scrubbed a real-shaped account identifier from the registry example and removed a dead internal-tool link + - `0004` — `allowed-tools` frontmatter split into one `Tool(pattern)` per token, per the Agent Skills spec - `0003` — scrubbed the remaining internal tool name from `SKILL.md` frontmatter and both antipattern references, and replaced named third-party platforms in `SKILL.md`'s decision tree and reference table with neutral wording - Pre-publication security review (`docs/SECURITY-REVIEW.md`): no credentials in the tree or in git history, registry rejects anonymous access, workflow token scoped read-only with actions pinned to SHAs, `SECURITY.md` added diff --git a/README.md b/README.md index bd0bfca..84b1024 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Do not hand-edit anything under `skills/` here. Edit it upstream, then resync. T scripts/sync-from-mcp.sh # pull skills + tool list from the MCP repo scripts/sync-from-mcp.sh --check # report drift without changing anything scripts/gen-host-components.py # regenerate host-namespace mirrors -python3 scripts/validate.py # schemas, frontmatter, links, tool names, manifests, hooks +python3 scripts/validate.py # schemas, frontmatter, links, tool names, host wiring, hook schemas claude plugin validate . --strict # Anthropic's own gate, run by their review pipeline ``` @@ -92,14 +92,14 @@ The bundle is also tested against the production MCP server, not just the source ``` plugin.json Agent Plugins 1.0 manifest (portable core) mcp.json Portable Agent Plugins MCP server config -.mcp.json Codex MCP server config +.mcp.json Same servers, dot-prefixed — Claude Code, Codex, Devin, Copilot CLI read this name skills/ ship-it-nexlayer, debug-nexlayer (verbatim from the MCP repo) commands/ agents/ rules/ Client extensions — thin wrappers over the skills .cursor-plugin/plugin.json Cursor manifest .claude-plugin/ Claude Code manifest and marketplace entry .codex-plugin/plugin.json Codex manifest, MCP pointer, and listing metadata .agents/plugins/ Codex marketplace entry -hooks/ nexlayer.yaml checker + per-host hook config +hooks/ nexlayer.yaml checker; hooks.json (Claude Code + Codex), cursor.json (Cursor) com.github.copilot/ Copilot namespace (generated mirror of agents/) patches/ Documented deviations from canon, reapplied on every sync scripts/ sync-from-mcp.sh, validate.py, generated tool list diff --git a/docs/PLATFORMS.md b/docs/PLATFORMS.md index 6d3246f..7c9aa78 100644 --- a/docs/PLATFORMS.md +++ b/docs/PLATFORMS.md @@ -29,32 +29,50 @@ A `nexlayer-cursor` / `nexlayer-codex` / `nexlayer-claude-code` split would fork |--------|-------------------|:------:|:---:|:--------:|:--------:|:-----:|:-----:|---------| | Claude Code | `.claude-plugin/plugin.json` | ✅ | ✅ | ✅ | ✅ | — | ✅ | `/plugin marketplace add Nexlayer/nexlayer-plugin` | | Cursor | `.cursor-plugin/plugin.json` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | Customize → Nexlayer → Install | -| Codex | `.codex-plugin/plugin.json` | ✅ | ✅ | — | — | — | ➖ | `codex plugin marketplace add Nexlayer/nexlayer-plugin` | +| Codex | `.codex-plugin/plugin.json` | ✅ | ✅ | — | — | — | ✅ | `codex plugin marketplace add Nexlayer/nexlayer-plugin` | | VS Code / Copilot | `plugin.json` + `com.github.copilot/` | ✅ | ✅ | — | ✅ | — | ➖ | Chat: Install Plugin From Source → repo URL | -| Devin CLI | `.devin-plugin/plugin.json` | ✅ | ✅ | — | ✅ | ✅ | ➖ | `devin plugins install Nexlayer/nexlayer-plugin` | +| Devin CLI | `.devin-plugin/plugin.json` | ✅ | ✅ | — | ✅ | ✅ | — | `devin plugins install Nexlayer/nexlayer-plugin` | | Grok | `.claude-plugin/plugin.json` | ✅ | ✅ | ✅ | ✅ | — | ✅ | Add repo as a marketplace source, then trust | | Windsurf | none — MCP only | — | ✅ | — | — | — | — | `mcp.json` snippet from `references/MCP-SETUP.md` | | Cline / Roo / Kilo | none — MCP only | — | ✅ | — | — | — | — | Same snippet, per-client config path | | Anything else with MCP | none | — | ✅ | — | — | — | — | Point it at `https://mcp.nexlayer.ai/api/mcp` | -✅ shipped · ➖ host supports it, hook schema not verified yet · — host has no such concept +✅ shipped · ➖ host supports it, plugin-root variable for the hook command not documented, so left off · — host has no such concept or reads a location this repo does not ship -**Hooks.** `hooks/nexlayer-yaml-check.py` runs on every file edit and checks `nexlayer.yaml` for the things the server-side validator lets through — untagged image, empty `servicePorts`, no pod with `path`, invalid pod name, unknown fields — plus the `version: 2.0` gate, `.pod` in browser-facing vars, loopback addresses, volume-size units, and the Postgres `PGDATA` trap. It is advisory: findings go to stdout, exit code is always 0, and it stays silent on a clean file or an unrelated edit. +Only Claude Code has been exercised by a live install (see the loading-rules section below and `docs/VALIDATION.md`). Codex was exercised by the Codex plugin validator and an isolated `CODEX_HOME` install. Cursor, VS Code, Devin, and Grok are conformance-by-documentation. -Cursor and Claude Code both read `hooks/hooks.json` by default but with **different schemas**, so Cursor keeps the default name (`hooks/hooks.json`, `"version": 1` plus `afterFileEdit`) and Claude Code gets an explicitly-named file (`hooks/claude-code.json` with `PostToolUse` and `${CLAUDE_PLUGIN_ROOT}`) that `.claude-plugin/plugin.json` points at. Marketplace scanners look for the default name, so a non-default filename means the hook is never detected. +**Hooks.** `hooks/nexlayer-yaml-check.py` runs after a file edit and checks `nexlayer.yaml` for the things the server-side validator lets through — untagged image, empty `servicePorts`, no pod with `path`, invalid pod name, unknown fields — plus the `version: 2.0` gate, `.pod` in browser-facing vars, loopback addresses, volume-size units, and the Postgres `PGDATA` trap. It is advisory: findings go to stdout, exit code is always 0, and it stays silent on a clean file or an unrelated edit. It walks every string in whatever JSON the host sends, so it finds the path in Claude Code's `tool_input.file_path`, Cursor's `file_path`, Codex's `apply_patch` patch text (resolved against `cwd`), and Copilot's `toolArgs`. -Two Claude Code loading rules were found by installing the plugin and reading `claude plugin details`, not from a spec — both fail silently and both pass every schema check: +Three hosts default to the same filename with two schemas, so the layout is: + +| File | Schema | Read by | How | +|------|--------|---------|-----| +| `hooks/hooks.json` | nested — `PostToolUse` → `matcher` → `hooks[]` with one shell-string `command` using `${CLAUDE_PLUGIN_ROOT}` | Claude Code, Codex | Both default to this path. Claude Code reads it **even when the manifest points elsewhere** (verified from a `--debug` session), so it has to be in this schema. Codex provides `CLAUDE_PLUGIN_ROOT` as an alias of `PLUGIN_ROOT`. Matcher covers Claude Code's `Write|Edit|MultiEdit|NotebookEdit` and Codex's `apply_patch`. | +| `hooks/cursor.json` | flat — `"version": 1`, `afterFileEdit` → `command` | Cursor | `.cursor-plugin/plugin.json` points at it. Cursor's docs are explicit that a manifest `hooks` field replaces default discovery. The command is a plugin-relative script path, the form Cursor's own examples use; `${CURSOR_PLUGIN_ROOT}` is the documented alternative if the live install shows the relative path resolving elsewhere. | + +The cost of this split is that community scanners that look only for `hooks/hooks.json` (cursor.directory) will report a `PostToolUse` hook rather than Cursor's `afterFileEdit`. Cursor itself reads the manifest. The previous layout — Cursor's schema at the default name — produced `[WARN] hooks.afterFileEdit: unknown hook event` on every Claude Code session start for every user. + +**Loading rules that no schema check catches.** Each was found by installing the plugin and inspecting `claude plugin details` or a `--debug` session log, and each is now enforced by `scripts/validate.py`: | Rule | Wrong form | Symptom | |------|-----------|---------| -| MCP is discovered only from a dot-prefixed `.mcp.json` at the plugin root | `mcp.json` + `"mcpServers": "./mcp.json"`, or an inline object | `MCP servers (0)` — the whole point of the plugin, absent | +| Claude Code discovers MCP only from a dot-prefixed `.mcp.json` at the plugin root | `mcp.json` + `"mcpServers": "./mcp.json"`, or an inline object | `MCP servers (0)` | | A hook `command` must be one shell string | `["python3", "..."]` | `Hooks (0)` | +| Claude Code reads `hooks/hooks.json` regardless of the manifest `hooks` pointer | Cursor's schema at the default name | `WARN unknown hook event` every session | +| Claude Code treats `commands/` as deprecated and also exposes each skill as a slash command | `commands/` + `skills/` with the same names | `Skills (4)` with duplicate names; `"commands": []` in `.claude-plugin/plugin.json` suppresses the deprecated dir | +| Claude Code dedupes a plugin MCP server against a manually-configured server with the same URL | — | `Suppressing plugin MCP server … duplicates manually-configured` — correct behaviour, and why a developer machine with the server in `~/.claude.json` will not show the plugin's copy | +| Codex's validator rejects any key but `mcpServers` in `.mcp.json` | `$schema` in `.mcp.json` | Codex validation fails | +| Cursor, Claude Code, Codex, and Agent Plugins all want manifest paths to start with `./` | `"skills": "skills"` | Undefined per host; normalised everywhere | + +So the repo ships **both** `mcp.json` (Agent Plugins 1.0, Cursor, VS Code) and `.mcp.json` (Claude Code, Codex, Devin, Copilot CLI) with the same `mcpServers` map; the dotted file omits `$schema`. `validate.py` compares the maps rather than the bytes. + +**Transport type.** Both files declare `"type": "streamable-http"`, the Agent Plugins 1.0 value. Claude Code accepts it and normalises to HTTP (verified: a project `.mcp.json` with both `streamable-http` and `http` lists both as `(HTTP)`). Cursor's native remote-server shape is `{"url": …}` with no `type`; whether Cursor's plugin loader tolerates the extra key is unverified until a live install. -So the repo ships **both** `mcp.json` (Agent Plugins 1.0 and Cursor) and an identical `.mcp.json` (Claude Code). `scripts/validate.py` fails if they diverge or if a hook command is a list. Codex and Copilot support hooks too; their schemas are not documented well enough to write blind, so they are left off rather than guessed. +**Windows.** The hook is a Python 3 script. Claude Code and Codex invoke it as `python3 "${CLAUDE_PLUGIN_ROOT}/…"`, which needs `python3` on `PATH` — present with the Microsoft Store Python, absent by default with the python.org installer, which ships `python` and `py`. Cursor invokes it by path, which relies on the shebang and does not work on Windows outside a POSIX shell. Cursor's own hook examples have the same property. Because the hook is advisory and exits 0, a Windows user loses the pre-deploy warnings and nothing else. Copilot's hook schema has separate `bash` and `powershell` commands — the only host that solves this — and is recorded below for when a plugin-root variable is confirmed for it. -**Copilot.** VS Code reads portable `skills/` and `mcp.json` from the root manifest, but custom agents only from `com.github.copilot/agents/*.agent.md`. That file is generated from `agents/` by `scripts/gen-host-components.py`, and `validate.py` fails if it drifts. Copilot CLI's own plugin reference lists agents, skills, hooks, MCP, and LSP — no commands or rules — so nothing is mirrored for those. +**Copilot.** VS Code auto-detects the format from the root manifest: a `plugin.json` carrying the Agent Plugins `$schema` is read as Agent Plugins 1.0, so skills come from `skills/`, MCP from `mcp.json`, and Copilot-specific content from `com.github.copilot/` — which VS Code documents as holding `agents/`, `hooks/`, `commands/`, and `rules/`. Custom agents are `com.github.copilot/agents/*.agent.md` (frontmatter `name`, `description`, optional `tools`), generated from `agents/` by `scripts/gen-host-components.py` and drift-checked by `validate.py`. `commands/` and `rules/` are not mirrored: VS Code's native formats there are `.prompt.md` and `.instructions.md` with `applyTo`, not the `.md`/`.mdc` this repo carries, and a wrong-format mirror is worse than none. Copilot CLI hooks are `{"version": 1, "hooks": {"postToolUse": [{"type": "command", "bash": "…", "powershell": "…"}]}}` with a camelCase `toolArgs` payload — schema verified, but no documented plugin-root variable for the command, so not shipped. -**Devin.** Devin CLI shipped plugins (closed beta). It reads `.devin-plugin/plugin.json`, falls back to `.claude-plugin/plugin.json` or the root `plugin.json`, and honors Agent Plugins 1.0 including `${PLUGIN_ROOT}`. It also reads `rules/` and `agents/`, so Devin gets more of this plugin than Codex does. +**Devin.** Devin CLI shipped plugins (closed beta). It reads `.devin-plugin/plugin.json`, falls back to `.claude-plugin/plugin.json` or the root `plugin.json`, and honors Agent Plugins 1.0 including `${PLUGIN_ROOT}`. Documented manifest keys are the metadata set plus `skills`, `mcpServers`, `requiredPlugins`, `optionalPlugins`, `forbiddenPlugins` — `validate.py` rejects anything else, which is why the earlier `agentSubagents` key was dropped; Devin reads `agents/.md` by convention. MCP precedence is `.mcp.json`, then `mcp.json`, then manifest paths. Devin reads hooks from `hooks.json` at the plugin **root**, not `hooks/`, so it gets no hook from this repo; its `rules/` use Windsurf-style trigger frontmatter, so `rules/nexlayer-yaml.mdc` (Cursor frontmatter) may load without its glob trigger. A client with an MCP marketplace but no plugin format has nothing here to package: the skills do not transfer, so it gets the tools and none of the judgment. That is the whole Windsurf / Cline / Roo / Kilo row above. diff --git a/hooks/claude-code.json b/hooks/claude-code.json deleted file mode 100644 index 8630648..0000000 --- a/hooks/claude-code.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "hooks": { - "PostToolUse": [ - { - "matcher": "Write|Edit|MultiEdit|NotebookEdit", - "hooks": [ - { - "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/nexlayer-yaml-check.py\"" - } - ] - } - ] - } -} diff --git a/hooks/cursor.json b/hooks/cursor.json new file mode 100644 index 0000000..bc77883 --- /dev/null +++ b/hooks/cursor.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "hooks": { + "afterFileEdit": [ + { + "command": "./hooks/nexlayer-yaml-check.py" + } + ] + } +} diff --git a/hooks/hooks.json b/hooks/hooks.json index bc77883..d924324 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,9 +1,15 @@ { - "version": 1, + "description": "Advisory nexlayer.yaml check after a file edit: catches the constraints the server-side validator returns VALID for. Prints findings, always exits 0. Read by Claude Code and Codex (both default to this path and share this schema). Cursor uses hooks/cursor.json.", "hooks": { - "afterFileEdit": [ + "PostToolUse": [ { - "command": "./hooks/nexlayer-yaml-check.py" + "matcher": "Write|Edit|MultiEdit|NotebookEdit|apply_patch", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/nexlayer-yaml-check.py\"" + } + ] } ] } diff --git a/hooks/nexlayer-yaml-check.py b/hooks/nexlayer-yaml-check.py index 33ed338..e40d8af 100755 --- a/hooks/nexlayer-yaml-check.py +++ b/hooks/nexlayer-yaml-check.py @@ -12,6 +12,13 @@ * findings go to stdout, which hosts surface back to the agent * exit code is always 0 — this is advice, never a block +Payload shapes this handles, all discovered by walking every string in the JSON: + * Claude Code PostToolUse — tool_input.file_path is the edited file + * Cursor afterFileEdit — file_path at the top level + * Codex PostToolUse — apply_patch puts the path inside patch text + ("*** Update File: nexlayer.yaml"), relative to `cwd` + * Copilot postToolUse — toolArgs carries the path (camelCase payload) + Run standalone too: hooks/nexlayer-yaml-check.py path/to/nexlayer.yaml """ @@ -40,20 +47,39 @@ V2_ONLY = ("resources", "resourceType", "replicas", "subdomain") -def candidate_paths(payload, out): +# A path-shaped token ending in nexlayer.yaml, embedded in a larger string. Codex's +# apply_patch payload is one big patch document, so the path is never a whole field. +EMBEDDED = re.compile(r"(? None: if len(meta.get("compatibility", "")) > 500: fail(f"skills/{skill.name}: compatibility over 500 chars") + allowed = meta.get("allowed-tools", "") + for token in allowed.split(): + if token.count("(") != token.count(")"): + fail(f"skills/{skill.name}: allowed-tools token {token!r} has unbalanced parens — one Tool(pattern) per space-separated token") + block = re.search(r"^metadata:\n((?:[ \t]+.*\n)+)", frontmatter + "\n", re.M) + if block: + for line in block.group(1).splitlines(): + if re.match(r"^\s+[\w-]+:\s*$", line): + fail(f"skills/{skill.name}: metadata values must be strings, not nested maps — {line.strip()!r}") + lines = len(text.splitlines()) if lines > 500: fail(f"skills/{skill.name}: SKILL.md is {lines} lines (keep under 500; move detail to references/)") @@ -193,6 +203,8 @@ def check_host_manifests() -> None: continue if candidate.startswith("/") or ".." in Path(candidate).parts: fail(f"{rel}: {key} must be a relative in-tree path — {candidate}") + elif key in PATH_KEYS and not candidate.startswith("./"): + fail(f"{rel}: {key} path must start with ./ — {candidate}") elif key in PATH_KEYS and not (ROOT / candidate.lstrip("./")).exists(): fail(f"{rel}: {key} points at missing path {candidate}") @@ -315,16 +327,22 @@ def check_generated_mirrors() -> None: HOOK_SCRIPT = re.compile(r"[\w./${}-]*hooks/([\w.-]+\.(?:py|sh))") +# The shared default `hooks/hooks.json` is read by Claude Code (always — even when +# the manifest points elsewhere, verified from a --debug session) and by Codex. They +# share one nested schema. Cursor reads a flat schema and honours a manifest pointer, +# so it gets `hooks/cursor.json`. Getting these crossed produces a WARN on every +# session start for every user, which no schema check will catch. +NESTED_EVENTS = {"PostToolUse", "PreToolUse", "Stop", "SessionStart", "SessionEnd", "UserPromptSubmit"} +FLAT_EVENTS = {"afterFileEdit", "postToolUse", "preToolUse", "sessionStart", "stop"} +HOST_HOOK_FILES = ("hooks/hooks.json", "hooks/cursor.json") -def check_hooks() -> None: - """Every script a hooks file names must exist and be executable. - Host hook schemas differ (Cursor's afterFileEdit vs Claude Code's PostToolUse), - so each host gets its own file; what they must share is a working script. - """ - for rel in ("hooks/hooks.json", "hooks/claude-code.json"): +def check_hooks() -> None: + """Every hook file names a script that exists and is executable.""" + for rel in HOST_HOOK_FILES: doc = load_json(ROOT / rel) - if not doc: + if doc is None: + fail(f"{rel}: missing") continue names = set(HOOK_SCRIPT.findall(json.dumps(doc))) if not names: @@ -337,10 +355,62 @@ def check_hooks() -> None: fail(f"{rel}: hook script hooks/{name} is not executable") +def check_hook_schemas() -> None: + nested = load_json(ROOT / "hooks/hooks.json") or {} + if "version" in nested: + fail('hooks/hooks.json: top-level "version" is Cursor\'s schema; this file is Claude Code + Codex') + events = set(nested.get("hooks", {})) + if stray := events & FLAT_EVENTS: + fail(f"hooks/hooks.json: Cursor event(s) {sorted(stray)} — Claude Code warns 'unknown hook event'") + for event, entries in nested.get("hooks", {}).items(): + for entry in entries: + inner = entry.get("hooks") + if not isinstance(inner, list): + fail(f"hooks/hooks.json: {event} entry needs a nested \"hooks\" list") + continue + for hook in inner: + # Claude Code silently drops a list-valued command. + if not isinstance(hook.get("command"), str): + fail(f"hooks/hooks.json: {event} command must be one shell string") + elif "${CLAUDE_PLUGIN_ROOT}" not in hook["command"]: + fail(f"hooks/hooks.json: {event} command must locate the script via ${{CLAUDE_PLUGIN_ROOT}}") + if event == "PostToolUse": + matcher = "|".join(e.get("matcher", "") for e in entries) + for tool in ("Write", "Edit", "apply_patch"): + if tool not in matcher: + fail(f"hooks/hooks.json: PostToolUse matcher must include {tool} (Claude Code / Codex edit tools)") + + flat = load_json(ROOT / "hooks/cursor.json") or {} + if flat.get("version") != 1: + fail('hooks/cursor.json: Cursor requires a top-level "version": 1') + if stray := set(flat.get("hooks", {})) & NESTED_EVENTS: + fail(f"hooks/cursor.json: Claude Code event(s) {sorted(stray)} in Cursor's file") + for event, entries in flat.get("hooks", {}).items(): + for entry in entries: + if not isinstance(entry.get("command"), str): + fail(f"hooks/cursor.json: {event} entry needs a string \"command\" (flat schema)") + + +def check_hook_wiring() -> None: + """Each host manifest must point at the hook file written in its schema.""" + claude = load_json(ROOT / ".claude-plugin/plugin.json") or {} + if claude.get("hooks") not in (None, "./hooks/hooks.json"): + fail('.claude-plugin/plugin.json: hooks must be absent (default) — Claude Code reads hooks/hooks.json regardless') + if "mcpServers" in claude: + fail('.claude-plugin/plugin.json: drop mcpServers — .mcp.json is the discovered path; a second declaration is at best redundant') + if claude.get("commands") != []: + fail('.claude-plugin/plugin.json: set "commands": [] — commands/ is deprecated there and duplicates the skills\' own slash names') + cursor = load_json(ROOT / ".cursor-plugin/plugin.json") or {} + if cursor.get("hooks") != "./hooks/cursor.json": + fail('.cursor-plugin/plugin.json: hooks must be "./hooks/cursor.json"') + codex = load_json(ROOT / ".codex-plugin/plugin.json") or {} + if codex.get("hooks") not in (None, "./hooks/hooks.json"): + fail('.codex-plugin/plugin.json: hooks must be "./hooks/hooks.json" or absent') + + # Verified by installing the plugin and running `claude plugin details`, not read # off a spec: Claude Code discovers MCP only from a dot-prefixed `.mcp.json` at -# the plugin root. A `mcpServers` path string or inline object in the manifest is -# ignored, and the plugin loads with zero servers while every schema check passes. +# the plugin root. Devin and Copilot/VS Code (Claude layout) read it too. # Agent Plugins 1.0 and Cursor want the undotted `mcp.json`, so both must exist # and agree. def check_mcp_discovery() -> None: @@ -355,36 +425,31 @@ def check_mcp_discovery() -> None: b = (load_json(dotted) or {}).get("mcpServers") if a != b: fail(".mcp.json and mcp.json declare different mcpServers") + for name, server in (a or {}).items(): + if server.get("type") not in {"streamable-http", "sse", "stdio"}: + fail(f"mcp.json: server {name!r} type must be an Agent Plugins value (streamable-http/sse/stdio)") -# Same method, same surprise: Claude Code silently drops a hook whose `command` -# is a list. Every working plugin in the official marketplace uses one shell -# string. Cursor's file additionally requires a top-level `version`. -def check_hook_schemas() -> None: - doc = load_json(ROOT / "hooks/claude-code.json") or {} - for event, entries in doc.get("hooks", {}).items(): - for entry in entries: - for hook in entry.get("hooks", []): - if not isinstance(hook.get("command"), str): - fail( - f"hooks/claude-code.json: {event} command must be one " - f"shell string; a list is silently ignored" - ) +# Devin documents exactly these manifest keys; anything else is a guess that +# may trip its validator. +DEVIN_KEYS = {"name", "version", "description", "author", "homepage", "repository", "license", + "keywords", "skills", "mcpServers", "requiredPlugins", "optionalPlugins", "forbiddenPlugins"} + - cursor = load_json(ROOT / "hooks/hooks.json") or {} - if cursor.get("version") != 1: - fail('hooks/hooks.json: Cursor requires a top-level "version": 1') - stray = set(cursor.get("hooks", {})) & {"PostToolUse", "PreToolUse", "Stop"} - if stray: - fail(f"hooks/hooks.json: Claude Code event(s) {sorted(stray)} in Cursor's file") +def check_devin_manifest() -> None: + devin = load_json(ROOT / ".devin-plugin/plugin.json") or {} + if unknown := set(devin) - DEVIN_KEYS: + fail(f".devin-plugin/plugin.json: undocumented key(s) {sorted(unknown)}") def main() -> int: check_schemas() check_mcp_discovery() + check_hooks() check_hook_schemas() + check_hook_wiring() + check_devin_manifest() check_generated_mirrors() - check_hooks() check_skills() check_links() check_tool_names() @@ -396,7 +461,7 @@ def main() -> int: for problem in problems: print(f" • {problem}") return 1 - print("PASS — manifests, skills, links, tool names, and host manifests all check out.") + print("PASS — manifests, skills, links, tool names, host wiring, and hook schemas all check out.") return 0 diff --git a/skills/ship-it-nexlayer/SKILL.md b/skills/ship-it-nexlayer/SKILL.md index 1c7fd8f..da2da46 100644 --- a/skills/ship-it-nexlayer/SKILL.md +++ b/skills/ship-it-nexlayer/SKILL.md @@ -6,7 +6,7 @@ metadata: author: nexlayer version: "3.0.0" validated: "MCP verified" -allowed-tools: Bash(npx:* docker:* git:*) Read Write Edit +allowed-tools: Bash(npx:*) Bash(docker:*) Bash(git:*) Read Write Edit --- # Ship It with Nexlayer