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