diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 185742f..618c731 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -6,7 +6,7 @@ }, "metadata": { "description": "Coder plugins for Cursor", - "version": "0.2.0", + "version": "0.3.0", "pluginRoot": "plugins" }, "plugins": [ diff --git a/.github/scripts/sync-skills-vendor.json b/.github/scripts/sync-skills-vendor.json index 6f497ce..4b3b3e5 100644 --- a/.github/scripts/sync-skills-vendor.json +++ b/.github/scripts/sync-skills-vendor.json @@ -1,6 +1,6 @@ { "repo": "coder/skills", - "pin": "e439e4bf0e9b517292adaac92d1c1b029810603c", + "pin": "3c998377afa72c4d03503468196721ef3c772d7f", "paths": [ "skills/setup", "skills/templates", diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 15e56f3..43fdbd2 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -28,9 +28,10 @@ jobs: run: | set -euo pipefail PLUGIN=$(jq -er '.version' plugins/coder/.cursor-plugin/plugin.json) + PORTABLE=$(jq -er '.version' plugins/coder/plugin.json) MARKET=$(jq -er '.metadata.version' .cursor-plugin/marketplace.json) - if [ "$PLUGIN" != "$MARKET" ]; then - echo "::error::plugin.json is $PLUGIN but marketplace.json is $MARKET" + if [ "$PLUGIN" != "$MARKET" ] || [ "$PLUGIN" != "$PORTABLE" ]; then + echo "::error::.cursor-plugin/plugin.json is $PLUGIN, plugin.json is $PORTABLE, marketplace.json is $MARKET" exit 1 fi if ! printf '%s' "$PLUGIN" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a3f22f0..c898c56 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,9 +5,10 @@ ```text .cursor-plugin/marketplace.json Marketplace manifest plugins/coder/ The Coder plugin - .cursor-plugin/plugin.json Plugin manifest (canonical version) + plugin.json Agent Plugins 1.0 manifest (portable) + .cursor-plugin/plugin.json Cursor manifest (canonical version) + .cursor-plugin/mcp.json Coder remote MCP server (Cursor only, used by /coder-agent) CHANGELOG.md Release history - mcp.json Coder remote MCP server (used by /coder-agent) skills/ vendored coder/skills (workspaces, setup, templates, modules) commands/ /coder-agent assets/logo.svg @@ -21,22 +22,28 @@ scripts/validate-template.mjs Manifest and frontmatter validation 2. Edit the plugin. Vendored skill directories are regenerated by the sync script; change them in [coder/skills](https://github.com/coder/skills) instead. See [VENDOR.md](VENDOR.md). -3. Validate: +3. Keep `plugins/coder/plugin.json` and `.cursor-plugin/plugin.json` in + step: `name`, `version`, `description`, `author`, `homepage`, + `repository`, `license`, and `keywords` must be identical. Cursor-only + fields (`displayName`, `logo`, `variables`) go in the Cursor manifest + only, because `plugin.json` is a closed schema. +4. Validate: ```sh node scripts/validate-template.mjs ``` -4. Test locally by copying `plugins/coder` to `~/.cursor/plugins/local/coder` +5. Test locally by copying `plugins/coder` to `~/.cursor/plugins/local/coder` and reloading Cursor. -5. Open a pull request. Use conventional commit titles +6. Open a pull request. Use conventional commit titles (`feat:`, `fix:`, `chore:`, `docs:`). ## Releasing -1. Bump `version` in `plugins/coder/.cursor-plugin/plugin.json` and - `metadata.version` in `.cursor-plugin/marketplace.json` to the same value. - The `validate` workflow fails if they differ. +1. Bump `version` in `plugins/coder/.cursor-plugin/plugin.json`, + `plugins/coder/plugin.json`, and `metadata.version` in + `.cursor-plugin/marketplace.json` to the same value. The `validate` + workflow fails if they differ. 2. Add an entry to `plugins/coder/CHANGELOG.md` for the new version. 3. Merge to `main`. The `release` workflow tags `vX.Y.Z` and publishes a GitHub Release when the version has no existing tag. diff --git a/README.md b/README.md index b5fcfcf..f1e8cc7 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,20 @@ whatever session the `coder` CLI already has from `coder login`. | "Set up Coder on this VM." | Uses the `setup` skill to install and bootstrap a deployment. | | "Delegate this refactor to a Coder Agent and tell me when it's done." | Creates a Coder Agents chat with `coder_create_chat` and waits with `coder_await_chat`. | +## Other agents + +The plugin is also an [Agent Plugins 1.0](https://github.com/agentplugins/agent-plugins-spec) +package (`plugins/coder/plugin.json`), so clients that support the standard, +such as Codex and GitHub Copilot, can load the four skills. The skills work +the same everywhere. + +The Coder MCP server and `/coder-agent` are Cursor-only. Every deployment has +its own URL, and the standard allows no placeholder in an MCP `url`, so the +server is declared in `.cursor-plugin/mcp.json` with Cursor's `${CODER_URL}` +plugin variable. The portable package has no root `mcp.json`, so other +clients load the skills and ignore the server. Commands are not part of the +standard either. + ## Self-hosted and air-gapped deployments Everything in this plugin operates against your own deployment. Nothing is diff --git a/VENDOR.md b/VENDOR.md index 837003f..b7b8442 100644 --- a/VENDOR.md +++ b/VENDOR.md @@ -20,8 +20,9 @@ to `main`. node .github/scripts/sync-skills.mjs ``` -3. Bump `version` in `plugins/coder/.cursor-plugin/plugin.json` and - `metadata.version` in `.cursor-plugin/marketplace.json`. Cursor skips +3. Bump `version` in `plugins/coder/.cursor-plugin/plugin.json`, + `plugins/coder/plugin.json`, and `metadata.version` in + `.cursor-plugin/marketplace.json`. Cursor skips installs whose version has not changed. 4. Add an entry to `plugins/coder/CHANGELOG.md` for the new version. 5. Run `node scripts/validate-template.mjs`. diff --git a/plugins/coder/mcp.json b/plugins/coder/.cursor-plugin/mcp.json similarity index 100% rename from plugins/coder/mcp.json rename to plugins/coder/.cursor-plugin/mcp.json diff --git a/plugins/coder/.cursor-plugin/plugin.json b/plugins/coder/.cursor-plugin/plugin.json index 9e4f2c6..1b74a78 100644 --- a/plugins/coder/.cursor-plugin/plugin.json +++ b/plugins/coder/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "coder", "displayName": "Coder", - "version": "0.2.0", + "version": "0.3.0", "description": "Skills for installing, configuring, and operating a self-hosted Coder deployment from the Cursor IDE, plus the Coder MCP server for managing and using the deployment: workspaces, templates, and Coder Agents.", "author": { "name": "Coder", @@ -24,6 +24,7 @@ "devops" ], "logo": "assets/logo.svg", + "mcpServers": ".cursor-plugin/mcp.json", "variables": { "type": "object", "properties": { diff --git a/plugins/coder/CHANGELOG.md b/plugins/coder/CHANGELOG.md index 4e752eb..91702a6 100644 --- a/plugins/coder/CHANGELOG.md +++ b/plugins/coder/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.3.0 + +- Made the plugin an [Agent Plugins 1.0](https://github.com/agentplugins/agent-plugins-spec) package so Codex, GitHub Copilot, and other conformant clients can load it: added a portable `plugin.json` next to `.cursor-plugin/plugin.json`. +- Re-vendored `coder/skills` at `3c99837`: the `workspaces` skill is now named `workspaces`, matching its directory, as the Agent Skills spec requires. It was `coder-workspaces`. +- `scripts/validate-template.mjs` now checks `plugin.json`, `mcp.json`, skill names, and that both manifests agree. CI also requires all three versions to match. +- Moved the Coder MCP server from `mcp.json` to `.cursor-plugin/mcp.json`, referenced by `mcpServers` in the Cursor manifest. Its URL uses the `${CODER_URL}` plugin variable, which Agent Plugins does not allow in `url`, so the server and `/coder-agent` work only in Cursor. Other clients no longer see an invalid MCP entry. + ## 0.2.0 - Added the Coder remote MCP server (`mcp.json`) and the `/coder-agent` command for delegating tasks to Coder Agents chats. Requires Coder v2.38 or later, with the `mcp-server-http` experiment and `CODER_OAUTH2_PROVIDER_ENABLE=true`. diff --git a/plugins/coder/README.md b/plugins/coder/README.md index fa66f2a..7bb33d7 100644 --- a/plugins/coder/README.md +++ b/plugins/coder/README.md @@ -6,8 +6,10 @@ and using the deployment: workspaces, templates, and Coder Agents. ## Included +- `plugin.json`: Agent Plugins 1.0 manifest, the portable metadata read by any conformant client +- `.cursor-plugin/plugin.json`: Cursor manifest, which adds `displayName`, `logo`, `mcpServers`, and the `CODER_URL` variable - `skills/workspaces/`, `skills/setup/`, `skills/templates/`, `skills/modules/`: vendored from [coder/skills](https://github.com/coder/skills) -- `mcp.json`: Coder remote MCP server at `${CODER_URL}/api/experimental/mcp/http`, where `${CODER_URL}` is the plugin variable the user sets under **Configure** (not a shell environment variable). Gives the agent Coder's tools for workspaces, templates, files, and Coder Agents chats; `/coder-agent` builds on it. +- `.cursor-plugin/mcp.json`: Coder remote MCP server (Cursor only; referenced by `mcpServers` in the Cursor manifest) at `${CODER_URL}/api/experimental/mcp/http`, where `${CODER_URL}` is the plugin variable the user sets under **Configure** (not a shell environment variable). Gives the agent Coder's tools for workspaces, templates, files, and Coder Agents chats; `/coder-agent` builds on it. - `commands/coder-agent.md`: delegate a task to a Coder Agent and supervise it, through MCP See the [repository README](../../README.md) for setup and usage, and diff --git a/plugins/coder/plugin.json b/plugins/coder/plugin.json new file mode 100644 index 0000000..721dc59 --- /dev/null +++ b/plugins/coder/plugin.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "coder", + "version": "0.3.0", + "description": "Skills for installing, configuring, and operating a self-hosted Coder deployment from the Cursor IDE, plus the Coder MCP server for managing and using the deployment: workspaces, templates, and Coder Agents.", + "author": { + "name": "Coder", + "email": "support@coder.com", + "url": "https://coder.com" + }, + "homepage": "https://coder.com", + "repository": "https://github.com/coder/cursor-plugin", + "license": "MIT", + "keywords": [ + "coder", + "cloud development environments", + "remote development", + "workspaces", + "terraform", + "templates", + "mcp", + "self-hosted", + "ai agents", + "devops" + ] +} diff --git a/plugins/coder/skills/workspaces/SKILL.md b/plugins/coder/skills/workspaces/SKILL.md index 9462d1e..28dcef3 100644 --- a/plugins/coder/skills/workspaces/SKILL.md +++ b/plugins/coder/skills/workspaces/SKILL.md @@ -1,5 +1,5 @@ --- -name: coder-workspaces +name: workspaces description: > Use an existing Coder deployment from the command line with the `coder` CLI: list, inspect, create, start, stop, and delete diff --git a/scripts/validate-template.mjs b/scripts/validate-template.mjs index 5310b9e..0536f29 100644 --- a/scripts/validate-template.mjs +++ b/scripts/validate-template.mjs @@ -232,6 +232,272 @@ async function validateComponentFrontmatter(pluginDir, pluginName) { } } +// Agent Plugins 1.0.0 (https://github.com/agentplugins/agent-plugins-spec). +// The checks below mirror the closed schemas in spec/1.0.0.md so CI needs no +// network access or schema validator dependency. +const AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"; +const AGENT_MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json"; +const agentPluginNamePattern = /^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; +const agentSkillNamePattern = /^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; +const agentManifestFields = [ + "$schema", + "name", + "version", + "description", + "author", + "homepage", + "repository", + "license", + "keywords", + "extensions", +]; +// Metadata that must be identical in plugin.json and .cursor-plugin/plugin.json. +const sharedManifestFields = [ + "name", + "version", + "description", + "author", + "homepage", + "repository", + "license", + "keywords", +]; + +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isDeepEqual(a, b) { + return JSON.stringify(a) === JSON.stringify(b); +} + +function validateAgentManifest(manifest, pluginName) { + const label = `${pluginName}: plugin.json`; + + for (const key of Object.keys(manifest)) { + if (!agentManifestFields.includes(key)) { + addError( + `${label} has unknown top-level field "${key}". Move client-specific data under "extensions" or keep it in .cursor-plugin/plugin.json.` + ); + } + } + + if (manifest.$schema !== AGENT_PLUGIN_SCHEMA) { + addError(`${label} "$schema" must be "${AGENT_PLUGIN_SCHEMA}".`); + } + + if ( + typeof manifest.name !== "string" || + manifest.name.length === 0 || + manifest.name.length > 64 || + !agentPluginNamePattern.test(manifest.name) + ) { + addError( + `${label} "name" must be 1-64 lowercase alphanumerics, hyphens, or periods, start and end alphanumeric, with no "--" or "..".` + ); + } + + for (const key of ["version", "description", "homepage", "repository", "license"]) { + if (manifest[key] !== undefined && typeof manifest[key] !== "string") { + addError(`${label} "${key}" must be a string.`); + } + } + + if (manifest.author !== undefined) { + if (!isPlainObject(manifest.author)) { + addError(`${label} "author" must be an object.`); + } else { + for (const [key, value] of Object.entries(manifest.author)) { + if (!["name", "email", "url"].includes(key) || typeof value !== "string") { + addError(`${label} "author" may only contain string "name", "email", and "url" fields.`); + break; + } + } + } + } + + if ( + manifest.keywords !== undefined && + (!Array.isArray(manifest.keywords) || manifest.keywords.some((k) => typeof k !== "string")) + ) { + addError(`${label} "keywords" must be an array of strings.`); + } + + if (manifest.extensions !== undefined) { + if (!isPlainObject(manifest.extensions)) { + addError(`${label} "extensions" must be an object.`); + } else if (Object.values(manifest.extensions).some((value) => !isPlainObject(value))) { + addError(`${label} every "extensions" namespace value must be an object.`); + } + } +} + +function validateAgentMcpServer(server, serverName, pluginName) { + const label = `${pluginName}: mcp.json server "${serverName}"`; + if (!isPlainObject(server)) { + addError(`${label} must be an object.`); + return; + } + + const variants = { + stdio: { required: ["type", "command"], allowed: ["type", "command", "args", "env", "cwd"] }, + "streamable-http": { required: ["type", "url"], allowed: ["type", "url", "headers"] }, + sse: { required: ["type", "url"], allowed: ["type", "url", "headers"] }, + }; + const variant = variants[server.type]; + if (!variant) { + addError(`${label} "type" must be one of: ${Object.keys(variants).join(", ")}.`); + return; + } + + for (const key of variant.required) { + if (typeof server[key] !== "string" || server[key].length === 0) { + addError(`${label} requires a non-empty string "${key}".`); + } + } + for (const key of Object.keys(server)) { + if (!variant.allowed.includes(key)) { + addError(`${label} has field "${key}" that is not valid for type "${server.type}".`); + } + } + + if (server.type === "stdio") { + if (server.args !== undefined && (!Array.isArray(server.args) || server.args.some((a) => typeof a !== "string"))) { + addError(`${label} "args" must be an array of strings.`); + } + if (server.env !== undefined) { + if (!isPlainObject(server.env) || Object.values(server.env).some((v) => typeof v !== "string")) { + addError(`${label} "env" must be an object of strings.`); + } else if ("PLUGIN_ROOT" in server.env || "PLUGIN_DATA" in server.env) { + addError(`${label} "env" must not set PLUGIN_ROOT or PLUGIN_DATA.`); + } + } + if (server.cwd !== undefined && !/^(?:\.\/|\$\{PLUGIN_ROOT\}(?:\/|$)|\$\{PLUGIN_DATA\}(?:\/|$))/.test(server.cwd)) { + addError(`${label} "cwd" must start with "./", "\${PLUGIN_ROOT}", or "\${PLUGIN_DATA}".`); + } + } else { + if (server.headers !== undefined) { + if (!isPlainObject(server.headers) || Object.values(server.headers).some((v) => typeof v !== "string")) { + addError(`${label} "headers" must be an object of strings.`); + } + } + // The spec forbids placeholder expansion in "url", so a client that + // follows it skips this server. Cursor expands plugin variables itself. + if (typeof server.url === "string" && server.url.includes("${")) { + addWarning( + `${pluginName}: mcp.json server "${serverName}" url contains a placeholder. Agent Plugins forbids expansion in "url", so only Cursor will connect to it.` + ); + } + } +} + +function readSkillFrontmatter(content) { + const match = normalizeNewlines(content).match(/^---\n([\s\S]*?)\n---\n/); + if (!match) { + return {}; + } + const lines = match[1].split("\n"); + const fields = {}; + for (let i = 0; i < lines.length; i += 1) { + const field = lines[i].match(/^([A-Za-z][\w-]*):\s*(.*)$/); + if (!field) { + continue; + } + let value = field[2].trim(); + // Folded (>) and literal (|) block scalars continue on indented lines. + if (/^[>|][+-]?$/.test(value)) { + const parts = []; + while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) { + parts.push(lines[i + 1].trim()); + i += 1; + } + value = parts.join(" "); + } + fields[field[1]] = value.replace(/^(["'])(.*)\1$/, "$2"); + } + return fields; +} + +async function validateAgentSkills(pluginDir, pluginName) { + const skillsDir = path.join(pluginDir, "skills"); + if (!(await pathExists(skillsDir))) { + return; + } + const entries = await fs.readdir(skillsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const skillFile = path.join(skillsDir, entry.name, "SKILL.md"); + if (!(await pathExists(skillFile))) { + continue; + } + const label = `${pluginName}: skills/${entry.name}/SKILL.md`; + const fields = readSkillFrontmatter(await fs.readFile(skillFile, "utf8")); + if (fields.name !== entry.name) { + addError( + `${label} "name" (${JSON.stringify(fields.name)}) must match its directory name "${entry.name}". Fix it in coder/skills and re-vendor.` + ); + } + if ( + typeof fields.name === "string" && + (fields.name.length > 64 || !agentSkillNamePattern.test(fields.name)) + ) { + addError(`${label} "name" must be 1-64 lowercase alphanumerics and single hyphens, not starting or ending with a hyphen.`); + } + if (typeof fields.description !== "string" || fields.description.length === 0 || fields.description.length > 1024) { + addError(`${label} "description" must be 1-1024 characters.`); + } + } +} + +async function validateAgentPlugin(pluginDir, pluginName, cursorManifest) { + const manifestPath = path.join(pluginDir, "plugin.json"); + const manifest = await readJsonFile(manifestPath, `${pluginName} Agent Plugins manifest`); + if (manifest) { + if (!isPlainObject(manifest)) { + addError(`${pluginName}: plugin.json must contain a JSON object.`); + } else { + validateAgentManifest(manifest, pluginName); + for (const field of sharedManifestFields) { + if (!isDeepEqual(manifest[field], cursorManifest[field])) { + addError( + `${pluginName}: "${field}" differs between plugin.json and .cursor-plugin/plugin.json. Keep them identical.` + ); + } + } + } + } + + const mcpPath = path.join(pluginDir, "mcp.json"); + if (await pathExists(mcpPath)) { + const mcp = await readJsonFile(mcpPath, `${pluginName} MCP configuration`); + if (mcp) { + if (!isPlainObject(mcp)) { + addError(`${pluginName}: mcp.json must contain a JSON object.`); + } else { + for (const key of Object.keys(mcp)) { + if (key !== "$schema" && key !== "mcpServers") { + addError(`${pluginName}: mcp.json has unknown top-level field "${key}".`); + } + } + if (mcp.$schema !== AGENT_MCP_SCHEMA) { + addError(`${pluginName}: mcp.json "$schema" must be "${AGENT_MCP_SCHEMA}".`); + } + if (!isPlainObject(mcp.mcpServers)) { + addError(`${pluginName}: mcp.json "mcpServers" must be an object.`); + } else { + for (const [serverName, server] of Object.entries(mcp.mcpServers)) { + validateAgentMcpServer(server, serverName, pluginName); + } + } + } + } + } + + await validateAgentSkills(pluginDir, pluginName); +} + function resolveMarketplaceSource(source, pluginRoot) { if (typeof source !== "string" || source.length === 0) { return null; @@ -343,6 +609,7 @@ async function main() { } await validateComponentFrontmatter(pluginDir, entry.name); + await validateAgentPlugin(pluginDir, entry.name, pluginManifest); const hooksPath = path.join(pluginDir, "hooks", "hooks.json"); if (!(await pathExists(hooksPath))) { @@ -350,7 +617,7 @@ async function main() { } const mcpPath = path.join(pluginDir, "mcp.json"); - if (!(await pathExists(mcpPath))) { + if (!pluginManifest.mcpServers && !(await pathExists(mcpPath))) { addWarning(`${entry.name}: no mcp.json file found (only needed when using MCP servers).`); } }