From f7efc7b9f1bd92ae50c4a059c983cf0641480e87 Mon Sep 17 00:00:00 2001 From: Michael Dougall <6801309+itsdouges@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:16:45 +1000 Subject: [PATCH 01/10] Switch Paper MCP from localhost HTTP to stdio via app-data CLI locator. Agent harnesses can load the plugin without Paper Desktop already listening on :29979. The locator resolves the production CLI Desktop installs into app data (no PATH/admin), and Claude uses CLAUDE_PLUGIN_ROOT while Cursor/Codex use a plugin-relative cwd. Co-authored-by: Cursor --- package.json | 3 +- .../paper-desktop/.claude-plugin/plugin.json | 4 +- .../paper-desktop/.codex-plugin/plugin.json | 2 +- .../paper-desktop/.cursor-plugin/plugin.json | 2 +- plugins/paper-desktop/README.md | 3 +- .../paper-desktop/bin/locate-paper-cli.mjs | 75 +++++++++++++++++++ .../bin/locate-paper-cli.spec.ts | 50 +++++++++++++ plugins/paper-desktop/mcp.claude.json | 8 ++ plugins/paper-desktop/mcp.json | 5 +- .../rules/ensure-paper-started.md | 2 +- .../skills/code-to-design/SKILL.md | 2 +- .../skills/design-to-code/SKILL.md | 2 +- 12 files changed, 147 insertions(+), 11 deletions(-) create mode 100755 plugins/paper-desktop/bin/locate-paper-cli.mjs create mode 100644 plugins/paper-desktop/bin/locate-paper-cli.spec.ts create mode 100644 plugins/paper-desktop/mcp.claude.json diff --git a/package.json b/package.json index 4448776..2775669 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "type": "module", "private": true, "scripts": { - "build": "bun ./scripts/validate-cursor-schema.mjs && bun ./scripts/validate-cursor-structure.mjs && bun ./scripts/validate-codex.mjs" + "build": "bun ./scripts/validate-cursor-schema.mjs && bun ./scripts/validate-cursor-structure.mjs && bun ./scripts/validate-codex.mjs && bun test", + "test": "bun test" }, "devDependencies": { "@types/bun": "latest", diff --git a/plugins/paper-desktop/.claude-plugin/plugin.json b/plugins/paper-desktop/.claude-plugin/plugin.json index bdb45a5..4c4447f 100644 --- a/plugins/paper-desktop/.claude-plugin/plugin.json +++ b/plugins/paper-desktop/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "paper-desktop", - "version": "0.1.0", + "version": "0.2.0", "description": "Design on a canvas that Claude can read and write to — built on web standards.", "author": { "name": "Paper", @@ -22,5 +22,5 @@ "design-system" ], "skills": "./skills", - "mcpServers": "./mcp.json" + "mcpServers": "./mcp.claude.json" } diff --git a/plugins/paper-desktop/.codex-plugin/plugin.json b/plugins/paper-desktop/.codex-plugin/plugin.json index 18d14da..dcd5406 100644 --- a/plugins/paper-desktop/.codex-plugin/plugin.json +++ b/plugins/paper-desktop/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "paper-desktop", - "version": "0.1.0", + "version": "0.2.0", "description": "Design on a canvas that Codex can read and write to — built on web standards.", "author": { "name": "Paper", diff --git a/plugins/paper-desktop/.cursor-plugin/plugin.json b/plugins/paper-desktop/.cursor-plugin/plugin.json index f36e0ef..2833fb3 100644 --- a/plugins/paper-desktop/.cursor-plugin/plugin.json +++ b/plugins/paper-desktop/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "paper-desktop", "displayName": "Paper", - "version": "0.1.0", + "version": "0.2.0", "description": "Design on a canvas that Cursor can read and write to — built on web standards.", "author": { "name": "Paper", diff --git a/plugins/paper-desktop/README.md b/plugins/paper-desktop/README.md index c285d22..1575027 100644 --- a/plugins/paper-desktop/README.md +++ b/plugins/paper-desktop/README.md @@ -14,7 +14,8 @@ Paper connects your designs, agents, code, and data on a single canvas built on ## Prerequisites -Paper Desktop must be running with a file open for the MCP server to be available. Download it at [paper.design/downloads](https://paper.design/downloads). +1. Install [Paper Desktop](https://paper.design/downloads) and open it once — that copies the production CLI into app data (no PATH / admin install required). +2. Keep Paper Desktop running with a file open when making canvas tool calls. The MCP stdio process can load before Paper is open; tool calls against the live canvas need the app. ## Examples diff --git a/plugins/paper-desktop/bin/locate-paper-cli.mjs b/plugins/paper-desktop/bin/locate-paper-cli.mjs new file mode 100755 index 0000000..7f06f53 --- /dev/null +++ b/plugins/paper-desktop/bin/locate-paper-cli.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +/** + * Resolve the production Paper CLI Desktop installs into app data (no PATH / admin), + * then exec it. Agent plugins point here so MCP configs stay portable across OS. + * + * Prod only — staging / local builds keep their own CLI paths for direct use. + */ + +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { spawn } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +/** @returns {string[]} Candidate absolute paths for the production CLI binary. */ +export function productionCLICandidates( + platform = process.platform, + env = process.env, + home = homedir() +) { + if (platform === "darwin") { + return [join(home, "Library", "Application Support", "Paper", "cli")]; + } + + if (platform === "win32") { + const appData = env.APPDATA || join(home, "AppData", "Roaming"); + return [join(appData, "Paper", "cli.cmd")]; + } + + // Electron appData on Linux is XDG config home. + const configHome = env.XDG_CONFIG_HOME || join(home, ".config"); + return [join(configHome, "Paper", "cli")]; +} + +/** @returns {string | undefined} */ +export function resolveProductionCLI(platform, env, home) { + return productionCLICandidates(platform, env, home).find((path) => existsSync(path)); +} + +export function main(argv = process.argv.slice(2)) { + const cli = resolveProductionCLI(); + if (!cli) { + console.error( + "Paper CLI not found. Install Paper Desktop from https://paper.design/downloads and open it once so it can install the CLI." + ); + process.exit(1); + } + + const args = argv.length > 0 ? argv : ["mcp"]; + + const child = spawn(cli, args, { + stdio: "inherit", + // Windows .cmd shims need a shell; Unix payload is a real executable. + shell: process.platform === "win32", + windowsHide: true, + }); + + child.on("error", (err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }); + + child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); + }); +} + +const entry = process.argv[1]; +if (entry && import.meta.url === pathToFileURL(entry).href) { + main(); +} diff --git a/plugins/paper-desktop/bin/locate-paper-cli.spec.ts b/plugins/paper-desktop/bin/locate-paper-cli.spec.ts new file mode 100644 index 0000000..65de805 --- /dev/null +++ b/plugins/paper-desktop/bin/locate-paper-cli.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "bun:test"; +import { join } from "node:path"; +import { productionCLICandidates } from "./locate-paper-cli.mjs"; + +describe("productionCLICandidates", () => { + it("resolves macOS Application Support / Paper / cli", () => { + expect(productionCLICandidates("darwin", {}, "/Users/ada")).toEqual([ + join("/Users/ada", "Library", "Application Support", "Paper", "cli"), + ]); + }); + + it("resolves Windows %APPDATA% / Paper / cli.cmd", () => { + expect( + productionCLICandidates( + "win32", + { APPDATA: "C:\\Users\\ada\\AppData\\Roaming" }, + "C:\\Users\\ada" + ) + ).toEqual([join("C:\\Users\\ada\\AppData\\Roaming", "Paper", "cli.cmd")]); + }); + + it("falls back to home AppData/Roaming when APPDATA is unset", () => { + expect(productionCLICandidates("win32", {}, "C:\\Users\\ada")).toEqual([ + join("C:\\Users\\ada", "AppData", "Roaming", "Paper", "cli.cmd"), + ]); + }); + + it("resolves Linux XDG config / Paper / cli", () => { + expect( + productionCLICandidates("linux", { XDG_CONFIG_HOME: "/home/ada/.config" }, "/home/ada") + ).toEqual([join("/home/ada/.config", "Paper", "cli")]); + }); + + it("falls back to ~/.config when XDG_CONFIG_HOME is unset", () => { + expect(productionCLICandidates("linux", {}, "/home/ada")).toEqual([ + join("/home/ada", ".config", "Paper", "cli"), + ]); + }); + + it("does not include staging or other non-prod subdirectories", () => { + const paths = [ + ...productionCLICandidates("darwin", {}, "/Users/ada"), + ...productionCLICandidates("linux", {}, "/home/ada"), + ...productionCLICandidates("win32", { APPDATA: "C:\\Roaming" }, "C:\\Users\\ada"), + ]; + for (const path of paths) { + expect(path.includes("staging")).toBe(false); + } + }); +}); diff --git a/plugins/paper-desktop/mcp.claude.json b/plugins/paper-desktop/mcp.claude.json new file mode 100644 index 0000000..49fbe2b --- /dev/null +++ b/plugins/paper-desktop/mcp.claude.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "paper": { + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/bin/locate-paper-cli.mjs"] + } + } +} diff --git a/plugins/paper-desktop/mcp.json b/plugins/paper-desktop/mcp.json index 034d089..da08c89 100644 --- a/plugins/paper-desktop/mcp.json +++ b/plugins/paper-desktop/mcp.json @@ -1,8 +1,9 @@ { "mcpServers": { "paper": { - "type": "http", - "url": "http://127.0.0.1:29979/mcp" + "command": "node", + "args": ["bin/locate-paper-cli.mjs"], + "cwd": "." } } } diff --git a/plugins/paper-desktop/rules/ensure-paper-started.md b/plugins/paper-desktop/rules/ensure-paper-started.md index 71eff56..14aa689 100644 --- a/plugins/paper-desktop/rules/ensure-paper-started.md +++ b/plugins/paper-desktop/rules/ensure-paper-started.md @@ -2,4 +2,4 @@ description: Guidelines for using the Paper MCP server --- -Before using any Paper MCP tools, ensure the Paper Desktop app is running on the user's machine. If a connection to the Paper MCP server fails, remind the user to open Paper Desktop first. +The Paper MCP server is started via the Paper CLI installed into app data by Paper Desktop. Harnesses can load the server before Paper is open; tool calls that need the live canvas require Paper Desktop running with a file open. If a tool returns that Paper isn't running, remind the user to open Paper Desktop and retry. diff --git a/plugins/paper-desktop/skills/code-to-design/SKILL.md b/plugins/paper-desktop/skills/code-to-design/SKILL.md index 2ba0ace..09dce98 100644 --- a/plugins/paper-desktop/skills/code-to-design/SKILL.md +++ b/plugins/paper-desktop/skills/code-to-design/SKILL.md @@ -3,6 +3,6 @@ name: code-to-design description: Generate a Paper design from the project's codebase — using its tokens, styles, and components as context. --- -Before starting, ensure Paper Desktop is running with a file open. If a connection to the Paper MCP server fails, remind the user to open Paper Desktop first. +Before starting canvas work, Paper Desktop should be installed (open it once so the CLI is available) and running with a file open for tool calls. If a tool says Paper isn't running, remind the user to open Paper Desktop and retry. Read the project's stylesheets, design tokens, or theme files to understand the existing design language. Then create a new artboard in Paper and build the requested UI using the codebase's actual colors, typography, spacing, and component patterns — not generic defaults. diff --git a/plugins/paper-desktop/skills/design-to-code/SKILL.md b/plugins/paper-desktop/skills/design-to-code/SKILL.md index d3b68a3..5249e0b 100644 --- a/plugins/paper-desktop/skills/design-to-code/SKILL.md +++ b/plugins/paper-desktop/skills/design-to-code/SKILL.md @@ -3,6 +3,6 @@ name: design-to-code description: Turn a Paper design into production code using the project's existing conventions. --- -Before starting, ensure Paper Desktop is running with a file open. If a connection to the Paper MCP server fails, remind the user to open Paper Desktop first. +Before starting canvas work, Paper Desktop should be installed (open it once so the CLI is available) and running with a file open for tool calls. If a tool says Paper isn't running, remind the user to open Paper Desktop and retry. Read the selected frame or artboard from Paper — including structure, styles, text content, and computed layout. Then generate production-ready components in the project's framework and coding style. Match the design's layout, spacing, typography, and colors using the codebase's existing conventions (e.g. Tailwind classes, CSS modules, design tokens, styled-components). From 756746ddeccb0dfc58813ec64a4e9e3e8f694021 Mon Sep 17 00:00:00 2001 From: Michael Dougall <6801309+itsdouges@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:52:14 +1000 Subject: [PATCH 02/10] chore: self review --- .../{ => __tests__}/locate-paper-cli.spec.ts | 18 +++++++++++++----- plugins/paper-desktop/bin/locate-paper-cli.mjs | 15 +++++---------- 2 files changed, 18 insertions(+), 15 deletions(-) rename plugins/paper-desktop/bin/{ => __tests__}/locate-paper-cli.spec.ts (81%) diff --git a/plugins/paper-desktop/bin/locate-paper-cli.spec.ts b/plugins/paper-desktop/bin/__tests__/locate-paper-cli.spec.ts similarity index 81% rename from plugins/paper-desktop/bin/locate-paper-cli.spec.ts rename to plugins/paper-desktop/bin/__tests__/locate-paper-cli.spec.ts index 65de805..66d6fb5 100644 --- a/plugins/paper-desktop/bin/locate-paper-cli.spec.ts +++ b/plugins/paper-desktop/bin/__tests__/locate-paper-cli.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import { join } from "node:path"; -import { productionCLICandidates } from "./locate-paper-cli.mjs"; +import { productionCLICandidates } from "../locate-paper-cli.mjs"; describe("productionCLICandidates", () => { it("resolves macOS Application Support / Paper / cli", () => { @@ -14,8 +14,8 @@ describe("productionCLICandidates", () => { productionCLICandidates( "win32", { APPDATA: "C:\\Users\\ada\\AppData\\Roaming" }, - "C:\\Users\\ada" - ) + "C:\\Users\\ada", + ), ).toEqual([join("C:\\Users\\ada\\AppData\\Roaming", "Paper", "cli.cmd")]); }); @@ -27,7 +27,11 @@ describe("productionCLICandidates", () => { it("resolves Linux XDG config / Paper / cli", () => { expect( - productionCLICandidates("linux", { XDG_CONFIG_HOME: "/home/ada/.config" }, "/home/ada") + productionCLICandidates( + "linux", + { XDG_CONFIG_HOME: "/home/ada/.config" }, + "/home/ada", + ), ).toEqual([join("/home/ada/.config", "Paper", "cli")]); }); @@ -41,7 +45,11 @@ describe("productionCLICandidates", () => { const paths = [ ...productionCLICandidates("darwin", {}, "/Users/ada"), ...productionCLICandidates("linux", {}, "/home/ada"), - ...productionCLICandidates("win32", { APPDATA: "C:\\Roaming" }, "C:\\Users\\ada"), + ...productionCLICandidates( + "win32", + { APPDATA: "C:\\Roaming" }, + "C:\\Users\\ada", + ), ]; for (const path of paths) { expect(path.includes("staging")).toBe(false); diff --git a/plugins/paper-desktop/bin/locate-paper-cli.mjs b/plugins/paper-desktop/bin/locate-paper-cli.mjs index 7f06f53..8b770be 100755 --- a/plugins/paper-desktop/bin/locate-paper-cli.mjs +++ b/plugins/paper-desktop/bin/locate-paper-cli.mjs @@ -1,11 +1,4 @@ #!/usr/bin/env node -/** - * Resolve the production Paper CLI Desktop installs into app data (no PATH / admin), - * then exec it. Agent plugins point here so MCP configs stay portable across OS. - * - * Prod only — staging / local builds keep their own CLI paths for direct use. - */ - import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -16,7 +9,7 @@ import { pathToFileURL } from "node:url"; export function productionCLICandidates( platform = process.platform, env = process.env, - home = homedir() + home = homedir(), ) { if (platform === "darwin") { return [join(home, "Library", "Application Support", "Paper", "cli")]; @@ -34,14 +27,16 @@ export function productionCLICandidates( /** @returns {string | undefined} */ export function resolveProductionCLI(platform, env, home) { - return productionCLICandidates(platform, env, home).find((path) => existsSync(path)); + return productionCLICandidates(platform, env, home).find((path) => + existsSync(path), + ); } export function main(argv = process.argv.slice(2)) { const cli = resolveProductionCLI(); if (!cli) { console.error( - "Paper CLI not found. Install Paper Desktop from https://paper.design/downloads and open it once so it can install the CLI." + "Paper CLI not found. Install Paper Desktop from https://paper.design/downloads and open it once so it can install the CLI.", ); process.exit(1); } From 55ccac87d627f791e93f2bd4781c93fa66caf25d Mon Sep 17 00:00:00 2001 From: Michael Dougall <6801309+itsdouges@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:52:14 +1000 Subject: [PATCH 03/10] chore: self review --- plugins/paper-desktop/README.md | 3 +- .../bin/__tests__/locate-paper-cli.spec.ts | 103 +++++++++++++++++- .../paper-desktop/bin/locate-paper-cli.mjs | 97 +++++++++++++++-- .../rules/ensure-paper-started.md | 5 - .../skills/code-to-design/SKILL.md | 8 -- .../skills/design-to-code/SKILL.md | 8 -- 6 files changed, 190 insertions(+), 34 deletions(-) delete mode 100644 plugins/paper-desktop/rules/ensure-paper-started.md delete mode 100644 plugins/paper-desktop/skills/code-to-design/SKILL.md delete mode 100644 plugins/paper-desktop/skills/design-to-code/SKILL.md diff --git a/plugins/paper-desktop/README.md b/plugins/paper-desktop/README.md index 1575027..a718093 100644 --- a/plugins/paper-desktop/README.md +++ b/plugins/paper-desktop/README.md @@ -14,8 +14,7 @@ Paper connects your designs, agents, code, and data on a single canvas built on ## Prerequisites -1. Install [Paper Desktop](https://paper.design/downloads) and open it once — that copies the production CLI into app data (no PATH / admin install required). -2. Keep Paper Desktop running with a file open when making canvas tool calls. The MCP stdio process can load before Paper is open; tool calls against the live canvas need the app. +Install [Paper Desktop](https://paper.design/downloads) and open it once. ## Examples diff --git a/plugins/paper-desktop/bin/__tests__/locate-paper-cli.spec.ts b/plugins/paper-desktop/bin/__tests__/locate-paper-cli.spec.ts index 66d6fb5..720bb98 100644 --- a/plugins/paper-desktop/bin/__tests__/locate-paper-cli.spec.ts +++ b/plugins/paper-desktop/bin/__tests__/locate-paper-cli.spec.ts @@ -1,6 +1,11 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, mock } from "bun:test"; +import { EventEmitter } from "node:events"; import { join } from "node:path"; -import { productionCLICandidates } from "../locate-paper-cli.mjs"; +import { + attachSignalForwarding, + productionCLICandidates, + spawnCommand, +} from "../locate-paper-cli.mjs"; describe("productionCLICandidates", () => { it("resolves macOS Application Support / Paper / cli", () => { @@ -56,3 +61,97 @@ describe("productionCLICandidates", () => { } }); }); + +describe("spawnCommand", () => { + it("quotes Windows paths so spaces survive shell: true", () => { + const cli = join( + "C:\\Users\\Ada Lovelace\\AppData\\Roaming", + "Paper", + "cli.cmd", + ); + expect(spawnCommand(cli, "win32")).toBe(`"${cli}"`); + }); + + it("escapes embedded double quotes for cmd.exe", () => { + expect(spawnCommand('C:\\odd"path\\cli.cmd', "win32")).toBe( + '"C:\\odd""path\\cli.cmd"', + ); + }); + + it("leaves Unix paths unchanged", () => { + const cli = join( + "/Users/ada", + "Library", + "Application Support", + "Paper", + "cli", + ); + expect(spawnCommand(cli, "darwin")).toBe(cli); + expect(spawnCommand(cli, "linux")).toBe(cli); + }); +}); + +describe("attachSignalForwarding", () => { + it("forwards SIGTERM from the host process to the child", () => { + const child = Object.assign(new EventEmitter(), { + killed: false, + exitCode: null, + signalCode: null, + kill: mock(() => true), + }); + const proc = new EventEmitter(); + + attachSignalForwarding(child, ["SIGTERM"], proc); + proc.emit("SIGTERM"); + + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + }); + + it("forwards SIGINT and SIGHUP to the child", () => { + const child = Object.assign(new EventEmitter(), { + killed: false, + exitCode: null, + signalCode: null, + kill: mock(() => true), + }); + const proc = new EventEmitter(); + + attachSignalForwarding(child, ["SIGINT", "SIGHUP"], proc); + proc.emit("SIGINT"); + proc.emit("SIGHUP"); + + expect(child.kill).toHaveBeenCalledWith("SIGINT"); + expect(child.kill).toHaveBeenCalledWith("SIGHUP"); + }); + + it("does not kill an already-exited child", () => { + const child = Object.assign(new EventEmitter(), { + killed: false, + exitCode: 0, + signalCode: null, + kill: mock(() => true), + }); + const proc = new EventEmitter(); + + attachSignalForwarding(child, ["SIGTERM"], proc); + proc.emit("SIGTERM"); + + expect(child.kill).not.toHaveBeenCalled(); + }); + + it("detaches listeners when the child exits", () => { + const child = Object.assign(new EventEmitter(), { + killed: false, + exitCode: null, + signalCode: null, + kill: mock(() => true), + }); + const proc = new EventEmitter(); + + attachSignalForwarding(child, ["SIGTERM"], proc); + child.emit("exit", 0, null); + proc.emit("SIGTERM"); + + expect(child.kill).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/paper-desktop/bin/locate-paper-cli.mjs b/plugins/paper-desktop/bin/locate-paper-cli.mjs index 8b770be..5731d18 100755 --- a/plugins/paper-desktop/bin/locate-paper-cli.mjs +++ b/plugins/paper-desktop/bin/locate-paper-cli.mjs @@ -5,7 +5,12 @@ import { join } from "node:path"; import { spawn } from "node:child_process"; import { pathToFileURL } from "node:url"; -/** @returns {string[]} Candidate absolute paths for the production CLI binary. */ +/** + * @param {NodeJS.Platform} [platform] + * @param {NodeJS.ProcessEnv} [env] + * @param {string} [home] + * @returns {string[]} + */ export function productionCLICandidates( platform = process.platform, env = process.env, @@ -25,15 +30,86 @@ export function productionCLICandidates( return [join(configHome, "Paper", "cli")]; } -/** @returns {string | undefined} */ -export function resolveProductionCLI(platform, env, home) { - return productionCLICandidates(platform, env, home).find((path) => - existsSync(path), - ); +/** + * Node's Windows `shell: true` path joins the command into a cmd.exe string + * without quoting, so paths with spaces must be quoted by the caller. + * @param {string} command + * @param {NodeJS.Platform} [platform] + * @returns {string} + */ +export function spawnCommand(command, platform = process.platform) { + if (platform !== "win32") { + return command; + } + return `"${command.replaceAll('"', '""')}"`; +} + +/** + * @typedef {object} SignalForwardableChild + * @property {boolean} killed + * @property {number | null} exitCode + * @property {NodeJS.Signals | null} signalCode + * @property {(signal?: NodeJS.Signals) => boolean} kill + * @property {(event: "exit" | "error", listener: () => void) => unknown} once + */ + +/** + * @typedef {object} SignalSource + * @property {(event: NodeJS.Signals, listener: () => void) => unknown} on + * @property {(event: NodeJS.Signals, listener: () => void) => unknown} off + */ + +/** + * Forward host termination signals to the spawned CLI so the child does not + * outlive the wrapper when the MCP host stops the configured `node` process. + * @param {SignalForwardableChild} child + * @param {NodeJS.Signals[]} [signals] + * @param {SignalSource} [proc] + */ +export function attachSignalForwarding( + child, + signals = ["SIGINT", "SIGTERM", "SIGHUP"], + proc = process, +) { + /** @type {{ signal: NodeJS.Signals, forward: () => void }[]} */ + const attached = []; + + for (const signal of signals) { + const forward = () => { + if (child.killed || child.exitCode !== null || child.signalCode) { + return; + } + try { + child.kill(signal); + } catch { + // Child may already be gone between the checks and kill. + } + }; + + try { + proc.on(signal, forward); + attached.push({ signal, forward }); + } catch { + // Unsupported on this platform (e.g. SIGHUP on Windows). + } + } + + const detach = () => { + for (const { signal, forward } of attached) { + proc.off(signal, forward); + } + }; + child.once("exit", detach); + child.once("error", detach); } +/** + * @param {string[]} [argv] + * @returns {void} + */ export function main(argv = process.argv.slice(2)) { - const cli = resolveProductionCLI(); + const cli = productionCLICandidates().find((path) => existsSync(path)); + if (!cli) { console.error( "Paper CLI not found. Install Paper Desktop from https://paper.design/downloads and open it once so it can install the CLI.", @@ -42,14 +118,17 @@ export function main(argv = process.argv.slice(2)) { } const args = argv.length > 0 ? argv : ["mcp"]; + const isWindows = process.platform === "win32"; - const child = spawn(cli, args, { + const child = spawn(spawnCommand(cli), args, { stdio: "inherit", // Windows .cmd shims need a shell; Unix payload is a real executable. - shell: process.platform === "win32", + shell: isWindows, windowsHide: true, }); + attachSignalForwarding(child); + child.on("error", (err) => { console.error(err instanceof Error ? err.message : err); process.exit(1); diff --git a/plugins/paper-desktop/rules/ensure-paper-started.md b/plugins/paper-desktop/rules/ensure-paper-started.md deleted file mode 100644 index 14aa689..0000000 --- a/plugins/paper-desktop/rules/ensure-paper-started.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -description: Guidelines for using the Paper MCP server ---- - -The Paper MCP server is started via the Paper CLI installed into app data by Paper Desktop. Harnesses can load the server before Paper is open; tool calls that need the live canvas require Paper Desktop running with a file open. If a tool returns that Paper isn't running, remind the user to open Paper Desktop and retry. diff --git a/plugins/paper-desktop/skills/code-to-design/SKILL.md b/plugins/paper-desktop/skills/code-to-design/SKILL.md deleted file mode 100644 index 09dce98..0000000 --- a/plugins/paper-desktop/skills/code-to-design/SKILL.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: code-to-design -description: Generate a Paper design from the project's codebase — using its tokens, styles, and components as context. ---- - -Before starting canvas work, Paper Desktop should be installed (open it once so the CLI is available) and running with a file open for tool calls. If a tool says Paper isn't running, remind the user to open Paper Desktop and retry. - -Read the project's stylesheets, design tokens, or theme files to understand the existing design language. Then create a new artboard in Paper and build the requested UI using the codebase's actual colors, typography, spacing, and component patterns — not generic defaults. diff --git a/plugins/paper-desktop/skills/design-to-code/SKILL.md b/plugins/paper-desktop/skills/design-to-code/SKILL.md deleted file mode 100644 index 5249e0b..0000000 --- a/plugins/paper-desktop/skills/design-to-code/SKILL.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: design-to-code -description: Turn a Paper design into production code using the project's existing conventions. ---- - -Before starting canvas work, Paper Desktop should be installed (open it once so the CLI is available) and running with a file open for tool calls. If a tool says Paper isn't running, remind the user to open Paper Desktop and retry. - -Read the selected frame or artboard from Paper — including structure, styles, text content, and computed layout. Then generate production-ready components in the project's framework and coding style. Match the design's layout, spacing, typography, and colors using the codebase's existing conventions (e.g. Tailwind classes, CSS modules, design tokens, styled-components). From 7e19cca5dc85f220d578eabf020e0b276d569c5d Mon Sep 17 00:00:00 2001 From: Michael Dougall <6801309+itsdouges@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:58:39 +1000 Subject: [PATCH 04/10] chore: rename / support dev --- ...-paper-cli.spec.ts => resolve-cli.spec.ts} | 83 ++++++++++++------- .../{locate-paper-cli.mjs => resolve-cli.mjs} | 37 +++++++-- plugins/paper-desktop/mcp.claude.json | 2 +- plugins/paper-desktop/mcp.json | 2 +- 4 files changed, 84 insertions(+), 40 deletions(-) rename plugins/paper-desktop/bin/__tests__/{locate-paper-cli.spec.ts => resolve-cli.spec.ts} (62%) rename plugins/paper-desktop/bin/{locate-paper-cli.mjs => resolve-cli.mjs} (77%) mode change 100755 => 100644 diff --git a/plugins/paper-desktop/bin/__tests__/locate-paper-cli.spec.ts b/plugins/paper-desktop/bin/__tests__/resolve-cli.spec.ts similarity index 62% rename from plugins/paper-desktop/bin/__tests__/locate-paper-cli.spec.ts rename to plugins/paper-desktop/bin/__tests__/resolve-cli.spec.ts index 720bb98..be72164 100644 --- a/plugins/paper-desktop/bin/__tests__/locate-paper-cli.spec.ts +++ b/plugins/paper-desktop/bin/__tests__/resolve-cli.spec.ts @@ -3,62 +3,81 @@ import { EventEmitter } from "node:events"; import { join } from "node:path"; import { attachSignalForwarding, - productionCLICandidates, + cliCandidates, + paperAppDataRoot, spawnCommand, -} from "../locate-paper-cli.mjs"; +} from "../resolve-cli.mjs"; -describe("productionCLICandidates", () => { - it("resolves macOS Application Support / Paper / cli", () => { - expect(productionCLICandidates("darwin", {}, "/Users/ada")).toEqual([ - join("/Users/ada", "Library", "Application Support", "Paper", "cli"), - ]); +describe("paperAppDataRoot", () => { + it("resolves macOS Application Support / Paper", () => { + expect(paperAppDataRoot("darwin", {}, "/Users/ada")).toBe( + join("/Users/ada", "Library", "Application Support", "Paper"), + ); }); - it("resolves Windows %APPDATA% / Paper / cli.cmd", () => { + it("resolves Windows %APPDATA% / Paper", () => { expect( - productionCLICandidates( + paperAppDataRoot( "win32", { APPDATA: "C:\\Users\\ada\\AppData\\Roaming" }, "C:\\Users\\ada", ), - ).toEqual([join("C:\\Users\\ada\\AppData\\Roaming", "Paper", "cli.cmd")]); + ).toBe(join("C:\\Users\\ada\\AppData\\Roaming", "Paper")); }); it("falls back to home AppData/Roaming when APPDATA is unset", () => { - expect(productionCLICandidates("win32", {}, "C:\\Users\\ada")).toEqual([ - join("C:\\Users\\ada", "AppData", "Roaming", "Paper", "cli.cmd"), - ]); + expect(paperAppDataRoot("win32", {}, "C:\\Users\\ada")).toBe( + join("C:\\Users\\ada", "AppData", "Roaming", "Paper"), + ); }); - it("resolves Linux XDG config / Paper / cli", () => { + it("resolves Linux XDG config / Paper", () => { expect( - productionCLICandidates( - "linux", - { XDG_CONFIG_HOME: "/home/ada/.config" }, - "/home/ada", - ), - ).toEqual([join("/home/ada/.config", "Paper", "cli")]); + paperAppDataRoot("linux", { XDG_CONFIG_HOME: "/home/ada/.config" }, "/home/ada"), + ).toBe(join("/home/ada/.config", "Paper")); }); it("falls back to ~/.config when XDG_CONFIG_HOME is unset", () => { - expect(productionCLICandidates("linux", {}, "/home/ada")).toEqual([ - join("/home/ada", ".config", "Paper", "cli"), + expect(paperAppDataRoot("linux", {}, "/home/ada")).toBe( + join("/home/ada", ".config", "Paper"), + ); + }); +}); + +describe("cliCandidates", () => { + it("prefers production, then staging, then localhost on macOS", () => { + const root = join("/Users/ada", "Library", "Application Support", "Paper"); + expect(cliCandidates("darwin", {}, "/Users/ada")).toEqual([ + join(root, "cli"), + join(root, "staging", "cli"), + join(root, "localhost", "cli"), ]); }); - it("does not include staging or other non-prod subdirectories", () => { - const paths = [ - ...productionCLICandidates("darwin", {}, "/Users/ada"), - ...productionCLICandidates("linux", {}, "/home/ada"), - ...productionCLICandidates( + it("prefers production, then staging, then localhost on Windows", () => { + const root = join("C:\\Users\\ada\\AppData\\Roaming", "Paper"); + expect( + cliCandidates( "win32", - { APPDATA: "C:\\Roaming" }, + { APPDATA: "C:\\Users\\ada\\AppData\\Roaming" }, "C:\\Users\\ada", ), - ]; - for (const path of paths) { - expect(path.includes("staging")).toBe(false); - } + ).toEqual([ + join(root, "cli.cmd"), + join(root, "staging", "cli.cmd"), + join(root, "localhost", "cli.cmd"), + ]); + }); + + it("prefers production, then staging, then localhost on Linux", () => { + const root = join("/home/ada/.config", "Paper"); + expect( + cliCandidates("linux", { XDG_CONFIG_HOME: "/home/ada/.config" }, "/home/ada"), + ).toEqual([ + join(root, "cli"), + join(root, "staging", "cli"), + join(root, "localhost", "cli"), + ]); }); }); diff --git a/plugins/paper-desktop/bin/locate-paper-cli.mjs b/plugins/paper-desktop/bin/resolve-cli.mjs old mode 100755 new mode 100644 similarity index 77% rename from plugins/paper-desktop/bin/locate-paper-cli.mjs rename to plugins/paper-desktop/bin/resolve-cli.mjs index 5731d18..7bcfe83 --- a/plugins/paper-desktop/bin/locate-paper-cli.mjs +++ b/plugins/paper-desktop/bin/resolve-cli.mjs @@ -6,28 +6,53 @@ import { spawn } from "node:child_process"; import { pathToFileURL } from "node:url"; /** + * App-data root for Paper Desktop (Electron `app.getPath('appData')` + app name). + * Production installs the CLI here; staging/localhost use subdirectories. * @param {NodeJS.Platform} [platform] * @param {NodeJS.ProcessEnv} [env] * @param {string} [home] - * @returns {string[]} + * @returns {string} */ -export function productionCLICandidates( +export function paperAppDataRoot( platform = process.platform, env = process.env, home = homedir(), ) { if (platform === "darwin") { - return [join(home, "Library", "Application Support", "Paper", "cli")]; + return join(home, "Library", "Application Support", "Paper"); } if (platform === "win32") { const appData = env.APPDATA || join(home, "AppData", "Roaming"); - return [join(appData, "Paper", "cli.cmd")]; + return join(appData, "Paper"); } // Electron appData on Linux is XDG config home. const configHome = env.XDG_CONFIG_HOME || join(home, ".config"); - return [join(configHome, "Paper", "cli")]; + return join(configHome, "Paper"); +} + +/** + * Candidate CLI paths in preference order: production, then staging, then localhost. + * Mirrors desktop `getAppDataPath()` — prod stays at the Paper root; other envs use a + * subdirectory so they don't share state with production. + * @param {NodeJS.Platform} [platform] + * @param {NodeJS.ProcessEnv} [env] + * @param {string} [home] + * @returns {string[]} + */ +export function cliCandidates( + platform = process.platform, + env = process.env, + home = homedir(), +) { + const root = paperAppDataRoot(platform, env, home); + const cliFile = platform === "win32" ? "cli.cmd" : "cli"; + return [ + join(root, cliFile), + join(root, "staging", cliFile), + join(root, "localhost", cliFile), + ]; } /** @@ -108,7 +133,7 @@ export function attachSignalForwarding( * @returns {void} */ export function main(argv = process.argv.slice(2)) { - const cli = productionCLICandidates().find((path) => existsSync(path)); + const cli = cliCandidates().find((path) => existsSync(path)); if (!cli) { console.error( diff --git a/plugins/paper-desktop/mcp.claude.json b/plugins/paper-desktop/mcp.claude.json index 49fbe2b..ca2987c 100644 --- a/plugins/paper-desktop/mcp.claude.json +++ b/plugins/paper-desktop/mcp.claude.json @@ -2,7 +2,7 @@ "mcpServers": { "paper": { "command": "node", - "args": ["${CLAUDE_PLUGIN_ROOT}/bin/locate-paper-cli.mjs"] + "args": ["${CLAUDE_PLUGIN_ROOT}/bin/resolve-cli.mjs"] } } } diff --git a/plugins/paper-desktop/mcp.json b/plugins/paper-desktop/mcp.json index da08c89..d37c147 100644 --- a/plugins/paper-desktop/mcp.json +++ b/plugins/paper-desktop/mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "paper": { "command": "node", - "args": ["bin/locate-paper-cli.mjs"], + "args": ["bin/resolve-cli.mjs"], "cwd": "." } } From 385d4eb2028cb514c185a9e83175ce3004ddc27f Mon Sep 17 00:00:00 2001 From: Michael Dougall <6801309+itsdouges@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:01:03 +1000 Subject: [PATCH 05/10] fix: build --- plugins/paper-desktop/.claude-plugin/plugin.json | 1 - plugins/paper-desktop/.codex-plugin/plugin.json | 1 - plugins/paper-desktop/bin/resolve-cli.mjs | 4 +++- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/paper-desktop/.claude-plugin/plugin.json b/plugins/paper-desktop/.claude-plugin/plugin.json index 4c4447f..880d659 100644 --- a/plugins/paper-desktop/.claude-plugin/plugin.json +++ b/plugins/paper-desktop/.claude-plugin/plugin.json @@ -21,6 +21,5 @@ "mcp", "design-system" ], - "skills": "./skills", "mcpServers": "./mcp.claude.json" } diff --git a/plugins/paper-desktop/.codex-plugin/plugin.json b/plugins/paper-desktop/.codex-plugin/plugin.json index dcd5406..9234f81 100644 --- a/plugins/paper-desktop/.codex-plugin/plugin.json +++ b/plugins/paper-desktop/.codex-plugin/plugin.json @@ -23,7 +23,6 @@ "mcp", "design-system" ], - "skills": "./skills/", "mcpServers": "./mcp.json", "interface": { "displayName": "Paper Desktop", diff --git a/plugins/paper-desktop/bin/resolve-cli.mjs b/plugins/paper-desktop/bin/resolve-cli.mjs index 7bcfe83..aca63ab 100644 --- a/plugins/paper-desktop/bin/resolve-cli.mjs +++ b/plugins/paper-desktop/bin/resolve-cli.mjs @@ -66,6 +66,7 @@ export function spawnCommand(command, platform = process.platform) { if (platform !== "win32") { return command; } + return `"${command.replaceAll('"', '""')}"`; } @@ -137,8 +138,9 @@ export function main(argv = process.argv.slice(2)) { if (!cli) { console.error( - "Paper CLI not found. Install Paper Desktop from https://paper.design/downloads and open it once so it can install the CLI.", + "Paper CLI could not be found. Install Paper Desktop (https://paper.design/downloads) and open it once.", ); + process.exit(1); } From 203d00f027d805a7b9b9f1f0d4a435f7b3751766 Mon Sep 17 00:00:00 2001 From: Michael Dougall <6801309+itsdouges@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:18:32 +1000 Subject: [PATCH 06/10] chore: simplify --- .../paper-desktop/.claude-plugin/plugin.json | 2 +- .../bin/__tests__/resolve-cli.spec.ts | 176 ------------------ plugins/paper-desktop/bin/resolve-cli.mjs | 176 ------------------ plugins/paper-desktop/mcp.claude.json | 8 - plugins/paper-desktop/mcp.json | 8 +- 5 files changed, 6 insertions(+), 364 deletions(-) delete mode 100644 plugins/paper-desktop/bin/__tests__/resolve-cli.spec.ts delete mode 100644 plugins/paper-desktop/bin/resolve-cli.mjs delete mode 100644 plugins/paper-desktop/mcp.claude.json diff --git a/plugins/paper-desktop/.claude-plugin/plugin.json b/plugins/paper-desktop/.claude-plugin/plugin.json index 880d659..dd80a5d 100644 --- a/plugins/paper-desktop/.claude-plugin/plugin.json +++ b/plugins/paper-desktop/.claude-plugin/plugin.json @@ -21,5 +21,5 @@ "mcp", "design-system" ], - "mcpServers": "./mcp.claude.json" + "mcpServers": "./mcp.json" } diff --git a/plugins/paper-desktop/bin/__tests__/resolve-cli.spec.ts b/plugins/paper-desktop/bin/__tests__/resolve-cli.spec.ts deleted file mode 100644 index be72164..0000000 --- a/plugins/paper-desktop/bin/__tests__/resolve-cli.spec.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { describe, expect, it, mock } from "bun:test"; -import { EventEmitter } from "node:events"; -import { join } from "node:path"; -import { - attachSignalForwarding, - cliCandidates, - paperAppDataRoot, - spawnCommand, -} from "../resolve-cli.mjs"; - -describe("paperAppDataRoot", () => { - it("resolves macOS Application Support / Paper", () => { - expect(paperAppDataRoot("darwin", {}, "/Users/ada")).toBe( - join("/Users/ada", "Library", "Application Support", "Paper"), - ); - }); - - it("resolves Windows %APPDATA% / Paper", () => { - expect( - paperAppDataRoot( - "win32", - { APPDATA: "C:\\Users\\ada\\AppData\\Roaming" }, - "C:\\Users\\ada", - ), - ).toBe(join("C:\\Users\\ada\\AppData\\Roaming", "Paper")); - }); - - it("falls back to home AppData/Roaming when APPDATA is unset", () => { - expect(paperAppDataRoot("win32", {}, "C:\\Users\\ada")).toBe( - join("C:\\Users\\ada", "AppData", "Roaming", "Paper"), - ); - }); - - it("resolves Linux XDG config / Paper", () => { - expect( - paperAppDataRoot("linux", { XDG_CONFIG_HOME: "/home/ada/.config" }, "/home/ada"), - ).toBe(join("/home/ada/.config", "Paper")); - }); - - it("falls back to ~/.config when XDG_CONFIG_HOME is unset", () => { - expect(paperAppDataRoot("linux", {}, "/home/ada")).toBe( - join("/home/ada", ".config", "Paper"), - ); - }); -}); - -describe("cliCandidates", () => { - it("prefers production, then staging, then localhost on macOS", () => { - const root = join("/Users/ada", "Library", "Application Support", "Paper"); - expect(cliCandidates("darwin", {}, "/Users/ada")).toEqual([ - join(root, "cli"), - join(root, "staging", "cli"), - join(root, "localhost", "cli"), - ]); - }); - - it("prefers production, then staging, then localhost on Windows", () => { - const root = join("C:\\Users\\ada\\AppData\\Roaming", "Paper"); - expect( - cliCandidates( - "win32", - { APPDATA: "C:\\Users\\ada\\AppData\\Roaming" }, - "C:\\Users\\ada", - ), - ).toEqual([ - join(root, "cli.cmd"), - join(root, "staging", "cli.cmd"), - join(root, "localhost", "cli.cmd"), - ]); - }); - - it("prefers production, then staging, then localhost on Linux", () => { - const root = join("/home/ada/.config", "Paper"); - expect( - cliCandidates("linux", { XDG_CONFIG_HOME: "/home/ada/.config" }, "/home/ada"), - ).toEqual([ - join(root, "cli"), - join(root, "staging", "cli"), - join(root, "localhost", "cli"), - ]); - }); -}); - -describe("spawnCommand", () => { - it("quotes Windows paths so spaces survive shell: true", () => { - const cli = join( - "C:\\Users\\Ada Lovelace\\AppData\\Roaming", - "Paper", - "cli.cmd", - ); - expect(spawnCommand(cli, "win32")).toBe(`"${cli}"`); - }); - - it("escapes embedded double quotes for cmd.exe", () => { - expect(spawnCommand('C:\\odd"path\\cli.cmd', "win32")).toBe( - '"C:\\odd""path\\cli.cmd"', - ); - }); - - it("leaves Unix paths unchanged", () => { - const cli = join( - "/Users/ada", - "Library", - "Application Support", - "Paper", - "cli", - ); - expect(spawnCommand(cli, "darwin")).toBe(cli); - expect(spawnCommand(cli, "linux")).toBe(cli); - }); -}); - -describe("attachSignalForwarding", () => { - it("forwards SIGTERM from the host process to the child", () => { - const child = Object.assign(new EventEmitter(), { - killed: false, - exitCode: null, - signalCode: null, - kill: mock(() => true), - }); - const proc = new EventEmitter(); - - attachSignalForwarding(child, ["SIGTERM"], proc); - proc.emit("SIGTERM"); - - expect(child.kill).toHaveBeenCalledWith("SIGTERM"); - }); - - it("forwards SIGINT and SIGHUP to the child", () => { - const child = Object.assign(new EventEmitter(), { - killed: false, - exitCode: null, - signalCode: null, - kill: mock(() => true), - }); - const proc = new EventEmitter(); - - attachSignalForwarding(child, ["SIGINT", "SIGHUP"], proc); - proc.emit("SIGINT"); - proc.emit("SIGHUP"); - - expect(child.kill).toHaveBeenCalledWith("SIGINT"); - expect(child.kill).toHaveBeenCalledWith("SIGHUP"); - }); - - it("does not kill an already-exited child", () => { - const child = Object.assign(new EventEmitter(), { - killed: false, - exitCode: 0, - signalCode: null, - kill: mock(() => true), - }); - const proc = new EventEmitter(); - - attachSignalForwarding(child, ["SIGTERM"], proc); - proc.emit("SIGTERM"); - - expect(child.kill).not.toHaveBeenCalled(); - }); - - it("detaches listeners when the child exits", () => { - const child = Object.assign(new EventEmitter(), { - killed: false, - exitCode: null, - signalCode: null, - kill: mock(() => true), - }); - const proc = new EventEmitter(); - - attachSignalForwarding(child, ["SIGTERM"], proc); - child.emit("exit", 0, null); - proc.emit("SIGTERM"); - - expect(child.kill).not.toHaveBeenCalled(); - }); -}); diff --git a/plugins/paper-desktop/bin/resolve-cli.mjs b/plugins/paper-desktop/bin/resolve-cli.mjs deleted file mode 100644 index aca63ab..0000000 --- a/plugins/paper-desktop/bin/resolve-cli.mjs +++ /dev/null @@ -1,176 +0,0 @@ -#!/usr/bin/env node -import { existsSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { spawn } from "node:child_process"; -import { pathToFileURL } from "node:url"; - -/** - * App-data root for Paper Desktop (Electron `app.getPath('appData')` + app name). - * Production installs the CLI here; staging/localhost use subdirectories. - * @param {NodeJS.Platform} [platform] - * @param {NodeJS.ProcessEnv} [env] - * @param {string} [home] - * @returns {string} - */ -export function paperAppDataRoot( - platform = process.platform, - env = process.env, - home = homedir(), -) { - if (platform === "darwin") { - return join(home, "Library", "Application Support", "Paper"); - } - - if (platform === "win32") { - const appData = env.APPDATA || join(home, "AppData", "Roaming"); - return join(appData, "Paper"); - } - - // Electron appData on Linux is XDG config home. - const configHome = env.XDG_CONFIG_HOME || join(home, ".config"); - return join(configHome, "Paper"); -} - -/** - * Candidate CLI paths in preference order: production, then staging, then localhost. - * Mirrors desktop `getAppDataPath()` — prod stays at the Paper root; other envs use a - * subdirectory so they don't share state with production. - * @param {NodeJS.Platform} [platform] - * @param {NodeJS.ProcessEnv} [env] - * @param {string} [home] - * @returns {string[]} - */ -export function cliCandidates( - platform = process.platform, - env = process.env, - home = homedir(), -) { - const root = paperAppDataRoot(platform, env, home); - const cliFile = platform === "win32" ? "cli.cmd" : "cli"; - return [ - join(root, cliFile), - join(root, "staging", cliFile), - join(root, "localhost", cliFile), - ]; -} - -/** - * Node's Windows `shell: true` path joins the command into a cmd.exe string - * without quoting, so paths with spaces must be quoted by the caller. - * @param {string} command - * @param {NodeJS.Platform} [platform] - * @returns {string} - */ -export function spawnCommand(command, platform = process.platform) { - if (platform !== "win32") { - return command; - } - - return `"${command.replaceAll('"', '""')}"`; -} - -/** - * @typedef {object} SignalForwardableChild - * @property {boolean} killed - * @property {number | null} exitCode - * @property {NodeJS.Signals | null} signalCode - * @property {(signal?: NodeJS.Signals) => boolean} kill - * @property {(event: "exit" | "error", listener: () => void) => unknown} once - */ - -/** - * @typedef {object} SignalSource - * @property {(event: NodeJS.Signals, listener: () => void) => unknown} on - * @property {(event: NodeJS.Signals, listener: () => void) => unknown} off - */ - -/** - * Forward host termination signals to the spawned CLI so the child does not - * outlive the wrapper when the MCP host stops the configured `node` process. - * @param {SignalForwardableChild} child - * @param {NodeJS.Signals[]} [signals] - * @param {SignalSource} [proc] - */ -export function attachSignalForwarding( - child, - signals = ["SIGINT", "SIGTERM", "SIGHUP"], - proc = process, -) { - /** @type {{ signal: NodeJS.Signals, forward: () => void }[]} */ - const attached = []; - - for (const signal of signals) { - const forward = () => { - if (child.killed || child.exitCode !== null || child.signalCode) { - return; - } - try { - child.kill(signal); - } catch { - // Child may already be gone between the checks and kill. - } - }; - - try { - proc.on(signal, forward); - attached.push({ signal, forward }); - } catch { - // Unsupported on this platform (e.g. SIGHUP on Windows). - } - } - - const detach = () => { - for (const { signal, forward } of attached) { - proc.off(signal, forward); - } - }; - child.once("exit", detach); - child.once("error", detach); -} - -/** - * @param {string[]} [argv] - * @returns {void} - */ -export function main(argv = process.argv.slice(2)) { - const cli = cliCandidates().find((path) => existsSync(path)); - - if (!cli) { - console.error( - "Paper CLI could not be found. Install Paper Desktop (https://paper.design/downloads) and open it once.", - ); - - process.exit(1); - } - - const args = argv.length > 0 ? argv : ["mcp"]; - const isWindows = process.platform === "win32"; - - const child = spawn(spawnCommand(cli), args, { - stdio: "inherit", - // Windows .cmd shims need a shell; Unix payload is a real executable. - shell: isWindows, - windowsHide: true, - }); - - attachSignalForwarding(child); - - child.on("error", (err) => { - console.error(err instanceof Error ? err.message : err); - process.exit(1); - }); - - child.on("exit", (code, signal) => { - if (signal) { - process.kill(process.pid, signal); - return; - } - process.exit(code ?? 1); - }); -} - -const entry = process.argv[1]; -if (entry && import.meta.url === pathToFileURL(entry).href) { - main(); -} diff --git a/plugins/paper-desktop/mcp.claude.json b/plugins/paper-desktop/mcp.claude.json deleted file mode 100644 index ca2987c..0000000 --- a/plugins/paper-desktop/mcp.claude.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "mcpServers": { - "paper": { - "command": "node", - "args": ["${CLAUDE_PLUGIN_ROOT}/bin/resolve-cli.mjs"] - } - } -} diff --git a/plugins/paper-desktop/mcp.json b/plugins/paper-desktop/mcp.json index d37c147..75112c8 100644 --- a/plugins/paper-desktop/mcp.json +++ b/plugins/paper-desktop/mcp.json @@ -1,9 +1,11 @@ { "mcpServers": { "paper": { - "command": "node", - "args": ["bin/resolve-cli.mjs"], - "cwd": "." + "command": "sh", + "args": [ + "-c", + "set -eu; if [ \"$(uname -s)\" = Darwin ]; then root=\"$HOME/Library/Application Support/Paper\"; else root=\"${XDG_CONFIG_HOME:-$HOME/.config}/Paper\"; fi; for c in \"$root/cli\" \"$root/staging/cli\" \"$root/localhost/cli\"; do if [ -e \"$c\" ]; then exec \"$c\" mcp; fi; done; echo \"Paper CLI could not be found. Install Paper Desktop (https://paper.design/downloads) and open it once.\" >&2; exit 1" + ] } } } From 0ac3925e90240f315df061fcff5653b10be7518c Mon Sep 17 00:00:00 2001 From: Michael Dougall <6801309+itsdouges@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:35:40 +1000 Subject: [PATCH 07/10] chore: use simplified cli --- plugins/paper-desktop/mcp.json | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/plugins/paper-desktop/mcp.json b/plugins/paper-desktop/mcp.json index 75112c8..919252f 100644 --- a/plugins/paper-desktop/mcp.json +++ b/plugins/paper-desktop/mcp.json @@ -1,11 +1,8 @@ { "mcpServers": { "paper": { - "command": "sh", - "args": [ - "-c", - "set -eu; if [ \"$(uname -s)\" = Darwin ]; then root=\"$HOME/Library/Application Support/Paper\"; else root=\"${XDG_CONFIG_HOME:-$HOME/.config}/Paper\"; fi; for c in \"$root/cli\" \"$root/staging/cli\" \"$root/localhost/cli\"; do if [ -e \"$c\" ]; then exec \"$c\" mcp; fi; done; echo \"Paper CLI could not be found. Install Paper Desktop (https://paper.design/downloads) and open it once.\" >&2; exit 1" - ] + "command": "${userHome}/.paper/bin/paper", + "args": ["mcp"] } } } From 0611d54023f34ff8a84e9ae5a846ce90183eb195 Mon Sep 17 00:00:00 2001 From: Michael Dougall <6801309+itsdouges@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:44:57 +1000 Subject: [PATCH 08/10] feat: add gh copilot --- .github/plugin/marketplace.json | 19 ++++++++++++ README.md | 48 +++++++++++++++++++++++++++++++ plugins/paper-desktop/plugin.json | 26 +++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 .github/plugin/marketplace.json create mode 100644 plugins/paper-desktop/plugin.json diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json new file mode 100644 index 0000000..1ee09c1 --- /dev/null +++ b/.github/plugin/marketplace.json @@ -0,0 +1,19 @@ +{ + "name": "paper", + "owner": { + "name": "Paper", + "email": "team@paper.design" + }, + "metadata": { + "description": "Paper connects your teams, agents, code, and data on a single design space built on web standards.", + "version": "0.1.0" + }, + "plugins": [ + { + "name": "paper-desktop", + "source": "./plugins/paper-desktop", + "description": "Connect to Paper Desktop using its MCP server.", + "version": "0.2.0" + } + ] +} diff --git a/README.md b/README.md index e2e0a9d..2eb6f05 100644 --- a/README.md +++ b/README.md @@ -43,3 +43,51 @@ codex plugin install paper-desktop@paper You can also browse and install plugins interactively by running `/plugins` inside Codex CLI after adding the marketplace. - [Read more about installing Codex plugins](https://developers.openai.com/codex/plugins) + +## Copilot CLI + +**Add the custom marketplace** + +```sh +copilot plugin marketplace add paper-design/agent-plugins +``` + +**Install the plugin** + +```sh +copilot plugin install paper-desktop@paper +``` + +Confirm with `copilot mcp list`. Plugins installed this way also appear in VS Code under **Agent Plugins - Installed**. + +## VS Code + +1. Enable agent plugins: set `chat.plugins.enabled` to `true`. +2. Add the marketplace in settings: + +```json +"chat.plugins.marketplaces": [ + "paper-design/agent-plugins" +] +``` + +3. Install **paper-desktop** from the Agent Plugins view (or install via Copilot CLI as above). + +### Manual MCP (optional) + +If you prefer not to use plugins, add this to your Copilot / VS Code MCP config: + +```json +{ + "servers": { + "paper": { + "type": "stdio", + "command": "${userHome}/.paper/bin/paper", + "args": ["mcp"] + } + } +} +``` + +- [About Copilot plugins](https://docs.github.com/en/copilot/concepts/agents/about-plugins) +- [Agent plugins in VS Code](https://code.visualstudio.com/docs/agent-customization/agent-plugins) diff --git a/plugins/paper-desktop/plugin.json b/plugins/paper-desktop/plugin.json new file mode 100644 index 0000000..b57dc4f --- /dev/null +++ b/plugins/paper-desktop/plugin.json @@ -0,0 +1,26 @@ +{ + "name": "paper-desktop", + "version": "0.2.0", + "description": "Design on a canvas that Copilot can read and write to — built on web standards.", + "author": { + "name": "Paper", + "email": "team@paper.design", + "url": "https://paper.design" + }, + "homepage": "https://paper.design", + "repository": "https://github.com/paper-design/agent-plugins", + "license": "MIT", + "keywords": [ + "paper", + "design", + "ui", + "canvas", + "html", + "css", + "design-to-code", + "code-to-design", + "mcp", + "design-system" + ], + "mcpServers": "./mcp.json" +} From 5181d2fca6539c061ce5443bf5faf770b1acd0f7 Mon Sep 17 00:00:00 2001 From: Michael Dougall <6801309+itsdouges@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:16:40 +1000 Subject: [PATCH 09/10] chore: validate copilot --- package.json | 2 +- plugins/paper-desktop/mcp.json | 2 + plugins/paper-desktop/plugin.json | 1 + schemas/agent-plugins/mcp.schema.json | 120 +++++++++++++++ schemas/agent-plugins/plugin.schema.json | 65 ++++++++ scripts/validate-copilot.mjs | 186 +++++++++++++++++++++++ 6 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 schemas/agent-plugins/mcp.schema.json create mode 100644 schemas/agent-plugins/plugin.schema.json create mode 100644 scripts/validate-copilot.mjs diff --git a/package.json b/package.json index 2775669..73cb039 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "type": "module", "private": true, "scripts": { - "build": "bun ./scripts/validate-cursor-schema.mjs && bun ./scripts/validate-cursor-structure.mjs && bun ./scripts/validate-codex.mjs && bun test", + "build": "bun ./scripts/validate-cursor-schema.mjs && bun ./scripts/validate-cursor-structure.mjs && bun ./scripts/validate-codex.mjs && bun ./scripts/validate-copilot.mjs && bun test", "test": "bun test" }, "devDependencies": { diff --git a/plugins/paper-desktop/mcp.json b/plugins/paper-desktop/mcp.json index 919252f..3afdae1 100644 --- a/plugins/paper-desktop/mcp.json +++ b/plugins/paper-desktop/mcp.json @@ -1,6 +1,8 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", "mcpServers": { "paper": { + "type": "stdio", "command": "${userHome}/.paper/bin/paper", "args": ["mcp"] } diff --git a/plugins/paper-desktop/plugin.json b/plugins/paper-desktop/plugin.json index b57dc4f..b56675f 100644 --- a/plugins/paper-desktop/plugin.json +++ b/plugins/paper-desktop/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "paper-desktop", "version": "0.2.0", "description": "Design on a canvas that Copilot can read and write to — built on web standards.", diff --git a/schemas/agent-plugins/mcp.schema.json b/schemas/agent-plugins/mcp.schema.json new file mode 100644 index 0000000..a9139a4 --- /dev/null +++ b/schemas/agent-plugins/mcp.schema.json @@ -0,0 +1,120 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "title": "Agent Plugins MCP Configuration", + "description": "Machine-readable schema for mcp.json in Agent Plugins 1.0.0. The Agent Plugins specification defines additional semantic and operational requirements.", + "type": "object", + "properties": { + "$schema": { + "const": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "description": "Canonical identifier of the MCP configuration schema for the Agent Plugins version targeted by this document." + }, + "mcpServers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/server" + } + } + }, + "required": ["$schema", "mcpServers"], + "additionalProperties": false, + "$defs": { + "server": { + "title": "MCP server", + "oneOf": [ + { + "$ref": "#/$defs/stdioServer" + }, + { + "$ref": "#/$defs/streamableHttpServer" + }, + { + "$ref": "#/$defs/sseServer" + } + ] + }, + "stdioServer": { + "title": "stdio MCP server", + "type": "object", + "properties": { + "type": { + "const": "stdio" + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Executable token. Resolution rules are defined by the Agent Plugins specification." + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "type": "object", + "propertyNames": { + "not": { + "enum": ["PLUGIN_ROOT", "PLUGIN_DATA"] + } + }, + "additionalProperties": { + "type": "string" + } + }, + "cwd": { + "type": "string", + "pattern": "^(?:\\./|\\$\\{PLUGIN_ROOT\\}(?:/|$)|\\$\\{PLUGIN_DATA\\}(?:/|$))", + "description": "Plugin-relative, PLUGIN_ROOT-rooted, or PLUGIN_DATA-rooted working directory. Filesystem containment is validated separately." + } + }, + "required": ["type", "command"], + "additionalProperties": false + }, + "streamableHttpServer": { + "title": "Streamable HTTP MCP server", + "type": "object", + "properties": { + "type": { + "const": "streamable-http" + }, + "url": { + "type": "string", + "minLength": 1, + "description": "MCP endpoint URL. URL semantics are defined by the Agent Plugins specification." + }, + "headers": { + "$ref": "#/$defs/headers" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + "sseServer": { + "title": "Legacy HTTP+SSE MCP server", + "type": "object", + "properties": { + "type": { + "const": "sse" + }, + "url": { + "type": "string", + "minLength": 1, + "description": "MCP endpoint URL. URL semantics are defined by the Agent Plugins specification." + }, + "headers": { + "$ref": "#/$defs/headers" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + "headers": { + "title": "HTTP headers", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } +} diff --git a/schemas/agent-plugins/plugin.schema.json b/schemas/agent-plugins/plugin.schema.json new file mode 100644 index 0000000..8fed0e1 --- /dev/null +++ b/schemas/agent-plugins/plugin.schema.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "title": "Agent Plugins Manifest", + "description": "Machine-readable schema for plugin.json in Agent Plugins 1.0.0. The Agent Plugins specification defines additional semantic and operational requirements.", + "type": "object", + "properties": { + "$schema": { + "const": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "description": "Canonical identifier of the plugin manifest schema for the Agent Plugins version targeted by this document." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^(?!.*(?:--|\\.\\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$", + "description": "Human-readable plugin name." + }, + "version": { + "type": "string" + }, + "description": { + "type": "string" + }, + "author": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "additionalProperties": false + }, + "homepage": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "license": { + "type": "string" + }, + "keywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "extensions": { + "type": "object", + "description": "Client-specific manifest data keyed by reverse-domain extension namespace. Agent Plugins assigns no semantics to namespace object contents.", + "additionalProperties": { + "type": "object" + } + } + }, + "required": ["$schema", "name"], + "additionalProperties": false +} diff --git a/scripts/validate-copilot.mjs b/scripts/validate-copilot.mjs new file mode 100644 index 0000000..c96b597 --- /dev/null +++ b/scripts/validate-copilot.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node + +/** + * Copilot plugin validator. + * - marketplace.json: GitHub Copilot marketplace layout + * - plugin.json: Open Plugin Spec fields (+ Copilot `mcpServers` path) + * - mcp.json: Open Plugin Spec MCP schema + */ + +import { readFileSync, existsSync, statSync } from "fs"; +import { resolve, dirname, relative } from "path"; +import { fileURLToPath } from "url"; +import Ajv2020 from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, ".."); + +function loadJSON(path) { + return JSON.parse(readFileSync(path, "utf-8")); +} + +function loadJSONSafe(path, label, fail) { + if (!existsSync(path)) { + fail(`${label}: file not found at ${relative(root, path)}`); + return null; + } + try { + return loadJSON(path); + } catch (err) { + fail(`${label}: invalid JSON — ${err.message}`); + return null; + } +} + +const pluginSchema = loadJSON( + resolve(root, "schemas/agent-plugins/plugin.schema.json") +); +const mcpSchema = loadJSON(resolve(root, "schemas/agent-plugins/mcp.schema.json")); + +const ajv = new Ajv2020({ allErrors: true, strict: false }); +addFormats(ajv); + +const validatePlugin = ajv.compile(pluginSchema); +const validateMcp = ajv.compile(mcpSchema); + +let errors = 0; + +function fail(message) { + console.error(`ERROR: ${message}`); + errors++; +} + +function reportSchemaErrors(label, validate) { + fail(`${label}: schema validation failed:`); + for (const err of validate.errors ?? []) { + const detail = + err.keyword === "additionalProperties" + ? `${err.message}: "${err.params.additionalProperty}"` + : err.message; + console.error(` ${err.instancePath || "/"}: ${detail}`); + } +} + +function isValidRelativePath(path) { + if (typeof path !== "string" || path.length === 0) return false; + const stripped = path.startsWith("./") ? path.slice(2) : null; + if (stripped === null || stripped.length === 0) return false; + const segments = stripped.replace(/\/+$/, "").split("/"); + return segments.every((s) => s.length > 0 && s !== ".." && s !== "."); +} + +// 1. Marketplace (GitHub Copilot layout; structural checks) +const marketplacePath = resolve(root, ".github/plugin/marketplace.json"); +const marketplace = loadJSONSafe(marketplacePath, "Marketplace", fail); + +if (!marketplace) { + process.exit(1); +} + +if (typeof marketplace.name !== "string" || marketplace.name.length === 0) { + fail("Marketplace: missing or empty `name`"); +} + +if (!Array.isArray(marketplace.plugins) || marketplace.plugins.length === 0) { + fail("Marketplace: `plugins` must be a non-empty array"); +} + +for (const [index, entry] of (marketplace.plugins ?? []).entries()) { + const label = `Marketplace plugins[${index}]`; + + if (typeof entry?.name !== "string" || entry.name.length === 0) { + fail(`${label}: missing or empty \`name\``); + continue; + } + + const pluginLabel = `Marketplace plugin "${entry.name}"`; + + if (!isValidRelativePath(entry.source)) { + fail( + `${pluginLabel}: \`source\` must be a relative path starting with "./" — got "${entry.source}"` + ); + continue; + } + + const pluginDir = resolve(root, entry.source.slice(2)); + if (!existsSync(pluginDir) || !statSync(pluginDir).isDirectory()) { + fail(`${pluginLabel}: source directory does not exist — ${entry.source}`); + continue; + } + + // 2. plugin.json — OPS core fields; Copilot allows mcpServers on top + const pluginJsonPath = resolve(pluginDir, "plugin.json"); + const pluginJson = loadJSONSafe( + pluginJsonPath, + `${pluginLabel} plugin.json`, + fail + ); + if (!pluginJson) continue; + + const { mcpServers, ...opsPluginJson } = pluginJson; + if (!validatePlugin(opsPluginJson)) { + reportSchemaErrors( + `${pluginLabel} (${relative(root, pluginJsonPath)})`, + validatePlugin + ); + } + + if (pluginJson.name && pluginJson.name !== entry.name) { + fail( + `${pluginLabel}: marketplace name does not match plugin.json name "${pluginJson.name}"` + ); + } + + // 3. MCP via Copilot mcpServers path (or inline object) + if (mcpServers === undefined) { + fail( + `${pluginLabel}: missing \`mcpServers\` (path to mcp.json or inline servers)` + ); + continue; + } + + let mcpJson = null; + let mcpLabel = `${pluginLabel} mcpServers`; + + if (typeof mcpServers === "string") { + if (!isValidRelativePath(mcpServers)) { + fail( + `${pluginLabel}: \`mcpServers\` path must start with "./" — got "${mcpServers}"` + ); + continue; + } + const mcpPath = resolve(pluginDir, mcpServers.slice(2)); + mcpLabel = `${pluginLabel} (${relative(root, mcpPath)})`; + mcpJson = loadJSONSafe(mcpPath, mcpLabel, fail); + } else if (mcpServers && typeof mcpServers === "object") { + mcpJson = { mcpServers }; + // Inline Copilot shape lacks OPS $schema; wrap only the servers object + // Validate by synthesizing a minimal OPS document when possible. + if (!("$schema" in mcpServers) && !("mcpServers" in mcpServers)) { + mcpJson = { + $schema: "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + mcpServers, + }; + } else { + mcpJson = mcpServers; + } + } else { + fail(`${pluginLabel}: \`mcpServers\` must be a path string or object`); + continue; + } + + if (!mcpJson) continue; + + if (!validateMcp(mcpJson)) { + reportSchemaErrors(mcpLabel, validateMcp); + } +} + +if (errors > 0) { + console.error(`\nCopilot validation failed with ${errors} error(s).`); + process.exit(1); +} + +console.log("Copilot validation passed."); +process.exit(0); From 336e6093a9f062bf038ddee95349b3c5aa4ceceb Mon Sep 17 00:00:00 2001 From: Michael Dougall <6801309+itsdouges@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:45:50 +1000 Subject: [PATCH 10/10] chore: remove bun test --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 73cb039..b0b1510 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,8 @@ "type": "module", "private": true, "scripts": { - "build": "bun ./scripts/validate-cursor-schema.mjs && bun ./scripts/validate-cursor-structure.mjs && bun ./scripts/validate-codex.mjs && bun ./scripts/validate-copilot.mjs && bun test", - "test": "bun test" + "build": "bun ./scripts/validate-cursor-schema.mjs && bun ./scripts/validate-cursor-structure.mjs && bun ./scripts/validate-codex.mjs && bun ./scripts/validate-copilot.mjs", + "test": "bun run build" }, "devDependencies": { "@types/bun": "latest",