From 9cd302061114fdd164cba7b2e90c9cf4f30a98e2 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 24 Apr 2026 17:06:16 -0700 Subject: [PATCH 01/37] feat(mcp): read element context from the OS clipboard Drop the localhost HTTP channel in favor of the existing `application/x-react-grab` clipboard MIME type. The browser plugin no longer makes any network requests, and the MCP server reads the payload directly via OS-native helpers (osascript/JXA on macOS, wl-paste/xclip on Linux, PowerShell -Sta on Windows, with a WSL bridge to the Windows host that falls back to WSLg). - Remove the POST /context and /health HTTP endpoints, the @react-grab/mcp/client export, and the browser IIFE bundle - Drop the react-grab and fkill workspace deps from @react-grab/mcp - The `react-grab-mcp` CLI now always runs over stdio - Add a /skills/react-grab skill telling agents to call the MCP tool when the user references a grabbed element - Drop the now-pointless --stdio flag from the CLI installer (existing configs keep working since the flag is silently ignored) --- packages/cli/src/utils/install-mcp.ts | 4 +- packages/cli/test/install-mcp.test.ts | 4 +- packages/mcp/package.json | 16 +- packages/mcp/src/cli.ts | 5 +- packages/mcp/src/client.ts | 98 ------- packages/mcp/src/constants.ts | 5 +- packages/mcp/src/server.ts | 252 +++--------------- .../mcp/src/utils/detect-clipboard-env.ts | 26 ++ packages/mcp/src/utils/has-error-code.ts | 2 + .../mcp/src/utils/parse-react-grab-payload.ts | 43 +++ .../mcp/src/utils/read-clipboard-linux.ts | 63 +++++ .../mcp/src/utils/read-clipboard-macos.ts | 26 ++ .../mcp/src/utils/read-clipboard-outcome.ts | 4 + .../mcp/src/utils/read-clipboard-payload.ts | 48 ++++ .../mcp/src/utils/read-clipboard-windows.ts | 53 ++++ packages/mcp/src/utils/read-clipboard-wsl.ts | 17 ++ packages/mcp/src/utils/run-exec-file.ts | 29 ++ packages/mcp/src/utils/surface-stderr.ts | 16 ++ .../mcp/test/detect-clipboard-env.test.ts | 80 ++++++ packages/mcp/test/has-error-code.test.ts | 27 ++ packages/mcp/test/helpers/mock-exec-file.ts | 41 +++ .../mcp/test/parse-react-grab-payload.test.ts | 49 ++++ .../mcp/test/read-clipboard-linux.test.ts | 70 +++++ .../mcp/test/read-clipboard-macos.test.ts | 48 ++++ .../mcp/test/read-clipboard-payload.test.ts | 119 +++++++++ .../mcp/test/read-clipboard-windows.test.ts | 46 ++++ packages/mcp/test/read-clipboard-wsl.test.ts | 68 +++++ packages/mcp/test/run-exec-file.test.ts | 42 +++ packages/mcp/test/server.test.ts | 116 ++++++++ packages/mcp/vite.config.ts | 52 ++-- packages/react-grab/docs/architecture.md | 2 +- pnpm-lock.yaml | 150 ----------- skills/react-grab/SKILL.md | 35 +++ 33 files changed, 1136 insertions(+), 520 deletions(-) delete mode 100644 packages/mcp/src/client.ts create mode 100644 packages/mcp/src/utils/detect-clipboard-env.ts create mode 100644 packages/mcp/src/utils/has-error-code.ts create mode 100644 packages/mcp/src/utils/parse-react-grab-payload.ts create mode 100644 packages/mcp/src/utils/read-clipboard-linux.ts create mode 100644 packages/mcp/src/utils/read-clipboard-macos.ts create mode 100644 packages/mcp/src/utils/read-clipboard-outcome.ts create mode 100644 packages/mcp/src/utils/read-clipboard-payload.ts create mode 100644 packages/mcp/src/utils/read-clipboard-windows.ts create mode 100644 packages/mcp/src/utils/read-clipboard-wsl.ts create mode 100644 packages/mcp/src/utils/run-exec-file.ts create mode 100644 packages/mcp/src/utils/surface-stderr.ts create mode 100644 packages/mcp/test/detect-clipboard-env.test.ts create mode 100644 packages/mcp/test/has-error-code.test.ts create mode 100644 packages/mcp/test/helpers/mock-exec-file.ts create mode 100644 packages/mcp/test/parse-react-grab-payload.test.ts create mode 100644 packages/mcp/test/read-clipboard-linux.test.ts create mode 100644 packages/mcp/test/read-clipboard-macos.test.ts create mode 100644 packages/mcp/test/read-clipboard-payload.test.ts create mode 100644 packages/mcp/test/read-clipboard-windows.test.ts create mode 100644 packages/mcp/test/read-clipboard-wsl.test.ts create mode 100644 packages/mcp/test/run-exec-file.test.ts create mode 100644 packages/mcp/test/server.test.ts create mode 100644 skills/react-grab/SKILL.md diff --git a/packages/cli/src/utils/install-mcp.ts b/packages/cli/src/utils/install-mcp.ts index ed9d9e591..ab68a1fd7 100644 --- a/packages/cli/src/utils/install-mcp.ts +++ b/packages/cli/src/utils/install-mcp.ts @@ -64,7 +64,7 @@ const getClients = (): ClientDefinition[] => { const stdioConfig = { command: "npx", - args: ["-y", PACKAGE_NAME, "--stdio"], + args: ["-y", PACKAGE_NAME], }; return [ @@ -96,7 +96,7 @@ const getClients = (): ClientDefinition[] => { format: "json", serverConfig: { type: "local", - command: ["npx", "-y", PACKAGE_NAME, "--stdio"], + command: ["npx", "-y", PACKAGE_NAME], }, }, { diff --git a/packages/cli/test/install-mcp.test.ts b/packages/cli/test/install-mcp.test.ts index 8f9500979..5afefa01a 100644 --- a/packages/cli/test/install-mcp.test.ts +++ b/packages/cli/test/install-mcp.test.ts @@ -26,7 +26,7 @@ const makeJsonClient = (overrides: Partial = {}): ClientDefini configPath: path.join(tempDir, "config.json"), configKey: "mcpServers", format: "json", - serverConfig: { command: "npx", args: ["-y", "@react-grab/mcp", "--stdio"] }, + serverConfig: { command: "npx", args: ["-y", "@react-grab/mcp"] }, ...overrides, }); @@ -35,7 +35,7 @@ const makeTomlClient = (overrides: Partial = {}): ClientDefini configPath: path.join(tempDir, "config.toml"), configKey: "mcp_servers", format: "toml", - serverConfig: { command: "npx", args: ["-y", "@react-grab/mcp", "--stdio"] }, + serverConfig: { command: "npx", args: ["-y", "@react-grab/mcp"] }, ...overrides, }); diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 3132c9d11..db5cc9d1f 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -8,24 +8,18 @@ "dist" ], "type": "module", - "browser": "dist/client.global.js", "exports": { - "./client": { - "types": "./dist/client.d.ts", - "import": "./dist/client.js", - "require": "./dist/client.cjs" - }, "./server": { "types": "./dist/server.d.ts", "import": "./dist/server.js", "require": "./dist/server.cjs" - }, - "./dist/*": "./dist/*.js", - "./dist/*.js": "./dist/*.js" + } }, "scripts": { "dev": "vp pack --watch", - "build": "rm -rf dist && NODE_ENV=production vp pack && cp dist/client.iife.js dist/client.global.js", + "build": "rm -rf dist && NODE_ENV=production vp pack", + "test": "vp test run", + "test:watch": "vp test", "lint": "vp lint", "format": "vp fmt", "format:check": "vp fmt --check", @@ -33,8 +27,6 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.25.0", - "fkill": "^9.0.0", - "react-grab": "workspace:*", "zod": "^3.25.0" }, "devDependencies": { diff --git a/packages/mcp/src/cli.ts b/packages/mcp/src/cli.ts index e85927513..ee65d721a 100644 --- a/packages/mcp/src/cli.ts +++ b/packages/mcp/src/cli.ts @@ -1,7 +1,4 @@ #!/usr/bin/env node import { startMcpServer } from "./server.js"; -startMcpServer({ - port: Number(process.env.PORT) || undefined, - stdio: process.argv.includes("--stdio"), -}); +startMcpServer(); diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts deleted file mode 100644 index 35a01e7fb..000000000 --- a/packages/mcp/src/client.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { init, ReactGrabAPI, Plugin, AgentContext } from "react-grab/core"; -import { DEFAULT_MCP_PORT, HEALTH_CHECK_TIMEOUT_MS } from "./constants.js"; - -interface McpPluginOptions { - port?: number; -} - -const sendContextToServer = async ( - contextUrl: string, - content: string[], - prompt?: string, -): Promise => { - await fetch(contextUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content, prompt }), - }).catch(() => {}); -}; - -export const createMcpPlugin = (options: McpPluginOptions = {}): Plugin => { - const port = options.port ?? DEFAULT_MCP_PORT; - const contextUrl = `http://localhost:${port}/context`; - - return { - name: "mcp", - hooks: { - onCopySuccess: (_elements: Element[], content: string) => { - void sendContextToServer(contextUrl, [content]); - }, - transformAgentContext: async (context: AgentContext): Promise => { - await sendContextToServer(contextUrl, context.content, context.prompt); - return context; - }, - }, - }; -}; - -const isReactGrabApi = (value: unknown): value is ReactGrabAPI => - typeof value === "object" && value !== null && "registerPlugin" in value; - -declare global { - interface Window { - __REACT_GRAB__?: ReturnType; - } -} - -const MCP_REACHABLE_KEY = "react-grab-mcp-reachable"; - -const checkIfMcpServerIsReachable = async (port: number): Promise => { - const cached = sessionStorage.getItem(MCP_REACHABLE_KEY); - if (cached !== null) return cached === "true"; - - const isReachable = await fetch(`http://localhost:${port}/health`, { - signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS), - }) - .then((response) => response.ok) - .catch(() => false); - - sessionStorage.setItem(MCP_REACHABLE_KEY, String(isReachable)); - return isReachable; -}; - -export const attachMcpPlugin = async (): Promise => { - if (typeof window === "undefined") return; - - const isReachable = await checkIfMcpServerIsReachable(DEFAULT_MCP_PORT); - if (!isReachable) return; - - const plugin = createMcpPlugin(); - - const attach = (api: ReactGrabAPI) => { - api.registerPlugin(plugin); - }; - - const existingApi = window.__REACT_GRAB__; - if (isReactGrabApi(existingApi)) { - attach(existingApi); - return; - } - - window.addEventListener( - "react-grab:init", - (event: Event) => { - if (!(event instanceof CustomEvent)) return; - if (!isReactGrabApi(event.detail)) return; - attach(event.detail); - }, - { once: true }, - ); - - // HACK: Check again after adding listener in case of race condition - const apiAfterListener = window.__REACT_GRAB__; - if (isReactGrabApi(apiAfterListener)) { - attach(apiAfterListener); - } -}; - -attachMcpPlugin(); diff --git a/packages/mcp/src/constants.ts b/packages/mcp/src/constants.ts index 0f42beb49..6d4f70821 100644 --- a/packages/mcp/src/constants.ts +++ b/packages/mcp/src/constants.ts @@ -1,4 +1,3 @@ export const CONTEXT_TTL_MS = 5 * 60 * 1000; -export const DEFAULT_MCP_PORT = 4723; -export const HEALTH_CHECK_TIMEOUT_MS = 1000; -export const POST_KILL_DELAY_MS = 100; +export const CLIPBOARD_READ_TIMEOUT_MS = 3000; +export const REACT_GRAB_MIME_TYPE = "application/x-react-grab"; diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index ebabf3feb..8fe7a82f1 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -1,47 +1,50 @@ -import { randomUUID } from "node:crypto"; -import { createServer, type Server } from "node:http"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import fkill from "fkill"; -import { z } from "zod"; +import { CONTEXT_TTL_MS } from "./constants.js"; import { - CONTEXT_TTL_MS, - DEFAULT_MCP_PORT, - HEALTH_CHECK_TIMEOUT_MS, - POST_KILL_DELAY_MS, -} from "./constants.js"; + readClipboardPayload, + type ReadClipboardPayloadResult, +} from "./utils/read-clipboard-payload.js"; +import type { ReactGrabPayload } from "./utils/parse-react-grab-payload.js"; -const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); +interface TextToolResult { + content: { type: "text"; text: string }[]; +} -const agentContextSchema = z.object({ - content: z.array(z.string()).describe("Array of context strings (HTML + component stack traces)"), - prompt: z.string().optional().describe("User prompt or instruction"), +const textResult = (text: string): TextToolResult => ({ + content: [{ type: "text", text }], }); -type AgentContext = z.infer; +const formatPayload = (payload: ReactGrabPayload): string => { + const promptLines = payload.entries + .map((entry) => entry.commentText) + .filter((commentText): commentText is string => Boolean(commentText)); + const elementsSection = `Elements (${payload.entries.length}):\n${payload.content}`; + return promptLines.length > 0 + ? `Prompt: ${promptLines.join("\n")}\n\n${elementsSection}` + : elementsSection; +}; -interface StoredContext { - context: AgentContext; - submittedAt: number; -} +const formatNoContextMessage = (result: ReadClipboardPayloadResult): string => { + const baseMessage = + "No React Grab context found on the clipboard. Click an element in the React Grab toolbar and try again."; + return result.hint ? `${baseMessage}\n\n${result.hint}` : baseMessage; +}; -let latestContext: StoredContext | null = null; +const isPayloadExpired = (payload: ReactGrabPayload): boolean => + Date.now() - payload.timestamp > CONTEXT_TTL_MS; -const textResult = (text: string) => ({ - content: [{ type: "text" as const, text }], -}); +export const handleGetElementContext = async (): Promise => { + const result = await readClipboardPayload(); -const formatContext = (context: AgentContext): string => { - const parts: string[] = []; - if (context.prompt) { - parts.push(`Prompt: ${context.prompt}`); + if (!result.payload || isPayloadExpired(result.payload)) { + return textResult(formatNoContextMessage(result)); } - parts.push(`Elements:\n${context.content.join("\n\n")}`); - return parts.join("\n\n"); + + return textResult(formatPayload(result.payload)); }; -const createMcpServer = (): McpServer => { +export const createMcpServer = (): McpServer => { const server = new McpServer( { name: "react-grab-mcp", version: "0.1.0" }, { capabilities: { logging: {} } }, @@ -51,193 +54,16 @@ const createMcpServer = (): McpServer => { "get_element_context", { description: - "Get the latest React Grab context that was submitted. Returns the most recent UI element selection with its prompt.", - }, - async () => { - if (!latestContext) { - return textResult("No context has been submitted yet."); - } - - const isExpired = Date.now() - latestContext.submittedAt > CONTEXT_TTL_MS; - if (isExpired) { - latestContext = null; - return textResult("No context has been submitted yet."); - } - - const result = textResult(formatContext(latestContext.context)); - latestContext = null; - return result; + "Get the latest React Grab context from the user's clipboard. Returns the most recent UI element selection (with prompt, HTML snippet, and entries) that was copied via the React Grab toolbar.", }, + handleGetElementContext, ); return server; }; -const checkIfServerIsRunning = async (port: number): Promise => { - try { - const response = await fetch(`http://localhost:${port}/health`, { - signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS), - }); - return response.ok; - } catch { - return false; - } -}; - -interface McpSession { - server: McpServer; - transport: StreamableHTTPServerTransport; -} - -const sessions = new Map(); - -const createHttpServer = (port: number): Server => { - return createServer(async (request, response) => { - const url = new URL(request.url ?? "/", `http://localhost:${port}`); - - response.setHeader("Access-Control-Allow-Origin", "*"); - response.setHeader("Access-Control-Allow-Methods", "POST, GET, DELETE, OPTIONS"); - response.setHeader("Access-Control-Allow-Headers", "Content-Type, mcp-session-id"); - response.setHeader("Access-Control-Expose-Headers", "mcp-session-id"); - - if (request.method === "OPTIONS") { - response.writeHead(204).end(); - return; - } - - if (url.pathname === "/health") { - response - .writeHead(200, { "Content-Type": "application/json" }) - .end(JSON.stringify({ status: "ok" })); - return; - } - - if (url.pathname === "/context" && request.method === "POST") { - const chunks: Buffer[] = []; - for await (const chunk of request) { - chunks.push(chunk as Buffer); - } - - try { - const body = JSON.parse(Buffer.concat(chunks).toString()); - latestContext = { - context: agentContextSchema.parse(body), - submittedAt: Date.now(), - }; - response - .writeHead(200, { "Content-Type": "application/json" }) - .end(JSON.stringify({ status: "ok" })); - } catch { - response - .writeHead(400, { "Content-Type": "application/json" }) - .end(JSON.stringify({ error: "Invalid context payload" })); - } - return; - } - - if (url.pathname === "/mcp") { - const sessionId = request.headers["mcp-session-id"] as string | undefined; - const existingSession = sessionId ? sessions.get(sessionId) : undefined; - - if (existingSession) { - await existingSession.transport.handleRequest(request, response); - return; - } - - if (request.method === "POST") { - const mcpServer = createMcpServer(); - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - }); - - transport.onclose = () => { - if (transport.sessionId) { - sessions.delete(transport.sessionId); - } - }; - - await mcpServer.server.connect(transport); - await transport.handleRequest(request, response); - - if (transport.sessionId) { - sessions.set(transport.sessionId, { server: mcpServer, transport }); - } - return; - } - - response.writeHead(400, { "Content-Type": "application/json" }).end( - JSON.stringify({ - error: "No valid session. Send an initialize request first.", - }), - ); - return; - } - - response.writeHead(404).end("Not found"); - }); -}; - -const listenWithRetry = (httpServer: Server, port: number): Promise => - new Promise((resolve, reject) => { - httpServer.once("error", async (error: NodeJS.ErrnoException) => { - if (error.code !== "EADDRINUSE") { - reject(error); - return; - } - - await fkill(`:${port}`, { force: true, silent: true }).catch(() => {}); - await sleep(POST_KILL_DELAY_MS); - - httpServer.once("error", reject); - httpServer.listen(port, () => resolve()); - }); - - httpServer.listen(port, "127.0.0.1", () => resolve()); - }); - -const startHttpServer = async (port: number): Promise => { - const isAlreadyRunning = await checkIfServerIsRunning(port); - - if (!isAlreadyRunning) { - await fkill(`:${port}`, { force: true, silent: true }).catch(() => {}); - await sleep(POST_KILL_DELAY_MS); - } - - const httpServer = createHttpServer(port); - await listenWithRetry(httpServer, port); - - const handleShutdown = () => { - httpServer.close(); - process.exit(0); - }; - - process.on("SIGTERM", handleShutdown); - process.on("SIGINT", handleShutdown); - - return httpServer; -}; - -interface StartMcpServerOptions { - port?: number; - stdio?: boolean; -} - -export const startMcpServer = async ({ - port = DEFAULT_MCP_PORT, - stdio = false, -}: StartMcpServerOptions = {}): Promise => { - if (stdio) { - const mcpServer = createMcpServer(); - const transport = new StdioServerTransport(); - await mcpServer.server.connect(transport); - - startHttpServer(port).then( - () => console.error(`React Grab context server listening on port ${port}`), - (error) => console.error(`Failed to start context server: ${error}`), - ); - return; - } - - await startHttpServer(port); - console.log(`React Grab MCP server listening on http://localhost:${port}/mcp`); +export const startMcpServer = async (): Promise => { + const mcpServer = createMcpServer(); + const transport = new StdioServerTransport(); + await mcpServer.server.connect(transport); }; diff --git a/packages/mcp/src/utils/detect-clipboard-env.ts b/packages/mcp/src/utils/detect-clipboard-env.ts new file mode 100644 index 000000000..d4a6e210e --- /dev/null +++ b/packages/mcp/src/utils/detect-clipboard-env.ts @@ -0,0 +1,26 @@ +import { readFileSync } from "node:fs"; + +export type ClipboardEnv = "ssh" | "wsl" | "macos" | "windows" | "linux"; + +const isInsideSshSession = (): boolean => + Boolean(process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION); + +const isInsideWsl = (): boolean => { + if (process.env.WSL_DISTRO_NAME) return true; + if (process.platform !== "linux") return false; + + try { + const procVersionContents = readFileSync("/proc/version", "utf8"); + return /microsoft/i.test(procVersionContents); + } catch { + return false; + } +}; + +export const detectClipboardEnv = (): ClipboardEnv => { + if (isInsideSshSession()) return "ssh"; + if (isInsideWsl()) return "wsl"; + if (process.platform === "darwin") return "macos"; + if (process.platform === "win32") return "windows"; + return "linux"; +}; diff --git a/packages/mcp/src/utils/has-error-code.ts b/packages/mcp/src/utils/has-error-code.ts new file mode 100644 index 000000000..c404962a6 --- /dev/null +++ b/packages/mcp/src/utils/has-error-code.ts @@ -0,0 +1,2 @@ +export const hasErrorCode = (caughtError: unknown, expectedCode: string): boolean => + caughtError instanceof Error && "code" in caughtError && caughtError.code === expectedCode; diff --git a/packages/mcp/src/utils/parse-react-grab-payload.ts b/packages/mcp/src/utils/parse-react-grab-payload.ts new file mode 100644 index 000000000..7431f515d --- /dev/null +++ b/packages/mcp/src/utils/parse-react-grab-payload.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +export interface ReactGrabPayloadEntry { + tagName?: string; + componentName?: string; + content: string; + commentText?: string; +} + +export interface ReactGrabPayload { + version: string; + content: string; + entries: ReactGrabPayloadEntry[]; + timestamp: number; +} + +const reactGrabEntrySchema: z.ZodType = z.object({ + tagName: z.string().optional(), + componentName: z.string().optional(), + content: z.string(), + commentText: z.string().optional(), +}); + +const reactGrabPayloadSchema: z.ZodType = z.object({ + version: z.string(), + content: z.string(), + entries: z.array(reactGrabEntrySchema), + timestamp: z.number(), +}); + +export const parseReactGrabPayload = (raw: string | null): ReactGrabPayload | null => { + if (!raw) return null; + + let parsedJson: unknown; + try { + parsedJson = JSON.parse(raw); + } catch { + return null; + } + + const validation = reactGrabPayloadSchema.safeParse(parsedJson); + return validation.success ? validation.data : null; +}; diff --git a/packages/mcp/src/utils/read-clipboard-linux.ts b/packages/mcp/src/utils/read-clipboard-linux.ts new file mode 100644 index 000000000..f26645de8 --- /dev/null +++ b/packages/mcp/src/utils/read-clipboard-linux.ts @@ -0,0 +1,63 @@ +import { CLIPBOARD_READ_TIMEOUT_MS, REACT_GRAB_MIME_TYPE } from "../constants.js"; +import { hasErrorCode } from "./has-error-code.js"; +import { runExecFile } from "./run-exec-file.js"; +import { surfaceStderr } from "./surface-stderr.js"; +import type { ClipboardReadOutcome } from "./read-clipboard-outcome.js"; + +const INSTALL_HINT = + "Install a custom-MIME clipboard reader: `apt install xclip` (X11) or `apt install wl-clipboard` (Wayland)."; + +interface PlatformReadResult { + stdout?: string; + error?: unknown; +} + +const tryRead = async (binary: string, binaryArgs: string[]): Promise => { + try { + const { stdout, stderr } = await runExecFile(binary, binaryArgs, { + timeout: CLIPBOARD_READ_TIMEOUT_MS, + maxBuffer: 4 * 1024 * 1024, + }); + surfaceStderr(binary, stderr); + return { stdout }; + } catch (caughtError) { + surfaceStderr(binary, caughtError); + return { error: caughtError }; + } +}; + +const isBinaryMissing = (caughtError: unknown): boolean => + hasErrorCode(caughtError, "ENOENT") || + (caughtError instanceof Error && /not found/i.test(caughtError.message)); + +const trimToPayload = (stdout: string): string | null => { + const trimmed = stdout.trimEnd(); + return trimmed.length > 0 ? trimmed : null; +}; + +export const readClipboardLinux = async (): Promise => { + if (process.env.WAYLAND_DISPLAY) { + const waylandResult = await tryRead("wl-paste", ["-t", REACT_GRAB_MIME_TYPE, "-n"]); + if (waylandResult.stdout !== undefined) { + return { payload: trimToPayload(waylandResult.stdout) }; + } + if (!isBinaryMissing(waylandResult.error)) { + return { payload: null }; + } + } + + const x11Result = await tryRead("xclip", [ + "-selection", + "clipboard", + "-t", + REACT_GRAB_MIME_TYPE, + "-o", + ]); + if (x11Result.stdout !== undefined) { + return { payload: trimToPayload(x11Result.stdout) }; + } + if (isBinaryMissing(x11Result.error)) { + return { payload: null, hint: INSTALL_HINT }; + } + return { payload: null }; +}; diff --git a/packages/mcp/src/utils/read-clipboard-macos.ts b/packages/mcp/src/utils/read-clipboard-macos.ts new file mode 100644 index 000000000..5f9806ed9 --- /dev/null +++ b/packages/mcp/src/utils/read-clipboard-macos.ts @@ -0,0 +1,26 @@ +import { CLIPBOARD_READ_TIMEOUT_MS, REACT_GRAB_MIME_TYPE } from "../constants.js"; +import { hasErrorCode } from "./has-error-code.js"; +import { runExecFile } from "./run-exec-file.js"; +import { surfaceStderr } from "./surface-stderr.js"; +import type { ClipboardReadOutcome } from "./read-clipboard-outcome.js"; + +const JXA_SCRIPT = `(function(){ObjC.import('AppKit');var pasteboard=$.NSPasteboard.generalPasteboard;var data=pasteboard.dataForType('${REACT_GRAB_MIME_TYPE}');if(data.isNil())return '';var decoded=$.NSString.alloc.initWithDataEncoding(data,$.NSUTF8StringEncoding);return ObjC.unwrap(decoded);})()`; + +export const readClipboardMacos = async (): Promise => { + try { + const { stdout, stderr } = await runExecFile( + "osascript", + ["-l", "JavaScript", "-e", JXA_SCRIPT], + { timeout: CLIPBOARD_READ_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 }, + ); + surfaceStderr("osascript", stderr); + const trimmed = stdout.trimEnd(); + return { payload: trimmed.length > 0 ? trimmed : null }; + } catch (caughtError) { + surfaceStderr("osascript", caughtError); + if (hasErrorCode(caughtError, "ENOENT")) { + return { payload: null, hint: "macOS requires `osascript` (preinstalled). Check $PATH." }; + } + return { payload: null }; + } +}; diff --git a/packages/mcp/src/utils/read-clipboard-outcome.ts b/packages/mcp/src/utils/read-clipboard-outcome.ts new file mode 100644 index 000000000..9b7b8da12 --- /dev/null +++ b/packages/mcp/src/utils/read-clipboard-outcome.ts @@ -0,0 +1,4 @@ +export interface ClipboardReadOutcome { + payload: string | null; + hint?: string; +} diff --git a/packages/mcp/src/utils/read-clipboard-payload.ts b/packages/mcp/src/utils/read-clipboard-payload.ts new file mode 100644 index 000000000..ba904ddca --- /dev/null +++ b/packages/mcp/src/utils/read-clipboard-payload.ts @@ -0,0 +1,48 @@ +import { detectClipboardEnv, type ClipboardEnv } from "./detect-clipboard-env.js"; +import { readClipboardMacos } from "./read-clipboard-macos.js"; +import { readClipboardLinux } from "./read-clipboard-linux.js"; +import { readClipboardWindows } from "./read-clipboard-windows.js"; +import { readClipboardWsl } from "./read-clipboard-wsl.js"; +import { parseReactGrabPayload, type ReactGrabPayload } from "./parse-react-grab-payload.js"; +import type { ClipboardReadOutcome } from "./read-clipboard-outcome.js"; + +export interface ReadClipboardPayloadResult { + payload: ReactGrabPayload | null; + env: ClipboardEnv; + hint?: string; +} + +const readRawByEnv = async (env: ClipboardEnv): Promise => { + switch (env) { + case "macos": + return readClipboardMacos(); + case "linux": + return readClipboardLinux(); + case "windows": + return readClipboardWindows(); + case "wsl": + return readClipboardWsl(); + case "ssh": + return { + payload: null, + hint: "Clipboard channel is unavailable in SSH sessions. Run `react-grab-mcp` on the same machine as your browser.", + }; + default: { + const exhaustiveCheck: never = env; + return { + payload: null, + hint: `Unsupported clipboard environment: ${String(exhaustiveCheck)}`, + }; + } + } +}; + +export const readClipboardPayload = async (): Promise => { + const env = detectClipboardEnv(); + const outcome = await readRawByEnv(env); + return { + env, + payload: parseReactGrabPayload(outcome.payload), + hint: outcome.hint, + }; +}; diff --git a/packages/mcp/src/utils/read-clipboard-windows.ts b/packages/mcp/src/utils/read-clipboard-windows.ts new file mode 100644 index 000000000..5088dc666 --- /dev/null +++ b/packages/mcp/src/utils/read-clipboard-windows.ts @@ -0,0 +1,53 @@ +import { CLIPBOARD_READ_TIMEOUT_MS, REACT_GRAB_MIME_TYPE } from "../constants.js"; +import { hasErrorCode } from "./has-error-code.js"; +import { runExecFile } from "./run-exec-file.js"; +import { surfaceStderr } from "./surface-stderr.js"; +import type { ClipboardReadOutcome } from "./read-clipboard-outcome.js"; + +const POWERSHELL_SCRIPT = ` +$ErrorActionPreference='Stop' +try { + Add-Type -AssemblyName System.Windows.Forms + $data = [System.Windows.Forms.Clipboard]::GetData('${REACT_GRAB_MIME_TYPE}') + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + if ($null -eq $data) { + [Console]::Out.Write('') + } elseif ($data -is [byte[]]) { + [Console]::Out.Write([System.Text.Encoding]::UTF8.GetString($data)) + } else { + [Console]::Out.Write($data.ToString()) + } +} catch { + [Console]::Error.WriteLine($_.Exception.Message) + exit 1 +} +`; + +const ENCODED_POWERSHELL_COMMAND = Buffer.from(POWERSHELL_SCRIPT, "utf16le").toString("base64"); + +export const readClipboardViaWindowsPowerShell = async ( + binary: string, +): Promise => { + try { + const { stdout, stderr } = await runExecFile( + binary, + ["-NoProfile", "-NonInteractive", "-Sta", "-EncodedCommand", ENCODED_POWERSHELL_COMMAND], + { timeout: CLIPBOARD_READ_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 }, + ); + surfaceStderr(binary, stderr); + const trimmed = stdout.trimEnd(); + return { payload: trimmed.length > 0 ? trimmed : null }; + } catch (caughtError) { + surfaceStderr(binary, caughtError); + if (hasErrorCode(caughtError, "ENOENT")) { + return { + payload: null, + hint: `Cannot launch ${binary}. Ensure Windows PowerShell is on PATH.`, + }; + } + return { payload: null }; + } +}; + +export const readClipboardWindows = (): Promise => + readClipboardViaWindowsPowerShell("powershell.exe"); diff --git a/packages/mcp/src/utils/read-clipboard-wsl.ts b/packages/mcp/src/utils/read-clipboard-wsl.ts new file mode 100644 index 000000000..b495ce52c --- /dev/null +++ b/packages/mcp/src/utils/read-clipboard-wsl.ts @@ -0,0 +1,17 @@ +import { readClipboardLinux } from "./read-clipboard-linux.js"; +import { readClipboardViaWindowsPowerShell } from "./read-clipboard-windows.js"; +import type { ClipboardReadOutcome } from "./read-clipboard-outcome.js"; + +const WSL_INTEROP_HINT = + "Could not reach the Windows clipboard from WSL. Enable WSL interop (set `enabled = true` under `[interop]` in `/etc/wsl.conf`) or run `react-grab-mcp` on the Windows host."; + +export const readClipboardWsl = async (): Promise => { + const hostOutcome = await readClipboardViaWindowsPowerShell("powershell.exe"); + if (hostOutcome.payload !== null) return hostOutcome; + + const wslgOutcome = await readClipboardLinux(); + if (wslgOutcome.payload !== null) return wslgOutcome; + + if (hostOutcome.hint) return { payload: null, hint: WSL_INTEROP_HINT }; + return wslgOutcome; +}; diff --git a/packages/mcp/src/utils/run-exec-file.ts b/packages/mcp/src/utils/run-exec-file.ts new file mode 100644 index 000000000..21a6eb023 --- /dev/null +++ b/packages/mcp/src/utils/run-exec-file.ts @@ -0,0 +1,29 @@ +import { execFile, type ExecFileOptions } from "node:child_process"; + +interface ExecFileSuccess { + stdout: string; + stderr: string; +} + +export interface ExecFileFailure extends Error { + stdout?: string; + stderr?: string; +} + +export const runExecFile = ( + file: string, + args: string[], + options: ExecFileOptions, +): Promise => + new Promise((resolve, reject) => { + execFile(file, args, { ...options, encoding: "utf8" }, (error, stdout, stderr) => { + if (error) { + const enriched: ExecFileFailure = error; + enriched.stdout = String(stdout ?? ""); + enriched.stderr = String(stderr ?? ""); + reject(enriched); + return; + } + resolve({ stdout: String(stdout), stderr: String(stderr) }); + }); + }); diff --git a/packages/mcp/src/utils/surface-stderr.ts b/packages/mcp/src/utils/surface-stderr.ts new file mode 100644 index 000000000..4eb69e335 --- /dev/null +++ b/packages/mcp/src/utils/surface-stderr.ts @@ -0,0 +1,16 @@ +const extractStderr = (source: unknown): string | undefined => { + if (typeof source === "string") return source; + if (source instanceof Error && "stderr" in source && typeof source.stderr === "string") { + return source.stderr; + } + return undefined; +}; + +export const surfaceStderr = (binary: string, source: unknown): void => { + const stderr = extractStderr(source); + if (!stderr) return; + const trimmed = stderr.trim(); + if (trimmed.length > 0) { + console.error(`[react-grab-mcp] ${binary} stderr: ${trimmed}`); + } +}; diff --git a/packages/mcp/test/detect-clipboard-env.test.ts b/packages/mcp/test/detect-clipboard-env.test.ts new file mode 100644 index 000000000..26c4c6de4 --- /dev/null +++ b/packages/mcp/test/detect-clipboard-env.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("node:fs", () => ({ + readFileSync: vi.fn(), +})); + +import { readFileSync } from "node:fs"; +import { detectClipboardEnv } from "../src/utils/detect-clipboard-env.js"; + +const mockReadFileSync = vi.mocked(readFileSync); +const originalEnv = { ...process.env }; +const originalPlatform = process.platform; + +const SSH_ENV_KEYS = ["SSH_CLIENT", "SSH_TTY", "SSH_CONNECTION", "WSL_DISTRO_NAME"] as const; + +const setPlatform = (platform: NodeJS.Platform): void => { + Object.defineProperty(process, "platform", { value: platform, configurable: true }); +}; + +beforeEach(() => { + vi.clearAllMocks(); + for (const key of SSH_ENV_KEYS) delete process.env[key]; + mockReadFileSync.mockImplementation(() => { + throw new Error("not mocked"); + }); +}); + +afterEach(() => { + process.env = { ...originalEnv }; + setPlatform(originalPlatform); +}); + +describe("detectClipboardEnv", () => { + it("detects SSH from SSH_CLIENT", () => { + process.env.SSH_CLIENT = "1.2.3.4 5678 22"; + setPlatform("linux"); + expect(detectClipboardEnv()).toBe("ssh"); + }); + + it("detects SSH from SSH_TTY", () => { + process.env.SSH_TTY = "/dev/pts/0"; + setPlatform("darwin"); + expect(detectClipboardEnv()).toBe("ssh"); + }); + + it("detects WSL from WSL_DISTRO_NAME", () => { + process.env.WSL_DISTRO_NAME = "Ubuntu"; + setPlatform("linux"); + expect(detectClipboardEnv()).toBe("wsl"); + }); + + it("detects WSL from /proc/version containing microsoft", () => { + setPlatform("linux"); + mockReadFileSync.mockReturnValue("Linux version 5.15.0-microsoft-standard"); + expect(detectClipboardEnv()).toBe("wsl"); + }); + + it("returns macos on darwin", () => { + setPlatform("darwin"); + expect(detectClipboardEnv()).toBe("macos"); + }); + + it("returns windows on win32", () => { + setPlatform("win32"); + expect(detectClipboardEnv()).toBe("windows"); + }); + + it("returns linux on plain linux", () => { + setPlatform("linux"); + mockReadFileSync.mockReturnValue("Linux version 6.0.0-generic"); + expect(detectClipboardEnv()).toBe("linux"); + }); + + it("prefers ssh over wsl when both are set", () => { + process.env.SSH_CLIENT = "1.2.3.4 5678 22"; + process.env.WSL_DISTRO_NAME = "Ubuntu"; + setPlatform("linux"); + expect(detectClipboardEnv()).toBe("ssh"); + }); +}); diff --git a/packages/mcp/test/has-error-code.test.ts b/packages/mcp/test/has-error-code.test.ts new file mode 100644 index 000000000..4dee5f8be --- /dev/null +++ b/packages/mcp/test/has-error-code.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vite-plus/test"; +import { hasErrorCode } from "../src/utils/has-error-code.js"; + +describe("hasErrorCode", () => { + it("returns true when an Error has the matching code", () => { + const error = new Error("boom") as NodeJS.ErrnoException; + error.code = "ENOENT"; + expect(hasErrorCode(error, "ENOENT")).toBe(true); + }); + + it("returns false when the Error has a different code", () => { + const error = new Error("boom") as NodeJS.ErrnoException; + error.code = "EACCES"; + expect(hasErrorCode(error, "ENOENT")).toBe(false); + }); + + it("returns false when the Error has no code", () => { + expect(hasErrorCode(new Error("boom"), "ENOENT")).toBe(false); + }); + + it("returns false for non-Error inputs", () => { + expect(hasErrorCode("ENOENT", "ENOENT")).toBe(false); + expect(hasErrorCode({ code: "ENOENT" }, "ENOENT")).toBe(false); + expect(hasErrorCode(null, "ENOENT")).toBe(false); + expect(hasErrorCode(undefined, "ENOENT")).toBe(false); + }); +}); diff --git a/packages/mcp/test/helpers/mock-exec-file.ts b/packages/mcp/test/helpers/mock-exec-file.ts new file mode 100644 index 000000000..7ede9f802 --- /dev/null +++ b/packages/mcp/test/helpers/mock-exec-file.ts @@ -0,0 +1,41 @@ +import type { Mock } from "vite-plus/test"; + +interface ExecFileResponse { + stdout?: string; + stderr?: string; + error?: NodeJS.ErrnoException; +} + +type ExecFileCallback = ( + error: NodeJS.ErrnoException | null, + stdout: string, + stderr: string, +) => void; + +const invokeCallbackWith = (callback: ExecFileCallback, response: ExecFileResponse): void => { + callback(response.error ?? null, response.stdout ?? "", response.stderr ?? ""); +}; + +const lastArgAsCallback = (mockArgs: unknown[]): ExecFileCallback => + mockArgs[mockArgs.length - 1] as ExecFileCallback; + +export const stubExecFile = (mockExecFile: Mock, response: ExecFileResponse): void => { + mockExecFile.mockImplementation((...mockArgs: unknown[]) => { + invokeCallbackWith(lastArgAsCallback(mockArgs), response); + }); +}; + +export const stubExecFilePerCall = (mockExecFile: Mock, responses: ExecFileResponse[]): void => { + let callIndex = 0; + mockExecFile.mockImplementation((...mockArgs: unknown[]) => { + const response = responses[callIndex] ?? {}; + callIndex += 1; + invokeCallbackWith(lastArgAsCallback(mockArgs), response); + }); +}; + +export const enoentError = (): NodeJS.ErrnoException => { + const error = new Error("ENOENT") as NodeJS.ErrnoException; + error.code = "ENOENT"; + return error; +}; diff --git a/packages/mcp/test/parse-react-grab-payload.test.ts b/packages/mcp/test/parse-react-grab-payload.test.ts new file mode 100644 index 000000000..61c7a2eaa --- /dev/null +++ b/packages/mcp/test/parse-react-grab-payload.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vite-plus/test"; +import { parseReactGrabPayload } from "../src/utils/parse-react-grab-payload.js"; + +describe("parseReactGrabPayload", () => { + it("returns null for null input", () => { + expect(parseReactGrabPayload(null)).toBeNull(); + }); + + it("returns null for empty string", () => { + expect(parseReactGrabPayload("")).toBeNull(); + }); + + it("returns null for malformed JSON", () => { + expect(parseReactGrabPayload("{not json")).toBeNull(); + }); + + it("returns null when required fields are missing", () => { + expect(parseReactGrabPayload(JSON.stringify({ version: "1.0.0" }))).toBeNull(); + }); + + it("parses a well-formed payload", () => { + const payload = { + version: "0.1.32", + content: "", + entries: [ + { + tagName: "button", + componentName: "Button", + content: "", + commentText: "Make this larger", + }, + ], + timestamp: 1700000000000, + }; + + expect(parseReactGrabPayload(JSON.stringify(payload))).toEqual(payload); + }); + + it("accepts entries with only required fields", () => { + const payload = { + version: "0.1.32", + content: "
", + entries: [{ content: "
" }], + timestamp: 1700000000000, + }; + + expect(parseReactGrabPayload(JSON.stringify(payload))?.entries[0].tagName).toBeUndefined(); + }); +}); diff --git a/packages/mcp/test/read-clipboard-linux.test.ts b/packages/mcp/test/read-clipboard-linux.test.ts new file mode 100644 index 000000000..50367355d --- /dev/null +++ b/packages/mcp/test/read-clipboard-linux.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("node:child_process", () => ({ + execFile: vi.fn(), +})); + +import { execFile } from "node:child_process"; +import { readClipboardLinux } from "../src/utils/read-clipboard-linux.js"; +import { enoentError, stubExecFile, stubExecFilePerCall } from "./helpers/mock-exec-file.js"; + +const mockExecFile = vi.mocked(execFile); + +beforeEach(() => { + vi.clearAllMocks(); + delete process.env.WAYLAND_DISPLAY; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("readClipboardLinux", () => { + it("uses xclip when not in Wayland", async () => { + stubExecFile(mockExecFile, { stdout: "clipboard-data" }); + + const result = await readClipboardLinux(); + expect(result.payload).toBe("clipboard-data"); + + const [binary, args] = mockExecFile.mock.calls[0]; + expect(binary).toBe("xclip"); + expect(args).toContain("application/x-react-grab"); + }); + + it("uses wl-paste in Wayland sessions", async () => { + process.env.WAYLAND_DISPLAY = "wayland-0"; + stubExecFile(mockExecFile, { stdout: "payload-from-wayland" }); + + const result = await readClipboardLinux(); + expect(result.payload).toBe("payload-from-wayland"); + + const [binary, args] = mockExecFile.mock.calls[0]; + expect(binary).toBe("wl-paste"); + expect(args).toContain("application/x-react-grab"); + }); + + it("falls back from missing wl-paste to xclip", async () => { + process.env.WAYLAND_DISPLAY = "wayland-0"; + stubExecFilePerCall(mockExecFile, [{ error: enoentError() }, { stdout: "from-xclip" }]); + + const result = await readClipboardLinux(); + expect(result.payload).toBe("from-xclip"); + expect(mockExecFile.mock.calls[0][0]).toBe("wl-paste"); + expect(mockExecFile.mock.calls[1][0]).toBe("xclip"); + }); + + it("returns install hint when xclip is missing", async () => { + stubExecFile(mockExecFile, { error: enoentError() }); + + const result = await readClipboardLinux(); + expect(result.payload).toBeNull(); + expect(result.hint).toContain("xclip"); + }); + + it("returns null payload when stdout is empty", async () => { + stubExecFile(mockExecFile, { stdout: "" }); + + const result = await readClipboardLinux(); + expect(result.payload).toBeNull(); + }); +}); diff --git a/packages/mcp/test/read-clipboard-macos.test.ts b/packages/mcp/test/read-clipboard-macos.test.ts new file mode 100644 index 000000000..63e4988cc --- /dev/null +++ b/packages/mcp/test/read-clipboard-macos.test.ts @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("node:child_process", () => ({ + execFile: vi.fn(), +})); + +import { execFile } from "node:child_process"; +import { readClipboardMacos } from "../src/utils/read-clipboard-macos.js"; +import { stubExecFile } from "./helpers/mock-exec-file.js"; + +const mockExecFile = vi.mocked(execFile); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("readClipboardMacos", () => { + it("invokes osascript with the JXA snippet and returns trimmed stdout", async () => { + stubExecFile(mockExecFile, { stdout: '{"hello":"world"}\n' }); + + const result = await readClipboardMacos(); + expect(result.payload).toBe('{"hello":"world"}'); + + const [binary, args] = mockExecFile.mock.calls[0]; + expect(binary).toBe("osascript"); + expect(args).toContain("-l"); + expect(args).toContain("JavaScript"); + expect(args.find((arg) => arg.includes("application/x-react-grab"))).toBeDefined(); + }); + + it("returns null when stdout is empty", async () => { + stubExecFile(mockExecFile, { stdout: "" }); + + const result = await readClipboardMacos(); + expect(result.payload).toBeNull(); + }); + + it("returns null when osascript fails", async () => { + stubExecFile(mockExecFile, { error: new Error("osascript boom") as NodeJS.ErrnoException }); + + const result = await readClipboardMacos(); + expect(result.payload).toBeNull(); + }); +}); diff --git a/packages/mcp/test/read-clipboard-payload.test.ts b/packages/mcp/test/read-clipboard-payload.test.ts new file mode 100644 index 000000000..848113e5b --- /dev/null +++ b/packages/mcp/test/read-clipboard-payload.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("../src/utils/detect-clipboard-env.js", () => ({ + detectClipboardEnv: vi.fn(), +})); + +vi.mock("../src/utils/read-clipboard-macos.js", () => ({ + readClipboardMacos: vi.fn(), +})); + +vi.mock("../src/utils/read-clipboard-linux.js", () => ({ + readClipboardLinux: vi.fn(), +})); + +vi.mock("../src/utils/read-clipboard-windows.js", () => ({ + readClipboardWindows: vi.fn(), +})); + +vi.mock("../src/utils/read-clipboard-wsl.js", () => ({ + readClipboardWsl: vi.fn(), +})); + +import { detectClipboardEnv } from "../src/utils/detect-clipboard-env.js"; +import { readClipboardMacos } from "../src/utils/read-clipboard-macos.js"; +import { readClipboardLinux } from "../src/utils/read-clipboard-linux.js"; +import { readClipboardWindows } from "../src/utils/read-clipboard-windows.js"; +import { readClipboardWsl } from "../src/utils/read-clipboard-wsl.js"; +import { readClipboardPayload } from "../src/utils/read-clipboard-payload.js"; + +const mockDetectClipboardEnv = vi.mocked(detectClipboardEnv); +const mockReadClipboardMacos = vi.mocked(readClipboardMacos); +const mockReadClipboardLinux = vi.mocked(readClipboardLinux); +const mockReadClipboardWindows = vi.mocked(readClipboardWindows); +const mockReadClipboardWsl = vi.mocked(readClipboardWsl); + +const validPayloadJson = JSON.stringify({ + version: "0.1.32", + content: "", + entries: [ + { + tagName: "button", + componentName: "Button", + content: "", + commentText: overrides.commentText ?? "Make this larger", + }, + ], + timestamp: overrides.timestamp ?? Date.now(), +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("handleGetElementContext", () => { + it("returns no-context message when clipboard is empty", async () => { + mockReadClipboardPayload.mockResolvedValue({ env: "macos", payload: null }); + + const result = await handleGetElementContext(); + expect(result.content[0].text).toContain("No React Grab context found"); + }); + + it("appends the hint when provided", async () => { + mockReadClipboardPayload.mockResolvedValue({ + env: "ssh", + payload: null, + hint: "Clipboard channel is unavailable in SSH sessions.", + }); + + const result = await handleGetElementContext(); + expect(result.content[0].text).toContain("No React Grab context found"); + expect(result.content[0].text).toContain("SSH sessions"); + }); + + it("returns formatted prompt and content for a fresh payload", async () => { + mockReadClipboardPayload.mockResolvedValue({ + env: "macos", + payload: buildPayload({ commentText: "Refactor this" }), + }); + + const result = await handleGetElementContext(); + expect(result.content[0].text).toContain("Prompt: Refactor this"); + expect(result.content[0].text).toContain("Elements (1):"); + expect(result.content[0].text).toContain(""); + }); + + it("treats payloads older than CONTEXT_TTL_MS as no context", async () => { + mockReadClipboardPayload.mockResolvedValue({ + env: "macos", + payload: buildPayload({ timestamp: Date.now() - CONTEXT_TTL_MS - 1000 }), + }); + + const result = await handleGetElementContext(); + expect(result.content[0].text).toContain("No React Grab context found"); + }); + + it("omits the prompt section when no entries carry commentText", async () => { + const payloadWithoutPrompt = buildPayload(); + payloadWithoutPrompt.entries[0].commentText = undefined; + mockReadClipboardPayload.mockResolvedValue({ + env: "linux", + payload: payloadWithoutPrompt, + }); + + const result = await handleGetElementContext(); + expect(result.content[0].text.startsWith("Elements (1):")).toBe(true); + }); +}); + +describe("createMcpServer", () => { + it("registers get_element_context wired to handleGetElementContext", () => { + const server = createMcpServer(); + const internals = server as unknown as { + _registeredTools?: Record; + }; + expect(internals._registeredTools).toBeDefined(); + expect(internals._registeredTools?.get_element_context).toBeDefined(); + }); + + it("invokes handleGetElementContext via the registered tool handler", async () => { + mockReadClipboardPayload.mockResolvedValue({ env: "macos", payload: null }); + + const server = createMcpServer(); + const internals = server as unknown as { + _registeredTools?: Record< + string, + { handler: () => Promise<{ content: { text: string }[] }> } + >; + }; + const registeredTool = internals._registeredTools?.get_element_context; + expect(registeredTool).toBeDefined(); + + const toolResult = await registeredTool?.handler(); + expect(toolResult?.content[0].text).toContain("No React Grab context found"); + }); +}); diff --git a/packages/mcp/vite.config.ts b/packages/mcp/vite.config.ts index 2c6f16321..dafbe0e74 100644 --- a/packages/mcp/vite.config.ts +++ b/packages/mcp/vite.config.ts @@ -7,40 +7,22 @@ const nodeBuiltins = [ ]; export default defineConfig({ - pack: [ - { - entry: ["src/client.ts"], - format: ["cjs", "esm"], - dts: true, - clean: false, - sourcemap: false, - platform: "browser", + test: { + globals: true, + include: ["test/**/*.test.ts"], + testTimeout: 10000, + }, + pack: { + entry: ["src/server.ts", "src/cli.ts"], + format: ["cjs", "esm"], + dts: true, + clean: false, + sourcemap: false, + platform: "node", + fixedExtension: false, + deps: { + alwaysBundle: [/.*/], + neverBundle: nodeBuiltins, }, - { - entry: ["src/client.ts"], - format: ["iife"], - globalName: "ReactGrabMcp", - dts: false, - clean: false, - minify: process.env.NODE_ENV === "production", - sourcemap: false, - platform: "browser", - deps: { - alwaysBundle: [/.*/], - }, - }, - { - entry: ["src/server.ts", "src/cli.ts"], - format: ["cjs", "esm"], - dts: true, - clean: false, - sourcemap: false, - platform: "node", - fixedExtension: false, - deps: { - alwaysBundle: [/.*/], - neverBundle: nodeBuiltins, - }, - }, - ], + }, }); diff --git a/packages/react-grab/docs/architecture.md b/packages/react-grab/docs/architecture.md index 74a7ac918..129e13564 100644 --- a/packages/react-grab/docs/architecture.md +++ b/packages/react-grab/docs/architecture.md @@ -207,4 +207,4 @@ The five built-in plugins are registered during `init()` through the same `regis ## Notes about MCP integration -The `@react-grab/mcp` package provides a plugin that bridges react-grab with AI coding assistants via the Model Context Protocol. The plugin hooks into `transformAgentContext` and `onCopySuccess` to POST element context to a local MCP server whenever the user copies or submits a prompt. The MCP server in turn exposes this context as MCP resources that coding assistants like Cursor and Claude Code can read. The plugin is registered like any other plugin and has no special privileges in the core. +The `@react-grab/mcp` package ships an MCP stdio server that bridges react-grab with AI coding assistants. The browser does not talk to the server over the network. Instead, react-grab's copy flow already writes a custom MIME type `application/x-react-grab` to the OS clipboard alongside the plain text and HTML representations (see [packages/react-grab/src/utils/copy-content.ts](../src/utils/copy-content.ts)). When the agent calls the `get_element_context` MCP tool, the server reads that MIME type directly from the clipboard via OS-native helpers (`osascript`/JXA on macOS, `wl-paste`/`xclip` on Linux, PowerShell `-Sta` on Windows, with a WSL bridge to the Windows host). This keeps the integration permissionless — no `localhost` requests from the browser, no port management. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc8f55a0f..4a70e17c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -249,12 +249,6 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.25.0 version: 1.26.0(zod@3.25.76) - fkill: - specifier: ^9.0.0 - version: 9.0.0 - react-grab: - specifier: workspace:* - version: link:../react-grab zod: specifier: ^3.25.0 version: 3.25.76 @@ -3050,10 +3044,6 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - aggregate-error@5.0.0: - resolution: {integrity: sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==} - engines: {node: '>=18'} - ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -3414,10 +3404,6 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - clean-stack@5.3.0: - resolution: {integrity: sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==} - engines: {node: '>=14.16'} - cli-boxes@3.0.0: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} @@ -3894,14 +3880,6 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} - execa@6.1.0: - resolution: {integrity: sha512-QVWlX2e50heYJcCPG0iWtf8r0xjEYfz/OYLGDYH+IyjWezzPNxz63qNFOu0l4YftGWuizFVZHHs8PrLU5p2IDA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - execa@8.0.1: - resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} - engines: {node: '>=16.17'} - execa@9.6.1: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} @@ -4010,10 +3988,6 @@ packages: engines: {node: '>=18'} hasBin: true - fkill@9.0.0: - resolution: {integrity: sha512-MdYSsbdCaIRjzo5edthZtWmEZVMfr1qrtYZUHIdO3swCE+CoZA8S5l0s4jDsYlTa9ZiXv0pTgpzE7s4N8NeUOA==} - engines: {node: '>=18'} - foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -4142,10 +4116,6 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} - get-stream@8.0.1: - resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} - engines: {node: '>=16'} - get-stream@9.0.1: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} @@ -4331,14 +4301,6 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} - human-signals@3.0.1: - resolution: {integrity: sha512-rQLskxnM/5OCldHo+wNXbpVgDn5A17CUoKX+7Sokwaknlq7CdSnphy0W39GU8dw59XiCXmFXDg4fRuckQRKewQ==} - engines: {node: '>=12.20.0'} - - human-signals@5.0.0: - resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} - engines: {node: '>=16.17.0'} - human-signals@8.0.1: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} @@ -4521,10 +4483,6 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} - is-stream@3.0.0: - resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - is-stream@4.0.1: resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} engines: {node: '>=18'} @@ -4920,10 +4878,6 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} - mimic-fn@4.0.0: - resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} - engines: {node: '>=12'} - mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -5117,10 +5071,6 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} - npm-run-path@5.3.0: - resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - npm-run-path@6.0.0: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} @@ -5186,10 +5136,6 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} - onetime@6.0.0: - resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} - engines: {node: '>=12'} - onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -5354,10 +5300,6 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - pid-port@1.0.2: - resolution: {integrity: sha512-Khqp07zX8IJpmIg56bHrLxS3M0iSL4cq6wnMq8YE7r/hSw3Kn4QxYS6QJg8Bs22Z7CSVj7eSsxFuigYVIFWmjg==} - engines: {node: '>=18'} - pify@4.0.1: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} @@ -5429,10 +5371,6 @@ packages: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} - process-exists@5.0.0: - resolution: {integrity: sha512-6QPRh5fyHD8MaXr4GYML8K/YY0Sq5dKHGIOrAKS3cYpHQdmygFCcijIu1dVoNKAZ0TWAMoeh8KDK9dF8auBkJA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -5486,10 +5424,6 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - ps-list@8.1.1: - resolution: {integrity: sha512-OPS9kEJYVmiO48u/B9qneqhkMvgCxT+Tm28VCEJpheTpl8cJ0ffZRRNgS5mrQRTrX5yRTpaJ+hRDeefXYmmorQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -5994,10 +5928,6 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} - strip-final-newline@3.0.0: - resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} - engines: {node: '>=12'} - strip-final-newline@4.0.0: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} @@ -6098,10 +6028,6 @@ packages: resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} engines: {node: '>=18'} - taskkill@5.0.0: - resolution: {integrity: sha512-+HRtZ40Vc+6YfCDWCeAsixwxJgMbPY4HHuTgzPYH3JXvqHWUlsCfy+ylXlAKhFNcuLp4xVeWeFBUhDk+7KYUvQ==} - engines: {node: '>=14.16'} - teeny-request@9.0.0: resolution: {integrity: sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==} engines: {node: '>=14'} @@ -9243,11 +9169,6 @@ snapshots: agent-base@7.1.4: {} - aggregate-error@5.0.0: - dependencies: - clean-stack: 5.3.0 - indent-string: 5.0.0 - ajv-formats@3.0.1(ajv@8.17.1): optionalDependencies: ajv: 8.17.1 @@ -9578,10 +9499,6 @@ snapshots: dependencies: clsx: 2.1.1 - clean-stack@5.3.0: - dependencies: - escape-string-regexp: 5.0.0 - cli-boxes@3.0.0: {} cli-cursor@4.0.0: @@ -10057,30 +9974,6 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 - execa@6.1.0: - dependencies: - cross-spawn: 7.0.6 - get-stream: 6.0.1 - human-signals: 3.0.1 - is-stream: 3.0.0 - merge-stream: 2.0.0 - npm-run-path: 5.3.0 - onetime: 6.0.0 - signal-exit: 3.0.7 - strip-final-newline: 3.0.0 - - execa@8.0.1: - dependencies: - cross-spawn: 7.0.6 - get-stream: 8.0.1 - human-signals: 5.0.0 - is-stream: 3.0.0 - merge-stream: 2.0.0 - npm-run-path: 5.3.0 - onetime: 6.0.0 - signal-exit: 4.1.0 - strip-final-newline: 3.0.0 - execa@9.6.1: dependencies: '@sindresorhus/merge-streams': 4.0.0 @@ -10259,15 +10152,6 @@ snapshots: minimist: 1.2.8 xml2js: 0.6.2 - fkill@9.0.0: - dependencies: - aggregate-error: 5.0.0 - execa: 8.0.1 - pid-port: 1.0.2 - process-exists: 5.0.0 - ps-list: 8.1.1 - taskkill: 5.0.0 - foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -10418,8 +10302,6 @@ snapshots: get-stream@6.0.1: {} - get-stream@8.0.1: {} - get-stream@9.0.1: dependencies: '@sec-ant/readable-stream': 0.4.1 @@ -10690,10 +10572,6 @@ snapshots: human-signals@2.1.0: {} - human-signals@3.0.1: {} - - human-signals@5.0.0: {} - human-signals@8.0.1: {} iconv-lite@0.7.0: @@ -10827,8 +10705,6 @@ snapshots: is-stream@2.0.1: {} - is-stream@3.0.0: {} - is-stream@4.0.1: {} is-subdir@1.2.0: @@ -11171,8 +11047,6 @@ snapshots: mimic-fn@2.1.0: {} - mimic-fn@4.0.0: {} - mimic-function@5.0.1: {} mimic-response@3.1.0: @@ -11351,10 +11225,6 @@ snapshots: dependencies: path-key: 3.1.1 - npm-run-path@5.3.0: - dependencies: - path-key: 4.0.0 - npm-run-path@6.0.0: dependencies: path-key: 4.0.0 @@ -11397,10 +11267,6 @@ snapshots: dependencies: mimic-fn: 2.1.0 - onetime@6.0.0: - dependencies: - mimic-fn: 4.0.0 - onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -11601,10 +11467,6 @@ snapshots: picomatch@4.0.4: {} - pid-port@1.0.2: - dependencies: - execa: 8.0.1 - pify@4.0.1: {} pino-abstract-transport@2.0.0: @@ -11691,10 +11553,6 @@ snapshots: dependencies: parse-ms: 4.0.0 - process-exists@5.0.0: - dependencies: - ps-list: 8.1.1 - process-nextick-args@2.0.1: {} process-warning@5.0.0: {} @@ -11780,8 +11638,6 @@ snapshots: proxy-from-env@1.1.0: {} - ps-list@8.1.1: {} - pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -12433,8 +12289,6 @@ snapshots: strip-final-newline@2.0.0: {} - strip-final-newline@3.0.0: {} - strip-final-newline@4.0.0: {} strip-indent@3.0.0: @@ -12537,10 +12391,6 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 - taskkill@5.0.0: - dependencies: - execa: 6.1.0 - teeny-request@9.0.0: dependencies: http-proxy-agent: 5.0.0 diff --git a/skills/react-grab/SKILL.md b/skills/react-grab/SKILL.md new file mode 100644 index 000000000..8262c72fe --- /dev/null +++ b/skills/react-grab/SKILL.md @@ -0,0 +1,35 @@ +--- +name: react-grab +description: >- + Pull the latest UI element grabbed via React Grab from the user's clipboard. + Use when the user references a grabbed element, "this thing", "the component + I just clicked", "the element I selected", or pastes/cites content that came + from React Grab's toolbar. +--- + +# React Grab Mode + +When the user references an element they grabbed with React Grab (phrases like "this thing", "the component I just clicked", "the element I grabbed", or when they've clearly pasted React Grab output), the **canonical description of that element lives in the system clipboard** under the custom MIME type `application/x-react-grab`. + +## What to do + +1. **Before doing anything else**, call the `get_element_context` tool from the `react-grab-mcp` MCP server **exactly once** at the top of the turn. +2. Treat its return value as the authoritative description of the target element. It contains: + - The user's prompt (if they typed one in the toolbar). + - The HTML snippet of the selected element(s). + - Component stack / source file paths. +3. Plan and execute the user's request against that returned context. + +## Failure modes + +- **`get_element_context` tool isn't registered with your MCP client** — React Grab MCP isn't installed. Tell the user to run `npx @react-grab/cli@latest install-mcp` and restart their MCP client. +- **"No React Grab context found on the clipboard."** — the clipboard either doesn't hold a React Grab payload or it's older than the TTL. Tell the user: _"I don't see a recent grab. Click the element in the React Grab toolbar (the box that appears when you hover the page) and try again."_ Do not retry the tool inside the same turn. +- **"Clipboard channel is unavailable in SSH sessions"** — the MCP server is on a different machine than the browser. Tell the user to run `react-grab-mcp` on the same machine as the browser. +- **Linux missing `xclip` / `wl-clipboard`** — surface the install command from the tool's error verbatim. +- **WSL with broken interop** — surface the WSL interop hint from the tool's error verbatim. + +## Constraints + +- Do **not** call `get_element_context` more than once per turn. The clipboard payload is short-lived; a second call usually returns the same data or stale data. +- Do **not** invent element details. If the tool returned no context, ask the user; do not fabricate. +- Do **not** rely on the user's chat message alone when they reference "this" / "that" / "the thing I grabbed" — always reach for the tool first. From a4b36e56db7067300e6a37ee7555db389e049ac2 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 24 Apr 2026 17:26:47 -0700 Subject: [PATCH 02/37] fix(mcp): decode clipboard data delivered as a System.IO.Stream on Windows Chromium/Edge write web-custom-format clipboard data as raw UTF-8 bytes, which .NET's Clipboard.GetData surfaces as System.IO.MemoryStream rather than byte[]. The previous PowerShell script only handled byte[] and fell through to $data.ToString(), yielding the literal string "System.IO.MemoryStream" and causing payload parsing to fail. --- .../mcp/src/utils/read-clipboard-windows.ts | 11 ++++++ .../mcp/test/read-clipboard-windows.test.ts | 34 +++++++++++++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/mcp/src/utils/read-clipboard-windows.ts b/packages/mcp/src/utils/read-clipboard-windows.ts index 5088dc666..b6154696c 100644 --- a/packages/mcp/src/utils/read-clipboard-windows.ts +++ b/packages/mcp/src/utils/read-clipboard-windows.ts @@ -14,6 +14,17 @@ try { [Console]::Out.Write('') } elseif ($data -is [byte[]]) { [Console]::Out.Write([System.Text.Encoding]::UTF8.GetString($data)) + } elseif ($data -is [System.IO.Stream]) { + # Browsers (Chromium, Edge) write web-custom-format clipboard data as a + # raw UTF-8 byte stream. .NET's Clipboard.GetData returns a MemoryStream + # for these unknown formats, so we read it to bytes and decode as UTF-8. + if ($data.CanSeek) { $data.Position = 0 } + $memoryStream = New-Object System.IO.MemoryStream + $data.CopyTo($memoryStream) + $bytes = $memoryStream.ToArray() + [Console]::Out.Write([System.Text.Encoding]::UTF8.GetString($bytes)) + } elseif ($data -is [string]) { + [Console]::Out.Write($data) } else { [Console]::Out.Write($data.ToString()) } diff --git a/packages/mcp/test/read-clipboard-windows.test.ts b/packages/mcp/test/read-clipboard-windows.test.ts index 5faacbd29..ef3295a3e 100644 --- a/packages/mcp/test/read-clipboard-windows.test.ts +++ b/packages/mcp/test/read-clipboard-windows.test.ts @@ -10,6 +10,17 @@ import { enoentError, stubExecFile } from "./helpers/mock-exec-file.js"; const mockExecFile = vi.mocked(execFile); +const getDecodedPowerShellScript = (): string => { + const firstCall = mockExecFile.mock.calls[0]; + if (!firstCall) throw new Error("expected execFile to have been called"); + const args = firstCall[1]; + if (!Array.isArray(args)) throw new Error("expected execFile args array"); + const encodedIndex = args.indexOf("-EncodedCommand") + 1; + const encoded = args[encodedIndex]; + if (typeof encoded !== "string") throw new Error("expected -EncodedCommand value"); + return Buffer.from(encoded, "base64").toString("utf16le"); +}; + beforeEach(() => { vi.clearAllMocks(); }); @@ -24,18 +35,35 @@ describe("readClipboardWindows", () => { await readClipboardWindows(); - const [binary, args] = mockExecFile.mock.calls[0]; + const firstCall = mockExecFile.mock.calls[0]; + expect(firstCall).toBeDefined(); + const [binary, args] = firstCall ?? []; expect(binary).toBe("powershell.exe"); expect(args).toContain("-Sta"); expect(args).toContain("-NoProfile"); expect(args).toContain("-EncodedCommand"); - const encodedIndex = args.indexOf("-EncodedCommand") + 1; - const decodedScript = Buffer.from(args[encodedIndex], "base64").toString("utf16le"); + const decodedScript = getDecodedPowerShellScript(); expect(decodedScript).toContain("System.Windows.Forms"); expect(decodedScript).toContain("application/x-react-grab"); }); + it("decodes clipboard payloads delivered as a System.IO.Stream", async () => { + stubExecFile(mockExecFile, { stdout: "{}" }); + + await readClipboardWindows(); + + const decodedScript = getDecodedPowerShellScript(); + + // Browsers store web-custom-format data as a UTF-8 byte stream that + // surfaces in .NET as System.IO.MemoryStream, so the script must read + // the stream rather than fall back to $data.ToString(). + expect(decodedScript).toContain("[System.IO.Stream]"); + expect(decodedScript).toContain("CopyTo"); + expect(decodedScript).toContain("ToArray()"); + expect(decodedScript).toContain("[System.Text.Encoding]::UTF8.GetString"); + }); + it("returns ENOENT hint when powershell is missing", async () => { stubExecFile(mockExecFile, { error: enoentError() }); From 0874363bc2ca9f10e31c117ac61263d5e9f9375e Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 24 Apr 2026 17:40:17 -0700 Subject: [PATCH 03/37] fix(mcp): deduplicate prompt and stop embedding it in the elements section The browser-side producer assigns the same prompt to every entry's commentText *and* prepends it to payload.content, so an N-element copy caused formatPayload to emit the prompt N+1 times (once per entry plus once embedded in payload.content). Deduplicate prompt lines via a Set and rebuild the elements section from per-entry content. --- packages/mcp/src/server.ts | 25 +++++++++---- packages/mcp/test/server.test.ts | 61 ++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 6 deletions(-) diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 8fe7a82f1..a65a1315f 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -16,12 +16,25 @@ const textResult = (text: string): TextToolResult => ({ }); const formatPayload = (payload: ReactGrabPayload): string => { - const promptLines = payload.entries - .map((entry) => entry.commentText) - .filter((commentText): commentText is string => Boolean(commentText)); - const elementsSection = `Elements (${payload.entries.length}):\n${payload.content}`; - return promptLines.length > 0 - ? `Prompt: ${promptLines.join("\n")}\n\n${elementsSection}` + // The browser-side producer assigns the same prompt to every entry's + // commentText *and* prepends it to payload.content, so we deduplicate + // prompt lines and rebuild the elements section from per-entry content + // to avoid surfacing the prompt multiple times to the LLM. + const uniquePrompts = Array.from( + new Set( + payload.entries + .map((entry) => entry.commentText?.trim()) + .filter((commentText): commentText is string => Boolean(commentText)), + ), + ); + const entriesBody = payload.entries + .map((entry) => entry.content) + .filter((entryContent) => entryContent.trim().length > 0) + .join("\n\n"); + const elementsBody = entriesBody.length > 0 ? entriesBody : payload.content; + const elementsSection = `Elements (${payload.entries.length}):\n${elementsBody}`; + return uniquePrompts.length > 0 + ? `Prompt: ${uniquePrompts.join("\n")}\n\n${elementsSection}` : elementsSection; }; diff --git a/packages/mcp/test/server.test.ts b/packages/mcp/test/server.test.ts index 32066753c..7f37b2cc7 100644 --- a/packages/mcp/test/server.test.ts +++ b/packages/mcp/test/server.test.ts @@ -85,6 +85,67 @@ describe("handleGetElementContext", () => { const result = await handleGetElementContext(); expect(result.content[0].text.startsWith("Elements (1):")).toBe(true); }); + + it("deduplicates the prompt across entries and excludes it from the elements section", async () => { + const sharedPrompt = "Fix all the buttons"; + mockReadClipboardPayload.mockResolvedValue({ + env: "macos", + payload: { + version: "0.1.32", + // The producer prepends the prompt to payload.content, so this is + // exactly what would land on the clipboard for a multi-element copy. + content: `${sharedPrompt}\n\n\n\n\n\n`, + entries: [ + { + tagName: "button", + content: "", + commentText: sharedPrompt, + }, + { + tagName: "button", + content: "", + commentText: sharedPrompt, + }, + { + tagName: "button", + content: "", + commentText: sharedPrompt, + }, + ], + timestamp: Date.now(), + }, + }); + + const result = await handleGetElementContext(); + const text = result.content[0].text; + + const promptOccurrences = text.split(sharedPrompt).length - 1; + expect(promptOccurrences).toBe(1); + expect(text).toContain(`Prompt: ${sharedPrompt}`); + expect(text).toContain("Elements (3):"); + expect(text).toContain(""); + expect(text).toContain(""); + expect(text).toContain(""); + }); + + it("preserves distinct prompts when entries carry different commentText values", async () => { + mockReadClipboardPayload.mockResolvedValue({ + env: "macos", + payload: { + version: "0.1.32", + content: "\n\n", + entries: [ + { tagName: "button", content: "", commentText: "Make it red" }, + { tagName: "button", content: "", commentText: "Make it blue" }, + ], + timestamp: Date.now(), + }, + }); + + const result = await handleGetElementContext(); + const text = result.content[0].text; + expect(text).toContain("Prompt: Make it red\nMake it blue"); + }); }); describe("createMcpServer", () => { From c70f029f48df38ccbcedd9b3405713f622d15e48 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 24 Apr 2026 18:04:36 -0700 Subject: [PATCH 04/37] fix(mcp): widen Wayland-to-X11 fallback and preserve hints across WSL channels - read-clipboard-linux: previously a non-ENOENT wl-paste failure returned early without trying xclip, even though XWayland setups commonly surface custom MIME types via X11. Now any wl-paste failure falls through to xclip. - read-clipboard-wsl: when WSL interop is unreachable AND the WSLg fallback has actionable guidance (e.g. "install xclip"), surface both hints instead of silently dropping the Linux one. --- packages/mcp/src/utils/read-clipboard-linux.ts | 6 +++--- packages/mcp/src/utils/read-clipboard-wsl.ts | 12 +++++++++++- packages/mcp/test/read-clipboard-linux.test.ts | 15 +++++++++++++++ packages/mcp/test/read-clipboard-wsl.test.ts | 16 ++++++++++++++++ 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/mcp/src/utils/read-clipboard-linux.ts b/packages/mcp/src/utils/read-clipboard-linux.ts index f26645de8..f5d6c23ac 100644 --- a/packages/mcp/src/utils/read-clipboard-linux.ts +++ b/packages/mcp/src/utils/read-clipboard-linux.ts @@ -41,9 +41,9 @@ export const readClipboardLinux = async (): Promise => { if (waylandResult.stdout !== undefined) { return { payload: trimToPayload(waylandResult.stdout) }; } - if (!isBinaryMissing(waylandResult.error)) { - return { payload: null }; - } + // Any wl-paste failure (missing binary or runtime error) falls through to + // xclip - XWayland setups commonly surface custom MIME types via X11 even + // when wl-paste cannot complete. } const x11Result = await tryRead("xclip", [ diff --git a/packages/mcp/src/utils/read-clipboard-wsl.ts b/packages/mcp/src/utils/read-clipboard-wsl.ts index b495ce52c..73f29e7b3 100644 --- a/packages/mcp/src/utils/read-clipboard-wsl.ts +++ b/packages/mcp/src/utils/read-clipboard-wsl.ts @@ -5,6 +5,11 @@ import type { ClipboardReadOutcome } from "./read-clipboard-outcome.js"; const WSL_INTEROP_HINT = "Could not reach the Windows clipboard from WSL. Enable WSL interop (set `enabled = true` under `[interop]` in `/etc/wsl.conf`) or run `react-grab-mcp` on the Windows host."; +const combineHints = (...hints: (string | undefined)[]): string | undefined => { + const present = hints.filter((hint): hint is string => Boolean(hint)); + return present.length > 0 ? present.join("\n\n") : undefined; +}; + export const readClipboardWsl = async (): Promise => { const hostOutcome = await readClipboardViaWindowsPowerShell("powershell.exe"); if (hostOutcome.payload !== null) return hostOutcome; @@ -12,6 +17,11 @@ export const readClipboardWsl = async (): Promise => { const wslgOutcome = await readClipboardLinux(); if (wslgOutcome.payload !== null) return wslgOutcome; - if (hostOutcome.hint) return { payload: null, hint: WSL_INTEROP_HINT }; + if (hostOutcome.hint) { + // When interop is unreachable AND the WSLg fallback also has actionable + // guidance (e.g. "install xclip"), surface both so the user can fix + // whichever channel they prefer. + return { payload: null, hint: combineHints(WSL_INTEROP_HINT, wslgOutcome.hint) }; + } return wslgOutcome; }; diff --git a/packages/mcp/test/read-clipboard-linux.test.ts b/packages/mcp/test/read-clipboard-linux.test.ts index 50367355d..1e2c8ffb0 100644 --- a/packages/mcp/test/read-clipboard-linux.test.ts +++ b/packages/mcp/test/read-clipboard-linux.test.ts @@ -53,6 +53,21 @@ describe("readClipboardLinux", () => { expect(mockExecFile.mock.calls[1][0]).toBe("xclip"); }); + it("falls back from a runtime wl-paste failure to xclip", async () => { + // XWayland setups can surface custom MIME types via X11 even when the + // Wayland reader fails for a non-ENOENT reason (timeout, no compositor, + // protocol error, etc.). + process.env.WAYLAND_DISPLAY = "wayland-0"; + const runtimeError = new Error("wl-paste: clipboard read failed") as NodeJS.ErrnoException; + runtimeError.code = "EPIPE"; + stubExecFilePerCall(mockExecFile, [{ error: runtimeError }, { stdout: "from-xclip" }]); + + const result = await readClipboardLinux(); + expect(result.payload).toBe("from-xclip"); + expect(mockExecFile.mock.calls[0][0]).toBe("wl-paste"); + expect(mockExecFile.mock.calls[1][0]).toBe("xclip"); + }); + it("returns install hint when xclip is missing", async () => { stubExecFile(mockExecFile, { error: enoentError() }); diff --git a/packages/mcp/test/read-clipboard-wsl.test.ts b/packages/mcp/test/read-clipboard-wsl.test.ts index ba6c82efe..c666b2f88 100644 --- a/packages/mcp/test/read-clipboard-wsl.test.ts +++ b/packages/mcp/test/read-clipboard-wsl.test.ts @@ -65,4 +65,20 @@ describe("readClipboardWsl", () => { const result = await readClipboardWsl(); expect(result.hint).toContain("xclip"); }); + + it("combines WSL interop and Linux install hints when both fallbacks have guidance", async () => { + mockReadClipboardViaWindowsPowerShell.mockResolvedValue({ + payload: null, + hint: "Cannot launch powershell.exe.", + }); + mockReadClipboardLinux.mockResolvedValue({ + payload: null, + hint: "Install xclip or wl-clipboard.", + }); + + const result = await readClipboardWsl(); + expect(result.payload).toBeNull(); + expect(result.hint).toContain("interop"); + expect(result.hint).toContain("xclip"); + }); }); From 1ad39d76e0ed18797dfe1b60232856c46f435433 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 24 Apr 2026 18:22:15 -0700 Subject: [PATCH 05/37] fix(mcp): preserve canonical content formatting and avoid PowerShell UTF-8 BOM - formatPayload: previously rebuilt the elements body from raw entry snippets, which silently dropped joinSnippets's [1]/[2]/[3] labels and any transformCopyContent output (copy-html, copy-styles, etc.). Use payload.content as the body and just strip the leading "${prompt}\n\n" prefix that the producer prepends when a prompt is set. - read-clipboard-windows: replace [System.Text.Encoding]::UTF8 (the singleton with emitUTF8Identifier=true) with New-Object UTF8Encoding $false. The singleton can prepend a BOM to piped stdout, which makes JSON.parse reject otherwise-valid payloads on Node. --- packages/mcp/src/server.ts | 30 +++++++++---- .../mcp/src/utils/read-clipboard-windows.ts | 5 ++- .../mcp/test/read-clipboard-windows.test.ts | 15 +++++++ packages/mcp/test/server.test.ts | 43 +++++++++++++++++-- 4 files changed, 80 insertions(+), 13 deletions(-) diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index a65a1315f..8b76a6f8e 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -15,11 +15,23 @@ const textResult = (text: string): TextToolResult => ({ content: [{ type: "text", text }], }); +const stripLeadingPromptPrefix = (content: string, uniquePrompts: string[]): string => { + // The browser-side producer prepends "${prompt}\n\n" to payload.content + // when a prompt is present, so we trim it back off to avoid the prompt + // showing up both in the "Prompt:" section and inside the elements body. + for (const prompt of uniquePrompts) { + const candidate = `${prompt}\n\n`; + if (content.startsWith(candidate)) { + return content.slice(candidate.length); + } + } + return content; +}; + const formatPayload = (payload: ReactGrabPayload): string => { - // The browser-side producer assigns the same prompt to every entry's - // commentText *and* prepends it to payload.content, so we deduplicate - // prompt lines and rebuild the elements section from per-entry content - // to avoid surfacing the prompt multiple times to the LLM. + // The producer also assigns the prompt to every entry's commentText, so + // dedupe via a Set to surface a single Prompt: line regardless of how + // many elements were copied. const uniquePrompts = Array.from( new Set( payload.entries @@ -27,11 +39,11 @@ const formatPayload = (payload: ReactGrabPayload): string => { .filter((commentText): commentText is string => Boolean(commentText)), ), ); - const entriesBody = payload.entries - .map((entry) => entry.content) - .filter((entryContent) => entryContent.trim().length > 0) - .join("\n\n"); - const elementsBody = entriesBody.length > 0 ? entriesBody : payload.content; + // Use payload.content as the body so we preserve canonical formatting: + // the [1]/[2]/[3] labels added by joinSnippets for multi-element copies + // and any transformCopyContent output contributed by plugins + // (e.g. copy-html, copy-styles). + const elementsBody = stripLeadingPromptPrefix(payload.content, uniquePrompts); const elementsSection = `Elements (${payload.entries.length}):\n${elementsBody}`; return uniquePrompts.length > 0 ? `Prompt: ${uniquePrompts.join("\n")}\n\n${elementsSection}` diff --git a/packages/mcp/src/utils/read-clipboard-windows.ts b/packages/mcp/src/utils/read-clipboard-windows.ts index b6154696c..baf0ffa2f 100644 --- a/packages/mcp/src/utils/read-clipboard-windows.ts +++ b/packages/mcp/src/utils/read-clipboard-windows.ts @@ -9,7 +9,10 @@ $ErrorActionPreference='Stop' try { Add-Type -AssemblyName System.Windows.Forms $data = [System.Windows.Forms.Clipboard]::GetData('${REACT_GRAB_MIME_TYPE}') - [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + # Use UTF8Encoding($false) instead of [System.Text.Encoding]::UTF8 - the + # singleton has emitUTF8Identifier enabled, which can prepend a BOM to the + # piped stdout that breaks JSON.parse on the Node side. + [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false if ($null -eq $data) { [Console]::Out.Write('') } elseif ($data -is [byte[]]) { diff --git a/packages/mcp/test/read-clipboard-windows.test.ts b/packages/mcp/test/read-clipboard-windows.test.ts index ef3295a3e..b3687acd5 100644 --- a/packages/mcp/test/read-clipboard-windows.test.ts +++ b/packages/mcp/test/read-clipboard-windows.test.ts @@ -64,6 +64,21 @@ describe("readClipboardWindows", () => { expect(decodedScript).toContain("[System.Text.Encoding]::UTF8.GetString"); }); + it("configures BOM-less UTF-8 output to keep JSON.parse happy", async () => { + stubExecFile(mockExecFile, { stdout: "{}" }); + + await readClipboardWindows(); + + const decodedScript = getDecodedPowerShellScript(); + // The [System.Text.Encoding]::UTF8 singleton has emitUTF8Identifier + // enabled; using New-Object UTF8Encoding $false avoids the BOM that + // would otherwise be prepended to stdout. + expect(decodedScript).toContain("New-Object System.Text.UTF8Encoding $false"); + expect(decodedScript).not.toMatch( + /\[Console\]::OutputEncoding\s*=\s*\[System\.Text\.Encoding\]::UTF8\b/, + ); + }); + it("returns ENOENT hint when powershell is missing", async () => { stubExecFile(mockExecFile, { error: enoentError() }); diff --git a/packages/mcp/test/server.test.ts b/packages/mcp/test/server.test.ts index 7f37b2cc7..b970ebcc5 100644 --- a/packages/mcp/test/server.test.ts +++ b/packages/mcp/test/server.test.ts @@ -88,13 +88,15 @@ describe("handleGetElementContext", () => { it("deduplicates the prompt across entries and excludes it from the elements section", async () => { const sharedPrompt = "Fix all the buttons"; + // The producer prepends the prompt to payload.content AND adds [1]/[2]/[3] + // labels via joinSnippets, so this is exactly what would land on the + // clipboard for a multi-element copy. + const canonicalContent = `${sharedPrompt}\n\n[1]\n\n\n[2]\n\n\n[3]\n`; mockReadClipboardPayload.mockResolvedValue({ env: "macos", payload: { version: "0.1.32", - // The producer prepends the prompt to payload.content, so this is - // exactly what would land on the clipboard for a multi-element copy. - content: `${sharedPrompt}\n\n\n\n\n\n`, + content: canonicalContent, entries: [ { tagName: "button", @@ -123,11 +125,46 @@ describe("handleGetElementContext", () => { expect(promptOccurrences).toBe(1); expect(text).toContain(`Prompt: ${sharedPrompt}`); expect(text).toContain("Elements (3):"); + expect(text).toContain("[1]"); + expect(text).toContain("[2]"); + expect(text).toContain("[3]"); expect(text).toContain(""); expect(text).toContain(""); expect(text).toContain(""); }); + it("preserves transformCopyContent output and snippet labels in the body", async () => { + // payload.content is the canonical, plugin-transformed copy. We must + // surface it verbatim (minus the leading prompt prefix) rather than + // reassembling the body from raw entry snippets. + const transformedBody = "\n[1]\n\n\n[2]\n"; + mockReadClipboardPayload.mockResolvedValue({ + env: "macos", + payload: { + version: "0.1.32", + content: `Style as primary\n\n${transformedBody}`, + entries: [ + { + tagName: "button", + content: "", + commentText: "Style as primary", + }, + { + tagName: "button", + content: "", + commentText: "Style as primary", + }, + ], + timestamp: Date.now(), + }, + }); + + const result = await handleGetElementContext(); + const text = result.content[0].text; + expect(text).toContain(""); + expect(text).toContain(`Elements (2):\n${transformedBody}`); + }); + it("preserves distinct prompts when entries carry different commentText values", async () => { mockReadClipboardPayload.mockResolvedValue({ env: "macos", From 07583449a0eb855129092c8a2459d34238aa3f52 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 24 Apr 2026 18:33:15 -0700 Subject: [PATCH 06/37] fix(mcp): strip prompt prefix using the untrimmed commentText The browser-side producer prepends the *untrimmed* prompt to payload.content (`${extraPrompt}\n\n${transformedContent}`), but the previous stripping logic used the trimmed prompt to match the prefix. For prompts with surrounding whitespace the match would fail and the prompt would re-appear at the top of the elements body. Track raw and trimmed prompts separately: raw values drive prefix matching, trimmed values (deduped) drive the user-visible `Prompt:` line. --- packages/mcp/src/server.ts | 48 ++++++++++++++++++++------------ packages/mcp/test/server.test.ts | 29 +++++++++++++++++++ 2 files changed, 59 insertions(+), 18 deletions(-) diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 8b76a6f8e..b6293733e 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -15,12 +15,18 @@ const textResult = (text: string): TextToolResult => ({ content: [{ type: "text", text }], }); -const stripLeadingPromptPrefix = (content: string, uniquePrompts: string[]): string => { +const stripLeadingPromptPrefix = (content: string, rawPrompts: string[]): string => { // The browser-side producer prepends "${prompt}\n\n" to payload.content - // when a prompt is present, so we trim it back off to avoid the prompt - // showing up both in the "Prompt:" section and inside the elements body. - for (const prompt of uniquePrompts) { - const candidate = `${prompt}\n\n`; + // using the *untrimmed* prompt, so we match against the raw commentText + // (and a trimmed variant as a safety net) to avoid the prompt showing up + // both in the "Prompt:" section and inside the elements body. + const candidates = new Set(); + for (const rawPrompt of rawPrompts) { + if (rawPrompt.length > 0) candidates.add(`${rawPrompt}\n\n`); + const trimmed = rawPrompt.trim(); + if (trimmed.length > 0) candidates.add(`${trimmed}\n\n`); + } + for (const candidate of candidates) { if (content.startsWith(candidate)) { return content.slice(candidate.length); } @@ -29,24 +35,30 @@ const stripLeadingPromptPrefix = (content: string, uniquePrompts: string[]): str }; const formatPayload = (payload: ReactGrabPayload): string => { - // The producer also assigns the prompt to every entry's commentText, so - // dedupe via a Set to surface a single Prompt: line regardless of how - // many elements were copied. - const uniquePrompts = Array.from( - new Set( - payload.entries - .map((entry) => entry.commentText?.trim()) - .filter((commentText): commentText is string => Boolean(commentText)), - ), - ); + // The producer assigns the prompt to every entry's commentText, so dedupe + // via a Set to surface a single Prompt: line regardless of how many + // elements were copied. We keep the raw values around for prefix matching + // (the producer doesn't trim) and surface trimmed values to the LLM. + const rawPrompts: string[] = []; + const trimmedPrompts: string[] = []; + const seenTrimmed = new Set(); + for (const entry of payload.entries) { + const rawPrompt = entry.commentText; + if (typeof rawPrompt !== "string" || rawPrompt.length === 0) continue; + rawPrompts.push(rawPrompt); + const trimmed = rawPrompt.trim(); + if (trimmed.length === 0 || seenTrimmed.has(trimmed)) continue; + seenTrimmed.add(trimmed); + trimmedPrompts.push(trimmed); + } // Use payload.content as the body so we preserve canonical formatting: // the [1]/[2]/[3] labels added by joinSnippets for multi-element copies // and any transformCopyContent output contributed by plugins // (e.g. copy-html, copy-styles). - const elementsBody = stripLeadingPromptPrefix(payload.content, uniquePrompts); + const elementsBody = stripLeadingPromptPrefix(payload.content, rawPrompts); const elementsSection = `Elements (${payload.entries.length}):\n${elementsBody}`; - return uniquePrompts.length > 0 - ? `Prompt: ${uniquePrompts.join("\n")}\n\n${elementsSection}` + return trimmedPrompts.length > 0 + ? `Prompt: ${trimmedPrompts.join("\n")}\n\n${elementsSection}` : elementsSection; }; diff --git a/packages/mcp/test/server.test.ts b/packages/mcp/test/server.test.ts index b970ebcc5..663dadd72 100644 --- a/packages/mcp/test/server.test.ts +++ b/packages/mcp/test/server.test.ts @@ -165,6 +165,35 @@ describe("handleGetElementContext", () => { expect(text).toContain(`Elements (2):\n${transformedBody}`); }); + it("strips the prompt prefix even when the original prompt had surrounding whitespace", async () => { + // The producer prepends the *untrimmed* prompt to payload.content, so + // the stripper must match against the raw commentText or the prompt + // would re-appear at the top of the elements body. + const rawPrompt = " Fix this button "; + mockReadClipboardPayload.mockResolvedValue({ + env: "macos", + payload: { + version: "0.1.32", + content: `${rawPrompt}\n\n`, + entries: [ + { + tagName: "button", + content: "", + commentText: rawPrompt, + }, + ], + timestamp: Date.now(), + }, + }); + + const result = await handleGetElementContext(); + const text = result.content[0].text; + + expect(text).toBe("Prompt: Fix this button\n\nElements (1):\n"); + const occurrences = text.split("Fix this button").length - 1; + expect(occurrences).toBe(1); + }); + it("preserves distinct prompts when entries carry different commentText values", async () => { mockReadClipboardPayload.mockResolvedValue({ env: "macos", From 1a0e80ccd8f321d141b627c881274e4c6ed865fe Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 24 Apr 2026 18:50:55 -0700 Subject: [PATCH 07/37] fix(mcp): match prompt prefix only against the raw commentText The producer never trims extraPrompt before prepending it to payload.content, so the trimmed-candidate safety net I added in the previous commit was unnecessary and could over-eagerly strip legitimate element content that happens to start with the prompt text. Drop the trimmed candidate; keep using trimmed values only for the user-visible "Prompt:" section. --- packages/mcp/src/server.ts | 17 +++++++---------- packages/mcp/test/server.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index b6293733e..0fb233e01 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -16,17 +16,14 @@ const textResult = (text: string): TextToolResult => ({ }); const stripLeadingPromptPrefix = (content: string, rawPrompts: string[]): string => { - // The browser-side producer prepends "${prompt}\n\n" to payload.content - // using the *untrimmed* prompt, so we match against the raw commentText - // (and a trimmed variant as a safety net) to avoid the prompt showing up - // both in the "Prompt:" section and inside the elements body. - const candidates = new Set(); + // The browser-side producer prepends the *untrimmed* prompt followed by + // "\n\n" to payload.content, so we match against the raw commentText. + // We deliberately do not also try a trimmed candidate: that would risk + // stripping legitimate element content that happens to start with the + // prompt text (e.g. prompt "Click me" + element body "Click me\n\n..."). for (const rawPrompt of rawPrompts) { - if (rawPrompt.length > 0) candidates.add(`${rawPrompt}\n\n`); - const trimmed = rawPrompt.trim(); - if (trimmed.length > 0) candidates.add(`${trimmed}\n\n`); - } - for (const candidate of candidates) { + if (rawPrompt.length === 0) continue; + const candidate = `${rawPrompt}\n\n`; if (content.startsWith(candidate)) { return content.slice(candidate.length); } diff --git a/packages/mcp/test/server.test.ts b/packages/mcp/test/server.test.ts index 663dadd72..26bc553ff 100644 --- a/packages/mcp/test/server.test.ts +++ b/packages/mcp/test/server.test.ts @@ -165,6 +165,31 @@ describe("handleGetElementContext", () => { expect(text).toContain(`Elements (2):\n${transformedBody}`); }); + it("does not strip element content that happens to start with the prompt text", async () => { + // The producer uses the untrimmed prompt to build payload.content. If + // the user typed no prompt but their element content starts with what + // *looks* like a prompt, we must not try to strip it. + mockReadClipboardPayload.mockResolvedValue({ + env: "macos", + payload: { + version: "0.1.32", + content: "Click me\n\n", + entries: [ + { + tagName: "button", + content: "", + commentText: undefined, + }, + ], + timestamp: Date.now(), + }, + }); + + const result = await handleGetElementContext(); + const text = result.content[0].text; + expect(text).toBe("Elements (1):\nClick me\n\n"); + }); + it("strips the prompt prefix even when the original prompt had surrounding whitespace", async () => { // The producer prepends the *untrimmed* prompt to payload.content, so // the stripper must match against the raw commentText or the prompt From 03b722a0a0314805c7941519ad12b1de4449b299 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 25 Apr 2026 18:08:33 -0700 Subject: [PATCH 08/37] feat(cli): replace MCP with `react-grab watch` CLI + agent skill installer Reduces @react-grab/mcp to a 0.2.0 deprecation stub and folds the clipboard reader into @react-grab/cli as a `react-grab watch` subcommand that polls the system clipboard's `application/x-react-grab` payload and exits when a fresh grab arrives (default 10-min timeout, `--timeout 0` blocks forever). SSH/WSL "unrecoverable" envs now fast-exit with code 2 instead of polling out the timeout. Adds `react-grab install-skill` that writes SKILL.md to known agent skill directories. Universal agents (Cursor, Codex, OpenCode, Amp, Cline, Gemini CLI, GitHub Copilot, Warp) collapse to a single canonical write at .agents/skills/ (project) or ~/.agents/skills/ (global), matching the vercel-labs/skills convention. Non-universal agents (Claude Code, Windsurf, Droid) get their own paths. Auto-detects installed agents via existsSync against telltale dirs and remembers the last-selected agents under ${XDG_STATE_HOME ?? ~/.local/state}/react-grab/. Honors CLAUDE_CONFIG_DIR, CODEX_HOME, XDG_CONFIG_HOME. Telemetry pings now skip under DISABLE_TELEMETRY / DO_NOT_TRACK and in common CI environments. Skill template ships `allowed-tools: [Bash]` frontmatter per the Agent Skills Specification. Adds parse-timeout-seconds, wait-for-next-grab, format-payload, is-telemetry-enabled, last-selected-agents, skill-template utilities plus 229 unit tests (clipboard readers, watch outcomes, install/remove dedup, SSH fast-exit, deprecation stub). --- CONTRIBUTING.md | 4 +- README.md | 6 +- package.json | 2 +- packages/cli/README.md | 63 ++- packages/cli/package.json | 4 +- packages/cli/src/cli.ts | 13 +- packages/cli/src/commands/add.ts | 80 ++-- packages/cli/src/commands/init.ts | 60 ++- packages/cli/src/commands/install-skill.ts | 156 +++++++ packages/cli/src/commands/remove.ts | 116 +++-- packages/cli/src/commands/watch.ts | 94 ++++ packages/cli/src/utils/constants.ts | 28 ++ .../src/utils/detect-clipboard-env.ts | 0 packages/cli/src/utils/format-payload.ts | 45 ++ .../{mcp => cli}/src/utils/has-error-code.ts | 0 packages/cli/src/utils/install-mcp.ts | 267 ------------ packages/cli/src/utils/install-skill.ts | 362 ++++++++++++++++ .../cli/src/utils/is-telemetry-enabled.ts | 15 + .../cli/src/utils/last-selected-agents.ts | 50 +++ .../src/utils/parse-react-grab-payload.ts | 0 .../cli/src/utils/parse-timeout-seconds.ts | 13 + .../src/utils/read-clipboard-linux.ts | 2 +- .../src/utils/read-clipboard-macos.ts | 2 +- .../src/utils/read-clipboard-outcome.ts | 1 + .../src/utils/read-clipboard-payload.ts | 6 +- .../src/utils/read-clipboard-windows.ts | 2 +- .../src/utils/read-clipboard-wsl.ts | 2 +- .../{mcp => cli}/src/utils/run-exec-file.ts | 0 packages/cli/src/utils/skill-template.ts | 54 +++ .../{mcp => cli}/src/utils/surface-stderr.ts | 2 +- packages/cli/src/utils/wait-for-next-grab.ts | 65 +++ .../test/detect-clipboard-env.test.ts | 0 packages/cli/test/format-payload.test.ts | 125 ++++++ .../{mcp => cli}/test/has-error-code.test.ts | 0 .../test/helpers/mock-exec-file.ts | 33 ++ packages/cli/test/install-mcp.test.ts | 270 ------------ packages/cli/test/install-skill.test.ts | 400 ++++++++++++++++++ .../cli/test/is-telemetry-enabled.test.ts | 65 +++ .../cli/test/last-selected-agents.test.ts | 75 ++++ .../test/parse-react-grab-payload.test.ts | 30 ++ .../cli/test/parse-timeout-seconds.test.ts | 48 +++ .../test/read-clipboard-linux.test.ts | 22 +- .../test/read-clipboard-macos.test.ts | 11 +- .../test/read-clipboard-payload.test.ts | 6 +- .../test/read-clipboard-windows.test.ts | 26 +- .../test/read-clipboard-wsl.test.ts | 0 .../{mcp => cli}/test/run-exec-file.test.ts | 0 packages/cli/test/watch-cli.test.ts | 57 +++ packages/cli/test/watch-format.test.ts | 41 ++ packages/cli/test/watch.test.ts | 239 +++++++++++ packages/cli/tsconfig.json | 5 +- packages/grab/README.md | 6 +- packages/mcp/CHANGELOG.md | 6 + packages/mcp/README.md | 30 ++ packages/mcp/package.json | 19 +- packages/mcp/src/cli.ts | 17 +- packages/mcp/src/constants.ts | 4 +- packages/mcp/src/server.ts | 103 ----- packages/mcp/test/deprecation-stub.test.ts | 26 ++ packages/mcp/test/server.test.ts | 268 ------------ packages/mcp/tsconfig.json | 2 +- packages/mcp/vite.config.ts | 3 +- packages/react-grab/README.md | 6 +- packages/react-grab/docs/architecture.md | 6 +- pnpm-lock.yaml | 56 +-- skills/react-grab/SKILL.md | 52 ++- 66 files changed, 2419 insertions(+), 1152 deletions(-) create mode 100644 packages/cli/src/commands/install-skill.ts create mode 100644 packages/cli/src/commands/watch.ts rename packages/{mcp => cli}/src/utils/detect-clipboard-env.ts (100%) create mode 100644 packages/cli/src/utils/format-payload.ts rename packages/{mcp => cli}/src/utils/has-error-code.ts (100%) delete mode 100644 packages/cli/src/utils/install-mcp.ts create mode 100644 packages/cli/src/utils/install-skill.ts create mode 100644 packages/cli/src/utils/is-telemetry-enabled.ts create mode 100644 packages/cli/src/utils/last-selected-agents.ts rename packages/{mcp => cli}/src/utils/parse-react-grab-payload.ts (100%) create mode 100644 packages/cli/src/utils/parse-timeout-seconds.ts rename packages/{mcp => cli}/src/utils/read-clipboard-linux.ts (99%) rename packages/{mcp => cli}/src/utils/read-clipboard-macos.ts (98%) rename packages/{mcp => cli}/src/utils/read-clipboard-outcome.ts (77%) rename packages/{mcp => cli}/src/utils/read-clipboard-payload.ts (88%) rename packages/{mcp => cli}/src/utils/read-clipboard-windows.ts (99%) rename packages/{mcp => cli}/src/utils/read-clipboard-wsl.ts (96%) rename packages/{mcp => cli}/src/utils/run-exec-file.ts (100%) create mode 100644 packages/cli/src/utils/skill-template.ts rename packages/{mcp => cli}/src/utils/surface-stderr.ts (87%) create mode 100644 packages/cli/src/utils/wait-for-next-grab.ts rename packages/{mcp => cli}/test/detect-clipboard-env.test.ts (100%) create mode 100644 packages/cli/test/format-payload.test.ts rename packages/{mcp => cli}/test/has-error-code.test.ts (100%) rename packages/{mcp => cli}/test/helpers/mock-exec-file.ts (53%) delete mode 100644 packages/cli/test/install-mcp.test.ts create mode 100644 packages/cli/test/install-skill.test.ts create mode 100644 packages/cli/test/is-telemetry-enabled.test.ts create mode 100644 packages/cli/test/last-selected-agents.test.ts rename packages/{mcp => cli}/test/parse-react-grab-payload.test.ts (61%) create mode 100644 packages/cli/test/parse-timeout-seconds.test.ts rename packages/{mcp => cli}/test/read-clipboard-linux.test.ts (79%) rename packages/{mcp => cli}/test/read-clipboard-macos.test.ts (73%) rename packages/{mcp => cli}/test/read-clipboard-payload.test.ts (93%) rename packages/{mcp => cli}/test/read-clipboard-windows.test.ts (68%) rename packages/{mcp => cli}/test/read-clipboard-wsl.test.ts (100%) rename packages/{mcp => cli}/test/run-exec-file.test.ts (100%) create mode 100644 packages/cli/test/watch-cli.test.ts create mode 100644 packages/cli/test/watch-format.test.ts create mode 100644 packages/cli/test/watch.test.ts create mode 100644 packages/mcp/README.md delete mode 100644 packages/mcp/src/server.ts create mode 100644 packages/mcp/test/deprecation-stub.test.ts delete mode 100644 packages/mcp/test/server.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0dc2e61c1..d10b7106a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,9 +46,9 @@ apps/ └── website/ # Documentation site (react-grab.com) packages/ -├── cli/ # CLI implementation (@react-grab/cli) +├── cli/ # CLI implementation (@react-grab/cli) including `react-grab watch` and `install-skill` ├── grab/ # Bundled package (library + CLI, published as `grab`) -├── mcp/ # MCP server (@react-grab/mcp) +├── mcp/ # Deprecated stub for @react-grab/mcp (prints migration notice) └── react-grab/ # Core library ``` diff --git a/README.md b/README.md index a3947d42c..fa2f272ff 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,14 @@ Run this command at your project root (where `next.config.ts` or `vite.config.ts npx grab@latest init ``` -## Connect to MCP +## Install agent skill ```bash -npx grab@latest add mcp +npx grab@latest install-skill ``` +Installs a `react-grab` skill into Cursor / Claude Code / Codex / OpenCode. Once installed, type `/react-grab` in your agent and click any element on the page — the agent receives the file name, React component, and HTML for that element. + ## Usage Once installed, hover over any UI element in your browser and press: diff --git a/package.json b/package.json index 4c0c17cf1..8f64df1a4 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ ], "type": "module", "scripts": { - "build": "cp README.md packages/react-grab/README.md && turbo run build --filter=@react-grab/cli --filter=react-grab --filter=grab --filter=@react-grab/mcp", + "build": "cp README.md packages/react-grab/README.md && turbo run build --filter=@react-grab/cli --filter=react-grab --filter=grab", "dev": "turbo run dev --filter=react-grab", "test": "turbo run test", "test:cli": "turbo run test --filter=@react-grab/cli", diff --git a/packages/cli/README.md b/packages/cli/README.md index a4a394620..bf568d4c1 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,6 +1,6 @@ # @react-grab/cli -Interactive CLI to install and configure React Grab in your project. +Interactive CLI to install and configure React Grab in your project, plus a `watch` subcommand that streams the next React Grab clipboard payload to stdout for AI coding agents. ## Quick Start @@ -27,31 +27,48 @@ npx grab@latest init | `--pkg ` | | Custom package URL | | `--cwd ` | `-c` | Working directory (default: current dir) | -### `grab add` +### `grab install-skill` -Connect React Grab to your coding agent via MCP. +Install the `react-grab` skill into known agent skill directories (Cursor, Claude Code, Codex, OpenCode). Once installed, the agent will auto-invoke it on `/react-grab` or when the user references a previously-grabbed element. ```bash -npx grab@latest add mcp +npx grab@latest install-skill ``` -| Option | Alias | Description | -| ------------- | ----- | ---------------------------------------- | -| `--yes` | `-y` | Skip confirmation prompts | -| `--cwd ` | `-c` | Working directory (default: current dir) | +| Option | Alias | Description | +| ------------------- | ----- | ------------------------------------------------------------- | +| `--yes` | `-y` | Install to all supported agents without prompting | +| `--agent ` | `-a` | Install only to the named agent(s) (e.g. Cursor, Claude Code) | + +Aliased as `grab add` (and the legacy `grab add mcp` redirects to skill install). ### `grab remove` -Disconnect React Grab from your coding agent. +Remove the React Grab skill from the selected agents. + +```bash +npx grab@latest remove +``` + +| Option | Alias | Description | +| ------------------- | ----- | -------------------------------------------------- | +| `--yes` | `-y` | Remove from all supported agents without prompting | +| `--agent ` | `-a` | Remove only from the named agent(s) | + +### `grab watch` + +Block until the next React Grab payload appears on the clipboard, print it to stdout, exit. The skill installed by `install-skill` shells out to this command — but you can also run it directly to script around grabs. ```bash -npx grab@latest remove mcp +npx -y @react-grab/cli watch ``` -| Option | Alias | Description | -| ------------- | ----- | ---------------------------------------- | -| `--yes` | `-y` | Skip confirmation prompts | -| `--cwd ` | `-c` | Working directory (default: current dir) | +| Option | Alias | Description | +| --------------------- | ----- | --------------------------------------------------------------- | +| `--timeout ` | `-t` | Seconds to wait before giving up (`0` = forever, default `600`) | +| `--json` | | Print the raw `ReactGrabPayload` JSON instead of formatted text | + +Exit codes: `0` on a fresh grab printed, `1` on timeout, `2` on clipboard read error. ### `grab configure` @@ -84,16 +101,24 @@ npx grab@latest init -y # Set a custom activation key npx grab@latest init -k "Meta+K" -# Connect MCP to your agent -npx grab@latest add mcp +# Install the React Grab skill into all supported agents +npx grab@latest install-skill -y + +# Wait up to 30s for the next grab and print as JSON +npx -y @react-grab/cli watch --timeout 30 --json # Change activation mode to hold npx grab@latest configure --mode hold --hold-duration 500 - -# Interactive configuration wizard -npx grab@latest configure ``` +## Migration from @react-grab/mcp + +`@react-grab/mcp` is deprecated. To migrate: + +1. Run `npx grab@latest install-skill`. +2. Remove the `react-grab-mcp` entry from your agent's `mcp.json` (Cursor, Claude Code, Codex, OpenCode, Windsurf, etc.). +3. Restart your agent. Type `/react-grab` and click an element. + ## Supported Frameworks | Framework | Detection | diff --git a/packages/cli/package.json b/packages/cli/package.json index f6c138920..b665bacf6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -21,6 +21,7 @@ "build": "rm -rf dist && NODE_ENV=production vp pack", "test": "vp test run", "test:watch": "vp test", + "typecheck": "tsc --noEmit", "lint": "vp lint", "format": "vp fmt", "format:check": "vp fmt --check", @@ -34,7 +35,8 @@ "ora": "^8.2.0", "picocolors": "^1.1.1", "prompts": "^2.4.2", - "smol-toml": "^1.6.0" + "smol-toml": "^1.6.0", + "zod": "^3.25.0" }, "devDependencies": { "@types/prompts": "^2.4.9" diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 1035d22cd..fc75590a2 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -2,8 +2,11 @@ import { Command } from "commander"; import { add } from "./commands/add.js"; import { configure } from "./commands/configure.js"; import { init } from "./commands/init.js"; +import { installSkill } from "./commands/install-skill.js"; import { remove } from "./commands/remove.js"; import { upgrade } from "./commands/upgrade.js"; +import { watch } from "./commands/watch.js"; +import { isTelemetryEnabled } from "./utils/is-telemetry-enabled.js"; const VERSION = process.env.VERSION ?? "0.0.1"; const VERSION_API_URL = "https://www.react-grab.com/api/version"; @@ -11,9 +14,11 @@ const VERSION_API_URL = "https://www.react-grab.com/api/version"; process.on("SIGINT", () => process.exit(0)); process.on("SIGTERM", () => process.exit(0)); -try { - fetch(`${VERSION_API_URL}?source=cli&v=${VERSION}&t=${Date.now()}`).catch(() => {}); -} catch {} +if (isTelemetryEnabled()) { + try { + fetch(`${VERSION_API_URL}?source=cli&v=${VERSION}&t=${Date.now()}`).catch(() => {}); + } catch {} +} const program = new Command() .name("grab") @@ -25,6 +30,8 @@ program.addCommand(add); program.addCommand(remove); program.addCommand(configure); program.addCommand(upgrade); +program.addCommand(installSkill); +program.addCommand(watch); const main = async () => { await program.parseAsync(); diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 2dac4fac6..543de4939 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -4,8 +4,13 @@ import { detectNonInteractive } from "../utils/is-non-interactive.js"; import { detectProject } from "../utils/detect.js"; import { handleError } from "../utils/handle-error.js"; import { highlighter } from "../utils/highlighter.js"; -import { installMcpServers, promptMcpInstall } from "../utils/install-mcp.js"; +import { + installDetectedOrAllSkills, + promptSkillInstall, + type SkillScope, +} from "../utils/install-skill.js"; import { logger } from "../utils/logger.js"; +import { prompts } from "../utils/prompts.js"; import { spinner } from "../utils/spinner.js"; const VERSION = process.env.VERSION ?? "0.0.1"; @@ -13,8 +18,8 @@ const VERSION = process.env.VERSION ?? "0.0.1"; export const add = new Command() .name("add") .alias("install") - .description("connect React Grab to your agent via MCP") - .argument("[agent]", "agent to connect (mcp)") + .description("connect React Grab to your agent by installing the skill") + .argument("[agent]", "legacy alias kept for backward compatibility (e.g. mcp, skill)") .option("-y, --yes", "skip confirmation prompts", false) .option("-c, --cwd ", "working directory (defaults to current directory)", process.cwd()) .action(async (agentArg, opts) => { @@ -39,48 +44,61 @@ export const add = new Command() preflightSpinner.succeed(); - if (agentArg && agentArg !== "mcp") { + const VALID_AGENT_ARGS: readonly string[] = ["mcp", "skill"]; + if (agentArg === "mcp") { logger.break(); logger.warn( - `Legacy agent packages are deprecated. Use ${highlighter.info("mcp")} instead.`, + `${highlighter.info("@react-grab/mcp")} is deprecated. Installing the React Grab skill instead.`, + ); + logger.log(`Run ${highlighter.info("grab install-skill")} directly going forward.`); + logger.break(); + } else if (agentArg && !VALID_AGENT_ARGS.includes(agentArg)) { + logger.break(); + logger.error( + `Unknown agent "${agentArg}". Valid values: ${VALID_AGENT_ARGS.join(", ")} (or omit the argument).`, ); - logger.log(`Run ${highlighter.info("grab add mcp")} to install the MCP server.`); logger.break(); process.exit(1); } - if (agentArg === "mcp" || isNonInteractive) { - if (isNonInteractive) { - const results = installMcpServers(); - const hasSuccess = results.some((result) => result.success); - if (!hasSuccess) { - logger.break(); - logger.error("Failed to install MCP server."); - logger.break(); - process.exit(1); - } - } else { - const didInstall = await promptMcpInstall(); - if (!didInstall) { - logger.break(); - process.exit(0); - } + if (isNonInteractive) { + const results = installDetectedOrAllSkills("project", cwd); + const hasSuccess = results.some((result) => result.success); + if (!hasSuccess) { + logger.break(); + logger.error("Failed to install React Grab skill."); + logger.break(); + process.exit(1); } - logger.break(); - logger.log(`${highlighter.success("Success!")} MCP server has been configured.`); - logger.log("Restart your agents to activate."); - logger.break(); } else { - const didInstall = await promptMcpInstall(); + logger.break(); + const { skillScope } = await prompts({ + type: "select", + name: "skillScope", + message: "Where should the React Grab skill be installed?", + choices: [ + { title: "In this project (committed to repo)", value: "project" }, + { title: "Globally (per-user)", value: "global" }, + ], + initial: 0, + }); + + if (skillScope === undefined) { + logger.break(); + process.exit(1); + } + + const didInstall = await promptSkillInstall(skillScope as SkillScope, cwd); if (!didInstall) { logger.break(); process.exit(0); } - logger.break(); - logger.log(`${highlighter.success("Success!")} MCP server has been configured.`); - logger.log("Restart your agents to activate."); - logger.break(); } + + logger.break(); + logger.log(`${highlighter.success("Success!")} React Grab skill installed.`); + logger.log("Restart your agent(s) to pick it up."); + logger.break(); } catch (error) { handleError(error); } diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 75588a0a2..6133dba87 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -5,7 +5,8 @@ import pc from "picocolors"; import { detectNonInteractive } from "../utils/is-non-interactive.js"; import { prompts } from "../utils/prompts.js"; import { applyTransformWithFeedback, installPackagesWithFeedback } from "../utils/cli-helpers.js"; -import { promptMcpInstall } from "../utils/install-mcp.js"; +import { installDetectedOrAllSkills, type SkillScope } from "../utils/install-skill.js"; +import { isTelemetryEnabled } from "../utils/is-telemetry-enabled.js"; import { detectProject, findReactProjects, @@ -40,6 +41,7 @@ interface ReportConfig { } const reportToCli = (type: "error" | "completed", config?: ReportConfig, error?: Error): void => { + if (!isTelemetryEnabled()) return; fetch(REPORT_URL, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -327,27 +329,33 @@ export const init = new Command() } logger.break(); - const { wantAddMcp } = await prompts({ - type: "confirm", - name: "wantAddMcp", - message: `Would you like to ${highlighter.info("connect it to your agent via MCP")}?`, - initial: false, + const { skillChoice } = await prompts({ + type: "select", + name: "skillChoice", + message: `Install the ${highlighter.info("React Grab skill")} into your agent?`, + choices: [ + { title: "Yes, in this project (committed to repo)", value: "project" }, + { title: "Yes, globally (per-user)", value: "global" }, + { title: "No", value: "no" }, + ], + initial: 0, }); - if (wantAddMcp === undefined) { + if (skillChoice === undefined) { logger.break(); process.exit(1); } - if (wantAddMcp) { - const didInstall = await promptMcpInstall(); + if (skillChoice !== "no") { + const results = installDetectedOrAllSkills(skillChoice as SkillScope, cwd); + const didInstall = results.some((result) => result.success); if (!didInstall) { logger.break(); process.exit(0); } logger.break(); - logger.success("MCP server has been configured."); - logger.log("Restart your agents to activate."); + logger.success("React Grab skill has been installed."); + logger.log("Restart your agent(s) to pick it up."); } logger.break(); @@ -450,30 +458,36 @@ export const init = new Command() const finalFramework = projectInfo.framework; const finalPackageManager = projectInfo.packageManager; const finalNextRouterType = projectInfo.nextRouterType; - let didInstallMcp = false; + let didInstallSkill = false; if (!isNonInteractive) { logger.break(); - const { wantAddMcp } = await prompts({ - type: "confirm", - name: "wantAddMcp", - message: `Would you like to ${highlighter.info("connect it to your agent via MCP")}?`, - initial: false, + const { skillChoice } = await prompts({ + type: "select", + name: "skillChoice", + message: `Install the ${highlighter.info("React Grab skill")} into your agent?`, + choices: [ + { title: "Yes, in this project (committed to repo)", value: "project" }, + { title: "Yes, globally (per-user)", value: "global" }, + { title: "No", value: "no" }, + ], + initial: 0, }); - if (wantAddMcp === undefined) { + if (skillChoice === undefined) { logger.break(); process.exit(1); } - if (wantAddMcp) { - didInstallMcp = Boolean(await promptMcpInstall()); - if (!didInstallMcp) { + if (skillChoice !== "no") { + const results = installDetectedOrAllSkills(skillChoice as SkillScope, cwd); + didInstallSkill = results.some((result) => result.success); + if (!didInstallSkill) { logger.break(); process.exit(0); } logger.break(); - logger.success("MCP server has been configured."); + logger.success("React Grab skill has been installed."); logger.log("Continuing with React Grab installation..."); logger.break(); } @@ -547,7 +561,7 @@ export const init = new Command() framework: finalFramework, packageManager: finalPackageManager, router: finalNextRouterType, - agent: didInstallMcp ? "mcp" : undefined, + agent: didInstallSkill ? "skill" : undefined, isMonorepo: projectInfo.isMonorepo, }); } catch (error) { diff --git a/packages/cli/src/commands/install-skill.ts b/packages/cli/src/commands/install-skill.ts new file mode 100644 index 000000000..d64300b17 --- /dev/null +++ b/packages/cli/src/commands/install-skill.ts @@ -0,0 +1,156 @@ +import { Command } from "commander"; +import pc from "picocolors"; +import { handleError } from "../utils/handle-error.js"; +import { highlighter } from "../utils/highlighter.js"; +import { + buildAgentChoices, + detectInstalledSkillClients, + getSkillClientNames, + installSkills, + type SkillScope, +} from "../utils/install-skill.js"; +import { readLastSelectedAgents, writeLastSelectedAgents } from "../utils/last-selected-agents.js"; +import { logger } from "../utils/logger.js"; +import { prompts } from "../utils/prompts.js"; + +const VERSION = process.env.VERSION ?? "0.0.1"; + +const SKILL_SCOPES: readonly SkillScope[] = ["global", "project"]; + +interface InstallSkillCommandOptions { + yes?: boolean; + agent?: string[]; + scope?: string; + cwd: string; +} + +const isSkillScope = (value: unknown): value is SkillScope => + typeof value === "string" && (SKILL_SCOPES as readonly string[]).includes(value); + +const promptForScope = async (): Promise => { + const { selectedScope } = await prompts({ + type: "select", + name: "selectedScope", + message: "Where should the React Grab skill be installed?", + choices: [ + { + title: "In this project (committed to repo, only this repo's agents see it)", + value: "project", + }, + { title: "Globally (per-user, every project sees it)", value: "global" }, + ], + initial: 0, + }); + if (selectedScope === undefined) return undefined; + if (!isSkillScope(selectedScope)) { + logger.error(`Unexpected scope value from prompt: ${String(selectedScope)}`); + return undefined; + } + return selectedScope; +}; + +export const installSkill = new Command() + .name("install-skill") + .description("install the React Grab skill into your agent's skills directory") + .option("-y, --yes", "install to detected agents (or all supported) without prompting", false) + .option( + "-a, --agent ", + "install only to the named agent(s) (e.g. --agent Cursor 'Claude Code')", + ) + .option("-s, --scope ", "install scope: global (per-user) or project (committed to repo)") + .option("-c, --cwd ", "working directory used for project-scope installs", process.cwd()) + .action(async (opts: InstallSkillCommandOptions) => { + console.log(`${pc.magenta("✿")} ${pc.bold("React Grab")} ${pc.gray(VERSION)}`); + console.log(); + + try { + if (opts.scope !== undefined && !isSkillScope(opts.scope)) { + logger.error(`Invalid --scope "${opts.scope}". Valid values: ${SKILL_SCOPES.join(", ")}.`); + logger.break(); + process.exit(1); + } + + const allNames = getSkillClientNames(); + const flagScope: SkillScope | undefined = isSkillScope(opts.scope) ? opts.scope : undefined; + + if (opts.agent && opts.agent.length > 0) { + const unknown = opts.agent.filter((name) => !allNames.includes(name)); + if (unknown.length > 0) { + logger.error(`Unknown agent(s): ${unknown.join(", ")}`); + logger.log(`Supported: ${allNames.join(", ")}`); + logger.break(); + process.exit(1); + } + const scope: SkillScope = flagScope ?? "project"; + installSkills({ scope, cwd: opts.cwd, selectedClients: opts.agent }); + writeLastSelectedAgents(opts.agent); + logger.break(); + logger.log("Restart your agent(s) to pick up the new skill."); + logger.break(); + return; + } + + let scope: SkillScope; + if (flagScope) { + scope = flagScope; + } else if (opts.yes) { + scope = "project"; + } else { + const promptedScope = await promptForScope(); + if (promptedScope === undefined) { + logger.break(); + process.exit(1); + } + scope = promptedScope; + } + + if (opts.yes) { + const detected = detectInstalledSkillClients(); + const targets = detected.length > 0 ? detected : allNames; + installSkills({ scope, cwd: opts.cwd, selectedClients: targets }); + writeLastSelectedAgents(targets); + logger.break(); + logger.log("Restart your agent(s) to pick up the new skill."); + logger.break(); + return; + } + + const detected = detectInstalledSkillClients(); + if (detected.length === 1 && readLastSelectedAgents().length === 0) { + const onlyDetected = detected[0]!; + logger.log( + `Auto-installing to ${highlighter.info(onlyDetected)} (only detected agent). Pass ${highlighter.info("--agent")} to override.`, + ); + logger.break(); + installSkills({ scope, cwd: opts.cwd, selectedClients: [onlyDetected] }); + writeLastSelectedAgents([onlyDetected]); + logger.break(); + logger.log(`${highlighter.success("Done.")} Restart your agent to pick up the new skill.`); + logger.break(); + return; + } + + const { selectedAgents } = await prompts({ + type: "multiselect", + name: "selectedAgents", + message: `Select agents to install the React Grab skill for (${scope}):`, + choices: buildAgentChoices(scope, { allClients: true }), + }); + + if (selectedAgents === undefined || selectedAgents.length === 0) { + logger.break(); + logger.log("No agents selected. Nothing to do."); + logger.break(); + process.exit(0); + } + + logger.break(); + installSkills({ scope, cwd: opts.cwd, selectedClients: selectedAgents }); + writeLastSelectedAgents(selectedAgents); + logger.break(); + logger.log(`${highlighter.success("Done.")} Restart your agent(s) to pick up the new skill.`); + logger.break(); + } catch (error) { + handleError(error); + } + }); diff --git a/packages/cli/src/commands/remove.ts b/packages/cli/src/commands/remove.ts index 0751e73d6..c3c4b2753 100644 --- a/packages/cli/src/commands/remove.ts +++ b/packages/cli/src/commands/remove.ts @@ -1,53 +1,113 @@ import { Command } from "commander"; import pc from "picocolors"; -import { detectProject } from "../utils/detect.js"; import { handleError } from "../utils/handle-error.js"; import { highlighter } from "../utils/highlighter.js"; import { logger } from "../utils/logger.js"; -import { spinner } from "../utils/spinner.js"; +import { prompts } from "../utils/prompts.js"; +import { + getSkillClientNames, + getSkillClients, + removeSkills, + type SkillScope, +} from "../utils/install-skill.js"; const VERSION = process.env.VERSION ?? "0.0.1"; +const SKILL_SCOPES: readonly SkillScope[] = ["global", "project"]; + +interface RemoveCommandOptions { + yes?: boolean; + agent?: string[]; + scope?: string; + cwd: string; +} + +const isSkillScope = (value: unknown): value is SkillScope => + typeof value === "string" && (SKILL_SCOPES as readonly string[]).includes(value); + export const remove = new Command() .name("remove") - .description("disconnect React Grab from your agent") - .argument("[agent]", "agent to disconnect (mcp)") - .option("-y, --yes", "skip confirmation prompts", false) - .option("-c, --cwd ", "working directory (defaults to current directory)", process.cwd()) - .action(async (agentArg, opts) => { + .description("remove the React Grab skill from your agent(s)") + .option("-y, --yes", "remove from all supported agents without prompting", false) + .option("-a, --agent ", "remove only from the named agent(s)") + .option("-s, --scope ", "scope to remove from: global or project") + .option("-c, --cwd ", "working directory used for project-scope removes", process.cwd()) + .action(async (opts: RemoveCommandOptions) => { console.log(`${pc.magenta("✿")} ${pc.bold("React Grab")} ${pc.gray(VERSION)}`); console.log(); try { - const cwd = opts.cwd; - - const preflightSpinner = spinner("Preflight checks.").start(); - - const projectInfo = await detectProject(cwd); - - if (!projectInfo.hasReactGrab) { - preflightSpinner.fail("React Grab is not installed."); - logger.break(); - logger.error(`Run ${highlighter.info("react-grab init")} first to install React Grab.`); + if (opts.scope !== undefined && !isSkillScope(opts.scope)) { + logger.error(`Invalid --scope "${opts.scope}". Valid values: ${SKILL_SCOPES.join(", ")}.`); logger.break(); process.exit(1); } - preflightSpinner.succeed(); + const supported = getSkillClients() + .filter((client) => client.supported) + .map((client) => client.name); + const allNames = getSkillClientNames(); - if (agentArg && agentArg !== "mcp") { - logger.break(); - logger.warn( - `Legacy agent packages are deprecated. Uninstall ${highlighter.info(`@react-grab/${agentArg}`)} manually with your package manager.`, - ); - logger.break(); - process.exit(0); + let targets: string[]; + if (opts.agent && opts.agent.length > 0) { + const unknown = opts.agent.filter((name) => !allNames.includes(name)); + if (unknown.length > 0) { + logger.error(`Unknown agent(s): ${unknown.join(", ")}`); + logger.log(`Supported: ${supported.join(", ")}`); + logger.break(); + process.exit(1); + } + targets = opts.agent; + } else if (opts.yes) { + targets = supported; + } else { + const { selectedAgents } = await prompts({ + type: "multiselect", + name: "selectedAgents", + message: "Select agents to remove the React Grab skill from:", + choices: supported.map((name) => ({ + title: name, + value: name, + selected: true, + })), + }); + + if (selectedAgents === undefined || selectedAgents.length === 0) { + logger.break(); + logger.log("No agents selected. Nothing to do."); + logger.break(); + process.exit(0); + } + targets = selectedAgents; } - logger.break(); - logger.warn( - "To remove the MCP server, delete the react-grab-mcp entry from your agent's MCP config file.", + const scopesToTry: SkillScope[] = isSkillScope(opts.scope) + ? [opts.scope] + : ["project", "global"]; + + const aggregated = scopesToTry.flatMap((scope) => + removeSkills({ scope, cwd: opts.cwd, selectedClients: targets }).map((result) => ({ + ...result, + scope, + })), ); + + logger.break(); + for (const result of aggregated) { + if (result.removed) { + logger.log( + ` ${highlighter.success("\u2713")} ${result.client} ${highlighter.dim(`(${result.scope})`)} ${highlighter.dim("\u2192")} removed`, + ); + } else if (result.deduped) { + logger.log( + ` ${highlighter.dim("\u2212")} ${result.client} ${highlighter.dim(`(${result.scope}, shared with another agent)`)}`, + ); + } + } + const removedAny = aggregated.some((result) => result.removed); + if (!removedAny) { + logger.log("Nothing to remove."); + } logger.break(); } catch (error) { handleError(error); diff --git a/packages/cli/src/commands/watch.ts b/packages/cli/src/commands/watch.ts new file mode 100644 index 000000000..cc26e481d --- /dev/null +++ b/packages/cli/src/commands/watch.ts @@ -0,0 +1,94 @@ +import { Command } from "commander"; +import { + MS_PER_SECOND, + WATCH_DEFAULT_TIMEOUT_MS, + WATCH_POLL_INTERVAL_MS, +} from "../utils/constants.js"; +import { formatPayload } from "../utils/format-payload.js"; +import { parseTimeoutSeconds } from "../utils/parse-timeout-seconds.js"; +import { + readClipboardPayload, + type ReadClipboardPayloadResult, +} from "../utils/read-clipboard-payload.js"; +import type { ReactGrabPayload } from "../utils/parse-react-grab-payload.js"; +import { waitForNextGrab } from "../utils/wait-for-next-grab.js"; + +interface WatchCommandOptions { + json?: boolean; + timeout: string; +} + +const fail = (message: string, exitCode: number): never => { + process.stderr.write(`${message}\n`); + process.exit(exitCode); +}; + +export const formatResultForStdout = (payload: ReactGrabPayload, asJson?: boolean): string => + asJson ? JSON.stringify(payload) : formatPayload(payload); + +const printPayload = (payload: ReactGrabPayload, asJson?: boolean): void => { + process.stdout.write(`${formatResultForStdout(payload, asJson)}\n`); +}; + +const formatUnrecoverableMessage = (result: ReadClipboardPayloadResult): string => + result.hint ?? `Clipboard channel is unavailable in this environment (${result.env}).`; + +const resolveTimeoutSeconds = (raw: string): number => { + try { + return parseTimeoutSeconds(raw); + } catch (caughtError) { + return fail(caughtError instanceof Error ? caughtError.message : String(caughtError), 2); + } +}; + +export const watch = new Command() + .name("watch") + .description("wait for the next React Grab selection on the clipboard, print it, exit") + .option("--json", "print the raw ReactGrabPayload JSON instead of formatted text") + .option( + "-t, --timeout ", + "seconds to wait before giving up (0 = forever)", + String(WATCH_DEFAULT_TIMEOUT_MS / MS_PER_SECOND), + ) + .action(async (rawOptions: WatchCommandOptions) => { + const timeoutSeconds = resolveTimeoutSeconds(rawOptions.timeout); + + const initialResult = await readClipboardPayload(); + if (!initialResult.recoverable) { + fail(formatUnrecoverableMessage(initialResult), 2); + } + + const initialTimestamp = initialResult.payload?.timestamp ?? null; + + process.stderr.write("Waiting for React Grab clipboard...\n"); + + const waitResult = await waitForNextGrab({ + initialTimestamp, + timeoutMs: timeoutSeconds * MS_PER_SECOND, + pollIntervalMs: WATCH_POLL_INTERVAL_MS, + read: readClipboardPayload, + }); + + switch (waitResult.outcome) { + case "match": + printPayload(waitResult.payload, rawOptions.json); + process.exit(0); + break; + case "unrecoverable": + fail(formatUnrecoverableMessage(waitResult.result), 2); + break; + case "timeout": + fail( + `Timed out after ${timeoutSeconds}s without a new React Grab clipboard payload.\nClick an element in the React Grab toolbar and re-run.`, + 1, + ); + break; + case "aborted": + fail("Aborted before a new React Grab payload arrived.", 1); + break; + default: { + const exhaustive: never = waitResult; + fail(`Unhandled watch outcome: ${JSON.stringify(exhaustive)}`, 2); + } + } + }); diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts index 0d6e85f61..c3e8d7961 100644 --- a/packages/cli/src/utils/constants.ts +++ b/packages/cli/src/utils/constants.ts @@ -1,3 +1,31 @@ export const MAX_SUGGESTIONS_COUNT = 30; export const MAX_KEY_HOLD_DURATION_MS = 2000; export const MAX_CONTEXT_LINES = 50; +export const CLIPBOARD_READ_TIMEOUT_MS = 3000; +export const REACT_GRAB_MIME_TYPE = "application/x-react-grab"; +export const WATCH_POLL_INTERVAL_MS = 250; +export const WATCH_DEFAULT_TIMEOUT_MS = 600_000; +export const MS_PER_SECOND = 1000; +export const NPM_PACKAGE_NAME = "@react-grab/cli"; +export const SKILL_NAME = "react-grab"; + +export const CANONICAL_AGENTS_DIR = ".agents"; +export const CANONICAL_SKILLS_SUBDIR = "skills"; +export const STATE_DIR_NAME = "react-grab"; +export const LAST_SELECTED_AGENTS_FILE = "last-selected-agents.json"; +export const FALLBACK_STATE_HOME_RELATIVE = ".local/state"; + +export const CI_ENV_KEYS = [ + "CI", + "GITHUB_ACTIONS", + "GITLAB_CI", + "CIRCLECI", + "TRAVIS", + "BUILDKITE", + "JENKINS_URL", + "TEAMCITY_VERSION", + "DRONE", + "BITBUCKET_BUILD_NUMBER", +] as const; + +export const TELEMETRY_OPT_OUT_ENV_KEYS = ["DISABLE_TELEMETRY", "DO_NOT_TRACK"] as const; diff --git a/packages/mcp/src/utils/detect-clipboard-env.ts b/packages/cli/src/utils/detect-clipboard-env.ts similarity index 100% rename from packages/mcp/src/utils/detect-clipboard-env.ts rename to packages/cli/src/utils/detect-clipboard-env.ts diff --git a/packages/cli/src/utils/format-payload.ts b/packages/cli/src/utils/format-payload.ts new file mode 100644 index 000000000..5deac57ab --- /dev/null +++ b/packages/cli/src/utils/format-payload.ts @@ -0,0 +1,45 @@ +import type { ReactGrabPayload } from "./parse-react-grab-payload.js"; + +const stripLeadingPromptPrefix = (content: string, rawPrompts: string[]): string => { + // The browser-side producer prepends the *untrimmed* prompt followed by + // "\n\n" to payload.content, so we match against the raw commentText. + // We deliberately do not also try a trimmed candidate: that would risk + // stripping legitimate element content that happens to start with the + // prompt text (e.g. prompt "Click me" + element body "Click me\n\n..."). + for (const rawPrompt of rawPrompts) { + if (rawPrompt.length === 0) continue; + const candidate = `${rawPrompt}\n\n`; + if (content.startsWith(candidate)) { + return content.slice(candidate.length); + } + } + return content; +}; + +export const formatPayload = (payload: ReactGrabPayload): string => { + // The producer assigns the prompt to every entry's commentText, so dedupe + // via a Set to surface a single Prompt: line regardless of how many + // elements were copied. We keep the raw values around for prefix matching + // (the producer doesn't trim) and surface trimmed values to the LLM. + const rawPrompts: string[] = []; + const trimmedPrompts: string[] = []; + const seenTrimmed = new Set(); + for (const entry of payload.entries) { + const rawPrompt = entry.commentText; + if (typeof rawPrompt !== "string" || rawPrompt.length === 0) continue; + rawPrompts.push(rawPrompt); + const trimmed = rawPrompt.trim(); + if (trimmed.length === 0 || seenTrimmed.has(trimmed)) continue; + seenTrimmed.add(trimmed); + trimmedPrompts.push(trimmed); + } + // Use payload.content as the body so we preserve canonical formatting: + // the [1]/[2]/[3] labels added by joinSnippets for multi-element copies + // and any transformCopyContent output contributed by plugins + // (e.g. copy-html, copy-styles). + const elementsBody = stripLeadingPromptPrefix(payload.content, rawPrompts); + const elementsSection = `Elements (${payload.entries.length}):\n${elementsBody}`; + return trimmedPrompts.length > 0 + ? `Prompt: ${trimmedPrompts.join("\n")}\n\n${elementsSection}` + : elementsSection; +}; diff --git a/packages/mcp/src/utils/has-error-code.ts b/packages/cli/src/utils/has-error-code.ts similarity index 100% rename from packages/mcp/src/utils/has-error-code.ts rename to packages/cli/src/utils/has-error-code.ts diff --git a/packages/cli/src/utils/install-mcp.ts b/packages/cli/src/utils/install-mcp.ts deleted file mode 100644 index ab68a1fd7..000000000 --- a/packages/cli/src/utils/install-mcp.ts +++ /dev/null @@ -1,267 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import process from "node:process"; -import * as jsonc from "jsonc-parser"; -import * as TOML from "smol-toml"; -import { highlighter } from "./highlighter.js"; -import { logger } from "./logger.js"; -import { prompts } from "./prompts.js"; -import { spinner } from "./spinner.js"; - -const SERVER_NAME = "react-grab-mcp"; -const PACKAGE_NAME = "@react-grab/mcp"; - -export interface ClientDefinition { - name: string; - configPath: string; - configKey: string; - format: "json" | "toml"; - serverConfig: Record; -} - -interface InstallResult { - client: string; - configPath: string; - success: boolean; - error?: string; -} - -const getXdgConfigHome = (): string => - process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); - -const getBaseDir = (): string => { - const homeDir = os.homedir(); - if (process.platform === "win32") { - return process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"); - } - if (process.platform === "darwin") { - return path.join(homeDir, "Library", "Application Support"); - } - return getXdgConfigHome(); -}; - -const getZedConfigPath = (): string => { - if (process.platform === "win32") { - return path.join(getBaseDir(), "Zed", "settings.json"); - } - return path.join(os.homedir(), ".config", "zed", "settings.json"); -}; - -export const getOpenCodeConfigPath = (): string => { - const configDir = path.join(getXdgConfigHome(), "opencode"); - const jsoncPath = path.join(configDir, "opencode.jsonc"); - const jsonPath = path.join(configDir, "opencode.json"); - - if (fs.existsSync(jsoncPath)) return jsoncPath; - if (fs.existsSync(jsonPath)) return jsonPath; - return jsoncPath; -}; - -const getClients = (): ClientDefinition[] => { - const homeDir = os.homedir(); - const baseDir = getBaseDir(); - - const stdioConfig = { - command: "npx", - args: ["-y", PACKAGE_NAME], - }; - - return [ - { - name: "Claude Code", - configPath: path.join(homeDir, ".claude.json"), - configKey: "mcpServers", - format: "json", - serverConfig: stdioConfig, - }, - { - name: "Codex", - configPath: path.join(process.env.CODEX_HOME || path.join(homeDir, ".codex"), "config.toml"), - configKey: "mcp_servers", - format: "toml", - serverConfig: stdioConfig, - }, - { - name: "Cursor", - configPath: path.join(homeDir, ".cursor", "mcp.json"), - configKey: "mcpServers", - format: "json", - serverConfig: stdioConfig, - }, - { - name: "OpenCode", - configPath: getOpenCodeConfigPath(), - configKey: "mcp", - format: "json", - serverConfig: { - type: "local", - command: ["npx", "-y", PACKAGE_NAME], - }, - }, - { - name: "VS Code", - configPath: path.join(baseDir, "Code", "User", "mcp.json"), - configKey: "servers", - format: "json", - serverConfig: { type: "stdio", ...stdioConfig }, - }, - { - name: "Amp", - configPath: path.join(homeDir, ".config", "amp", "settings.json"), - configKey: "amp.mcpServers", - format: "json", - serverConfig: stdioConfig, - }, - { - name: "Droid", - configPath: path.join(homeDir, ".factory", "mcp.json"), - configKey: "mcpServers", - format: "json", - serverConfig: { type: "stdio", ...stdioConfig }, - }, - { - name: "Windsurf", - configPath: path.join(homeDir, ".codeium", "windsurf", "mcp_config.json"), - configKey: "mcpServers", - format: "json", - serverConfig: stdioConfig, - }, - { - name: "Zed", - configPath: getZedConfigPath(), - configKey: "context_servers", - format: "json", - serverConfig: { source: "custom", ...stdioConfig, env: {} }, - }, - ]; -}; - -const ensureDirectory = (filePath: string): void => { - const directory = path.dirname(filePath); - if (!fs.existsSync(directory)) { - fs.mkdirSync(directory, { recursive: true }); - } -}; - -const JSONC_FORMAT_OPTIONS: jsonc.FormattingOptions = { - tabSize: 2, - insertSpaces: true, -}; - -export const upsertIntoJsonc = ( - filePath: string, - content: string, - configKey: string, - serverName: string, - serverConfig: Record, -): void => { - const edits = jsonc.modify(content, [configKey, serverName], serverConfig, { - formattingOptions: JSONC_FORMAT_OPTIONS, - }); - fs.writeFileSync(filePath, jsonc.applyEdits(content, edits)); -}; - -export const installJsonClient = (client: ClientDefinition): void => { - ensureDirectory(client.configPath); - - const content = fs.existsSync(client.configPath) - ? fs.readFileSync(client.configPath, "utf8") - : "{}"; - - upsertIntoJsonc(client.configPath, content, client.configKey, SERVER_NAME, client.serverConfig); -}; - -export const installTomlClient = (client: ClientDefinition): void => { - ensureDirectory(client.configPath); - - const existingConfig: Record = fs.existsSync(client.configPath) - ? TOML.parse(fs.readFileSync(client.configPath, "utf8")) - : {}; - - const serverSection = (existingConfig[client.configKey] ?? {}) as Record; - serverSection[SERVER_NAME] = client.serverConfig; - existingConfig[client.configKey] = serverSection; - - fs.writeFileSync(client.configPath, TOML.stringify(existingConfig)); -}; - -export const getMcpClientNames = (): string[] => getClients().map((client) => client.name); - -export const installMcpServers = (selectedClients?: string[]): InstallResult[] => { - const allClients = getClients(); - const clients = selectedClients - ? allClients.filter((client) => selectedClients.includes(client.name)) - : allClients; - const results: InstallResult[] = []; - - const installSpinner = spinner("Installing MCP server.").start(); - - for (const client of clients) { - try { - if (client.format === "toml") { - installTomlClient(client); - } else { - installJsonClient(client); - } - results.push({ - client: client.name, - configPath: client.configPath, - success: true, - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - results.push({ - client: client.name, - configPath: client.configPath, - success: false, - error: message, - }); - } - } - - const successCount = results.filter((result) => result.success).length; - - if (successCount < results.length) { - installSpinner.warn(`Installed to ${successCount}/${results.length} agents.`); - } else { - installSpinner.succeed(`Installed to ${successCount} agents.`); - } - - for (const result of results) { - if (result.success) { - logger.log( - ` ${highlighter.success("\u2713")} ${result.client} ${highlighter.dim("\u2192")} ${highlighter.dim(result.configPath)}`, - ); - } else { - logger.log( - ` ${highlighter.error("\u2717")} ${result.client} ${highlighter.dim("\u2192")} ${result.error}`, - ); - } - } - - return results; -}; - -export const promptMcpInstall = async (): Promise => { - const clientNames = getMcpClientNames(); - const { selectedAgents } = await prompts({ - type: "multiselect", - name: "selectedAgents", - message: "Select agents to install MCP server for:", - choices: clientNames.map((name) => ({ - title: name, - value: name, - selected: true, - })), - }); - - if (selectedAgents === undefined || selectedAgents.length === 0) { - return false; - } - - logger.break(); - const results = installMcpServers(selectedAgents); - const hasSuccess = results.some((result) => result.success); - return hasSuccess; -}; diff --git a/packages/cli/src/utils/install-skill.ts b/packages/cli/src/utils/install-skill.ts new file mode 100644 index 000000000..751ecc382 --- /dev/null +++ b/packages/cli/src/utils/install-skill.ts @@ -0,0 +1,362 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { highlighter } from "./highlighter.js"; +import { logger } from "./logger.js"; +import { prompts } from "./prompts.js"; +import { spinner } from "./spinner.js"; +import { SKILL_TEMPLATE } from "./skill-template.js"; +import { CANONICAL_AGENTS_DIR, CANONICAL_SKILLS_SUBDIR, SKILL_NAME } from "./constants.js"; +import { readLastSelectedAgents, writeLastSelectedAgents } from "./last-selected-agents.js"; + +export type SkillScope = "global" | "project"; + +export interface SkillClientDefinition { + name: string; + universal: boolean; + globalRoot: string | null; + projectRoot: string | null; + detectInstalled: () => boolean; + supported: boolean; + unsupportedReason?: string; +} + +export interface InstallResult { + client: string; + skillPath: string; + success: boolean; + skipped?: boolean; + deduped?: boolean; + error?: string; +} + +export interface RemoveResult { + client: string; + skillRoot: string; + removed: boolean; + deduped?: boolean; +} + +export interface InstallSkillsOptions { + scope: SkillScope; + cwd: string; + selectedClients?: string[]; +} + +export interface RemoveSkillsOptions { + scope: SkillScope; + cwd: string; + selectedClients?: string[]; +} + +export interface AgentChoice { + title: string; + value: string; + selected: boolean; +} + +const getXdgConfigHome = (): string => + process.env.XDG_CONFIG_HOME?.trim() || path.join(os.homedir(), ".config"); + +const getClaudeHome = (): string => + process.env.CLAUDE_CONFIG_DIR?.trim() || path.join(os.homedir(), ".claude"); + +const getCodexHome = (): string => + process.env.CODEX_HOME?.trim() || path.join(os.homedir(), ".codex"); + +const getCanonicalGlobalRoot = (): string => + path.join(os.homedir(), CANONICAL_AGENTS_DIR, CANONICAL_SKILLS_SUBDIR); + +const universalClient = (name: string, detectInstalled: () => boolean): SkillClientDefinition => ({ + name, + universal: true, + globalRoot: getCanonicalGlobalRoot(), + projectRoot: path.join(CANONICAL_AGENTS_DIR, CANONICAL_SKILLS_SUBDIR), + detectInstalled, + supported: true, +}); + +const unsupportedClient = (name: string, reason: string): SkillClientDefinition => ({ + name, + universal: false, + globalRoot: null, + projectRoot: null, + detectInstalled: () => false, + supported: false, + unsupportedReason: reason, +}); + +export const getSkillClients = (): SkillClientDefinition[] => { + const homeDir = os.homedir(); + const claudeHome = getClaudeHome(); + const codexHome = getCodexHome(); + const xdgConfigHome = getXdgConfigHome(); + + return [ + { + name: "Claude Code", + universal: false, + globalRoot: path.join(claudeHome, "skills"), + projectRoot: ".claude/skills", + detectInstalled: () => fs.existsSync(claudeHome), + supported: true, + }, + universalClient("Cursor", () => fs.existsSync(path.join(homeDir, ".cursor"))), + universalClient("Codex", () => fs.existsSync(codexHome)), + universalClient("OpenCode", () => fs.existsSync(path.join(xdgConfigHome, "opencode"))), + universalClient("Amp", () => fs.existsSync(path.join(xdgConfigHome, "amp"))), + universalClient("Cline", () => fs.existsSync(path.join(homeDir, ".cline"))), + universalClient("Gemini CLI", () => fs.existsSync(path.join(homeDir, ".gemini"))), + universalClient("GitHub Copilot", () => fs.existsSync(path.join(homeDir, ".copilot"))), + universalClient("Warp", () => fs.existsSync(path.join(homeDir, ".warp"))), + { + name: "Windsurf", + universal: false, + globalRoot: path.join(homeDir, ".codeium", "windsurf", "skills"), + projectRoot: ".windsurf/skills", + detectInstalled: () => fs.existsSync(path.join(homeDir, ".codeium", "windsurf")), + supported: true, + }, + { + name: "Droid", + universal: false, + globalRoot: path.join(homeDir, ".factory", "skills"), + projectRoot: ".factory/skills", + detectInstalled: () => fs.existsSync(path.join(homeDir, ".factory")), + supported: true, + }, + unsupportedClient( + "VS Code", + "VS Code does not yet support skills. Run `react-grab watch` directly.", + ), + unsupportedClient("Zed", "Zed does not yet support skills. Run `react-grab watch` directly."), + ]; +}; + +export const getSkillClientNames = (): string[] => getSkillClients().map((client) => client.name); + +export const detectInstalledSkillClients = (): string[] => + getSkillClients() + .filter((client) => client.supported && client.detectInstalled()) + .map((client) => client.name); + +export const resolveSkillRoot = ( + client: SkillClientDefinition, + scope: SkillScope, + cwd: string, +): string | null => { + if (!client.supported) return null; + if (scope === "global") return client.globalRoot; + if (!client.projectRoot) return null; + return path.resolve(cwd, client.projectRoot); +}; + +const ensureDirectory = (filePath: string): void => { + const directory = path.dirname(filePath); + if (!fs.existsSync(directory)) { + fs.mkdirSync(directory, { recursive: true }); + } +}; + +const skillFilePathFor = (skillRoot: string): string => + path.join(skillRoot, SKILL_NAME, "SKILL.md"); + +export const writeSkillFile = (skillRoot: string): string => { + const skillFilePath = skillFilePathFor(skillRoot); + ensureDirectory(skillFilePath); + fs.writeFileSync(skillFilePath, SKILL_TEMPLATE); + return skillFilePath; +}; + +export const removeSkillFile = (skillRoot: string): boolean => { + const skillDirectory = path.join(skillRoot, SKILL_NAME); + if (!fs.existsSync(skillDirectory)) return false; + fs.rmSync(skillDirectory, { recursive: true, force: true }); + return true; +}; + +const filterClientsByName = ( + clients: SkillClientDefinition[], + selectedClients: string[] | undefined, +): SkillClientDefinition[] => + selectedClients ? clients.filter((client) => selectedClients.includes(client.name)) : clients; + +const buildSkippedResult = (client: SkillClientDefinition): InstallResult => ({ + client: client.name, + skillPath: "", + success: false, + skipped: true, + error: client.unsupportedReason ?? "Unsupported client.", +}); + +export const installSkills = (options: InstallSkillsOptions): InstallResult[] => { + const { scope, cwd, selectedClients } = options; + const clients = filterClientsByName(getSkillClients(), selectedClients); + const writtenRoots = new Set(); + const results: InstallResult[] = []; + + const installSpinner = spinner(`Installing react-grab skill (${scope}).`).start(); + + for (const client of clients) { + const skillRoot = resolveSkillRoot(client, scope, cwd); + if (skillRoot === null) { + results.push(buildSkippedResult(client)); + continue; + } + + const skillPath = skillFilePathFor(skillRoot); + + if (writtenRoots.has(skillRoot)) { + results.push({ client: client.name, skillPath, success: true, deduped: true }); + continue; + } + + try { + writeSkillFile(skillRoot); + writtenRoots.add(skillRoot); + results.push({ client: client.name, skillPath, success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + results.push({ + client: client.name, + skillPath, + success: false, + error: message, + }); + } + } + + const successCount = results.filter((result) => result.success).length; + const failureCount = results.filter((result) => !result.success && !result.skipped).length; + const skippedCount = results.filter((result) => result.skipped).length; + const uniqueWriteCount = writtenRoots.size; + + if (failureCount > 0) { + installSpinner.warn( + `Installed to ${successCount}/${results.length - skippedCount} agents. ${failureCount} failed.`, + ); + } else { + installSpinner.succeed( + uniqueWriteCount === successCount + ? `Installed to ${successCount} agents.` + : `Installed to ${successCount} agents (${uniqueWriteCount} unique skill file${uniqueWriteCount === 1 ? "" : "s"}).`, + ); + } + + for (const result of results) { + if (result.success) { + const note = result.deduped ? ` ${highlighter.dim("(shared)")}` : ""; + logger.log( + ` ${highlighter.success("\u2713")} ${result.client} ${highlighter.dim("\u2192")} ${highlighter.dim(result.skillPath)}${note}`, + ); + } else if (result.skipped) { + logger.log( + ` ${highlighter.dim("\u2212")} ${result.client} ${highlighter.dim("(skipped)")} ${highlighter.dim(result.error ?? "")}`, + ); + } else { + logger.log( + ` ${highlighter.error("\u2717")} ${result.client} ${highlighter.dim("\u2192")} ${result.error ?? "unknown error"}`, + ); + } + } + + return results; +}; + +const supportedAtScope = (scope: SkillScope): SkillClientDefinition[] => + getSkillClients().filter((client) => { + if (!client.supported) return false; + if (scope === "global" && !client.globalRoot) return false; + if (scope === "project" && !client.projectRoot) return false; + return true; + }); + +export const buildAgentChoices = ( + scope: SkillScope, + options: { allClients?: boolean } = {}, +): AgentChoice[] => { + const installedNames = new Set(detectInstalledSkillClients()); + const lastSelected = new Set(readLastSelectedAgents()); + const candidates = options.allClients ? getSkillClients() : supportedAtScope(scope); + + return candidates.map((client) => { + const isInstalled = installedNames.has(client.name); + const detectedSuffix = isInstalled ? ` ${highlighter.dim("(detected)")}` : ""; + return { + title: `${client.name}${detectedSuffix}`, + value: client.name, + selected: lastSelected.size > 0 ? lastSelected.has(client.name) : isInstalled, + }; + }); +}; + +export const promptSkillInstall = async (scope: SkillScope, cwd: string): Promise => { + const choices = buildAgentChoices(scope); + if (choices.length === 0) { + logger.warn("No agents support skills at this scope."); + return false; + } + + // If exactly one supported agent is installed and the user has no prior + // history, install to it directly without a prompt - skips a redundant + // selection step for the common single-editor case. + const installedNames = detectInstalledSkillClients(); + const lastSelected = readLastSelectedAgents(); + if (installedNames.length === 1 && lastSelected.length === 0) { + const onlyInstalled = installedNames[0]; + if (onlyInstalled) { + logger.log(`Installing to ${highlighter.info(onlyInstalled)} (only detected agent).`); + logger.break(); + const results = installSkills({ scope, cwd, selectedClients: [onlyInstalled] }); + const ok = results.some((result) => result.success); + if (ok) writeLastSelectedAgents([onlyInstalled]); + return ok; + } + } + + const { selectedAgents } = await prompts({ + type: "multiselect", + name: "selectedAgents", + message: `Select agents to install the React Grab skill for (${scope}):`, + choices, + }); + + if (selectedAgents === undefined || selectedAgents.length === 0) { + return false; + } + + logger.break(); + const results = installSkills({ scope, cwd, selectedClients: selectedAgents }); + const ok = results.some((result) => result.success); + if (ok) writeLastSelectedAgents(selectedAgents); + return ok; +}; + +export const installDetectedOrAllSkills = (scope: SkillScope, cwd: string): InstallResult[] => { + const detected = detectInstalledSkillClients(); + const targets = detected.length > 0 ? detected : supportedAtScope(scope).map((c) => c.name); + return installSkills({ scope, cwd, selectedClients: targets }); +}; + +export const removeSkills = (options: RemoveSkillsOptions): RemoveResult[] => { + const { scope, cwd, selectedClients } = options; + const clients = filterClientsByName( + getSkillClients().filter((client) => client.supported), + selectedClients, + ); + + const removedRoots = new Set(); + return clients.map((client) => { + const skillRoot = resolveSkillRoot(client, scope, cwd); + if (skillRoot === null) { + return { client: client.name, skillRoot: "", removed: false }; + } + if (removedRoots.has(skillRoot)) { + return { client: client.name, skillRoot, removed: false, deduped: true }; + } + const removed = removeSkillFile(skillRoot); + if (removed) removedRoots.add(skillRoot); + return { client: client.name, skillRoot, removed }; + }); +}; diff --git a/packages/cli/src/utils/is-telemetry-enabled.ts b/packages/cli/src/utils/is-telemetry-enabled.ts new file mode 100644 index 000000000..0b85372da --- /dev/null +++ b/packages/cli/src/utils/is-telemetry-enabled.ts @@ -0,0 +1,15 @@ +import { CI_ENV_KEYS, TELEMETRY_OPT_OUT_ENV_KEYS } from "./constants.js"; + +const isTruthy = (rawValue: string | undefined): boolean => { + if (!rawValue) return false; + const normalized = rawValue.trim().toLowerCase(); + return normalized !== "" && normalized !== "0" && normalized !== "false"; +}; + +const isOptedOut = (): boolean => + TELEMETRY_OPT_OUT_ENV_KEYS.some((key) => isTruthy(process.env[key])); + +const isInsideContinuousIntegration = (): boolean => + CI_ENV_KEYS.some((key) => isTruthy(process.env[key])); + +export const isTelemetryEnabled = (): boolean => !isOptedOut() && !isInsideContinuousIntegration(); diff --git a/packages/cli/src/utils/last-selected-agents.ts b/packages/cli/src/utils/last-selected-agents.ts new file mode 100644 index 000000000..204e7f2a7 --- /dev/null +++ b/packages/cli/src/utils/last-selected-agents.ts @@ -0,0 +1,50 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + FALLBACK_STATE_HOME_RELATIVE, + LAST_SELECTED_AGENTS_FILE, + STATE_DIR_NAME, +} from "./constants.js"; + +const getStateDir = (): string => { + const xdgStateHome = process.env.XDG_STATE_HOME?.trim(); + if (xdgStateHome) return path.join(xdgStateHome, STATE_DIR_NAME); + return path.join(os.homedir(), FALLBACK_STATE_HOME_RELATIVE, STATE_DIR_NAME); +}; + +const getStatePath = (): string => path.join(getStateDir(), LAST_SELECTED_AGENTS_FILE); + +interface LastSelectedAgentsState { + agents: string[]; +} + +const isValidState = (raw: unknown): raw is LastSelectedAgentsState => { + if (!raw || typeof raw !== "object") return false; + const candidate = raw as { agents?: unknown }; + return ( + Array.isArray(candidate.agents) && candidate.agents.every((entry) => typeof entry === "string") + ); +}; + +export const readLastSelectedAgents = (): string[] => { + try { + const content = fs.readFileSync(getStatePath(), "utf8"); + const parsed = JSON.parse(content); + return isValidState(parsed) ? parsed.agents : []; + } catch { + return []; + } +}; + +export const writeLastSelectedAgents = (agents: string[]): void => { + try { + const stateDir = getStateDir(); + fs.mkdirSync(stateDir, { recursive: true }); + const payload: LastSelectedAgentsState = { agents }; + fs.writeFileSync(getStatePath(), `${JSON.stringify(payload, null, 2)}\n`); + } catch { + // State persistence is best-effort: never break the install if we can't + // write the file (read-only home, sandboxed env, etc.). + } +}; diff --git a/packages/mcp/src/utils/parse-react-grab-payload.ts b/packages/cli/src/utils/parse-react-grab-payload.ts similarity index 100% rename from packages/mcp/src/utils/parse-react-grab-payload.ts rename to packages/cli/src/utils/parse-react-grab-payload.ts diff --git a/packages/cli/src/utils/parse-timeout-seconds.ts b/packages/cli/src/utils/parse-timeout-seconds.ts new file mode 100644 index 000000000..c171b2a2c --- /dev/null +++ b/packages/cli/src/utils/parse-timeout-seconds.ts @@ -0,0 +1,13 @@ +const NUMERIC_PATTERN = /^\d+(?:\.\d+)?$/; + +export const parseTimeoutSeconds = (raw: string): number => { + const trimmed = raw.trim(); + if (!NUMERIC_PATTERN.test(trimmed)) { + throw new Error(`Invalid --timeout value: "${raw}". Pass a non-negative number of seconds.`); + } + const seconds = Number.parseFloat(trimmed); + if (!Number.isFinite(seconds)) { + throw new Error(`Invalid --timeout value: "${raw}". Pass a non-negative number of seconds.`); + } + return seconds; +}; diff --git a/packages/mcp/src/utils/read-clipboard-linux.ts b/packages/cli/src/utils/read-clipboard-linux.ts similarity index 99% rename from packages/mcp/src/utils/read-clipboard-linux.ts rename to packages/cli/src/utils/read-clipboard-linux.ts index f5d6c23ac..d257204f4 100644 --- a/packages/mcp/src/utils/read-clipboard-linux.ts +++ b/packages/cli/src/utils/read-clipboard-linux.ts @@ -1,4 +1,4 @@ -import { CLIPBOARD_READ_TIMEOUT_MS, REACT_GRAB_MIME_TYPE } from "../constants.js"; +import { CLIPBOARD_READ_TIMEOUT_MS, REACT_GRAB_MIME_TYPE } from "./constants.js"; import { hasErrorCode } from "./has-error-code.js"; import { runExecFile } from "./run-exec-file.js"; import { surfaceStderr } from "./surface-stderr.js"; diff --git a/packages/mcp/src/utils/read-clipboard-macos.ts b/packages/cli/src/utils/read-clipboard-macos.ts similarity index 98% rename from packages/mcp/src/utils/read-clipboard-macos.ts rename to packages/cli/src/utils/read-clipboard-macos.ts index 5f9806ed9..cadeeb53a 100644 --- a/packages/mcp/src/utils/read-clipboard-macos.ts +++ b/packages/cli/src/utils/read-clipboard-macos.ts @@ -1,4 +1,4 @@ -import { CLIPBOARD_READ_TIMEOUT_MS, REACT_GRAB_MIME_TYPE } from "../constants.js"; +import { CLIPBOARD_READ_TIMEOUT_MS, REACT_GRAB_MIME_TYPE } from "./constants.js"; import { hasErrorCode } from "./has-error-code.js"; import { runExecFile } from "./run-exec-file.js"; import { surfaceStderr } from "./surface-stderr.js"; diff --git a/packages/mcp/src/utils/read-clipboard-outcome.ts b/packages/cli/src/utils/read-clipboard-outcome.ts similarity index 77% rename from packages/mcp/src/utils/read-clipboard-outcome.ts rename to packages/cli/src/utils/read-clipboard-outcome.ts index 9b7b8da12..9546ef698 100644 --- a/packages/mcp/src/utils/read-clipboard-outcome.ts +++ b/packages/cli/src/utils/read-clipboard-outcome.ts @@ -1,4 +1,5 @@ export interface ClipboardReadOutcome { payload: string | null; hint?: string; + recoverable?: boolean; } diff --git a/packages/mcp/src/utils/read-clipboard-payload.ts b/packages/cli/src/utils/read-clipboard-payload.ts similarity index 88% rename from packages/mcp/src/utils/read-clipboard-payload.ts rename to packages/cli/src/utils/read-clipboard-payload.ts index ba904ddca..7004f9238 100644 --- a/packages/mcp/src/utils/read-clipboard-payload.ts +++ b/packages/cli/src/utils/read-clipboard-payload.ts @@ -10,6 +10,7 @@ export interface ReadClipboardPayloadResult { payload: ReactGrabPayload | null; env: ClipboardEnv; hint?: string; + recoverable: boolean; } const readRawByEnv = async (env: ClipboardEnv): Promise => { @@ -25,13 +26,15 @@ const readRawByEnv = async (env: ClipboardEnv): Promise => case "ssh": return { payload: null, - hint: "Clipboard channel is unavailable in SSH sessions. Run `react-grab-mcp` on the same machine as your browser.", + hint: "Clipboard channel is unavailable in SSH sessions. Run `react-grab watch` on the same machine as your browser.", + recoverable: false, }; default: { const exhaustiveCheck: never = env; return { payload: null, hint: `Unsupported clipboard environment: ${String(exhaustiveCheck)}`, + recoverable: false, }; } } @@ -44,5 +47,6 @@ export const readClipboardPayload = async (): Promise { const present = hints.filter((hint): hint is string => Boolean(hint)); diff --git a/packages/mcp/src/utils/run-exec-file.ts b/packages/cli/src/utils/run-exec-file.ts similarity index 100% rename from packages/mcp/src/utils/run-exec-file.ts rename to packages/cli/src/utils/run-exec-file.ts diff --git a/packages/cli/src/utils/skill-template.ts b/packages/cli/src/utils/skill-template.ts new file mode 100644 index 000000000..c6ead6a7b --- /dev/null +++ b/packages/cli/src/utils/skill-template.ts @@ -0,0 +1,54 @@ +export const SKILL_TEMPLATE = `--- +name: react-grab +description: >- + Pull the next React Grab selection from the user's clipboard. Use when the + user invokes /react-grab, references "this thing", "the element I clicked", + "the component I just grabbed", or pastes content that came from the React + Grab toolbar. +allowed-tools: + - Bash +--- + +# React Grab + +When the user wants the agent to act on a UI element they're about to (or just +did) click in the React Grab toolbar, the canonical description of that element +travels through the system clipboard under the custom MIME type +\`application/x-react-grab\`. + +## What to do + +1. Run \`npx -y @react-grab/cli watch\` exactly once at the top of the turn. +2. The CLI blocks until a fresh React Grab payload appears on the clipboard + (or 10 minutes elapse, whichever comes first). While it waits, tell the user + to click an element in the React Grab toolbar (the box that appears when + they hover the page). They can also type a prompt into the toolbar before + clicking - that prompt is included in the output. +3. Treat the CLI's stdout as the authoritative description of the target + element. It contains: + - The user's prompt (if any). + - The HTML snippet of the selected element(s). + - Component stack / source file paths. +4. Plan and execute the user's request against that returned context. + +## Failure modes + +- **Exit code 1 ("Timed out...")** - the user didn't click anything within the + timeout. Ask them to click and re-run; do not retry inside the same turn. +- **Exit code 2 ("Clipboard channel is unavailable in SSH sessions")** - the + CLI is on a different machine than the browser. Tell the user to run the + agent on the same machine as the browser. +- **Linux missing \`xclip\` / \`wl-clipboard\`** - surface the install command + from the CLI's stderr verbatim. +- **WSL with broken interop** - surface the WSL interop hint from stderr + verbatim. + +## Constraints + +- Do NOT call \`react-grab watch\` more than once per turn. The CLI blocks + until a fresh grab arrives; a second call would just block again. +- Do NOT invent element details. If the CLI failed, ask the user; do not + fabricate. +- Do NOT rely on the user's chat message alone when they reference "this" / + "that" / "the thing I grabbed" - always invoke the CLI first. +`; diff --git a/packages/mcp/src/utils/surface-stderr.ts b/packages/cli/src/utils/surface-stderr.ts similarity index 87% rename from packages/mcp/src/utils/surface-stderr.ts rename to packages/cli/src/utils/surface-stderr.ts index 4eb69e335..4808374af 100644 --- a/packages/mcp/src/utils/surface-stderr.ts +++ b/packages/cli/src/utils/surface-stderr.ts @@ -11,6 +11,6 @@ export const surfaceStderr = (binary: string, source: unknown): void => { if (!stderr) return; const trimmed = stderr.trim(); if (trimmed.length > 0) { - console.error(`[react-grab-mcp] ${binary} stderr: ${trimmed}`); + console.error(`[react-grab] ${binary} stderr: ${trimmed}`); } }; diff --git a/packages/cli/src/utils/wait-for-next-grab.ts b/packages/cli/src/utils/wait-for-next-grab.ts new file mode 100644 index 000000000..55e5adb54 --- /dev/null +++ b/packages/cli/src/utils/wait-for-next-grab.ts @@ -0,0 +1,65 @@ +import type { ReadClipboardPayloadResult } from "./read-clipboard-payload.js"; +import type { ReactGrabPayload } from "./parse-react-grab-payload.js"; + +export interface WaitForNextGrabOptions { + initialTimestamp: number | null; + timeoutMs: number; + pollIntervalMs: number; + read: () => Promise; + signal?: AbortSignal; + getCurrentMs?: () => number; + sleepMs?: (durationMs: number) => Promise; +} + +interface WaitForNextGrabMatch { + outcome: "match"; + result: ReadClipboardPayloadResult; + payload: ReactGrabPayload; +} + +interface WaitForNextGrabUnrecoverable { + outcome: "unrecoverable"; + result: ReadClipboardPayloadResult; +} + +interface WaitForNextGrabTimeout { + outcome: "timeout"; +} + +interface WaitForNextGrabAborted { + outcome: "aborted"; +} + +export type WaitForNextGrabResult = + | WaitForNextGrabMatch + | WaitForNextGrabUnrecoverable + | WaitForNextGrabTimeout + | WaitForNextGrabAborted; + +const defaultSleepMs = (durationMs: number): Promise => + new Promise((resolve) => setTimeout(resolve, durationMs)); + +export const waitForNextGrab = async ( + options: WaitForNextGrabOptions, +): Promise => { + const { initialTimestamp, timeoutMs, pollIntervalMs, read, signal } = options; + const getCurrentMs = options.getCurrentMs ?? Date.now; + const sleepMs = options.sleepMs ?? defaultSleepMs; + const deadlineMs = timeoutMs > 0 ? getCurrentMs() + timeoutMs : Number.POSITIVE_INFINITY; + + while (true) { + if (signal?.aborted) return { outcome: "aborted" }; + + const result = await read(); + if (!result.recoverable) { + return { outcome: "unrecoverable", result }; + } + + if (result.payload && result.payload.timestamp !== initialTimestamp) { + return { outcome: "match", result, payload: result.payload }; + } + + if (getCurrentMs() >= deadlineMs) return { outcome: "timeout" }; + await sleepMs(pollIntervalMs); + } +}; diff --git a/packages/mcp/test/detect-clipboard-env.test.ts b/packages/cli/test/detect-clipboard-env.test.ts similarity index 100% rename from packages/mcp/test/detect-clipboard-env.test.ts rename to packages/cli/test/detect-clipboard-env.test.ts diff --git a/packages/cli/test/format-payload.test.ts b/packages/cli/test/format-payload.test.ts new file mode 100644 index 000000000..61e222abe --- /dev/null +++ b/packages/cli/test/format-payload.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vite-plus/test"; +import { formatPayload } from "../src/utils/format-payload.js"; +import type { ReactGrabPayload } from "../src/utils/parse-react-grab-payload.js"; + +const buildPayload = (overrides: Partial = {}): ReactGrabPayload => ({ + version: "0.1.32", + content: "", + entries: [ + { + tagName: "button", + componentName: "Button", + content: "", + commentText: "Make this larger", + }, + ], + timestamp: 1700000000000, + ...overrides, +}); + +describe("formatPayload", () => { + it("returns formatted prompt and content for a single-entry payload", () => { + const text = formatPayload( + buildPayload({ + entries: [ + { + tagName: "button", + componentName: "Button", + content: "", + commentText: "Refactor this", + }, + ], + }), + ); + expect(text).toContain("Prompt: Refactor this"); + expect(text).toContain("Elements (1):"); + expect(text).toContain(""); + }); + + it("omits the prompt section when no entries carry commentText", () => { + const payload = buildPayload(); + payload.entries[0].commentText = undefined; + const text = formatPayload(payload); + expect(text.startsWith("Elements (1):")).toBe(true); + }); + + it("deduplicates the prompt across entries and excludes it from the elements section", () => { + const sharedPrompt = "Fix all the buttons"; + const canonicalContent = `${sharedPrompt}\n\n[1]\n\n\n[2]\n\n\n[3]\n`; + const text = formatPayload({ + version: "0.1.32", + content: canonicalContent, + entries: [ + { tagName: "button", content: "", commentText: sharedPrompt }, + { tagName: "button", content: "", commentText: sharedPrompt }, + { tagName: "button", content: "", commentText: sharedPrompt }, + ], + timestamp: Date.now(), + }); + + const promptOccurrences = text.split(sharedPrompt).length - 1; + expect(promptOccurrences).toBe(1); + expect(text).toContain(`Prompt: ${sharedPrompt}`); + expect(text).toContain("Elements (3):"); + expect(text).toContain("[1]"); + expect(text).toContain("[2]"); + expect(text).toContain("[3]"); + }); + + it("preserves transformCopyContent output and snippet labels in the body", () => { + const transformedBody = + "\n[1]\n\n\n[2]\n"; + const text = formatPayload({ + version: "0.1.32", + content: `Style as primary\n\n${transformedBody}`, + entries: [ + { tagName: "button", content: "", commentText: "Style as primary" }, + { tagName: "button", content: "", commentText: "Style as primary" }, + ], + timestamp: Date.now(), + }); + expect(text).toContain(""); + expect(text).toContain(`Elements (2):\n${transformedBody}`); + }); + + it("does not strip element content that happens to start with the prompt text", () => { + const text = formatPayload({ + version: "0.1.32", + content: "Click me\n\n", + entries: [ + { tagName: "button", content: "", commentText: undefined }, + ], + timestamp: Date.now(), + }); + expect(text).toBe("Elements (1):\nClick me\n\n"); + }); + + it("strips the prompt prefix even when the original prompt had surrounding whitespace", () => { + const rawPrompt = " Fix this button "; + const text = formatPayload({ + version: "0.1.32", + content: `${rawPrompt}\n\n`, + entries: [ + { tagName: "button", content: "", commentText: rawPrompt }, + ], + timestamp: Date.now(), + }); + + expect(text).toBe("Prompt: Fix this button\n\nElements (1):\n"); + const occurrences = text.split("Fix this button").length - 1; + expect(occurrences).toBe(1); + }); + + it("preserves distinct prompts when entries carry different commentText values", () => { + const text = formatPayload({ + version: "0.1.32", + content: "\n\n", + entries: [ + { tagName: "button", content: "", commentText: "Make it red" }, + { tagName: "button", content: "", commentText: "Make it blue" }, + ], + timestamp: Date.now(), + }); + expect(text).toContain("Prompt: Make it red\nMake it blue"); + }); +}); diff --git a/packages/mcp/test/has-error-code.test.ts b/packages/cli/test/has-error-code.test.ts similarity index 100% rename from packages/mcp/test/has-error-code.test.ts rename to packages/cli/test/has-error-code.test.ts diff --git a/packages/mcp/test/helpers/mock-exec-file.ts b/packages/cli/test/helpers/mock-exec-file.ts similarity index 53% rename from packages/mcp/test/helpers/mock-exec-file.ts rename to packages/cli/test/helpers/mock-exec-file.ts index 7ede9f802..43ee453f8 100644 --- a/packages/mcp/test/helpers/mock-exec-file.ts +++ b/packages/cli/test/helpers/mock-exec-file.ts @@ -39,3 +39,36 @@ export const enoentError = (): NodeJS.ErrnoException => { error.code = "ENOENT"; return error; }; + +export interface ExecFileCallSnapshot { + binary: string; + args: string[]; +} + +export const getExecFileCall = (mockExecFile: Mock, callIndex = 0): ExecFileCallSnapshot => { + const call = mockExecFile.mock.calls[callIndex]; + if (!call) { + throw new Error(`expected execFile to have been called at least ${callIndex + 1} time(s)`); + } + const [binary, args] = call; + if (typeof binary !== "string") { + throw new Error("expected execFile binary to be a string"); + } + if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string")) { + throw new Error("expected execFile args to be a string array"); + } + return { binary, args }; +}; + +export const getExecFileFlagValue = (mockExecFile: Mock, flag: string, callIndex = 0): string => { + const { args } = getExecFileCall(mockExecFile, callIndex); + const flagIndex = args.indexOf(flag); + if (flagIndex === -1) { + throw new Error(`expected execFile args to contain '${flag}'`); + } + const value = args[flagIndex + 1]; + if (typeof value !== "string") { + throw new Error(`expected '${flag}' to be followed by a string value`); + } + return value; +}; diff --git a/packages/cli/test/install-mcp.test.ts b/packages/cli/test/install-mcp.test.ts deleted file mode 100644 index 5afefa01a..000000000 --- a/packages/cli/test/install-mcp.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; -import { - type ClientDefinition, - upsertIntoJsonc, - installJsonClient, - installTomlClient, - getMcpClientNames, - getOpenCodeConfigPath, -} from "../src/utils/install-mcp.js"; - -let tempDir: string; - -beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "install-mcp-test-")); -}); - -afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); -}); - -const makeJsonClient = (overrides: Partial = {}): ClientDefinition => ({ - name: "TestClient", - configPath: path.join(tempDir, "config.json"), - configKey: "mcpServers", - format: "json", - serverConfig: { command: "npx", args: ["-y", "@react-grab/mcp"] }, - ...overrides, -}); - -const makeTomlClient = (overrides: Partial = {}): ClientDefinition => ({ - name: "TestToml", - configPath: path.join(tempDir, "config.toml"), - configKey: "mcp_servers", - format: "toml", - serverConfig: { command: "npx", args: ["-y", "@react-grab/mcp"] }, - ...overrides, -}); - -describe("getMcpClientNames", () => { - it("should return all 9 client names", () => { - const names = getMcpClientNames(); - - expect(names).toHaveLength(9); - expect(names).toContain("Claude Code"); - expect(names).toContain("Codex"); - expect(names).toContain("Cursor"); - expect(names).toContain("OpenCode"); - expect(names).toContain("VS Code"); - expect(names).toContain("Amp"); - expect(names).toContain("Droid"); - expect(names).toContain("Windsurf"); - expect(names).toContain("Zed"); - }); -}); - -describe("installJsonClient", () => { - it("should create a new config file when none exists", () => { - const client = makeJsonClient(); - - installJsonClient(client); - - const content = JSON.parse(fs.readFileSync(client.configPath, "utf8")); - expect(content.mcpServers["react-grab-mcp"]).toEqual(client.serverConfig); - }); - - it("should merge into an existing config file", () => { - const client = makeJsonClient(); - fs.writeFileSync( - client.configPath, - JSON.stringify({ - mcpServers: { "other-server": { command: "other" } }, - }), - ); - - installJsonClient(client); - - const content = JSON.parse(fs.readFileSync(client.configPath, "utf8")); - expect(content.mcpServers["other-server"]).toEqual({ command: "other" }); - expect(content.mcpServers["react-grab-mcp"]).toEqual(client.serverConfig); - }); - - it("should overwrite existing react-grab-mcp entry", () => { - const client = makeJsonClient(); - fs.writeFileSync( - client.configPath, - JSON.stringify({ - mcpServers: { "react-grab-mcp": { command: "old" } }, - }), - ); - - installJsonClient(client); - - const content = JSON.parse(fs.readFileSync(client.configPath, "utf8")); - expect(content.mcpServers["react-grab-mcp"]).toEqual(client.serverConfig); - }); - - it("should create the configKey when it does not exist", () => { - const client = makeJsonClient(); - fs.writeFileSync(client.configPath, JSON.stringify({ someOtherKey: "value" })); - - installJsonClient(client); - - const content = JSON.parse(fs.readFileSync(client.configPath, "utf8")); - expect(content.someOtherKey).toBe("value"); - expect(content.mcpServers["react-grab-mcp"]).toEqual(client.serverConfig); - }); - - it("should create nested directories if needed", () => { - const client = makeJsonClient({ - configPath: path.join(tempDir, "deep", "nested", "config.json"), - }); - - installJsonClient(client); - - expect(fs.existsSync(client.configPath)).toBe(true); - const content = JSON.parse(fs.readFileSync(client.configPath, "utf8")); - expect(content.mcpServers["react-grab-mcp"]).toEqual(client.serverConfig); - }); - - it("should handle a dot-separated configKey like amp.mcpServers", () => { - const client = makeJsonClient({ configKey: "amp.mcpServers" }); - - installJsonClient(client); - - const content = JSON.parse(fs.readFileSync(client.configPath, "utf8")); - expect(content["amp.mcpServers"]["react-grab-mcp"]).toEqual(client.serverConfig); - }); -}); - -describe("upsertIntoJsonc", () => { - it("should insert into existing configKey section", () => { - const filePath = path.join(tempDir, "settings.json"); - const content = `// comment\n{\n "context_servers": {\n "existing": {}\n }\n}`; - fs.writeFileSync(filePath, content); - - upsertIntoJsonc(filePath, content, "context_servers", "react-grab-mcp", { - command: "npx", - }); - - const result = fs.readFileSync(filePath, "utf8"); - expect(result).toContain('"react-grab-mcp"'); - expect(result).toContain("// comment"); - expect(result).toContain('"existing"'); - }); - - it("should add a new configKey section when none exists", () => { - const filePath = path.join(tempDir, "settings.json"); - const content = `// comment\n{\n "theme": "dark"\n}`; - fs.writeFileSync(filePath, content); - - upsertIntoJsonc(filePath, content, "context_servers", "react-grab-mcp", { - command: "npx", - }); - - const result = fs.readFileSync(filePath, "utf8"); - expect(result).toContain('"context_servers"'); - expect(result).toContain('"react-grab-mcp"'); - expect(result).toContain("// comment"); - expect(result).toContain('"theme"'); - }); - - it("should overwrite existing server entry", () => { - const filePath = path.join(tempDir, "settings.json"); - const content = `{\n "servers": {\n "react-grab-mcp": { "old": true }\n }\n}`; - fs.writeFileSync(filePath, content); - - upsertIntoJsonc(filePath, content, "servers", "react-grab-mcp", { - command: "new", - }); - - const result = fs.readFileSync(filePath, "utf8"); - expect(result).toContain('"command": "new"'); - expect(result).not.toContain('"old"'); - }); -}); - -describe("getOpenCodeConfigPath", () => { - let originalXdgConfigHome: string | undefined; - - beforeEach(() => { - originalXdgConfigHome = process.env.XDG_CONFIG_HOME; - process.env.XDG_CONFIG_HOME = tempDir; - }); - - afterEach(() => { - if (originalXdgConfigHome === undefined) { - delete process.env.XDG_CONFIG_HOME; - } else { - process.env.XDG_CONFIG_HOME = originalXdgConfigHome; - } - }); - - it("should prefer opencode.jsonc when both files exist", () => { - const opencodeDir = path.join(tempDir, "opencode"); - fs.mkdirSync(opencodeDir, { recursive: true }); - fs.writeFileSync(path.join(opencodeDir, "opencode.json"), "{}"); - fs.writeFileSync(path.join(opencodeDir, "opencode.jsonc"), "{}"); - - const result = getOpenCodeConfigPath(); - - expect(result).toBe(path.join(opencodeDir, "opencode.jsonc")); - }); - - it("should use opencode.json when only it exists", () => { - const opencodeDir = path.join(tempDir, "opencode"); - fs.mkdirSync(opencodeDir, { recursive: true }); - fs.writeFileSync(path.join(opencodeDir, "opencode.json"), "{}"); - - const result = getOpenCodeConfigPath(); - - expect(result).toBe(path.join(opencodeDir, "opencode.json")); - }); - - it("should default to opencode.jsonc when neither file exists", () => { - const result = getOpenCodeConfigPath(); - - expect(result).toBe(path.join(tempDir, "opencode", "opencode.jsonc")); - }); -}); - -describe("installTomlClient", () => { - it("should create a new TOML file when none exists", () => { - const client = makeTomlClient(); - - installTomlClient(client); - - const content = fs.readFileSync(client.configPath, "utf8"); - expect(content).toContain("[mcp_servers.react-grab-mcp]"); - expect(content).toContain('command = "npx"'); - }); - - it("should append to an existing TOML file", () => { - const client = makeTomlClient(); - fs.writeFileSync(client.configPath, '[mcp_servers.other]\ncommand = "other"\n'); - - installTomlClient(client); - - const content = fs.readFileSync(client.configPath, "utf8"); - expect(content).toContain("[mcp_servers.other]"); - expect(content).toContain("[mcp_servers.react-grab-mcp]"); - }); - - it("should replace an existing react-grab-mcp section", () => { - const client = makeTomlClient(); - fs.writeFileSync( - client.configPath, - '[mcp_servers.react-grab-mcp]\ncommand = "old"\n\n[other]\nkey = "val"\n', - ); - - installTomlClient(client); - - const content = fs.readFileSync(client.configPath, "utf8"); - expect(content).toContain('command = "npx"'); - expect(content).not.toContain('command = "old"'); - expect(content).toContain("[other]"); - }); - - it("should create nested directories if needed", () => { - const client = makeTomlClient({ - configPath: path.join(tempDir, "deep", "config.toml"), - }); - - installTomlClient(client); - - expect(fs.existsSync(client.configPath)).toBe(true); - }); -}); diff --git a/packages/cli/test/install-skill.test.ts b/packages/cli/test/install-skill.test.ts new file mode 100644 index 000000000..9ce5cf895 --- /dev/null +++ b/packages/cli/test/install-skill.test.ts @@ -0,0 +1,400 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; +import { + detectInstalledSkillClients, + getSkillClientNames, + getSkillClients, + installDetectedOrAllSkills, + installSkills, + removeSkillFile, + removeSkills, + resolveSkillRoot, + type SkillClientDefinition, + writeSkillFile, +} from "../src/utils/install-skill.js"; +import { + CANONICAL_AGENTS_DIR, + CANONICAL_SKILLS_SUBDIR, + SKILL_NAME, +} from "../src/utils/constants.js"; +import { SKILL_TEMPLATE } from "../src/utils/skill-template.js"; + +let tempDir: string; + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "install-skill-test-")); +}); + +afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +const findClient = (name: string): SkillClientDefinition => { + const client = getSkillClients().find((entry) => entry.name === name); + if (!client) throw new Error(`expected client ${name} in getSkillClients`); + return client; +}; + +describe("getSkillClients", () => { + it("flags Cursor, Codex, OpenCode as universal sharing canonical .agents/skills", () => { + const universalNames = ["Cursor", "Codex", "OpenCode"]; + for (const name of universalNames) { + const client = findClient(name); + expect(client.universal).toBe(true); + expect(client.projectRoot).toBe(`${CANONICAL_AGENTS_DIR}/${CANONICAL_SKILLS_SUBDIR}`); + expect(client.globalRoot).toBe( + path.join(os.homedir(), CANONICAL_AGENTS_DIR, CANONICAL_SKILLS_SUBDIR), + ); + } + }); + + it("flags Claude Code as non-universal with .claude paths", () => { + const claudeCode = findClient("Claude Code"); + expect(claudeCode.universal).toBe(false); + expect(claudeCode.projectRoot).toBe(".claude/skills"); + expect(claudeCode.globalRoot).toContain(".claude"); + expect(claudeCode.globalRoot).toContain("skills"); + }); + + it("includes additional universal agents (Amp, Cline, Gemini CLI, GitHub Copilot, Warp)", () => { + const universalAdditions = ["Amp", "Cline", "Gemini CLI", "GitHub Copilot", "Warp"]; + for (const name of universalAdditions) { + const client = findClient(name); + expect(client.universal).toBe(true); + expect(client.supported).toBe(true); + } + }); + + it("flags VS Code, Zed as unsupported with reasons", () => { + const unsupported = getSkillClients().filter((client) => !client.supported); + const names = unsupported.map((client) => client.name); + expect(names).toEqual(expect.arrayContaining(["VS Code", "Zed"])); + for (const client of unsupported) { + expect(client.unsupportedReason).toBeTruthy(); + expect(client.projectRoot).toBeNull(); + expect(client.globalRoot).toBeNull(); + } + }); +}); + +describe("getSkillClientNames", () => { + it("returns at least the legacy 4 plus the universal additions", () => { + const names = getSkillClientNames(); + expect(names).toContain("Claude Code"); + expect(names).toContain("Cursor"); + expect(names).toContain("Codex"); + expect(names).toContain("OpenCode"); + expect(names).toContain("Amp"); + expect(names).toContain("Cline"); + }); +}); + +describe("resolveSkillRoot", () => { + it("resolves universal project to /.agents/skills", () => { + const cursor = findClient("Cursor"); + expect(resolveSkillRoot(cursor, "project", tempDir)).toBe( + path.resolve(tempDir, CANONICAL_AGENTS_DIR, CANONICAL_SKILLS_SUBDIR), + ); + }); + + it("resolves universal global to ~/.agents/skills (not the per-agent global)", () => { + const cursor = findClient("Cursor"); + expect(resolveSkillRoot(cursor, "global", tempDir)).toBe( + path.join(os.homedir(), CANONICAL_AGENTS_DIR, CANONICAL_SKILLS_SUBDIR), + ); + }); + + it("resolves Claude Code project to /.claude/skills", () => { + const claudeCode = findClient("Claude Code"); + expect(resolveSkillRoot(claudeCode, "project", tempDir)).toBe( + path.resolve(tempDir, ".claude", "skills"), + ); + }); + + it("returns null for unsupported clients regardless of scope", () => { + const zed = findClient("Zed"); + expect(resolveSkillRoot(zed, "global", tempDir)).toBeNull(); + expect(resolveSkillRoot(zed, "project", tempDir)).toBeNull(); + }); +}); + +describe("installSkills", () => { + it("dedups writes when multiple universal agents share the canonical project root", () => { + const results = installSkills({ + scope: "project", + cwd: tempDir, + selectedClients: ["Cursor", "Codex", "OpenCode"], + }); + + const successes = results.filter((result) => result.success); + expect(successes).toHaveLength(3); + expect(successes.filter((result) => result.deduped)).toHaveLength(2); + + const canonical = path.join(tempDir, CANONICAL_AGENTS_DIR, CANONICAL_SKILLS_SUBDIR); + expect(fs.existsSync(path.join(canonical, SKILL_NAME, "SKILL.md"))).toBe(true); + // Should NOT have written to per-agent dirs + expect(fs.existsSync(path.join(tempDir, ".cursor", "skills"))).toBe(false); + expect(fs.existsSync(path.join(tempDir, ".codex", "skills"))).toBe(false); + expect(fs.existsSync(path.join(tempDir, ".opencode", "skills"))).toBe(false); + }); + + it("writes a separate file for non-universal Claude Code", () => { + installSkills({ + scope: "project", + cwd: tempDir, + selectedClients: ["Cursor", "Claude Code"], + }); + + const canonical = path.join(tempDir, CANONICAL_AGENTS_DIR, CANONICAL_SKILLS_SUBDIR); + const claudePath = path.join(tempDir, ".claude", "skills"); + expect(fs.existsSync(path.join(canonical, SKILL_NAME, "SKILL.md"))).toBe(true); + expect(fs.existsSync(path.join(claudePath, SKILL_NAME, "SKILL.md"))).toBe(true); + }); + + it("rewrites a stale skill file (different content) on every run", () => { + const skillFilePath = path.join( + tempDir, + CANONICAL_AGENTS_DIR, + CANONICAL_SKILLS_SUBDIR, + SKILL_NAME, + "SKILL.md", + ); + fs.mkdirSync(path.dirname(skillFilePath), { recursive: true }); + fs.writeFileSync(skillFilePath, "stale-content\n"); + + installSkills({ scope: "project", cwd: tempDir, selectedClients: ["Cursor"] }); + expect(fs.readFileSync(skillFilePath, "utf8")).toBe(SKILL_TEMPLATE); + + // Re-run also writes (no TOCTOU optimization), but content stays canonical. + installSkills({ scope: "project", cwd: tempDir, selectedClients: ["Cursor"] }); + expect(fs.readFileSync(skillFilePath, "utf8")).toBe(SKILL_TEMPLATE); + }); + + it("returns skipped results for unsupported clients without writing", () => { + const results = installSkills({ + scope: "project", + cwd: tempDir, + selectedClients: ["VS Code"], + }); + expect(results).toHaveLength(1); + expect(results[0]?.skipped).toBe(true); + expect(results[0]?.error).toContain("VS Code"); + }); +}); + +describe("writeSkillFile", () => { + it("creates a SKILL.md file at //SKILL.md", () => { + const skillRoot = path.join(tempDir, "skill-home"); + const skillPath = writeSkillFile(skillRoot); + expect(skillPath).toBe(path.join(skillRoot, SKILL_NAME, "SKILL.md")); + expect(fs.existsSync(skillPath)).toBe(true); + expect(fs.readFileSync(skillPath, "utf8")).toBe(SKILL_TEMPLATE); + }); + + it("creates nested directories if they do not exist", () => { + const skillRoot = path.join(tempDir, "deep", "nested", "skill-home"); + const skillPath = writeSkillFile(skillRoot); + expect(fs.existsSync(skillPath)).toBe(true); + }); + + it("overwrites an existing SKILL.md", () => { + const skillRoot = path.join(tempDir, "skill-home"); + fs.mkdirSync(path.join(skillRoot, SKILL_NAME), { recursive: true }); + fs.writeFileSync(path.join(skillRoot, SKILL_NAME, "SKILL.md"), "old content"); + + const skillPath = writeSkillFile(skillRoot); + expect(fs.readFileSync(skillPath, "utf8")).toBe(SKILL_TEMPLATE); + }); +}); + +describe("removeSkillFile", () => { + it("returns false when the skill directory does not exist", () => { + expect(removeSkillFile(path.join(tempDir, "missing"))).toBe(false); + }); + + it("returns true and deletes the directory when present", () => { + const skillRoot = path.join(tempDir, "skill-home"); + writeSkillFile(skillRoot); + expect(removeSkillFile(skillRoot)).toBe(true); + expect(fs.existsSync(path.join(skillRoot, SKILL_NAME))).toBe(false); + }); +}); + +describe("detectInstalledSkillClients", () => { + let homeBackup: string | undefined; + let claudeBackup: string | undefined; + let codexBackup: string | undefined; + let xdgBackup: string | undefined; + + beforeEach(() => { + homeBackup = process.env.HOME; + claudeBackup = process.env.CLAUDE_CONFIG_DIR; + codexBackup = process.env.CODEX_HOME; + xdgBackup = process.env.XDG_CONFIG_HOME; + process.env.HOME = tempDir; + delete process.env.CLAUDE_CONFIG_DIR; + delete process.env.CODEX_HOME; + delete process.env.XDG_CONFIG_HOME; + }); + + afterEach(() => { + if (homeBackup === undefined) delete process.env.HOME; + else process.env.HOME = homeBackup; + if (claudeBackup === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = claudeBackup; + if (codexBackup === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = codexBackup; + if (xdgBackup === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = xdgBackup; + }); + + it("returns empty when no agent dirs exist under HOME", () => { + expect(detectInstalledSkillClients()).toEqual([]); + }); + + it("detects Cursor when ~/.cursor exists", () => { + fs.mkdirSync(path.join(tempDir, ".cursor")); + expect(detectInstalledSkillClients()).toEqual(["Cursor"]); + }); + + it("detects Cursor and Claude Code when both home dirs exist", () => { + fs.mkdirSync(path.join(tempDir, ".cursor")); + fs.mkdirSync(path.join(tempDir, ".claude")); + const detected = detectInstalledSkillClients(); + expect(detected).toContain("Cursor"); + expect(detected).toContain("Claude Code"); + }); + + it("honors CODEX_HOME env override", () => { + const customCodex = path.join(tempDir, "custom-codex"); + fs.mkdirSync(customCodex); + process.env.CODEX_HOME = customCodex; + expect(detectInstalledSkillClients()).toContain("Codex"); + }); + + it("honors CLAUDE_CONFIG_DIR env override", () => { + const customClaude = path.join(tempDir, "custom-claude"); + fs.mkdirSync(customClaude); + process.env.CLAUDE_CONFIG_DIR = customClaude; + expect(detectInstalledSkillClients()).toContain("Claude Code"); + }); + + it("honors XDG_CONFIG_HOME for OpenCode detection", () => { + const customXdg = path.join(tempDir, "custom-xdg"); + fs.mkdirSync(path.join(customXdg, "opencode"), { recursive: true }); + process.env.XDG_CONFIG_HOME = customXdg; + expect(detectInstalledSkillClients()).toContain("OpenCode"); + }); + + it("does not include unsupported clients (VS Code, Zed) regardless of detection", () => { + expect(detectInstalledSkillClients()).not.toContain("VS Code"); + expect(detectInstalledSkillClients()).not.toContain("Zed"); + }); +}); + +describe("installDetectedOrAllSkills", () => { + let homeBackup: string | undefined; + + beforeEach(() => { + homeBackup = process.env.HOME; + process.env.HOME = tempDir; + delete process.env.CLAUDE_CONFIG_DIR; + delete process.env.CODEX_HOME; + delete process.env.XDG_CONFIG_HOME; + }); + + afterEach(() => { + if (homeBackup === undefined) delete process.env.HOME; + else process.env.HOME = homeBackup; + }); + + it("installs to detected agents when at least one is detected", () => { + fs.mkdirSync(path.join(tempDir, ".cursor")); + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "iorall-cwd-")); + try { + const results = installDetectedOrAllSkills("project", cwd); + const successes = results.filter((r) => r.success); + expect(successes.map((r) => r.client)).toEqual(["Cursor"]); + expect( + fs.existsSync( + path.join(cwd, CANONICAL_AGENTS_DIR, CANONICAL_SKILLS_SUBDIR, SKILL_NAME, "SKILL.md"), + ), + ).toBe(true); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } + }); + + it("falls back to all supported agents when nothing is detected", () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "iorall-cwd-")); + try { + const results = installDetectedOrAllSkills("project", cwd); + const successes = results.filter((r) => r.success); + expect(successes.length).toBeGreaterThan(1); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } + }); +}); + +describe("installSkills with no selectedClients", () => { + it("installs to every supported client (project scope)", () => { + const results = installSkills({ scope: "project", cwd: tempDir }); + const supportedCount = getSkillClients().filter((c) => c.supported).length; + expect(results.filter((r) => r.success)).toHaveLength(supportedCount); + expect(results.filter((r) => r.skipped)).toHaveLength( + getSkillClients().filter((c) => !c.supported).length, + ); + }); +}); + +describe("removeSkills", () => { + it("removes once and reports deduped: true for shared canonical roots", () => { + installSkills({ + scope: "project", + cwd: tempDir, + selectedClients: ["Cursor", "Codex", "OpenCode"], + }); + + const results = removeSkills({ + scope: "project", + cwd: tempDir, + selectedClients: ["Cursor", "Codex", "OpenCode"], + }); + + expect(results).toHaveLength(3); + expect(results.filter((r) => r.removed)).toHaveLength(1); + const dedupedResults = results.filter((r) => r.deduped); + expect(dedupedResults).toHaveLength(2); + expect( + fs.existsSync(path.join(tempDir, CANONICAL_AGENTS_DIR, CANONICAL_SKILLS_SUBDIR, SKILL_NAME)), + ).toBe(false); + }); + + it("returns removed: false (not deduped) when nothing was installed", () => { + const results = removeSkills({ + scope: "project", + cwd: tempDir, + selectedClients: ["Cursor"], + }); + expect(results[0]?.removed).toBe(false); + expect(results[0]?.deduped).toBeUndefined(); + }); +}); + +describe("SKILL_TEMPLATE", () => { + it("starts with valid YAML frontmatter naming the skill", () => { + expect(SKILL_TEMPLATE.startsWith("---\n")).toBe(true); + expect(SKILL_TEMPLATE).toContain(`name: ${SKILL_NAME}`); + }); + + it("declares Bash in allowed-tools", () => { + expect(SKILL_TEMPLATE).toMatch(/allowed-tools:\s*\n\s*-\s*Bash/); + }); + + it("instructs the agent to run the watch CLI", () => { + expect(SKILL_TEMPLATE).toContain("npx -y @react-grab/cli watch"); + }); +}); diff --git a/packages/cli/test/is-telemetry-enabled.test.ts b/packages/cli/test/is-telemetry-enabled.test.ts new file mode 100644 index 000000000..20b2a06e3 --- /dev/null +++ b/packages/cli/test/is-telemetry-enabled.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; +import { isTelemetryEnabled } from "../src/utils/is-telemetry-enabled.js"; +import { CI_ENV_KEYS, TELEMETRY_OPT_OUT_ENV_KEYS } from "../src/utils/constants.js"; + +const ALL_KEYS = [...CI_ENV_KEYS, ...TELEMETRY_OPT_OUT_ENV_KEYS] as const; +const originalEnv = { ...process.env }; + +beforeEach(() => { + for (const key of ALL_KEYS) delete process.env[key]; +}); + +afterEach(() => { + process.env = { ...originalEnv }; +}); + +describe("isTelemetryEnabled", () => { + it("returns true when no opt-out and no CI env vars are set", () => { + expect(isTelemetryEnabled()).toBe(true); + }); + + it("disables when DISABLE_TELEMETRY=1", () => { + process.env.DISABLE_TELEMETRY = "1"; + expect(isTelemetryEnabled()).toBe(false); + }); + + it("disables when DO_NOT_TRACK=1", () => { + process.env.DO_NOT_TRACK = "1"; + expect(isTelemetryEnabled()).toBe(false); + }); + + it("disables when DISABLE_TELEMETRY=true (case-insensitive)", () => { + process.env.DISABLE_TELEMETRY = "TRUE"; + expect(isTelemetryEnabled()).toBe(false); + }); + + it("does NOT disable when DISABLE_TELEMETRY=0 (treats 0 as falsy)", () => { + process.env.DISABLE_TELEMETRY = "0"; + expect(isTelemetryEnabled()).toBe(true); + }); + + it("does NOT disable when DISABLE_TELEMETRY=false", () => { + process.env.DISABLE_TELEMETRY = "false"; + expect(isTelemetryEnabled()).toBe(true); + }); + + it("disables in GITHUB_ACTIONS", () => { + process.env.GITHUB_ACTIONS = "true"; + expect(isTelemetryEnabled()).toBe(false); + }); + + it("disables when CI=1", () => { + process.env.CI = "1"; + expect(isTelemetryEnabled()).toBe(false); + }); + + it("disables when CIRCLECI=true", () => { + process.env.CIRCLECI = "true"; + expect(isTelemetryEnabled()).toBe(false); + }); + + it("ignores empty-string env values", () => { + process.env.CI = ""; + expect(isTelemetryEnabled()).toBe(true); + }); +}); diff --git a/packages/cli/test/last-selected-agents.test.ts b/packages/cli/test/last-selected-agents.test.ts new file mode 100644 index 000000000..42699c2e3 --- /dev/null +++ b/packages/cli/test/last-selected-agents.test.ts @@ -0,0 +1,75 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; +import { + readLastSelectedAgents, + writeLastSelectedAgents, +} from "../src/utils/last-selected-agents.js"; + +let tempDir: string; +const originalXdg = process.env.XDG_STATE_HOME; + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "last-selected-test-")); + process.env.XDG_STATE_HOME = tempDir; +}); + +afterEach(() => { + if (originalXdg === undefined) delete process.env.XDG_STATE_HOME; + else process.env.XDG_STATE_HOME = originalXdg; + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe("readLastSelectedAgents", () => { + it("returns [] when the state file does not exist", () => { + expect(readLastSelectedAgents()).toEqual([]); + }); + + it("returns [] when the state file is invalid JSON", () => { + const stateDir = path.join(tempDir, "react-grab"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(path.join(stateDir, "last-selected-agents.json"), "{not json"); + expect(readLastSelectedAgents()).toEqual([]); + }); + + it("returns [] when the state file has an invalid shape", () => { + const stateDir = path.join(tempDir, "react-grab"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "last-selected-agents.json"), + JSON.stringify({ agents: [1, 2, 3] }), + ); + expect(readLastSelectedAgents()).toEqual([]); + }); + + it("returns the persisted agent list", () => { + writeLastSelectedAgents(["Cursor", "Claude Code"]); + expect(readLastSelectedAgents()).toEqual(["Cursor", "Claude Code"]); + }); +}); + +describe("writeLastSelectedAgents", () => { + it("creates the state file under XDG_STATE_HOME/react-grab/", () => { + writeLastSelectedAgents(["Cursor"]); + const expectedPath = path.join(tempDir, "react-grab", "last-selected-agents.json"); + expect(fs.existsSync(expectedPath)).toBe(true); + const content = JSON.parse(fs.readFileSync(expectedPath, "utf8")); + expect(content).toEqual({ agents: ["Cursor"] }); + }); + + it("overwrites previous selection", () => { + writeLastSelectedAgents(["Cursor"]); + writeLastSelectedAgents(["Claude Code", "Codex"]); + expect(readLastSelectedAgents()).toEqual(["Claude Code", "Codex"]); + }); + + it("never throws when the state directory cannot be written", () => { + // Pointing at /dev/null forces mkdir to fail (file, not directory). + // The function should swallow the error silently rather than throw, + // since state persistence is best-effort. + if (process.platform === "win32") return; + process.env.XDG_STATE_HOME = path.join("/dev/null", "child"); + expect(() => writeLastSelectedAgents(["Cursor"])).not.toThrow(); + }); +}); diff --git a/packages/mcp/test/parse-react-grab-payload.test.ts b/packages/cli/test/parse-react-grab-payload.test.ts similarity index 61% rename from packages/mcp/test/parse-react-grab-payload.test.ts rename to packages/cli/test/parse-react-grab-payload.test.ts index 61c7a2eaa..8c12f1001 100644 --- a/packages/mcp/test/parse-react-grab-payload.test.ts +++ b/packages/cli/test/parse-react-grab-payload.test.ts @@ -46,4 +46,34 @@ describe("parseReactGrabPayload", () => { expect(parseReactGrabPayload(JSON.stringify(payload))?.entries[0].tagName).toBeUndefined(); }); + + it("rejects payloads where timestamp is not a number", () => { + const payload = { + version: "0.1.32", + content: "", + entries: [ + { + tagName: "button", + componentName: "Button", + content: "", + commentText: "Refactor", + }, + ], + timestamp: 1700000000000, +}; + +describe("formatResultForStdout", () => { + it("emits formatted text by default", () => { + const text = formatResultForStdout(samplePayload, false); + expect(text).toContain("Prompt: Refactor"); + expect(text).toContain("Elements (1):"); + expect(text).toContain(""); + expect(text).not.toMatch(/^\{"version":/); + }); + + it("emits raw JSON when asJson is true", () => { + const text = formatResultForStdout(samplePayload, true); + const parsed: ReactGrabPayload = JSON.parse(text); + expect(parsed.version).toBe("0.1.32"); + expect(parsed.entries).toHaveLength(1); + expect(parsed.entries[0].componentName).toBe("Button"); + expect(parsed.timestamp).toBe(1700000000000); + }); + + it("treats undefined asJson the same as false", () => { + const text = formatResultForStdout(samplePayload, undefined); + expect(text).toContain("Elements (1):"); + }); +}); diff --git a/packages/cli/test/watch.test.ts b/packages/cli/test/watch.test.ts new file mode 100644 index 000000000..b7c304742 --- /dev/null +++ b/packages/cli/test/watch.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { waitForNextGrab } from "../src/utils/wait-for-next-grab.js"; +import type { ReadClipboardPayloadResult } from "../src/utils/read-clipboard-payload.js"; +import type { ReactGrabPayload } from "../src/utils/parse-react-grab-payload.js"; + +const buildPayload = (timestamp: number): ReactGrabPayload => ({ + version: "0.1.32", + content: "", - entries: [ - { - tagName: "button", - componentName: "Button", - content: "", - commentText: overrides.commentText ?? "Make this larger", - }, - ], - timestamp: overrides.timestamp ?? Date.now(), -}); - -beforeEach(() => { - vi.clearAllMocks(); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("handleGetElementContext", () => { - it("returns no-context message when clipboard is empty", async () => { - mockReadClipboardPayload.mockResolvedValue({ env: "macos", payload: null }); - - const result = await handleGetElementContext(); - expect(result.content[0].text).toContain("No React Grab context found"); - }); - - it("appends the hint when provided", async () => { - mockReadClipboardPayload.mockResolvedValue({ - env: "ssh", - payload: null, - hint: "Clipboard channel is unavailable in SSH sessions.", - }); - - const result = await handleGetElementContext(); - expect(result.content[0].text).toContain("No React Grab context found"); - expect(result.content[0].text).toContain("SSH sessions"); - }); - - it("returns formatted prompt and content for a fresh payload", async () => { - mockReadClipboardPayload.mockResolvedValue({ - env: "macos", - payload: buildPayload({ commentText: "Refactor this" }), - }); - - const result = await handleGetElementContext(); - expect(result.content[0].text).toContain("Prompt: Refactor this"); - expect(result.content[0].text).toContain("Elements (1):"); - expect(result.content[0].text).toContain(""); - }); - - it("treats payloads older than CONTEXT_TTL_MS as no context", async () => { - mockReadClipboardPayload.mockResolvedValue({ - env: "macos", - payload: buildPayload({ timestamp: Date.now() - CONTEXT_TTL_MS - 1000 }), - }); - - const result = await handleGetElementContext(); - expect(result.content[0].text).toContain("No React Grab context found"); - }); - - it("omits the prompt section when no entries carry commentText", async () => { - const payloadWithoutPrompt = buildPayload(); - payloadWithoutPrompt.entries[0].commentText = undefined; - mockReadClipboardPayload.mockResolvedValue({ - env: "linux", - payload: payloadWithoutPrompt, - }); - - const result = await handleGetElementContext(); - expect(result.content[0].text.startsWith("Elements (1):")).toBe(true); - }); - - it("deduplicates the prompt across entries and excludes it from the elements section", async () => { - const sharedPrompt = "Fix all the buttons"; - // The producer prepends the prompt to payload.content AND adds [1]/[2]/[3] - // labels via joinSnippets, so this is exactly what would land on the - // clipboard for a multi-element copy. - const canonicalContent = `${sharedPrompt}\n\n[1]\n\n\n[2]\n\n\n[3]\n`; - mockReadClipboardPayload.mockResolvedValue({ - env: "macos", - payload: { - version: "0.1.32", - content: canonicalContent, - entries: [ - { - tagName: "button", - content: "", - commentText: sharedPrompt, - }, - { - tagName: "button", - content: "", - commentText: sharedPrompt, - }, - { - tagName: "button", - content: "", - commentText: sharedPrompt, - }, - ], - timestamp: Date.now(), - }, - }); - - const result = await handleGetElementContext(); - const text = result.content[0].text; - - const promptOccurrences = text.split(sharedPrompt).length - 1; - expect(promptOccurrences).toBe(1); - expect(text).toContain(`Prompt: ${sharedPrompt}`); - expect(text).toContain("Elements (3):"); - expect(text).toContain("[1]"); - expect(text).toContain("[2]"); - expect(text).toContain("[3]"); - expect(text).toContain(""); - expect(text).toContain(""); - expect(text).toContain(""); - }); - - it("preserves transformCopyContent output and snippet labels in the body", async () => { - // payload.content is the canonical, plugin-transformed copy. We must - // surface it verbatim (minus the leading prompt prefix) rather than - // reassembling the body from raw entry snippets. - const transformedBody = "\n[1]\n\n\n[2]\n"; - mockReadClipboardPayload.mockResolvedValue({ - env: "macos", - payload: { - version: "0.1.32", - content: `Style as primary\n\n${transformedBody}`, - entries: [ - { - tagName: "button", - content: "", - commentText: "Style as primary", - }, - { - tagName: "button", - content: "", - commentText: "Style as primary", - }, - ], - timestamp: Date.now(), - }, - }); - - const result = await handleGetElementContext(); - const text = result.content[0].text; - expect(text).toContain(""); - expect(text).toContain(`Elements (2):\n${transformedBody}`); - }); - - it("does not strip element content that happens to start with the prompt text", async () => { - // The producer uses the untrimmed prompt to build payload.content. If - // the user typed no prompt but their element content starts with what - // *looks* like a prompt, we must not try to strip it. - mockReadClipboardPayload.mockResolvedValue({ - env: "macos", - payload: { - version: "0.1.32", - content: "Click me\n\n", - entries: [ - { - tagName: "button", - content: "", - commentText: undefined, - }, - ], - timestamp: Date.now(), - }, - }); - - const result = await handleGetElementContext(); - const text = result.content[0].text; - expect(text).toBe("Elements (1):\nClick me\n\n"); - }); - - it("strips the prompt prefix even when the original prompt had surrounding whitespace", async () => { - // The producer prepends the *untrimmed* prompt to payload.content, so - // the stripper must match against the raw commentText or the prompt - // would re-appear at the top of the elements body. - const rawPrompt = " Fix this button "; - mockReadClipboardPayload.mockResolvedValue({ - env: "macos", - payload: { - version: "0.1.32", - content: `${rawPrompt}\n\n`, - entries: [ - { - tagName: "button", - content: "", - commentText: rawPrompt, - }, - ], - timestamp: Date.now(), - }, - }); - - const result = await handleGetElementContext(); - const text = result.content[0].text; - - expect(text).toBe("Prompt: Fix this button\n\nElements (1):\n"); - const occurrences = text.split("Fix this button").length - 1; - expect(occurrences).toBe(1); - }); - - it("preserves distinct prompts when entries carry different commentText values", async () => { - mockReadClipboardPayload.mockResolvedValue({ - env: "macos", - payload: { - version: "0.1.32", - content: "\n\n", - entries: [ - { tagName: "button", content: "", commentText: "Make it red" }, - { tagName: "button", content: "", commentText: "Make it blue" }, - ], - timestamp: Date.now(), - }, - }); - - const result = await handleGetElementContext(); - const text = result.content[0].text; - expect(text).toContain("Prompt: Make it red\nMake it blue"); - }); -}); - -describe("createMcpServer", () => { - it("registers get_element_context wired to handleGetElementContext", () => { - const server = createMcpServer(); - const internals = server as unknown as { - _registeredTools?: Record; - }; - expect(internals._registeredTools).toBeDefined(); - expect(internals._registeredTools?.get_element_context).toBeDefined(); - }); - - it("invokes handleGetElementContext via the registered tool handler", async () => { - mockReadClipboardPayload.mockResolvedValue({ env: "macos", payload: null }); - - const server = createMcpServer(); - const internals = server as unknown as { - _registeredTools?: Record< - string, - { handler: () => Promise<{ content: { text: string }[] }> } - >; - }; - const registeredTool = internals._registeredTools?.get_element_context; - expect(registeredTool).toBeDefined(); - - const toolResult = await registeredTool?.handler(); - expect(toolResult?.content[0].text).toContain("No React Grab context found"); - }); -}); diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json index f73b95c76..c56fa1cf7 100644 --- a/packages/mcp/tsconfig.json +++ b/packages/mcp/tsconfig.json @@ -11,5 +11,5 @@ "noEmit": true, "types": ["node"] }, - "include": ["src"] + "include": ["src", "test"] } diff --git a/packages/mcp/vite.config.ts b/packages/mcp/vite.config.ts index dafbe0e74..685e06e5c 100644 --- a/packages/mcp/vite.config.ts +++ b/packages/mcp/vite.config.ts @@ -13,13 +13,14 @@ export default defineConfig({ testTimeout: 10000, }, pack: { - entry: ["src/server.ts", "src/cli.ts"], + entry: ["src/cli.ts"], format: ["cjs", "esm"], dts: true, clean: false, sourcemap: false, platform: "node", fixedExtension: false, + banner: "#!/usr/bin/env node", deps: { alwaysBundle: [/.*/], neverBundle: nodeBuiltins, diff --git a/packages/react-grab/README.md b/packages/react-grab/README.md index a3947d42c..34399ff66 100644 --- a/packages/react-grab/README.md +++ b/packages/react-grab/README.md @@ -19,12 +19,14 @@ Run this command at your project root (where `next.config.ts` or `vite.config.ts npx grab@latest init ``` -## Connect to MCP +## Connect to your agent ```bash -npx grab@latest add mcp +npx grab@latest install-skill ``` +Installs a `react-grab` skill into Cursor / Claude Code / Codex / OpenCode. Once installed, type `/react-grab` in your agent and click any element on the page — the agent receives the file name, React component, and HTML for that element. + ## Usage Once installed, hover over any UI element in your browser and press: diff --git a/packages/react-grab/docs/architecture.md b/packages/react-grab/docs/architecture.md index 129e13564..16f04d5fa 100644 --- a/packages/react-grab/docs/architecture.md +++ b/packages/react-grab/docs/architecture.md @@ -205,6 +205,8 @@ The five built-in plugins are registered during `init()` through the same `regis - **copy-html** registers "Copy HTML" which copies the element's `outerHTML` with stack context appended. - **copy-styles** registers "Copy styles" which extracts the element's computed CSS (compared against a baseline from a hidden iframe) and copies it with stack context. -## Notes about MCP integration +## Notes about agent integration -The `@react-grab/mcp` package ships an MCP stdio server that bridges react-grab with AI coding assistants. The browser does not talk to the server over the network. Instead, react-grab's copy flow already writes a custom MIME type `application/x-react-grab` to the OS clipboard alongside the plain text and HTML representations (see [packages/react-grab/src/utils/copy-content.ts](../src/utils/copy-content.ts)). When the agent calls the `get_element_context` MCP tool, the server reads that MIME type directly from the clipboard via OS-native helpers (`osascript`/JXA on macOS, `wl-paste`/`xclip` on Linux, PowerShell `-Sta` on Windows, with a WSL bridge to the Windows host). This keeps the integration permissionless — no `localhost` requests from the browser, no port management. +`@react-grab/cli` exposes a `react-grab watch` subcommand that bridges react-grab with AI coding assistants. The browser does not talk to the CLI over the network. Instead, react-grab's copy flow already writes a custom MIME type `application/x-react-grab` to the OS clipboard alongside the plain text and HTML representations (see [packages/react-grab/src/utils/copy-content.ts](../src/utils/copy-content.ts)). When the agent runs `react-grab watch`, the CLI snapshots the current payload's `timestamp` and polls the clipboard via OS-native helpers (`osascript`/JXA on macOS, `wl-paste`/`xclip` on Linux, PowerShell `-Sta` on Windows, with a WSL bridge to the Windows host) until a payload with a different timestamp arrives, then prints it to stdout. This keeps the integration permissionless — no `localhost` requests from the browser, no port management, no long-running server. + +Agent invocation is driven by an installable skill (`react-grab install-skill`) that ships a `SKILL.md` to known agent skill directories (Cursor, Claude Code, Codex, OpenCode). Agents that support skills auto-invoke it on `/react-grab` or when the user references a grabbed element (e.g. "this thing", "the component I clicked"). The legacy `@react-grab/mcp` MCP server is deprecated — see its [package README](../../../packages/mcp/README.md) for migration steps. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a70e17c8..ceb4be4a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -233,6 +233,9 @@ importers: smol-toml: specifier: ^1.6.0 version: 1.6.0 + zod: + specifier: ^3.25.0 + version: 3.25.76 devDependencies: '@types/prompts': specifier: ^2.4.9 @@ -245,13 +248,6 @@ importers: version: link:../cli packages/mcp: - dependencies: - '@modelcontextprotocol/sdk': - specifier: ^1.25.0 - version: 1.26.0(zod@3.25.76) - zod: - specifier: ^3.25.0 - version: 3.25.76 devDependencies: '@types/node': specifier: ^22.10.7 @@ -1350,16 +1346,6 @@ packages: '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} - '@modelcontextprotocol/sdk@1.26.0': - resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==} - engines: {node: '>=18'} - peerDependencies: - '@cfworker/json-schema': ^4.1.1 - zod: ^3.25 || ^4.0 - peerDependenciesMeta: - '@cfworker/json-schema': - optional: true - '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -6537,17 +6523,17 @@ snapshots: '@agentclientprotocol/claude-agent-acp@0.24.2': dependencies: - '@agentclientprotocol/sdk': 0.17.0(zod@4.3.5) - '@anthropic-ai/claude-agent-sdk': 0.2.84(zod@4.3.5) - zod: 4.3.5 + '@agentclientprotocol/sdk': 0.17.0(zod@3.25.76) + '@anthropic-ai/claude-agent-sdk': 0.2.84(zod@3.25.76) + zod: 3.25.76 '@agentclientprotocol/sdk@0.12.0(zod@3.25.76)': dependencies: zod: 3.25.76 - '@agentclientprotocol/sdk@0.17.0(zod@4.3.5)': + '@agentclientprotocol/sdk@0.17.0(zod@3.25.76)': dependencies: - zod: 4.3.5 + zod: 3.25.76 '@alcalzone/ansi-tokenize@0.2.5': dependencies: @@ -6558,9 +6544,9 @@ snapshots: '@antfu/ni@0.23.2': {} - '@anthropic-ai/claude-agent-sdk@0.2.84(zod@4.3.5)': + '@anthropic-ai/claude-agent-sdk@0.2.84(zod@3.25.76)': dependencies: - zod: 4.3.5 + zod: 3.25.76 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.4 '@img/sharp-darwin-x64': 0.34.4 @@ -7722,28 +7708,6 @@ snapshots: '@mixmark-io/domino@2.2.0': {} - '@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)': - dependencies: - '@hono/node-server': 1.19.9(hono@4.11.7) - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) - content-type: 1.0.5 - cors: 2.8.6 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.0.6 - express: 5.2.1 - express-rate-limit: 8.2.1(express@5.2.1) - hono: 4.11.7 - jose: 6.1.3 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 - zod: 3.25.76 - zod-to-json-schema: 3.25.1(zod@3.25.76) - transitivePeerDependencies: - - supports-color - '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.9(hono@4.11.7) diff --git a/skills/react-grab/SKILL.md b/skills/react-grab/SKILL.md index 8262c72fe..666cbfa14 100644 --- a/skills/react-grab/SKILL.md +++ b/skills/react-grab/SKILL.md @@ -1,35 +1,51 @@ --- name: react-grab description: >- - Pull the latest UI element grabbed via React Grab from the user's clipboard. - Use when the user references a grabbed element, "this thing", "the component - I just clicked", "the element I selected", or pastes/cites content that came - from React Grab's toolbar. + Pull the next React Grab selection from the user's clipboard. Use when the + user invokes /react-grab, references "this thing", "the element I clicked", + "the component I just grabbed", or pastes content that came from the React + Grab toolbar. --- -# React Grab Mode +# React Grab -When the user references an element they grabbed with React Grab (phrases like "this thing", "the component I just clicked", "the element I grabbed", or when they've clearly pasted React Grab output), the **canonical description of that element lives in the system clipboard** under the custom MIME type `application/x-react-grab`. +When the user wants the agent to act on a UI element they're about to (or just +did) click in the React Grab toolbar, the canonical description of that element +travels through the system clipboard under the custom MIME type +`application/x-react-grab`. ## What to do -1. **Before doing anything else**, call the `get_element_context` tool from the `react-grab-mcp` MCP server **exactly once** at the top of the turn. -2. Treat its return value as the authoritative description of the target element. It contains: - - The user's prompt (if they typed one in the toolbar). +1. Run `npx -y @react-grab/cli watch` exactly once at the top of the turn. +2. The CLI blocks until a fresh React Grab payload appears on the clipboard + (or 10 minutes elapse, whichever comes first). While it waits, tell the user + to click an element in the React Grab toolbar (the box that appears when + they hover the page). They can also type a prompt into the toolbar before + clicking - that prompt is included in the output. +3. Treat the CLI's stdout as the authoritative description of the target + element. It contains: + - The user's prompt (if any). - The HTML snippet of the selected element(s). - Component stack / source file paths. -3. Plan and execute the user's request against that returned context. +4. Plan and execute the user's request against that returned context. ## Failure modes -- **`get_element_context` tool isn't registered with your MCP client** — React Grab MCP isn't installed. Tell the user to run `npx @react-grab/cli@latest install-mcp` and restart their MCP client. -- **"No React Grab context found on the clipboard."** — the clipboard either doesn't hold a React Grab payload or it's older than the TTL. Tell the user: _"I don't see a recent grab. Click the element in the React Grab toolbar (the box that appears when you hover the page) and try again."_ Do not retry the tool inside the same turn. -- **"Clipboard channel is unavailable in SSH sessions"** — the MCP server is on a different machine than the browser. Tell the user to run `react-grab-mcp` on the same machine as the browser. -- **Linux missing `xclip` / `wl-clipboard`** — surface the install command from the tool's error verbatim. -- **WSL with broken interop** — surface the WSL interop hint from the tool's error verbatim. +- **Exit code 1 ("Timed out...")** - the user didn't click anything within the + timeout. Ask them to click and re-run; do not retry inside the same turn. +- **Exit code 2 ("Clipboard channel is unavailable in SSH sessions")** - the + CLI is on a different machine than the browser. Tell the user to run the + agent on the same machine as the browser. +- **Linux missing `xclip` / `wl-clipboard`** - surface the install command + from the CLI's stderr verbatim. +- **WSL with broken interop** - surface the WSL interop hint from stderr + verbatim. ## Constraints -- Do **not** call `get_element_context` more than once per turn. The clipboard payload is short-lived; a second call usually returns the same data or stale data. -- Do **not** invent element details. If the tool returned no context, ask the user; do not fabricate. -- Do **not** rely on the user's chat message alone when they reference "this" / "that" / "the thing I grabbed" — always reach for the tool first. +- Do NOT call `react-grab watch` more than once per turn. The CLI blocks until + a fresh grab arrives; a second call would just block again. +- Do NOT invent element details. If the CLI failed, ask the user; do not + fabricate. +- Do NOT rely on the user's chat message alone when they reference "this" / + "that" / "the thing I grabbed" - always invoke the CLI first. From 39d153452fe47dc99eaec4a801f1876c426a72fc Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 25 Apr 2026 21:13:36 -0700 Subject: [PATCH 09/37] fix(cli): address cubic review on the watch CLI + skill installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - watch: don't `process.exit(0)` after writing the payload — return so Node drains stdout naturally (avoids truncation when piped). - init: pass `projectInfo.projectRoot` (not the original `cwd`) to the skill installer so subprojects in monorepos get the skill in the right dir. - root: re-add `--filter=@react-grab/mcp` to the build script so the deprecation stub `dist` artifact is generated before publish. - install-skill / remove: reject `--agent` arguments that name unsupported clients (VS Code, Zed) instead of silently no-opping. - skill template: clarify the trigger — agents should NOT run `watch` if the user has already pasted React Grab toolbar output (it would block waiting for a fresh clipboard timestamp that is not coming). - last-selected-agents: ignore relative `$XDG_STATE_HOME` values per the XDG Base Directory spec; fall back to `~/.local/state`. - subprocess tests (watch-cli, deprecation-stub): use `fileURLToPath(import.meta.url)` instead of `__dirname` so they're ESM-pure. --- package.json | 2 +- packages/cli/src/commands/init.ts | 10 ++++++++-- packages/cli/src/commands/install-skill.ts | 11 ++++++++++- packages/cli/src/commands/remove.ts | 14 +++++--------- packages/cli/src/commands/watch.ts | 6 ++++-- packages/cli/src/utils/install-skill.ts | 5 +++++ packages/cli/src/utils/last-selected-agents.ts | 6 +++++- packages/cli/src/utils/skill-template.ts | 8 +++++--- packages/cli/test/install-skill.test.ts | 5 +++++ packages/cli/test/watch-cli.test.ts | 4 +++- packages/mcp/test/deprecation-stub.test.ts | 4 +++- packages/react-grab/README.md | 2 +- skills/react-grab/SKILL.md | 8 +++++--- 13 files changed, 60 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index 8f64df1a4..4c0c17cf1 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ ], "type": "module", "scripts": { - "build": "cp README.md packages/react-grab/README.md && turbo run build --filter=@react-grab/cli --filter=react-grab --filter=grab", + "build": "cp README.md packages/react-grab/README.md && turbo run build --filter=@react-grab/cli --filter=react-grab --filter=grab --filter=@react-grab/mcp", "dev": "turbo run dev --filter=react-grab", "test": "turbo run test", "test:cli": "turbo run test --filter=@react-grab/cli", diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 6133dba87..47935ceb7 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -347,7 +347,10 @@ export const init = new Command() } if (skillChoice !== "no") { - const results = installDetectedOrAllSkills(skillChoice as SkillScope, cwd); + const results = installDetectedOrAllSkills( + skillChoice as SkillScope, + projectInfo.projectRoot, + ); const didInstall = results.some((result) => result.success); if (!didInstall) { logger.break(); @@ -480,7 +483,10 @@ export const init = new Command() } if (skillChoice !== "no") { - const results = installDetectedOrAllSkills(skillChoice as SkillScope, cwd); + const results = installDetectedOrAllSkills( + skillChoice as SkillScope, + projectInfo.projectRoot, + ); didInstallSkill = results.some((result) => result.success); if (!didInstallSkill) { logger.break(); diff --git a/packages/cli/src/commands/install-skill.ts b/packages/cli/src/commands/install-skill.ts index d64300b17..06ae7d5b5 100644 --- a/packages/cli/src/commands/install-skill.ts +++ b/packages/cli/src/commands/install-skill.ts @@ -6,6 +6,7 @@ import { buildAgentChoices, detectInstalledSkillClients, getSkillClientNames, + getSupportedSkillClientNames, installSkills, type SkillScope, } from "../utils/install-skill.js"; @@ -71,13 +72,21 @@ export const installSkill = new Command() } const allNames = getSkillClientNames(); + const supportedNames = getSupportedSkillClientNames(); const flagScope: SkillScope | undefined = isSkillScope(opts.scope) ? opts.scope : undefined; if (opts.agent && opts.agent.length > 0) { const unknown = opts.agent.filter((name) => !allNames.includes(name)); if (unknown.length > 0) { logger.error(`Unknown agent(s): ${unknown.join(", ")}`); - logger.log(`Supported: ${allNames.join(", ")}`); + logger.log(`Supported: ${supportedNames.join(", ")}`); + logger.break(); + process.exit(1); + } + const unsupported = opts.agent.filter((name) => !supportedNames.includes(name)); + if (unsupported.length > 0) { + logger.error(`Agent(s) do not support skills yet: ${unsupported.join(", ")}`); + logger.log(`Supported: ${supportedNames.join(", ")}`); logger.break(); process.exit(1); } diff --git a/packages/cli/src/commands/remove.ts b/packages/cli/src/commands/remove.ts index c3c4b2753..3a7288c27 100644 --- a/packages/cli/src/commands/remove.ts +++ b/packages/cli/src/commands/remove.ts @@ -5,8 +5,7 @@ import { highlighter } from "../utils/highlighter.js"; import { logger } from "../utils/logger.js"; import { prompts } from "../utils/prompts.js"; import { - getSkillClientNames, - getSkillClients, + getSupportedSkillClientNames, removeSkills, type SkillScope, } from "../utils/install-skill.js"; @@ -43,16 +42,13 @@ export const remove = new Command() process.exit(1); } - const supported = getSkillClients() - .filter((client) => client.supported) - .map((client) => client.name); - const allNames = getSkillClientNames(); + const supported = getSupportedSkillClientNames(); let targets: string[]; if (opts.agent && opts.agent.length > 0) { - const unknown = opts.agent.filter((name) => !allNames.includes(name)); - if (unknown.length > 0) { - logger.error(`Unknown agent(s): ${unknown.join(", ")}`); + const unsupported = opts.agent.filter((name) => !supported.includes(name)); + if (unsupported.length > 0) { + logger.error(`Unknown or unsupported agent(s): ${unsupported.join(", ")}`); logger.log(`Supported: ${supported.join(", ")}`); logger.break(); process.exit(1); diff --git a/packages/cli/src/commands/watch.ts b/packages/cli/src/commands/watch.ts index cc26e481d..cba43b4d0 100644 --- a/packages/cli/src/commands/watch.ts +++ b/packages/cli/src/commands/watch.ts @@ -71,9 +71,11 @@ export const watch = new Command() switch (waitResult.outcome) { case "match": + // Don't process.exit(0) here: an immediate exit can truncate stdout + // when the writer is piped through another process. Returning lets + // Node drain the buffer and exit naturally with code 0. printPayload(waitResult.payload, rawOptions.json); - process.exit(0); - break; + return; case "unrecoverable": fail(formatUnrecoverableMessage(waitResult.result), 2); break; diff --git a/packages/cli/src/utils/install-skill.ts b/packages/cli/src/utils/install-skill.ts index 751ecc382..c1f4c12f5 100644 --- a/packages/cli/src/utils/install-skill.ts +++ b/packages/cli/src/utils/install-skill.ts @@ -136,6 +136,11 @@ export const getSkillClients = (): SkillClientDefinition[] => { export const getSkillClientNames = (): string[] => getSkillClients().map((client) => client.name); +export const getSupportedSkillClientNames = (): string[] => + getSkillClients() + .filter((client) => client.supported) + .map((client) => client.name); + export const detectInstalledSkillClients = (): string[] => getSkillClients() .filter((client) => client.supported && client.detectInstalled()) diff --git a/packages/cli/src/utils/last-selected-agents.ts b/packages/cli/src/utils/last-selected-agents.ts index 204e7f2a7..2dc822ba5 100644 --- a/packages/cli/src/utils/last-selected-agents.ts +++ b/packages/cli/src/utils/last-selected-agents.ts @@ -9,7 +9,11 @@ import { const getStateDir = (): string => { const xdgStateHome = process.env.XDG_STATE_HOME?.trim(); - if (xdgStateHome) return path.join(xdgStateHome, STATE_DIR_NAME); + // Per the XDG Base Directory spec, $XDG_STATE_HOME MUST be an absolute path; + // relative values are ignored. Falls through to ~/.local/state otherwise. + if (xdgStateHome && path.isAbsolute(xdgStateHome)) { + return path.join(xdgStateHome, STATE_DIR_NAME); + } return path.join(os.homedir(), FALLBACK_STATE_HOME_RELATIVE, STATE_DIR_NAME); }; diff --git a/packages/cli/src/utils/skill-template.ts b/packages/cli/src/utils/skill-template.ts index c6ead6a7b..ed29c0bd9 100644 --- a/packages/cli/src/utils/skill-template.ts +++ b/packages/cli/src/utils/skill-template.ts @@ -2,9 +2,11 @@ export const SKILL_TEMPLATE = `--- name: react-grab description: >- Pull the next React Grab selection from the user's clipboard. Use when the - user invokes /react-grab, references "this thing", "the element I clicked", - "the component I just grabbed", or pastes content that came from the React - Grab toolbar. + user invokes /react-grab or references "this thing", "the element I clicked", + or "the component I just grabbed" without having already pasted the content. + If the user has already pasted React Grab toolbar output into the chat, use + the pasted content directly - do NOT run this skill (it would block waiting + for a fresh clipboard timestamp that is not coming). allowed-tools: - Bash --- diff --git a/packages/cli/test/install-skill.test.ts b/packages/cli/test/install-skill.test.ts index 9ce5cf895..2804a44ee 100644 --- a/packages/cli/test/install-skill.test.ts +++ b/packages/cli/test/install-skill.test.ts @@ -397,4 +397,9 @@ describe("SKILL_TEMPLATE", () => { it("instructs the agent to run the watch CLI", () => { expect(SKILL_TEMPLATE).toContain("npx -y @react-grab/cli watch"); }); + + it("warns the agent against running watch on already-pasted content", () => { + expect(SKILL_TEMPLATE).toMatch(/already pasted/i); + expect(SKILL_TEMPLATE).toMatch(/do NOT run this skill/i); + }); }); diff --git a/packages/cli/test/watch-cli.test.ts b/packages/cli/test/watch-cli.test.ts index 08ea15cf0..ab6249bff 100644 --- a/packages/cli/test/watch-cli.test.ts +++ b/packages/cli/test/watch-cli.test.ts @@ -1,9 +1,11 @@ import { spawnSync, type SpawnSyncOptions } from "node:child_process"; import { existsSync } from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vite-plus/test"; -const CLI_PATH = path.resolve(__dirname, "..", "dist", "cli.js"); +const TEST_DIR = path.dirname(fileURLToPath(import.meta.url)); +const CLI_PATH = path.resolve(TEST_DIR, "..", "dist", "cli.js"); const SSH_DETECTION_KEYS = ["SSH_CLIENT", "SSH_TTY", "SSH_CONNECTION", "WSL_DISTRO_NAME"] as const; diff --git a/packages/mcp/test/deprecation-stub.test.ts b/packages/mcp/test/deprecation-stub.test.ts index a33acd2e2..ac393acd5 100644 --- a/packages/mcp/test/deprecation-stub.test.ts +++ b/packages/mcp/test/deprecation-stub.test.ts @@ -1,9 +1,11 @@ import { spawnSync } from "node:child_process"; import path from "node:path"; import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vite-plus/test"; -const STUB_PATH = path.resolve(__dirname, "..", "dist", "cli.cjs"); +const TEST_DIR = path.dirname(fileURLToPath(import.meta.url)); +const STUB_PATH = path.resolve(TEST_DIR, "..", "dist", "cli.cjs"); describe("@react-grab/mcp deprecation stub", () => { it("exits with code 1 and prints a migration message to stderr", () => { diff --git a/packages/react-grab/README.md b/packages/react-grab/README.md index 34399ff66..fa2f272ff 100644 --- a/packages/react-grab/README.md +++ b/packages/react-grab/README.md @@ -19,7 +19,7 @@ Run this command at your project root (where `next.config.ts` or `vite.config.ts npx grab@latest init ``` -## Connect to your agent +## Install agent skill ```bash npx grab@latest install-skill diff --git a/skills/react-grab/SKILL.md b/skills/react-grab/SKILL.md index 666cbfa14..774f334db 100644 --- a/skills/react-grab/SKILL.md +++ b/skills/react-grab/SKILL.md @@ -2,9 +2,11 @@ name: react-grab description: >- Pull the next React Grab selection from the user's clipboard. Use when the - user invokes /react-grab, references "this thing", "the element I clicked", - "the component I just grabbed", or pastes content that came from the React - Grab toolbar. + user invokes /react-grab or references "this thing", "the element I clicked", + or "the component I just grabbed" without having already pasted the content. + If the user has already pasted React Grab toolbar output into the chat, use + the pasted content directly - do NOT run this skill (it would block waiting + for a fresh clipboard timestamp that is not coming). --- # React Grab From 4ec019ff7c4f2a5b6cfd4d9592a6a48256aa1fe1 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 25 Apr 2026 21:25:53 -0700 Subject: [PATCH 10/37] fix(cli): address second cubic + bugbot review pass - install-skill: filter unsupported clients out of `--yes` fallback, multiselect prompt, and only print "Restart your agent(s)..." when at least one install actually succeeded. Mirror the result-checking in the single-detected and explicit-agent branches too. - clipboard readers: when the OS helper binary is missing (osascript / xclip+wl-paste / powershell), set `recoverable: false` so `watch` fast-exits with code 2 and the install hint, instead of polling out the timeout. - mcp deprecation stub: replace `process.exit(1)` with `process.exitCode = 1` so the deprecation notice fully flushes before exit. - cli README: clarify that `grab add` is a wrapper around `install-skill`, not a strict alias. Skipping two pre-existing/edge-case items: format-payload prompt-mode (producer always uses entries[].commentText, schema has no top-level prompt) and wait-for-next-grab read-blocks-timeout (refactor cost > 0.5% overshoot benefit at the default 600s timeout). --- packages/cli/README.md | 2 +- packages/cli/src/commands/install-skill.ts | 48 +++++++++++++++---- .../cli/src/utils/read-clipboard-linux.ts | 2 +- .../cli/src/utils/read-clipboard-macos.ts | 6 ++- .../cli/src/utils/read-clipboard-windows.ts | 1 + packages/cli/src/utils/read-clipboard-wsl.ts | 9 +++- .../cli/test/read-clipboard-linux.test.ts | 3 +- .../cli/test/read-clipboard-macos.test.ts | 11 +++++ .../cli/test/read-clipboard-windows.test.ts | 3 +- packages/cli/test/read-clipboard-wsl.test.ts | 3 +- packages/grab/README.md | 4 +- packages/mcp/src/cli.ts | 5 +- 12 files changed, 76 insertions(+), 21 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index bf568d4c1..bfae83308 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -40,7 +40,7 @@ npx grab@latest install-skill | `--yes` | `-y` | Install to all supported agents without prompting | | `--agent ` | `-a` | Install only to the named agent(s) (e.g. Cursor, Claude Code) | -Aliased as `grab add` (and the legacy `grab add mcp` redirects to skill install). +Also reachable through the wrapper command `grab add` (and the legacy `grab add mcp` redirects to skill install with a deprecation notice). ### `grab remove` diff --git a/packages/cli/src/commands/install-skill.ts b/packages/cli/src/commands/install-skill.ts index 06ae7d5b5..e596f4c0a 100644 --- a/packages/cli/src/commands/install-skill.ts +++ b/packages/cli/src/commands/install-skill.ts @@ -91,10 +91,16 @@ export const installSkill = new Command() process.exit(1); } const scope: SkillScope = flagScope ?? "project"; - installSkills({ scope, cwd: opts.cwd, selectedClients: opts.agent }); + const results = installSkills({ scope, cwd: opts.cwd, selectedClients: opts.agent }); writeLastSelectedAgents(opts.agent); logger.break(); - logger.log("Restart your agent(s) to pick up the new skill."); + if (results.some((r) => r.success)) { + logger.log("Restart your agent(s) to pick up the new skill."); + } else { + logger.error("No skill files were written."); + logger.break(); + process.exit(1); + } logger.break(); return; } @@ -115,11 +121,17 @@ export const installSkill = new Command() if (opts.yes) { const detected = detectInstalledSkillClients(); - const targets = detected.length > 0 ? detected : allNames; - installSkills({ scope, cwd: opts.cwd, selectedClients: targets }); + const targets = detected.length > 0 ? detected : supportedNames; + const results = installSkills({ scope, cwd: opts.cwd, selectedClients: targets }); writeLastSelectedAgents(targets); logger.break(); - logger.log("Restart your agent(s) to pick up the new skill."); + if (results.some((r) => r.success)) { + logger.log("Restart your agent(s) to pick up the new skill."); + } else { + logger.error("No skill files were written."); + logger.break(); + process.exit(1); + } logger.break(); return; } @@ -131,10 +143,18 @@ export const installSkill = new Command() `Auto-installing to ${highlighter.info(onlyDetected)} (only detected agent). Pass ${highlighter.info("--agent")} to override.`, ); logger.break(); - installSkills({ scope, cwd: opts.cwd, selectedClients: [onlyDetected] }); + const results = installSkills({ scope, cwd: opts.cwd, selectedClients: [onlyDetected] }); writeLastSelectedAgents([onlyDetected]); logger.break(); - logger.log(`${highlighter.success("Done.")} Restart your agent to pick up the new skill.`); + if (results.some((r) => r.success)) { + logger.log( + `${highlighter.success("Done.")} Restart your agent to pick up the new skill.`, + ); + } else { + logger.error("Skill install failed."); + logger.break(); + process.exit(1); + } logger.break(); return; } @@ -143,7 +163,7 @@ export const installSkill = new Command() type: "multiselect", name: "selectedAgents", message: `Select agents to install the React Grab skill for (${scope}):`, - choices: buildAgentChoices(scope, { allClients: true }), + choices: buildAgentChoices(scope), }); if (selectedAgents === undefined || selectedAgents.length === 0) { @@ -154,10 +174,18 @@ export const installSkill = new Command() } logger.break(); - installSkills({ scope, cwd: opts.cwd, selectedClients: selectedAgents }); + const results = installSkills({ scope, cwd: opts.cwd, selectedClients: selectedAgents }); writeLastSelectedAgents(selectedAgents); logger.break(); - logger.log(`${highlighter.success("Done.")} Restart your agent(s) to pick up the new skill.`); + if (results.some((r) => r.success)) { + logger.log( + `${highlighter.success("Done.")} Restart your agent(s) to pick up the new skill.`, + ); + } else { + logger.error("No skill files were written."); + logger.break(); + process.exit(1); + } logger.break(); } catch (error) { handleError(error); diff --git a/packages/cli/src/utils/read-clipboard-linux.ts b/packages/cli/src/utils/read-clipboard-linux.ts index d257204f4..981b2f46c 100644 --- a/packages/cli/src/utils/read-clipboard-linux.ts +++ b/packages/cli/src/utils/read-clipboard-linux.ts @@ -57,7 +57,7 @@ export const readClipboardLinux = async (): Promise => { return { payload: trimToPayload(x11Result.stdout) }; } if (isBinaryMissing(x11Result.error)) { - return { payload: null, hint: INSTALL_HINT }; + return { payload: null, hint: INSTALL_HINT, recoverable: false }; } return { payload: null }; }; diff --git a/packages/cli/src/utils/read-clipboard-macos.ts b/packages/cli/src/utils/read-clipboard-macos.ts index cadeeb53a..f01550f96 100644 --- a/packages/cli/src/utils/read-clipboard-macos.ts +++ b/packages/cli/src/utils/read-clipboard-macos.ts @@ -19,7 +19,11 @@ export const readClipboardMacos = async (): Promise => { } catch (caughtError) { surfaceStderr("osascript", caughtError); if (hasErrorCode(caughtError, "ENOENT")) { - return { payload: null, hint: "macOS requires `osascript` (preinstalled). Check $PATH." }; + return { + payload: null, + hint: "macOS requires `osascript` (preinstalled). Check $PATH.", + recoverable: false, + }; } return { payload: null }; } diff --git a/packages/cli/src/utils/read-clipboard-windows.ts b/packages/cli/src/utils/read-clipboard-windows.ts index 4c2a84c53..480d6c0cf 100644 --- a/packages/cli/src/utils/read-clipboard-windows.ts +++ b/packages/cli/src/utils/read-clipboard-windows.ts @@ -57,6 +57,7 @@ export const readClipboardViaWindowsPowerShell = async ( return { payload: null, hint: `Cannot launch ${binary}. Ensure Windows PowerShell is on PATH.`, + recoverable: false, }; } return { payload: null }; diff --git a/packages/cli/src/utils/read-clipboard-wsl.ts b/packages/cli/src/utils/read-clipboard-wsl.ts index 8b90fad73..ad72cc49f 100644 --- a/packages/cli/src/utils/read-clipboard-wsl.ts +++ b/packages/cli/src/utils/read-clipboard-wsl.ts @@ -20,8 +20,13 @@ export const readClipboardWsl = async (): Promise => { if (hostOutcome.hint) { // When interop is unreachable AND the WSLg fallback also has actionable // guidance (e.g. "install xclip"), surface both so the user can fix - // whichever channel they prefer. - return { payload: null, hint: combineHints(WSL_INTEROP_HINT, wslgOutcome.hint) }; + // whichever channel they prefer. Both channels failing is unrecoverable + // - polling won't fix a missing binary or broken interop. + return { + payload: null, + hint: combineHints(WSL_INTEROP_HINT, wslgOutcome.hint), + recoverable: false, + }; } return wslgOutcome; }; diff --git a/packages/cli/test/read-clipboard-linux.test.ts b/packages/cli/test/read-clipboard-linux.test.ts index ecbf7262b..c9799b57f 100644 --- a/packages/cli/test/read-clipboard-linux.test.ts +++ b/packages/cli/test/read-clipboard-linux.test.ts @@ -70,12 +70,13 @@ describe("readClipboardLinux", () => { expect(getExecFileCall(mockExecFile, 1).binary).toBe("xclip"); }); - it("returns install hint when xclip is missing", async () => { + it("returns install hint when xclip is missing and marks the outcome unrecoverable", async () => { stubExecFile(mockExecFile, { error: enoentError() }); const result = await readClipboardLinux(); expect(result.payload).toBeNull(); expect(result.hint).toContain("xclip"); + expect(result.recoverable).toBe(false); }); it("returns null payload when stdout is empty", async () => { diff --git a/packages/cli/test/read-clipboard-macos.test.ts b/packages/cli/test/read-clipboard-macos.test.ts index c5b6b931c..83dbe9278 100644 --- a/packages/cli/test/read-clipboard-macos.test.ts +++ b/packages/cli/test/read-clipboard-macos.test.ts @@ -48,4 +48,15 @@ describe("readClipboardMacos", () => { const result = await readClipboardMacos(); expect(result.payload).toBeNull(); }); + + it("flags ENOENT (osascript missing) as unrecoverable with an actionable hint", async () => { + const enoent = new Error("ENOENT") as NodeJS.ErrnoException; + enoent.code = "ENOENT"; + stubExecFile(mockExecFile, { error: enoent }); + + const result = await readClipboardMacos(); + expect(result.payload).toBeNull(); + expect(result.hint).toContain("osascript"); + expect(result.recoverable).toBe(false); + }); }); diff --git a/packages/cli/test/read-clipboard-windows.test.ts b/packages/cli/test/read-clipboard-windows.test.ts index cb3f66423..1b69ad511 100644 --- a/packages/cli/test/read-clipboard-windows.test.ts +++ b/packages/cli/test/read-clipboard-windows.test.ts @@ -69,11 +69,12 @@ describe("readClipboardWindows", () => { ); }); - it("returns ENOENT hint when powershell is missing", async () => { + it("returns ENOENT hint when powershell is missing and marks the outcome unrecoverable", async () => { stubExecFile(mockExecFile, { error: enoentError() }); const result = await readClipboardWindows(); expect(result.payload).toBeNull(); expect(result.hint).toContain("powershell"); + expect(result.recoverable).toBe(false); }); }); diff --git a/packages/cli/test/read-clipboard-wsl.test.ts b/packages/cli/test/read-clipboard-wsl.test.ts index c666b2f88..f50a84035 100644 --- a/packages/cli/test/read-clipboard-wsl.test.ts +++ b/packages/cli/test/read-clipboard-wsl.test.ts @@ -66,7 +66,7 @@ describe("readClipboardWsl", () => { expect(result.hint).toContain("xclip"); }); - it("combines WSL interop and Linux install hints when both fallbacks have guidance", async () => { + it("combines WSL interop and Linux install hints when both fallbacks have guidance, marks unrecoverable", async () => { mockReadClipboardViaWindowsPowerShell.mockResolvedValue({ payload: null, hint: "Cannot launch powershell.exe.", @@ -80,5 +80,6 @@ describe("readClipboardWsl", () => { expect(result.payload).toBeNull(); expect(result.hint).toContain("interop"); expect(result.hint).toContain("xclip"); + expect(result.recoverable).toBe(false); }); }); diff --git a/packages/grab/README.md b/packages/grab/README.md index 6d1342682..32288ebdb 100644 --- a/packages/grab/README.md +++ b/packages/grab/README.md @@ -5,9 +5,9 @@ Select context for coding agents directly from your website -How? Point at any element and press **⌘C** (Mac) or **Ctrl+C** (Windows/Linux) to copy the file name, React component, and HTML source code. +How? Point at any element and press ⌘C (Mac) or Ctrl+C (Windows/Linux) to copy the file name, React component, and HTML source code. -It makes tools like Cursor, Claude Code, Copilot run up to [**3× faster**](https://react-grab.com/blog/intro) and more accurate. +It makes tools like Cursor, Claude Code, Copilot run up to [**2× faster**](https://react-grab.com/blog/intro) and more accurate. ### [Try out a demo! →](https://react-grab.com) diff --git a/packages/mcp/src/cli.ts b/packages/mcp/src/cli.ts index 39f7fb297..57cc53136 100644 --- a/packages/mcp/src/cli.ts +++ b/packages/mcp/src/cli.ts @@ -12,4 +12,7 @@ See https://github.com/aidenybai/react-grab for details. `; process.stderr.write(DEPRECATION_NOTICE); -process.exit(1); +// Set exitCode and let the process finish naturally so stderr fully flushes +// before exit. process.exit() can truncate output when piped through other +// processes (e.g. an agent's tool harness). +process.exitCode = 1; From 8fdc2c8c96316176b4a3ce4585cf682d7c0902cf Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 25 Apr 2026 21:29:45 -0700 Subject: [PATCH 11/37] web --- README.md | 6 +++--- packages/cli/test/watch-cli.test.ts | 9 ++++++++- packages/grab/README.md | 6 +++--- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index fa2f272ff..5c0d0b5a5 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Select context for coding agents directly from your website How? Point at any element and press **⌘C** (Mac) or **Ctrl+C** (Windows/Linux) to copy the file name, React component, and HTML source code. -It makes tools like Cursor, Claude Code, Copilot run up to [**3× faster**](https://react-grab.com/blog/intro) and more accurate. +It makes tools like Cursor, Claude Code, Copilot run up to [**2× faster**](https://benchmark.react-grab.com) and more accurate. ### [Try out a demo! →](https://react-grab.com) @@ -31,8 +31,8 @@ Installs a `react-grab` skill into Cursor / Claude Code / Codex / OpenCode. Once Once installed, hover over any UI element in your browser and press: -- **⌘C** (Cmd+C) on Mac -- **Ctrl+C** on Windows/Linux +- ⌘C on Mac +- Ctrl+C on Windows/Linux This copies the element's context (file name, React component, and HTML source code) to your clipboard ready to paste into your coding agent. For example: diff --git a/packages/cli/test/watch-cli.test.ts b/packages/cli/test/watch-cli.test.ts index ab6249bff..3865fb7f8 100644 --- a/packages/cli/test/watch-cli.test.ts +++ b/packages/cli/test/watch-cli.test.ts @@ -48,10 +48,17 @@ describe("react-grab watch CLI", () => { expect(result.stderr).not.toContain("Waiting for React Grab clipboard"); }); - it("exits 1 with a click-and-retry message after a short timeout", () => { + it("exits with a recognizable diagnostic after a short timeout", () => { const result = runWatch(["--timeout", "0.5"], {}); if (result === null) return; + // CI runners without a clipboard helper (e.g. Linux without xclip / + // wl-clipboard) make the reader fast-exit 2 with an install hint. + // Local runs on macOS reach the timeout path and exit 1. + if (result.status === 2) { + expect(result.stderr).toMatch(/xclip|wl-clipboard|osascript|powershell|SSH/); + return; + } expect(result.status).toBe(1); expect(result.stderr).toContain("Timed out"); expect(result.stderr).toContain("Click an element"); diff --git a/packages/grab/README.md b/packages/grab/README.md index 32288ebdb..8aea0f13f 100644 --- a/packages/grab/README.md +++ b/packages/grab/README.md @@ -7,7 +7,7 @@ Select context for coding agents directly from your website How? Point at any element and press ⌘C (Mac) or Ctrl+C (Windows/Linux) to copy the file name, React component, and HTML source code. -It makes tools like Cursor, Claude Code, Copilot run up to [**2× faster**](https://react-grab.com/blog/intro) and more accurate. +It makes tools like Cursor, Claude Code, Copilot run up to [**2× faster**](https://benchmark.react-grab.com) and more accurate. ### [Try out a demo! →](https://react-grab.com) @@ -31,8 +31,8 @@ Installs a `react-grab` skill into Cursor / Claude Code / Codex / OpenCode. Once Once installed, hover over any UI element in your browser and press: -- **⌘C** (Cmd+C) on Mac -- **Ctrl+C** on Windows/Linux +- ⌘C (Cmd+C) on Mac +- Ctrl+C on Windows/Linux This copies the element's context (file name, React component, and HTML source code) to your clipboard ready to paste into your coding agent. For example: From fcc4871e54f7c9d344637a3d8fe969ae08262d85 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 25 Apr 2026 21:29:57 -0700 Subject: [PATCH 12/37] web --- packages/grab/README.md | 4 ++-- packages/react-grab/README.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/grab/README.md b/packages/grab/README.md index 8aea0f13f..9851c44a4 100644 --- a/packages/grab/README.md +++ b/packages/grab/README.md @@ -5,7 +5,7 @@ Select context for coding agents directly from your website -How? Point at any element and press ⌘C (Mac) or Ctrl+C (Windows/Linux) to copy the file name, React component, and HTML source code. +How? Point at any element and press **⌘C** (Mac) or **Ctrl+C** (Windows/Linux) to copy the file name, React component, and HTML source code. It makes tools like Cursor, Claude Code, Copilot run up to [**2× faster**](https://benchmark.react-grab.com) and more accurate. @@ -31,7 +31,7 @@ Installs a `react-grab` skill into Cursor / Claude Code / Codex / OpenCode. Once Once installed, hover over any UI element in your browser and press: -- ⌘C (Cmd+C) on Mac +- ⌘C on Mac - Ctrl+C on Windows/Linux This copies the element's context (file name, React component, and HTML source code) to your clipboard ready to paste into your coding agent. For example: diff --git a/packages/react-grab/README.md b/packages/react-grab/README.md index fa2f272ff..5c0d0b5a5 100644 --- a/packages/react-grab/README.md +++ b/packages/react-grab/README.md @@ -7,7 +7,7 @@ Select context for coding agents directly from your website How? Point at any element and press **⌘C** (Mac) or **Ctrl+C** (Windows/Linux) to copy the file name, React component, and HTML source code. -It makes tools like Cursor, Claude Code, Copilot run up to [**3× faster**](https://react-grab.com/blog/intro) and more accurate. +It makes tools like Cursor, Claude Code, Copilot run up to [**2× faster**](https://benchmark.react-grab.com) and more accurate. ### [Try out a demo! →](https://react-grab.com) @@ -31,8 +31,8 @@ Installs a `react-grab` skill into Cursor / Claude Code / Codex / OpenCode. Once Once installed, hover over any UI element in your browser and press: -- **⌘C** (Cmd+C) on Mac -- **Ctrl+C** on Windows/Linux +- ⌘C on Mac +- Ctrl+C on Windows/Linux This copies the element's context (file name, React component, and HTML source code) to your clipboard ready to paste into your coding agent. For example: From 1d12d7b47ec6b4b2ba58e7d9f7b556a03649f58a Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 25 Apr 2026 21:38:00 -0700 Subject: [PATCH 13/37] fix(cli): address bugbot WSL recoverable + last-selected persist on success - WSL reader: stay recoverable as long as either the Windows host or the WSLg Linux channel can still produce. Previously, when one channel returned ENOENT the combined outcome was marked unrecoverable even when the other channel could still serve a valid grab once one appeared. - install-skill: only call writeLastSelectedAgents AFTER confirming at least one install succeeded. Previously the --agent and --yes branches persisted the selection unconditionally, so a failed run biased future interactive multiselect pre-checks. New WSL test confirms the recoverability composition: both-channels-failed => unrecoverable; one-channel-failed-but-other-empty => still recoverable. --- packages/cli/src/commands/install-skill.ts | 10 ++++++---- packages/cli/src/utils/read-clipboard-wsl.ts | 10 +++++++--- packages/cli/test/read-clipboard-wsl.test.ts | 21 +++++++++++++++++++- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/install-skill.ts b/packages/cli/src/commands/install-skill.ts index e596f4c0a..14c1c09d9 100644 --- a/packages/cli/src/commands/install-skill.ts +++ b/packages/cli/src/commands/install-skill.ts @@ -92,9 +92,11 @@ export const installSkill = new Command() } const scope: SkillScope = flagScope ?? "project"; const results = installSkills({ scope, cwd: opts.cwd, selectedClients: opts.agent }); - writeLastSelectedAgents(opts.agent); logger.break(); if (results.some((r) => r.success)) { + // Only persist the selection when something was actually installed, + // so a failed run doesn't bias future interactive multiselects. + writeLastSelectedAgents(opts.agent); logger.log("Restart your agent(s) to pick up the new skill."); } else { logger.error("No skill files were written."); @@ -123,9 +125,9 @@ export const installSkill = new Command() const detected = detectInstalledSkillClients(); const targets = detected.length > 0 ? detected : supportedNames; const results = installSkills({ scope, cwd: opts.cwd, selectedClients: targets }); - writeLastSelectedAgents(targets); logger.break(); if (results.some((r) => r.success)) { + writeLastSelectedAgents(targets); logger.log("Restart your agent(s) to pick up the new skill."); } else { logger.error("No skill files were written."); @@ -144,9 +146,9 @@ export const installSkill = new Command() ); logger.break(); const results = installSkills({ scope, cwd: opts.cwd, selectedClients: [onlyDetected] }); - writeLastSelectedAgents([onlyDetected]); logger.break(); if (results.some((r) => r.success)) { + writeLastSelectedAgents([onlyDetected]); logger.log( `${highlighter.success("Done.")} Restart your agent to pick up the new skill.`, ); @@ -175,9 +177,9 @@ export const installSkill = new Command() logger.break(); const results = installSkills({ scope, cwd: opts.cwd, selectedClients: selectedAgents }); - writeLastSelectedAgents(selectedAgents); logger.break(); if (results.some((r) => r.success)) { + writeLastSelectedAgents(selectedAgents); logger.log( `${highlighter.success("Done.")} Restart your agent(s) to pick up the new skill.`, ); diff --git a/packages/cli/src/utils/read-clipboard-wsl.ts b/packages/cli/src/utils/read-clipboard-wsl.ts index ad72cc49f..07a80d0a8 100644 --- a/packages/cli/src/utils/read-clipboard-wsl.ts +++ b/packages/cli/src/utils/read-clipboard-wsl.ts @@ -20,12 +20,16 @@ export const readClipboardWsl = async (): Promise => { if (hostOutcome.hint) { // When interop is unreachable AND the WSLg fallback also has actionable // guidance (e.g. "install xclip"), surface both so the user can fix - // whichever channel they prefer. Both channels failing is unrecoverable - // - polling won't fix a missing binary or broken interop. + // whichever channel they prefer. Stay recoverable as long as either + // channel is still capable of producing a payload - polling can recover + // a transient empty clipboard on the working channel even if the other + // is permanently broken. + const bothChannelsUnrecoverable = + hostOutcome.recoverable === false && wslgOutcome.recoverable === false; return { payload: null, hint: combineHints(WSL_INTEROP_HINT, wslgOutcome.hint), - recoverable: false, + recoverable: !bothChannelsUnrecoverable, }; } return wslgOutcome; diff --git a/packages/cli/test/read-clipboard-wsl.test.ts b/packages/cli/test/read-clipboard-wsl.test.ts index f50a84035..1dc89b518 100644 --- a/packages/cli/test/read-clipboard-wsl.test.ts +++ b/packages/cli/test/read-clipboard-wsl.test.ts @@ -66,14 +66,16 @@ describe("readClipboardWsl", () => { expect(result.hint).toContain("xclip"); }); - it("combines WSL interop and Linux install hints when both fallbacks have guidance, marks unrecoverable", async () => { + it("combines WSL interop and Linux install hints when both fallbacks have guidance, marks unrecoverable when both channels are", async () => { mockReadClipboardViaWindowsPowerShell.mockResolvedValue({ payload: null, hint: "Cannot launch powershell.exe.", + recoverable: false, }); mockReadClipboardLinux.mockResolvedValue({ payload: null, hint: "Install xclip or wl-clipboard.", + recoverable: false, }); const result = await readClipboardWsl(); @@ -82,4 +84,21 @@ describe("readClipboardWsl", () => { expect(result.hint).toContain("xclip"); expect(result.recoverable).toBe(false); }); + + it("stays recoverable when only the Windows host channel is unrecoverable but WSLg can still produce", async () => { + mockReadClipboardViaWindowsPowerShell.mockResolvedValue({ + payload: null, + hint: "Cannot launch powershell.exe.", + recoverable: false, + }); + // WSLg is healthy (clipboard is just empty right now). recoverable + // defaults to true (omitted). + mockReadClipboardLinux.mockResolvedValue({ + payload: null, + }); + + const result = await readClipboardWsl(); + expect(result.payload).toBeNull(); + expect(result.recoverable).not.toBe(false); + }); }); From 2b21723c4c3fafbf01d03adce482be9fe7836e5e Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 25 Apr 2026 21:46:01 -0700 Subject: [PATCH 14/37] fix(cli): add command also passes projectInfo.projectRoot to skill install Same fix as init.ts (subprojects in monorepos). The non-interactive `installDetectedOrAllSkills` and the interactive `promptSkillInstall` paths both now anchor on the resolved project root rather than the launch cwd, so agents find the installed skill in the dir they're scanning. --- packages/cli/src/commands/add.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 543de4939..b909608df 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -62,7 +62,10 @@ export const add = new Command() } if (isNonInteractive) { - const results = installDetectedOrAllSkills("project", cwd); + // Project-scope installs anchor on the resolved project root, not + // the original cwd, so a subdirectory invocation in a monorepo still + // lands the skill in the same dir the project's agents will read. + const results = installDetectedOrAllSkills("project", projectInfo.projectRoot); const hasSuccess = results.some((result) => result.success); if (!hasSuccess) { logger.break(); @@ -88,7 +91,10 @@ export const add = new Command() process.exit(1); } - const didInstall = await promptSkillInstall(skillScope as SkillScope, cwd); + const didInstall = await promptSkillInstall( + skillScope as SkillScope, + projectInfo.projectRoot, + ); if (!didInstall) { logger.break(); process.exit(0); From c0ba21a6ee5ce8a74c0ec040bf30204f98e4cbe6 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 25 Apr 2026 22:00:08 -0700 Subject: [PATCH 15/37] fix(cli): flush stderr before exit on watch error paths bugbot flagged that fail() writes to stderr then immediately calls process.exit, which can truncate output on piped consumers (every agent tool harness pipes stderr). The match path already returned naturally; fix the same pattern for unrecoverable/timeout/aborted/default by: - Using process.stderr.write(msg, callback) and putting process.exit inside the callback - the callback only fires once the kernel buffer has accepted the write. - Throwing an ExitSignal sentinel to halt synchronous execution; the action's outer try/catch swallows it so the user sees only the message we just wrote. --- packages/cli/src/commands/watch.ts | 94 ++++++++++++++++++------------ 1 file changed, 57 insertions(+), 37 deletions(-) diff --git a/packages/cli/src/commands/watch.ts b/packages/cli/src/commands/watch.ts index cba43b4d0..f508990c3 100644 --- a/packages/cli/src/commands/watch.ts +++ b/packages/cli/src/commands/watch.ts @@ -18,9 +18,20 @@ interface WatchCommandOptions { timeout: string; } +class ExitSignal extends Error { + constructor(public readonly exitCode: number) { + super(""); + } +} + +// `process.exit` can truncate buffered stderr when the consumer is a pipe +// (every agent tool harness reads our stderr through a pipe). Using the +// write-callback form guarantees the buffer drains before exit. The throw +// halts synchronous execution; the action wrapper swallows ExitSignal so +// the user only sees the message we just wrote. const fail = (message: string, exitCode: number): never => { - process.stderr.write(`${message}\n`); - process.exit(exitCode); + process.stderr.write(`${message}\n`, () => process.exit(exitCode)); + throw new ExitSignal(exitCode); }; export const formatResultForStdout = (payload: ReactGrabPayload, asJson?: boolean): string => @@ -51,46 +62,55 @@ export const watch = new Command() String(WATCH_DEFAULT_TIMEOUT_MS / MS_PER_SECOND), ) .action(async (rawOptions: WatchCommandOptions) => { - const timeoutSeconds = resolveTimeoutSeconds(rawOptions.timeout); + try { + const timeoutSeconds = resolveTimeoutSeconds(rawOptions.timeout); - const initialResult = await readClipboardPayload(); - if (!initialResult.recoverable) { - fail(formatUnrecoverableMessage(initialResult), 2); - } + const initialResult = await readClipboardPayload(); + if (!initialResult.recoverable) { + fail(formatUnrecoverableMessage(initialResult), 2); + } - const initialTimestamp = initialResult.payload?.timestamp ?? null; + const initialTimestamp = initialResult.payload?.timestamp ?? null; - process.stderr.write("Waiting for React Grab clipboard...\n"); + process.stderr.write("Waiting for React Grab clipboard...\n"); - const waitResult = await waitForNextGrab({ - initialTimestamp, - timeoutMs: timeoutSeconds * MS_PER_SECOND, - pollIntervalMs: WATCH_POLL_INTERVAL_MS, - read: readClipboardPayload, - }); + const waitResult = await waitForNextGrab({ + initialTimestamp, + timeoutMs: timeoutSeconds * MS_PER_SECOND, + pollIntervalMs: WATCH_POLL_INTERVAL_MS, + read: readClipboardPayload, + }); - switch (waitResult.outcome) { - case "match": - // Don't process.exit(0) here: an immediate exit can truncate stdout - // when the writer is piped through another process. Returning lets - // Node drain the buffer and exit naturally with code 0. - printPayload(waitResult.payload, rawOptions.json); - return; - case "unrecoverable": - fail(formatUnrecoverableMessage(waitResult.result), 2); - break; - case "timeout": - fail( - `Timed out after ${timeoutSeconds}s without a new React Grab clipboard payload.\nClick an element in the React Grab toolbar and re-run.`, - 1, - ); - break; - case "aborted": - fail("Aborted before a new React Grab payload arrived.", 1); - break; - default: { - const exhaustive: never = waitResult; - fail(`Unhandled watch outcome: ${JSON.stringify(exhaustive)}`, 2); + switch (waitResult.outcome) { + case "match": + // Don't process.exit(0) here: an immediate exit can truncate + // stdout when the writer is piped through another process. + // Returning lets Node drain the buffer and exit naturally with + // code 0. + printPayload(waitResult.payload, rawOptions.json); + return; + case "unrecoverable": + fail(formatUnrecoverableMessage(waitResult.result), 2); + break; + case "timeout": + fail( + `Timed out after ${timeoutSeconds}s without a new React Grab clipboard payload.\nClick an element in the React Grab toolbar and re-run.`, + 1, + ); + break; + case "aborted": + fail("Aborted before a new React Grab payload arrived.", 1); + break; + default: { + const exhaustive: never = waitResult; + fail(`Unhandled watch outcome: ${JSON.stringify(exhaustive)}`, 2); + } } + } catch (caughtError) { + // ExitSignal carries the user-facing message via the stderr write + // already in flight from `fail`. Just let Node finish; process.exit + // fires from the write callback once stderr drains. + if (caughtError instanceof ExitSignal) return; + throw caughtError; } }); From e91b8b306bd841fa941f6d16a3c39c09a53ff2f9 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 25 Apr 2026 22:10:30 -0700 Subject: [PATCH 16/37] fix(init): skill install failure does not abort the React Grab install bugbot: when init() runs in a fresh project, an optional skill install write failure was calling process.exit(0) before the main React Grab install/transform happened. Treat skill install as decoration: warn on failure and continue to install React Grab itself. --- packages/cli/src/commands/init.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 47935ceb7..b673b9b44 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -488,12 +488,14 @@ export const init = new Command() projectInfo.projectRoot, ); didInstallSkill = results.some((result) => result.success); - if (!didInstallSkill) { - logger.break(); - process.exit(0); - } logger.break(); - logger.success("React Grab skill has been installed."); + if (didInstallSkill) { + logger.success("React Grab skill has been installed."); + } else { + // Skill install is optional decoration on top of the React Grab + // install. Don't abort the main install if the skill write fails. + logger.warn("React Grab skill install did not write any files."); + } logger.log("Continuing with React Grab installation..."); logger.break(); } From fc50233f67428858f9266b9a84e398067eb10ae4 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 25 Apr 2026 22:27:57 -0700 Subject: [PATCH 17/37] fix(cli): two more bugbot findings - Linux Wayland reader: only fall through to xclip when wl-paste binary is missing (ENOENT). Previously any non-zero exit fell through, which on Wayland-only systems with the common 'no data of that mime' case surfaced a misleading 'install xclip' hint. Update the test to assert the new behavior: runtime wl-paste failure returns empty payload, not an xclip retry. - init.ts already-installed branch: when the user opts into a skill install and it fails, surface the failure and exit 1 (was silent exit 0). Mirrors the warn-and-continue pattern in the fresh-install branch but with non-zero exit since skill install was the only action on this path. --- packages/cli/src/commands/init.ts | 15 ++++++++++----- packages/cli/src/utils/read-clipboard-linux.ts | 12 +++++++++--- packages/cli/test/read-clipboard-linux.test.ts | 14 ++++++++++---- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index b673b9b44..b04bb826b 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -352,13 +352,18 @@ export const init = new Command() projectInfo.projectRoot, ); const didInstall = results.some((result) => result.success); - if (!didInstall) { + logger.break(); + if (didInstall) { + logger.success("React Grab skill has been installed."); + logger.log("Restart your agent(s) to pick it up."); + } else { + // The user explicitly opted into skill install but no files were + // written. Surface the failure with a non-zero exit so wrapper + // scripts can detect it. + logger.error("React Grab skill install did not write any files."); logger.break(); - process.exit(0); + process.exit(1); } - logger.break(); - logger.success("React Grab skill has been installed."); - logger.log("Restart your agent(s) to pick it up."); } logger.break(); diff --git a/packages/cli/src/utils/read-clipboard-linux.ts b/packages/cli/src/utils/read-clipboard-linux.ts index 981b2f46c..ad80b9306 100644 --- a/packages/cli/src/utils/read-clipboard-linux.ts +++ b/packages/cli/src/utils/read-clipboard-linux.ts @@ -41,9 +41,15 @@ export const readClipboardLinux = async (): Promise => { if (waylandResult.stdout !== undefined) { return { payload: trimToPayload(waylandResult.stdout) }; } - // Any wl-paste failure (missing binary or runtime error) falls through to - // xclip - XWayland setups commonly surface custom MIME types via X11 even - // when wl-paste cannot complete. + // Only fall through to xclip when the wl-paste binary itself is + // unavailable (ENOENT). A non-zero exit with a present binary means + // the MIME type just isn't on the clipboard right now (common: user + // hasn't grabbed yet) - treat that as an empty payload instead of + // trying X11, which would ENOENT on Wayland-only systems and surface + // a misleading "install xclip" hint. + if (!isBinaryMissing(waylandResult.error)) { + return { payload: null }; + } } const x11Result = await tryRead("xclip", [ diff --git a/packages/cli/test/read-clipboard-linux.test.ts b/packages/cli/test/read-clipboard-linux.test.ts index c9799b57f..095a44daa 100644 --- a/packages/cli/test/read-clipboard-linux.test.ts +++ b/packages/cli/test/read-clipboard-linux.test.ts @@ -58,16 +58,22 @@ describe("readClipboardLinux", () => { expect(getExecFileCall(mockExecFile, 1).binary).toBe("xclip"); }); - it("falls back from a runtime wl-paste failure to xclip", async () => { + it("treats a runtime wl-paste failure as empty payload (does not fall through to xclip)", async () => { + // A non-zero wl-paste exit when the binary is present is the common + // "MIME type not on clipboard right now" case. Falling through to xclip + // would surface a misleading "install xclip" hint on Wayland-only + // systems; instead, return an empty payload so the watch loop keeps + // polling. process.env.WAYLAND_DISPLAY = "wayland-0"; const runtimeError = new Error("wl-paste: clipboard read failed") as NodeJS.ErrnoException; runtimeError.code = "EPIPE"; - stubExecFilePerCall(mockExecFile, [{ error: runtimeError }, { stdout: "from-xclip" }]); + stubExecFile(mockExecFile, { error: runtimeError }); const result = await readClipboardLinux(); - expect(result.payload).toBe("from-xclip"); + expect(result.payload).toBeNull(); + expect(result.recoverable).not.toBe(false); + expect(mockExecFile.mock.calls).toHaveLength(1); expect(getExecFileCall(mockExecFile, 0).binary).toBe("wl-paste"); - expect(getExecFileCall(mockExecFile, 1).binary).toBe("xclip"); }); it("returns install hint when xclip is missing and marks the outcome unrecoverable", async () => { From e9b2c3eb5bacc69e08624e484fe5793be0059289 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 25 Apr 2026 23:07:54 -0700 Subject: [PATCH 18/37] fix(macos): decode Chromium web-custom-data pickle so the reader actually finds the payload Chromium and WebKit on macOS do NOT expose web-custom-format MIME types (anything the page wrote via clipboardData.setData(type, data) for a non-standard MIME) under the raw type name on NSPasteboard. They bundle all such entries into a single pasteboard type: org.chromium.web-custom-data org.webkit.web-custom-data containing a base::Pickle with [count, then for each: mime length in UTF-16 code units, mime UTF-16 LE bytes, padded to 4 bytes, value length in UTF-16 code units, value UTF-16 LE bytes, padded]. Our previous JXA called dataForType('application/x-react-grab') directly and got nil for browser-written grabs, so watch polled forever without ever seeing the payload. Confirmed live: a Cursor session's clipboard exposed `org.chromium.web-custom-data` (with vscode-editor-data inside) and our raw lookup returned nil. Fix: - New util `decode-chromium-web-custom-data.ts` parses the pickle format with proper 4-byte alignment. - macos JXA now tries direct first (covers Safari/Firefox direct exposure and any future browser change), then falls back to org.chromium.web-custom-data, then org.webkit.web-custom-data, emitting a sentinel-prefixed base64 dump that the Node side decodes. Tests: - 6 unit tests for the pickle decoder (single entry, scan past unrelated, alignment padding, truncation handling, missing target). - macos reader tests now assert the JXA script references all three pasteboard types and decodes the sentinel-prefixed pickle correctly. Verified end-to-end live: wrote a fake pickle to NSPasteboard, ran `watch --timeout 8 --json`, swapped in a fresh-timestamp pickle during polling, watch exited 0 with the correct JSON on stdout. --- .../utils/decode-chromium-web-custom-data.ts | 53 ++++++++++++ .../cli/src/utils/read-clipboard-macos.ts | 50 ++++++++++- .../decode-chromium-web-custom-data.test.ts | 82 +++++++++++++++++++ .../cli/test/read-clipboard-macos.test.ts | 43 +++++++++- 4 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/utils/decode-chromium-web-custom-data.ts create mode 100644 packages/cli/test/decode-chromium-web-custom-data.test.ts diff --git a/packages/cli/src/utils/decode-chromium-web-custom-data.ts b/packages/cli/src/utils/decode-chromium-web-custom-data.ts new file mode 100644 index 000000000..d2e93843d --- /dev/null +++ b/packages/cli/src/utils/decode-chromium-web-custom-data.ts @@ -0,0 +1,53 @@ +// Chromium serializes web-custom-format clipboard data on macOS into a single +// pasteboard type 'org.chromium.web-custom-data' using base::Pickle: +// +// uint32 LE payload_size_bytes // total size after this 4-byte prefix +// uint32 LE num_entries +// for each entry: +// uint32 LE mime_codeunits // length of MIME type in UTF-16 code units +// bytes mime_utf16_le // mime_codeunits * 2 bytes +// padding align to 4 bytes +// uint32 LE data_codeunits // length of value in UTF-16 code units +// bytes data_utf16_le // data_codeunits * 2 bytes +// padding align to 4 bytes +// +// (See Chromium ui/base/clipboard/clipboard_format_type_mac.mm). Each MIME +// type the page wrote via clipboardData.setData(type, data) becomes one entry. + +const PICKLE_ALIGNMENT = 4; + +const alignTo = (offset: number, alignment: number): number => + (offset + alignment - 1) & ~(alignment - 1); + +export const decodeChromiumWebCustomData = (payload: Buffer, targetMime: string): string | null => { + if (payload.length < 8) return null; + + const declaredPayloadSize = payload.readUInt32LE(0); + const end = Math.min(payload.length, 4 + declaredPayloadSize); + + let offset = 4; + const entryCount = payload.readUInt32LE(offset); + offset += 4; + + for (let entryIndex = 0; entryIndex < entryCount; entryIndex += 1) { + if (offset + 4 > end) return null; + const mimeCodeUnits = payload.readUInt32LE(offset); + offset += 4; + const mimeBytes = mimeCodeUnits * 2; + if (offset + mimeBytes > end) return null; + const mime = payload.subarray(offset, offset + mimeBytes).toString("utf16le"); + offset = alignTo(offset + mimeBytes, PICKLE_ALIGNMENT); + + if (offset + 4 > end) return null; + const dataCodeUnits = payload.readUInt32LE(offset); + offset += 4; + const dataBytes = dataCodeUnits * 2; + if (offset + dataBytes > end) return null; + const data = payload.subarray(offset, offset + dataBytes).toString("utf16le"); + offset = alignTo(offset + dataBytes, PICKLE_ALIGNMENT); + + if (mime === targetMime) return data; + } + + return null; +}; diff --git a/packages/cli/src/utils/read-clipboard-macos.ts b/packages/cli/src/utils/read-clipboard-macos.ts index f01550f96..5a46bc4d4 100644 --- a/packages/cli/src/utils/read-clipboard-macos.ts +++ b/packages/cli/src/utils/read-clipboard-macos.ts @@ -1,10 +1,54 @@ import { CLIPBOARD_READ_TIMEOUT_MS, REACT_GRAB_MIME_TYPE } from "./constants.js"; +import { decodeChromiumWebCustomData } from "./decode-chromium-web-custom-data.js"; import { hasErrorCode } from "./has-error-code.js"; import { runExecFile } from "./run-exec-file.js"; import { surfaceStderr } from "./surface-stderr.js"; import type { ClipboardReadOutcome } from "./read-clipboard-outcome.js"; -const JXA_SCRIPT = `(function(){ObjC.import('AppKit');var pasteboard=$.NSPasteboard.generalPasteboard;var data=pasteboard.dataForType('${REACT_GRAB_MIME_TYPE}');if(data.isNil())return '';var decoded=$.NSString.alloc.initWithDataEncoding(data,$.NSUTF8StringEncoding);return ObjC.unwrap(decoded);})()`; +// Chromium and WebKit on macOS bundle web-custom-format clipboard data +// (anything the page wrote via clipboardData.setData(type, data) for a +// non-standard MIME type) into a single pasteboard entry under either +// 'org.chromium.web-custom-data' or 'org.webkit.web-custom-data'. The +// raw MIME type ('application/x-react-grab') is NOT exposed directly on +// the macOS pasteboard, so a naive dataForType lookup returns nil and +// we'd never find the payload. +// +// JXA emits one of three forms for the Node side: +// - empty string: nothing on the clipboard +// - : direct read succeeded (Safari/Firefox direct exposure) +// - : the pasteboard exposed +// web-custom-data; Node decodes the base::Pickle and extracts our +// MIME entry. +const PICKLE_SENTINEL = "__react_grab_chromium_pickle_b64__"; + +const JXA_SCRIPT = `(function(){ + ObjC.import('AppKit'); + var pb = $.NSPasteboard.generalPasteboard; + var direct = pb.dataForType('${REACT_GRAB_MIME_TYPE}'); + if (!direct.isNil()) { + var s = $.NSString.alloc.initWithDataEncoding(direct, $.NSUTF8StringEncoding); + return ObjC.unwrap(s); + } + var chromium = pb.dataForType('org.chromium.web-custom-data'); + if (!chromium.isNil()) { + return '${PICKLE_SENTINEL}' + ObjC.unwrap(chromium.base64EncodedStringWithOptions(0)); + } + var webkit = pb.dataForType('org.webkit.web-custom-data'); + if (!webkit.isNil()) { + return '${PICKLE_SENTINEL}' + ObjC.unwrap(webkit.base64EncodedStringWithOptions(0)); + } + return ''; +})()`; + +const decodeJxaOutput = (raw: string): string | null => { + if (raw.length === 0) return null; + if (raw.startsWith(PICKLE_SENTINEL)) { + const base64Pickle = raw.slice(PICKLE_SENTINEL.length); + const buffer = Buffer.from(base64Pickle, "base64"); + return decodeChromiumWebCustomData(buffer, REACT_GRAB_MIME_TYPE); + } + return raw; +}; export const readClipboardMacos = async (): Promise => { try { @@ -14,8 +58,8 @@ export const readClipboardMacos = async (): Promise => { { timeout: CLIPBOARD_READ_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 }, ); surfaceStderr("osascript", stderr); - const trimmed = stdout.trimEnd(); - return { payload: trimmed.length > 0 ? trimmed : null }; + const payload = decodeJxaOutput(stdout.trimEnd()); + return { payload }; } catch (caughtError) { surfaceStderr("osascript", caughtError); if (hasErrorCode(caughtError, "ENOENT")) { diff --git a/packages/cli/test/decode-chromium-web-custom-data.test.ts b/packages/cli/test/decode-chromium-web-custom-data.test.ts new file mode 100644 index 000000000..9664838a9 --- /dev/null +++ b/packages/cli/test/decode-chromium-web-custom-data.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vite-plus/test"; +import { decodeChromiumWebCustomData } from "../src/utils/decode-chromium-web-custom-data.js"; + +interface PickleEntry { + mime: string; + data: string; +} + +const buildChromiumPickle = (entries: PickleEntry[]): Buffer => { + const headerBytes = 4; // payload size prefix + const entryCountBytes = 4; + let payloadSize = entryCountBytes; + for (const entry of entries) { + const mimeBytes = Buffer.byteLength(entry.mime, "utf16le"); + const dataBytes = Buffer.byteLength(entry.data, "utf16le"); + const alignedMime = (mimeBytes + 3) & ~3; + const alignedData = (dataBytes + 3) & ~3; + payloadSize += 4 + alignedMime + 4 + alignedData; + } + + const buffer = Buffer.alloc(headerBytes + payloadSize); + buffer.writeUInt32LE(payloadSize, 0); + buffer.writeUInt32LE(entries.length, 4); + let offset = 8; + for (const entry of entries) { + const mimeUtf16 = Buffer.from(entry.mime, "utf16le"); + buffer.writeUInt32LE(entry.mime.length, offset); + offset += 4; + mimeUtf16.copy(buffer, offset); + offset += (mimeUtf16.length + 3) & ~3; + + const dataUtf16 = Buffer.from(entry.data, "utf16le"); + buffer.writeUInt32LE(entry.data.length, offset); + offset += 4; + dataUtf16.copy(buffer, offset); + offset += (dataUtf16.length + 3) & ~3; + } + return buffer; +}; + +describe("decodeChromiumWebCustomData", () => { + it("returns null for an empty buffer", () => { + expect(decodeChromiumWebCustomData(Buffer.alloc(0), "application/x-react-grab")).toBeNull(); + }); + + it("returns null for a buffer too short to hold the header", () => { + expect(decodeChromiumWebCustomData(Buffer.from([1, 2, 3]), "any")).toBeNull(); + }); + + it("extracts a single matching MIME entry", () => { + const json = '{"version":"0.1.32","content":"", + entries: [{ content: "" }], + timestamp: 0, + ...overrides, +}); + +describe("extractPromptAndContent", () => { + it("returns content only when no entry has a commentText", () => { + const payload = buildPayload({ content: "\n[2] ", + entries: [ + { content: "", commentText: "Refactor" }, + { content: "", commentText: "Refactor" }, + ], + }); + expect(extractPromptAndContent(payload)).toEqual({ + prompt: "Refactor", + content: "[1] \n[2] ", + }); + }); + + it("strips the leading prompt prefix from content using the raw (untrimmed) prompt", () => { + const rawPrompt = " click me "; + const payload = buildPayload({ + content: `${rawPrompt}\n\n`, + entries: [{ content: "", commentText: rawPrompt }], + }); + expect(extractPromptAndContent(payload)).toEqual({ + prompt: "click me", + content: "", + }); + }); + + it("does not strip when content does not actually start with the raw prompt + '\\n\\n'", () => { + const payload = buildPayload({ + content: "", + entries: [{ content: "", commentText: "click me" }], + }); + expect(extractPromptAndContent(payload)).toEqual({ + prompt: "click me", + content: "", + }); + }); + + it("ignores empty / whitespace-only commentTexts", () => { + const payload = buildPayload({ + entries: [ + { content: "", commentText: "" }, + { content: "", commentText: " " }, + ], + }); + expect(extractPromptAndContent(payload)).toEqual({ content: "" }); + }); + + it("joins multiple distinct prompts with newlines", () => { + const payload = buildPayload({ + entries: [ + { content: "", commentText: "rename" }, + { content: "", commentText: "rename" }, + { content: "", commentText: "fix spacing" }, + ], + }); + const { prompt } = extractPromptAndContent(payload); + expect(prompt).toBe("rename\nfix spacing"); + }); +}); diff --git a/packages/cli/test/install-skill.test.ts b/packages/cli/test/install-skill.test.ts index 652368ffc..a2cfa0cd1 100644 --- a/packages/cli/test/install-skill.test.ts +++ b/packages/cli/test/install-skill.test.ts @@ -459,11 +459,11 @@ describe("SKILL_TEMPLATE", () => { expect(SKILL_TEMPLATE).toMatch(/allowed-tools:\s*\n\s*-\s*Bash/); }); - it("instructs the agent to run the watch CLI", () => { - expect(SKILL_TEMPLATE).toContain("npx -y @react-grab/cli watch"); + it("instructs the agent to run the log CLI", () => { + expect(SKILL_TEMPLATE).toContain("npx -y @react-grab/cli log"); }); - it("warns the agent against running watch on already-pasted content", () => { + it("warns the agent against running log on already-pasted content", () => { expect(SKILL_TEMPLATE).toMatch(/already pasted/i); expect(SKILL_TEMPLATE).toMatch(/do NOT run this skill/i); }); diff --git a/packages/cli/test/log-cli.test.ts b/packages/cli/test/log-cli.test.ts new file mode 100644 index 000000000..0c1d5ee13 --- /dev/null +++ b/packages/cli/test/log-cli.test.ts @@ -0,0 +1,51 @@ +import { spawnSync, type SpawnSyncOptions } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vite-plus/test"; + +const TEST_DIR = path.dirname(fileURLToPath(import.meta.url)); +const CLI_PATH = path.resolve(TEST_DIR, "..", "dist", "cli.js"); + +const SSH_DETECTION_KEYS = ["SSH_CLIENT", "SSH_TTY", "SSH_CONNECTION", "WSL_DISTRO_NAME"] as const; + +const buildCleanEnv = (): NodeJS.ProcessEnv => { + const cleaned: NodeJS.ProcessEnv = { ...process.env }; + for (const key of SSH_DETECTION_KEYS) delete cleaned[key]; + return cleaned; +}; + +const runLog = ( + args: string[], + envOverrides: Record, + spawnOptions: Partial = {}, +): ReturnType | null => { + if (!existsSync(CLI_PATH)) return null; + return spawnSync(process.execPath, [CLI_PATH, "log", ...args], { + encoding: "utf8", + timeout: 10_000, + env: { ...buildCleanEnv(), ...envOverrides }, + ...spawnOptions, + }); +}; + +describe("react-grab log CLI", () => { + it("exits 2 immediately under SSH without polling", () => { + const result = runLog([], { SSH_CLIENT: "1.2.3.4 5678 22" }); + if (result === null) return; + + expect(result.status).toBe(2); + expect(result.stderr).toContain("SSH"); + expect(result.stderr).not.toContain("Streaming React Grab clipboard"); + }); + + it("rejects unknown flags (log takes none)", () => { + const result = runLog(["--timeout", "30"], {}); + if (result === null) return; + + // Commander exits with a non-zero status and reports the unknown option + // on stderr; the exact code is implementation-defined but must not be 0. + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/unknown option|--timeout/i); + }); +}); diff --git a/packages/cli/test/log.test.ts b/packages/cli/test/log.test.ts new file mode 100644 index 000000000..f3189eb8c --- /dev/null +++ b/packages/cli/test/log.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import type { ReadClipboardPayloadResult } from "../src/utils/read-clipboard-payload.js"; +import type { ReactGrabPayload } from "../src/utils/parse-react-grab-payload.js"; +import { runLogLoop } from "../src/utils/run-log-loop.js"; + +const buildPayload = ( + timestamp: number, + overrides: Partial = {}, +): ReactGrabPayload => ({ + version: "0.1.32", + content: "", + entries: [{ content: "" }], + }); + const grabB = buildPayload(3000, { + content: "", + entries: [{ content: "" }], + }); + const grabC = buildPayload(4000, { + content: "", + entries: [{ content: "" }], + }); + // SSH-style unrecoverable read after three matches forces the loop to + // exit so the test terminates deterministically. + const stop: ReadClipboardPayloadResult = { + env: "ssh", + payload: null, + hint: "SSH detected", + recoverable: false, + rawPayloadPresent: false, + }; + const read = vi + .fn<() => Promise>() + .mockResolvedValueOnce(buildResult(grabA)) + .mockResolvedValueOnce(buildResult(grabB)) + .mockResolvedValueOnce(buildResult(grabC)) + .mockResolvedValue(stop); + const writes: string[] = []; + const clock = createFakeClock(); + + const result = await runLogLoop({ + initialResult: buildResult(null), + read, + write: (line) => writes.push(line), + getCurrentMs: clock.getCurrentMs, + sleepMs: clock.sleepMs, + }); + + expect(result.outcome).toBe("fail"); + if (result.outcome !== "fail") return; + expect(result.exitCode).toBe(2); + expect(result.message).toBe("SSH detected"); + expect(writes).toHaveLength(3); + expect(JSON.parse(writes[0])).toEqual({ content: "" }); + expect(JSON.parse(writes[1])).toEqual({ content: "" }); + expect(JSON.parse(writes[2])).toEqual({ content: "" }); + }); + + it("includes prompt when the user typed one in the toolbar", async () => { + const grab = buildPayload(2000, { + content: "Refactor\n\n", + entries: [{ content: "" }], + }); + const read = vi + .fn<() => Promise>() + .mockResolvedValue(buildResult(grab)); + const writes: string[] = []; + const fileWrites: string[] = []; + const clock = createFakeClock(); + + const result = await runLogLoop({ + initialResult: buildResult(null), + read, + write: (line) => writes.push(line), + appendToFile: (line) => fileWrites.push(line), + exitOnFirstMatch: true, + getCurrentMs: clock.getCurrentMs, + sleepMs: clock.sleepMs, + }); + + expect(result.outcome).toBe("ok"); + expect(writes).toHaveLength(1); + expect(fileWrites).toHaveLength(1); + expect(JSON.parse(writes[0])).toEqual({ content: "" }); + // Should not have polled again - one read, one match, then exit ok. + expect(read).toHaveBeenCalledTimes(1); + }); + + it("respects an abort signal between iterations", async () => { + const grab = buildPayload(2000); + const controller = new AbortController(); + const read = vi.fn(async (): Promise => { + controller.abort(); + return buildResult(grab); + }); + const writes: string[] = []; + const clock = createFakeClock(); + + const result = await runLogLoop({ + initialResult: buildResult(null), + read, + write: (line) => writes.push(line), + signal: controller.signal, + getCurrentMs: clock.getCurrentMs, + sleepMs: clock.sleepMs, + }); + + expect(result.outcome).toBe("fail"); + if (result.outcome !== "fail") return; + expect(result.exitCode).toBe(1); + expect(result.message).toContain("Aborted"); + }); +}); diff --git a/packages/cli/test/parse-timeout-seconds.test.ts b/packages/cli/test/parse-timeout-seconds.test.ts deleted file mode 100644 index ef3facafc..000000000 --- a/packages/cli/test/parse-timeout-seconds.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { parseTimeoutSeconds } from "../src/utils/parse-timeout-seconds.js"; - -describe("parseTimeoutSeconds", () => { - it("parses positive integers", () => { - expect(parseTimeoutSeconds("60")).toBe(60); - expect(parseTimeoutSeconds("0")).toBe(0); - expect(parseTimeoutSeconds("600")).toBe(600); - }); - - it("parses positive decimals", () => { - expect(parseTimeoutSeconds("1.5")).toBeCloseTo(1.5); - expect(parseTimeoutSeconds("0.25")).toBeCloseTo(0.25); - }); - - it("trims surrounding whitespace", () => { - expect(parseTimeoutSeconds(" 30 ")).toBe(30); - }); - - it("rejects negative values", () => { - expect(() => parseTimeoutSeconds("-1")).toThrow(/Invalid --timeout/); - expect(() => parseTimeoutSeconds("-0.5")).toThrow(/Invalid --timeout/); - }); - - it("rejects non-numeric input", () => { - expect(() => parseTimeoutSeconds("abc")).toThrow(/Invalid --timeout/); - expect(() => parseTimeoutSeconds("")).toThrow(/Invalid --timeout/); - }); - - it("rejects partially-numeric input that parseFloat would silently accept", () => { - expect(() => parseTimeoutSeconds("5abc")).toThrow(/Invalid --timeout/); - expect(() => parseTimeoutSeconds("5e2")).toThrow(/Invalid --timeout/); - expect(() => parseTimeoutSeconds("5.5.5")).toThrow(/Invalid --timeout/); - }); - - it("rejects Infinity and NaN literals", () => { - expect(() => parseTimeoutSeconds("Infinity")).toThrow(/Invalid --timeout/); - expect(() => parseTimeoutSeconds("NaN")).toThrow(/Invalid --timeout/); - }); - - it("rejects whitespace-only input", () => { - expect(() => parseTimeoutSeconds(" ")).toThrow(/Invalid --timeout/); - }); - - it("includes the offending value in the error message", () => { - expect(() => parseTimeoutSeconds("xyz")).toThrow(/"xyz"/); - }); -}); diff --git a/packages/cli/test/read-clipboard-linux.test.ts b/packages/cli/test/read-clipboard-linux.test.ts index 095a44daa..aee2d1134 100644 --- a/packages/cli/test/read-clipboard-linux.test.ts +++ b/packages/cli/test/read-clipboard-linux.test.ts @@ -62,7 +62,7 @@ describe("readClipboardLinux", () => { // A non-zero wl-paste exit when the binary is present is the common // "MIME type not on clipboard right now" case. Falling through to xclip // would surface a misleading "install xclip" hint on Wayland-only - // systems; instead, return an empty payload so the watch loop keeps + // systems; instead, return an empty payload so the polling loop keeps // polling. process.env.WAYLAND_DISPLAY = "wayland-0"; const runtimeError = new Error("wl-paste: clipboard read failed") as NodeJS.ErrnoException; diff --git a/packages/cli/test/resolve-log-file-sink-location.test.ts b/packages/cli/test/resolve-log-file-sink-location.test.ts new file mode 100644 index 000000000..59038bea1 --- /dev/null +++ b/packages/cli/test/resolve-log-file-sink-location.test.ts @@ -0,0 +1,22 @@ +import path from "node:path"; +import { describe, expect, it } from "vite-plus/test"; +import { resolveLogFileSinkLocation } from "../src/utils/resolve-log-file-sink-location.js"; +import { PROJECT_LOG_FILE_NAME, PROJECT_REACT_GRAB_DIR } from "../src/utils/constants.js"; + +describe("resolveLogFileSinkLocation", () => { + it("resolves the log file under .react-grab/ at the given cwd", () => { + const location = resolveLogFileSinkLocation("/tmp/example"); + expect(location.dir).toBe(path.join("/tmp/example", PROJECT_REACT_GRAB_DIR)); + expect(location.logPath).toBe( + path.join("/tmp/example", PROJECT_REACT_GRAB_DIR, PROJECT_LOG_FILE_NAME), + ); + expect(location.gitignorePath).toBe( + path.join("/tmp/example", PROJECT_REACT_GRAB_DIR, ".gitignore"), + ); + }); + + it("does not normalize away trailing path segments", () => { + const location = resolveLogFileSinkLocation("/tmp/with-trailing/"); + expect(location.dir).toBe(path.join("/tmp/with-trailing", PROJECT_REACT_GRAB_DIR)); + }); +}); diff --git a/packages/cli/test/setup-log-file-sink.test.ts b/packages/cli/test/setup-log-file-sink.test.ts new file mode 100644 index 000000000..fac77c5c7 --- /dev/null +++ b/packages/cli/test/setup-log-file-sink.test.ts @@ -0,0 +1,91 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; +import { setupLogFileSink } from "../src/utils/setup-log-file-sink.js"; +import { PROJECT_LOG_GITIGNORE_CONTENT, PROJECT_REACT_GRAB_DIR } from "../src/utils/constants.js"; + +let tempCwd: string; + +beforeEach(() => { + tempCwd = fs.mkdtempSync(path.join(os.tmpdir(), "setup-log-file-sink-")); +}); + +afterEach(() => { + fs.rmSync(tempCwd, { recursive: true, force: true }); +}); + +const writeAll = (sink: { append: (line: string) => void }, lines: string[]): void => { + for (const line of lines) sink.append(line); +}; + +describe("setupLogFileSink", () => { + it("creates the .react-grab directory, writes a gitignore that excludes the log file, and returns an append-able sink", () => { + const setup = setupLogFileSink(tempCwd); + expect(setup.outcome).toBe("ok"); + if (setup.outcome !== "ok") return; + + const dir = path.join(tempCwd, PROJECT_REACT_GRAB_DIR); + expect(fs.existsSync(dir)).toBe(true); + expect(fs.statSync(dir).isDirectory()).toBe(true); + + expect(fs.readFileSync(path.join(dir, ".gitignore"), "utf8")).toBe( + PROJECT_LOG_GITIGNORE_CONTENT, + ); + + writeAll(setup.sink, [`{"content":""}`, `{"content":""}`]); + const written = fs.readFileSync(setup.sink.path, "utf8"); + expect(written).toBe(`{"content":""}\n{"content":""}\n`); + }); + + it("does not overwrite an existing user-curated .gitignore", () => { + const dir = path.join(tempCwd, PROJECT_REACT_GRAB_DIR); + fs.mkdirSync(dir, { recursive: true }); + const userContent = "# my own rules\nlogs\nfoo\n"; + fs.writeFileSync(path.join(dir, ".gitignore"), userContent, "utf8"); + + const setup = setupLogFileSink(tempCwd); + expect(setup.outcome).toBe("ok"); + + expect(fs.readFileSync(path.join(dir, ".gitignore"), "utf8")).toBe(userContent); + }); + + it("does not duplicate content when an existing .gitignore already matches the expected content", () => { + const dir = path.join(tempCwd, PROJECT_REACT_GRAB_DIR); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, ".gitignore"), PROJECT_LOG_GITIGNORE_CONTENT, "utf8"); + + const setup = setupLogFileSink(tempCwd); + expect(setup.outcome).toBe("ok"); + + expect(fs.readFileSync(path.join(dir, ".gitignore"), "utf8")).toBe( + PROJECT_LOG_GITIGNORE_CONTENT, + ); + }); + + it("appends to an existing log file across multiple setup calls (no truncation)", () => { + const first = setupLogFileSink(tempCwd); + expect(first.outcome).toBe("ok"); + if (first.outcome !== "ok") return; + writeAll(first.sink, [`{"content":""}`]); + + const second = setupLogFileSink(tempCwd); + expect(second.outcome).toBe("ok"); + if (second.outcome !== "ok") return; + writeAll(second.sink, [`{"content":""}`]); + + const written = fs.readFileSync(second.sink.path, "utf8"); + expect(written).toBe(`{"content":""}\n{"content":""}\n`); + }); + + it("returns 'skipped' when the parent cwd is not a directory we can mkdir into", () => { + // Pointing the sink at a path inside a regular file produces ENOTDIR, + // which we treat as "skip the file mirror, keep streaming stdout". + const blockingFile = path.join(tempCwd, "not-a-dir"); + fs.writeFileSync(blockingFile, "block"); + const setup = setupLogFileSink(blockingFile); + expect(setup.outcome).toBe("skipped"); + if (setup.outcome !== "skipped") return; + expect(setup.reason).toMatch(/ENOTDIR|not a directory|EEXIST/i); + }); +}); diff --git a/packages/cli/test/watch.test.ts b/packages/cli/test/wait-for-next-grab.test.ts similarity index 100% rename from packages/cli/test/watch.test.ts rename to packages/cli/test/wait-for-next-grab.test.ts diff --git a/packages/cli/test/watch-cli.test.ts b/packages/cli/test/watch-cli.test.ts deleted file mode 100644 index 3865fb7f8..000000000 --- a/packages/cli/test/watch-cli.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { spawnSync, type SpawnSyncOptions } from "node:child_process"; -import { existsSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vite-plus/test"; - -const TEST_DIR = path.dirname(fileURLToPath(import.meta.url)); -const CLI_PATH = path.resolve(TEST_DIR, "..", "dist", "cli.js"); - -const SSH_DETECTION_KEYS = ["SSH_CLIENT", "SSH_TTY", "SSH_CONNECTION", "WSL_DISTRO_NAME"] as const; - -const buildCleanEnv = (): NodeJS.ProcessEnv => { - const cleaned: NodeJS.ProcessEnv = { ...process.env }; - for (const key of SSH_DETECTION_KEYS) delete cleaned[key]; - return cleaned; -}; - -const runWatch = ( - args: string[], - envOverrides: Record, - spawnOptions: Partial = {}, -): ReturnType | null => { - if (!existsSync(CLI_PATH)) return null; - return spawnSync(process.execPath, [CLI_PATH, "watch", ...args], { - encoding: "utf8", - timeout: 10_000, - env: { ...buildCleanEnv(), ...envOverrides }, - ...spawnOptions, - }); -}; - -describe("react-grab watch CLI", () => { - it("exits 2 immediately under SSH without polling", () => { - const result = runWatch(["--timeout", "30"], { SSH_CLIENT: "1.2.3.4 5678 22" }); - if (result === null) return; - - expect(result.status).toBe(2); - expect(result.stderr).toContain("SSH"); - expect(result.stderr).not.toContain("Waiting for React Grab clipboard"); - }); - - it("exits 2 with a parse-error message for an invalid --timeout value", () => { - const result = runWatch(["--timeout", "abc"], {}); - if (result === null) return; - - expect(result.status).toBe(2); - expect(result.stderr).toContain('Invalid --timeout value: "abc"'); - expect(result.stderr).not.toContain("Waiting for React Grab clipboard"); - }); - - it("exits with a recognizable diagnostic after a short timeout", () => { - const result = runWatch(["--timeout", "0.5"], {}); - if (result === null) return; - - // CI runners without a clipboard helper (e.g. Linux without xclip / - // wl-clipboard) make the reader fast-exit 2 with an install hint. - // Local runs on macOS reach the timeout path and exit 1. - if (result.status === 2) { - expect(result.stderr).toMatch(/xclip|wl-clipboard|osascript|powershell|SSH/); - return; - } - expect(result.status).toBe(1); - expect(result.stderr).toContain("Timed out"); - expect(result.stderr).toContain("Click an element"); - }); -}); diff --git a/packages/cli/test/watch-format.test.ts b/packages/cli/test/watch-format.test.ts deleted file mode 100644 index 489e36509..000000000 --- a/packages/cli/test/watch-format.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { formatResultForStdout } from "../src/commands/watch.js"; -import type { ReactGrabPayload } from "../src/utils/parse-react-grab-payload.js"; - -const samplePayload: ReactGrabPayload = { - version: "0.1.32", - content: "Refactor\n\n", - entries: [ - { - tagName: "button", - componentName: "Button", - content: "", - commentText: "Refactor", - }, - ], - timestamp: 1700000000000, -}; - -describe("formatResultForStdout", () => { - it("emits formatted text by default", () => { - const text = formatResultForStdout(samplePayload, false); - expect(text).toContain("Prompt: Refactor"); - expect(text).toContain("Elements (1):"); - expect(text).toContain(""); - expect(text).not.toMatch(/^\{"version":/); - }); - - it("emits raw JSON when asJson is true", () => { - const text = formatResultForStdout(samplePayload, true); - const parsed: ReactGrabPayload = JSON.parse(text); - expect(parsed.version).toBe("0.1.32"); - expect(parsed.entries).toHaveLength(1); - expect(parsed.entries[0].componentName).toBe("Button"); - expect(parsed.timestamp).toBe(1700000000000); - }); - - it("treats undefined asJson the same as false", () => { - const text = formatResultForStdout(samplePayload, undefined); - expect(text).toContain("Elements (1):"); - }); -}); diff --git a/packages/cli/vite.config.ts b/packages/cli/vite.config.ts index c42d915f0..5b87f43aa 100644 --- a/packages/cli/vite.config.ts +++ b/packages/cli/vite.config.ts @@ -11,10 +11,7 @@ const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8")) as { // surfaced at the repo's top-level `skills/react-grab/SKILL.md` (via symlink) // so it stays GitHub-visible and never drifts from what the CLI installs. const HERE = path.dirname(fileURLToPath(import.meta.url)); -const SKILL_TEMPLATE_MD = fs.readFileSync( - path.join(HERE, "src/utils/skill-template.md"), - "utf8", -); +const SKILL_TEMPLATE_MD = fs.readFileSync(path.join(HERE, "src/utils/skill-template.md"), "utf8"); const sharedDefine = { __REACT_GRAB_SKILL_TEMPLATE__: JSON.stringify(SKILL_TEMPLATE_MD), diff --git a/packages/mcp/README.md b/packages/mcp/README.md index cb6b5c92e..3a8d3d921 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -22,8 +22,8 @@ Skills auto-trigger on `/react-grab` or when the user references a grabbed eleme | Old | New | | ----------------------------------- | --------------------------------------------------- | -| `react-grab-mcp` (stdio MCP server) | `react-grab watch` (one-shot CLI) | -| MCP tool `get_element_context` | Skill that runs `npx -y @react-grab/cli watch` | +| `react-grab-mcp` (stdio MCP server) | `react-grab log` (streaming NDJSON CLI) | +| MCP tool `get_element_context` | Skill that runs `npx -y @react-grab/cli log` | | `npx @react-grab/cli install-mcp` | `npx @react-grab/cli install-skill` | | Manual `mcp.json` entry per agent | `~/.cursor/skills-cursor/react-grab/SKILL.md`, etc. | diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 946bec3f7..dfe8bb291 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -22,5 +22,5 @@ "devDependencies": { "@types/node": "^22.10.7" }, - "deprecated": "Replaced by @react-grab/cli's `react-grab watch` and the agent skill installed via `install-skill`. See https://github.com/aidenybai/react-grab" + "deprecated": "Replaced by @react-grab/cli's `react-grab log` and the agent skill installed via `install-skill`. See https://github.com/aidenybai/react-grab" } diff --git a/packages/react-grab/docs/architecture.md b/packages/react-grab/docs/architecture.md index 16f04d5fa..ecab012b2 100644 --- a/packages/react-grab/docs/architecture.md +++ b/packages/react-grab/docs/architecture.md @@ -207,6 +207,6 @@ The five built-in plugins are registered during `init()` through the same `regis ## Notes about agent integration -`@react-grab/cli` exposes a `react-grab watch` subcommand that bridges react-grab with AI coding assistants. The browser does not talk to the CLI over the network. Instead, react-grab's copy flow already writes a custom MIME type `application/x-react-grab` to the OS clipboard alongside the plain text and HTML representations (see [packages/react-grab/src/utils/copy-content.ts](../src/utils/copy-content.ts)). When the agent runs `react-grab watch`, the CLI snapshots the current payload's `timestamp` and polls the clipboard via OS-native helpers (`osascript`/JXA on macOS, `wl-paste`/`xclip` on Linux, PowerShell `-Sta` on Windows, with a WSL bridge to the Windows host) until a payload with a different timestamp arrives, then prints it to stdout. This keeps the integration permissionless — no `localhost` requests from the browser, no port management, no long-running server. +`@react-grab/cli` exposes a `react-grab log` subcommand that bridges react-grab with AI coding assistants. The browser does not talk to the CLI over the network. Instead, react-grab's copy flow already writes a custom MIME type `application/x-react-grab` to the OS clipboard alongside the plain text and HTML representations (see [packages/react-grab/src/utils/copy-content.ts](../src/utils/copy-content.ts)). When the agent runs `react-grab log`, the CLI snapshots the current payload's `timestamp` and polls the clipboard via OS-native helpers (`osascript`/JXA on macOS, `wl-paste`/`xclip` on Linux, PowerShell `-Sta` on Windows, with a WSL bridge to the Windows host); each time a payload with a different timestamp arrives it is emitted as a single line of NDJSON (`{"prompt":"...","content":"..."}`) and the loop continues. This keeps the integration permissionless — no `localhost` requests from the browser, no port management, no long-running server. Agent invocation is driven by an installable skill (`react-grab install-skill`) that ships a `SKILL.md` to known agent skill directories (Cursor, Claude Code, Codex, OpenCode). Agents that support skills auto-invoke it on `/react-grab` or when the user references a grabbed element (e.g. "this thing", "the component I clicked"). The legacy `@react-grab/mcp` MCP server is deprecated — see its [package README](../../../packages/mcp/README.md) for migration steps. From 4df3a223f8147f977253e5dd3ca7d7053bc1fc8f Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Tue, 28 Apr 2026 19:20:14 -0700 Subject: [PATCH 35/37] fix(cli): two more bugbot findings on remove default scope + project root resolve - `grab remove` no longer wipes the user's global skill by default. Without an explicit `--scope`, only project scope is touched. The interactive multiselect only asks WHICH agents - never which scope - so a user cleaning up a single project would silently lose their global install across every other project on the machine. Pass `--scope global` to remove the per-user copy. - `findNearestProjectRoot` returns the resolved absolute path on the no-package.json fallback branch. Every other branch already returned an absolute path; the relative-`--cwd` leak only fired here. 282 tests passing (+1). --- packages/cli/src/commands/remove.ts | 12 ++++++++--- packages/cli/src/utils/detect.ts | 10 +++++++-- .../test/find-nearest-project-root.test.ts | 21 +++++++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/remove.ts b/packages/cli/src/commands/remove.ts index e44b0a720..b610d20ef 100644 --- a/packages/cli/src/commands/remove.ts +++ b/packages/cli/src/commands/remove.ts @@ -78,9 +78,15 @@ export const remove = new Command() targets = selectedAgents; } - const scopesToTry: SkillScope[] = isSkillScope(opts.scope) - ? [opts.scope] - : ["project", "global"]; + // Default to project-only scope when --scope is not passed. Sweeping + // both scopes by default would silently delete the user's per-user + // skill at `~/.agents/skills/react-grab` for every other project on + // the machine - the interactive multiselect only asks WHICH agents, + // never which scope, so a user cleaning up a single project would + // have no way to keep their global install. To remove the global + // copy explicitly, run `grab remove --scope global` (or pass both + // commands separately). + const scopesToTry: SkillScope[] = isSkillScope(opts.scope) ? [opts.scope] : ["project"]; // Walk up from cwd to the nearest project root so `grab remove` invoked // from a subdirectory still finds skills installed at the canonical diff --git a/packages/cli/src/utils/detect.ts b/packages/cli/src/utils/detect.ts index d83277fb1..28bf1f922 100644 --- a/packages/cli/src/utils/detect.ts +++ b/packages/cli/src/utils/detect.ts @@ -495,7 +495,8 @@ const isWorkspaceRoot = (dir: string): boolean => { // // Capped at 64 levels of walking so a malformed cwd can't loop forever. export const findNearestProjectRoot = (start: string): string => { - let dir = resolve(start); + const resolvedStart = resolve(start); + let dir = resolvedStart; let firstWithPackageJson: string | null = null; let outermostWorkspaceRoot: string | null = null; @@ -511,7 +512,12 @@ export const findNearestProjectRoot = (start: string): string => { if (outermostWorkspaceRoot !== null) return outermostWorkspaceRoot; if (firstWithPackageJson !== null) return firstWithPackageJson; - return start; + // Fall back to the resolved absolute path rather than the raw `start` + // argument so callers like `add`/`remove`/`install-skill` (which pass it + // straight into `path.resolve(cwd, ...)` and JSON output) receive an + // absolute path on every code branch. Previously a relative `--cwd` + // value would leak through unresolved here. + return resolvedStart; }; export const detectProject = async (projectRoot: string = process.cwd()): Promise => { diff --git a/packages/cli/test/find-nearest-project-root.test.ts b/packages/cli/test/find-nearest-project-root.test.ts index 7b3e14b94..a58c747ad 100644 --- a/packages/cli/test/find-nearest-project-root.test.ts +++ b/packages/cli/test/find-nearest-project-root.test.ts @@ -78,4 +78,25 @@ describe("findNearestProjectRoot", () => { fs.mkdirSync(subdir, { recursive: true }); expect(findNearestProjectRoot(subdir)).toBe(subdir); }); + + it("resolves a relative input on the no-package.json fallback path", () => { + // Walk into a deep subdir of the temp dir so the fallback branch fires + // (no package.json anywhere up the chain to the filesystem root). We + // call findNearestProjectRoot with a *relative* path and expect an + // absolute path back, otherwise downstream `path.resolve(cwd, ...)` + // calls would silently use the process-cwd as a base instead of the + // intended directory. + const previousCwd = process.cwd(); + const relativeStart = path.join("no-project", "deep"); + const absoluteStart = path.join(tempDir, relativeStart); + fs.mkdirSync(absoluteStart, { recursive: true }); + process.chdir(tempDir); + try { + const result = findNearestProjectRoot(relativeStart); + expect(path.isAbsolute(result)).toBe(true); + expect(result).toBe(absoluteStart); + } finally { + process.chdir(previousCwd); + } + }); }); From 6740b6661ae7304162cb3b02f1f799665fefa939 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Tue, 28 Apr 2026 19:20:32 -0700 Subject: [PATCH 36/37] feat(cli): standardize on .agents/skills for compatible agents Re-verified each agent's actual support for the Agent Skills `.agents/skills/` convention (the open standard at agentskills.io) and updated the install map accordingly: - Windsurf: now universal. Cascade scans `.agents/skills/` and `~/.agents/skills/` per the official Windsurf docs, so installs dedup against the canonical location instead of writing a duplicate file under `.windsurf/skills/`. - Pi (`badlogic/pi-mono`): added as universal. Detected via `~/.pi/`, scans `.agents/skills/` and `~/.agents/skills/` per the pi-mono docs. - Amp: split paths. Project scope is canonical (`.agents/skills/`) so it dedups with other universal agents, but global scope writes to `~/.config/agents/skills/` because that's what Amp actually reads - previously the global install silently went to a directory Amp doesn't scan. - Cline: demoted to unsupportedClient. Cline only reads from `.cline/skills/`, never `.agents/skills/`, so installing was a no-op. Surfaces a migration message instead of silently writing to the wrong path. `--agent Cline` still gets a clear "do not support skills yet" error rather than the misleading "unknown agent" path. Also fixes a stale-state bug found during review: - `readKnownLastSelectedAgents` filters the persisted last-selected list against the current client roster before consumption. Without this, a `["Cline"]` entry from a previous CLI version would skew the `lastSelected.length === 0` short-circuits used by the install flow and keep the multiselect's "user has a saved choice" branch active even when none of the saved choices map to a real agent anymore. 282 tests passing (+8). --- packages/cli/src/commands/install-skill.ts | 5 +- packages/cli/src/utils/install-skill.ts | 42 +++++-- packages/cli/test/install-skill.test.ts | 127 +++++++++++++++++++-- 3 files changed, 149 insertions(+), 25 deletions(-) diff --git a/packages/cli/src/commands/install-skill.ts b/packages/cli/src/commands/install-skill.ts index 6bfe55cfa..c8c5cb082 100644 --- a/packages/cli/src/commands/install-skill.ts +++ b/packages/cli/src/commands/install-skill.ts @@ -9,9 +9,10 @@ import { getSkillClientNames, getSupportedSkillClientNames, installSkills, + readKnownLastSelectedAgents, type SkillScope, } from "../utils/install-skill.js"; -import { readLastSelectedAgents, writeLastSelectedAgents } from "../utils/last-selected-agents.js"; +import { writeLastSelectedAgents } from "../utils/last-selected-agents.js"; import { logger } from "../utils/logger.js"; import { prompts } from "../utils/prompts.js"; @@ -150,7 +151,7 @@ export const installSkill = new Command() } const detected = detectInstalledSkillClients(); - if (detected.length === 1 && readLastSelectedAgents().length === 0) { + if (detected.length === 1 && readKnownLastSelectedAgents().length === 0) { const onlyDetected = detected[0]!; logger.log( `Auto-installing to ${highlighter.info(onlyDetected)} (only detected agent). Pass ${highlighter.info("--agent")} to override.`, diff --git a/packages/cli/src/utils/install-skill.ts b/packages/cli/src/utils/install-skill.ts index 4b7bada8f..2ddafe736 100644 --- a/packages/cli/src/utils/install-skill.ts +++ b/packages/cli/src/utils/install-skill.ts @@ -106,19 +106,24 @@ export const getSkillClients = (): SkillClientDefinition[] => { universalClient("Cursor", () => fs.existsSync(path.join(homeDir, ".cursor"))), universalClient("Codex", () => fs.existsSync(codexHome)), universalClient("OpenCode", () => fs.existsSync(path.join(xdgConfigHome, "opencode"))), - universalClient("Amp", () => fs.existsSync(path.join(xdgConfigHome, "amp"))), - universalClient("Cline", () => fs.existsSync(path.join(homeDir, ".cline"))), - universalClient("Gemini CLI", () => fs.existsSync(path.join(homeDir, ".gemini"))), - universalClient("GitHub Copilot", () => fs.existsSync(path.join(homeDir, ".copilot"))), - universalClient("Warp", () => fs.existsSync(path.join(homeDir, ".warp"))), + // Amp is universal at project scope (`.agents/skills/`) but reads + // user-level skills from `~/.config/agents/skills/` rather than + // `~/.agents/skills/`. Set both explicitly so project installs still + // dedup against the canonical root, while global installs land where + // Amp will actually find them. { - name: "Windsurf", + name: "Amp", universal: false, - globalRoot: path.join(homeDir, ".codeium", "windsurf", "skills"), - projectRoot: ".windsurf/skills", - detectInstalled: () => fs.existsSync(path.join(homeDir, ".codeium", "windsurf")), + globalRoot: path.join(xdgConfigHome, "agents", "skills"), + projectRoot: path.join(CANONICAL_AGENTS_DIR, CANONICAL_SKILLS_SUBDIR), + detectInstalled: () => fs.existsSync(path.join(xdgConfigHome, "amp")), supported: true, }, + universalClient("Gemini CLI", () => fs.existsSync(path.join(homeDir, ".gemini"))), + universalClient("GitHub Copilot", () => fs.existsSync(path.join(homeDir, ".copilot"))), + universalClient("Warp", () => fs.existsSync(path.join(homeDir, ".warp"))), + universalClient("Windsurf", () => fs.existsSync(path.join(homeDir, ".codeium", "windsurf"))), + universalClient("Pi", () => fs.existsSync(path.join(homeDir, ".pi"))), { name: "Droid", universal: false, @@ -132,6 +137,10 @@ export const getSkillClients = (): SkillClientDefinition[] => { "VS Code does not yet support skills. Run `react-grab log` directly.", ), unsupportedClient("Zed", "Zed does not yet support skills. Run `react-grab log` directly."), + unsupportedClient( + "Cline", + "Cline reads from .cline/skills/, not the canonical .agents/skills/. React Grab no longer auto-installs to Cline; copy the skill template into your Cline skills directory manually if needed.", + ), ]; }; @@ -142,6 +151,17 @@ export const getSupportedSkillClientNames = (): string[] => .filter((client) => client.supported) .map((client) => client.name); +// Wrap `readLastSelectedAgents` so callers always get a list pruned to the +// currently-known client roster. Without this, a stale entry for a client +// that has since been removed would skew the `lastSelected.length === 0` +// short-circuits used by the install flow, and would keep the multiselect's +// "user has a saved choice" branch active when none of the saved choices +// map to a real agent anymore. +export const readKnownLastSelectedAgents = (): string[] => { + const known = new Set(getSkillClientNames()); + return readLastSelectedAgents().filter((name) => known.has(name)); +}; + export const detectInstalledSkillClients = (): string[] => getSkillClients() .filter((client) => client.supported && client.detectInstalled()) @@ -296,7 +316,7 @@ export const buildAgentChoices = ( options: { allClients?: boolean } = {}, ): AgentChoice[] => { const installedNames = new Set(detectInstalledSkillClients()); - const lastSelected = new Set(readLastSelectedAgents()); + const lastSelected = new Set(readKnownLastSelectedAgents()); const candidates = options.allClients ? getSkillClients() : supportedAtScope(scope); return candidates.map((client) => { @@ -326,7 +346,7 @@ export const promptSkillInstall = async ( // history, install to it directly without a prompt - skips a redundant // selection step for the common single-editor case. const installedNames = detectInstalledSkillClients(); - const lastSelected = readLastSelectedAgents(); + const lastSelected = readKnownLastSelectedAgents(); if (installedNames.length === 1 && lastSelected.length === 0) { const onlyInstalled = installedNames[0]; if (onlyInstalled) { diff --git a/packages/cli/test/install-skill.test.ts b/packages/cli/test/install-skill.test.ts index a2cfa0cd1..f946bdab4 100644 --- a/packages/cli/test/install-skill.test.ts +++ b/packages/cli/test/install-skill.test.ts @@ -8,6 +8,7 @@ import { getSkillClients, installDetectedOrAllSkills, installSkills, + readKnownLastSelectedAgents, removeSkillFile, removeSkills, resolveSkillRoot, @@ -19,6 +20,7 @@ import { CANONICAL_SKILLS_SUBDIR, SKILL_NAME, } from "../src/utils/constants.js"; +import { writeLastSelectedAgents } from "../src/utils/last-selected-agents.js"; import { SKILL_TEMPLATE } from "../src/utils/skill-template.js"; let tempDir: string; @@ -58,8 +60,8 @@ describe("getSkillClients", () => { expect(claudeCode.globalRoot).toContain("skills"); }); - it("includes additional universal agents (Amp, Cline, Gemini CLI, GitHub Copilot, Warp)", () => { - const universalAdditions = ["Amp", "Cline", "Gemini CLI", "GitHub Copilot", "Warp"]; + it("includes additional universal agents (Gemini CLI, GitHub Copilot, Warp, Windsurf, Pi)", () => { + const universalAdditions = ["Gemini CLI", "GitHub Copilot", "Warp", "Windsurf", "Pi"]; for (const name of universalAdditions) { const client = findClient(name); expect(client.universal).toBe(true); @@ -67,10 +69,27 @@ describe("getSkillClients", () => { } }); - it("flags VS Code, Zed as unsupported with reasons", () => { + it("flags Amp as project-canonical with Amp-specific global path", () => { + const amp = findClient("Amp"); + expect(amp.universal).toBe(false); + expect(amp.supported).toBe(true); + expect(amp.projectRoot).toBe(`${CANONICAL_AGENTS_DIR}/${CANONICAL_SKILLS_SUBDIR}`); + expect(amp.globalRoot).toContain(path.join("agents", "skills")); + expect(amp.globalRoot).not.toContain(".agents"); + }); + + it("flags Cline as unsupported with a migration reason that mentions .cline/skills", () => { + const cline = findClient("Cline"); + expect(cline.supported).toBe(false); + expect(cline.projectRoot).toBeNull(); + expect(cline.globalRoot).toBeNull(); + expect(cline.unsupportedReason).toMatch(/\.cline\/skills/); + }); + + it("flags VS Code, Zed, Cline as unsupported with reasons", () => { const unsupported = getSkillClients().filter((client) => !client.supported); const names = unsupported.map((client) => client.name); - expect(names).toEqual(expect.arrayContaining(["VS Code", "Zed"])); + expect(names).toEqual(expect.arrayContaining(["VS Code", "Zed", "Cline"])); for (const client of unsupported) { expect(client.unsupportedReason).toBeTruthy(); expect(client.projectRoot).toBeNull(); @@ -87,7 +106,8 @@ describe("getSkillClientNames", () => { expect(names).toContain("Codex"); expect(names).toContain("OpenCode"); expect(names).toContain("Amp"); - expect(names).toContain("Cline"); + expect(names).toContain("Windsurf"); + expect(names).toContain("Pi"); }); }); @@ -140,6 +160,42 @@ describe("installSkills", () => { expect(fs.existsSync(path.join(tempDir, ".opencode", "skills"))).toBe(false); }); + it("dedups Amp at project scope with universal canonical sharers", () => { + // Amp's universal flag is false because its global path is Amp-specific, + // but at project scope it must share the canonical .agents/skills root + // so that a Cursor + Amp install produces a single file write. + const results = installSkills({ + scope: "project", + cwd: tempDir, + selectedClients: ["Cursor", "Amp"], + }); + + const successes = results.filter((result) => result.success); + expect(successes).toHaveLength(2); + expect(successes.filter((result) => result.deduped)).toHaveLength(1); + expect( + fs.existsSync( + path.join(tempDir, CANONICAL_AGENTS_DIR, CANONICAL_SKILLS_SUBDIR, SKILL_NAME, "SKILL.md"), + ), + ).toBe(true); + // Amp must NOT have written to its per-agent global path at project scope. + expect(fs.existsSync(path.join(tempDir, ".config", "agents", "skills"))).toBe(false); + }); + + it("skips Cline as unsupported and surfaces the migration reason", () => { + const results = installSkills({ + scope: "project", + cwd: tempDir, + selectedClients: ["Cline"], + }); + expect(results).toHaveLength(1); + expect(results[0]?.skipped).toBe(true); + expect(results[0]?.error).toMatch(/\.cline\/skills/); + // Refuses to write anywhere. + expect(fs.existsSync(path.join(tempDir, CANONICAL_AGENTS_DIR))).toBe(false); + expect(fs.existsSync(path.join(tempDir, ".cline"))).toBe(false); + }); + it("writes a separate file for non-universal Claude Code", () => { installSkills({ scope: "project", @@ -350,27 +406,74 @@ describe("installSkills with no selectedClients", () => { }); }); +describe("readKnownLastSelectedAgents", () => { + let xdgStateBackup: string | undefined; + let stateDir: string; + + beforeEach(() => { + xdgStateBackup = process.env.XDG_STATE_HOME; + stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "rkls-state-")); + process.env.XDG_STATE_HOME = stateDir; + }); + + afterEach(() => { + if (xdgStateBackup === undefined) delete process.env.XDG_STATE_HOME; + else process.env.XDG_STATE_HOME = xdgStateBackup; + fs.rmSync(stateDir, { recursive: true, force: true }); + }); + + it("returns the persisted agents that still exist in the current roster", () => { + writeLastSelectedAgents(["Cursor", "Codex"]); + expect(readKnownLastSelectedAgents().sort()).toEqual(["Codex", "Cursor"]); + }); + + it("filters out names no longer in the client roster (e.g. removed agents)", () => { + // Simulate an old persisted state from a prior CLI version that knew + // about an agent we have since renamed or dropped. The unknown name + // must not leak into callers, otherwise the multiselect's + // `lastSelected.size > 0` branch would fire on a phantom selection. + writeLastSelectedAgents(["Cursor", "GhostAgent", "AnotherGhost"]); + expect(readKnownLastSelectedAgents()).toEqual(["Cursor"]); + }); + + it("returns an empty array when none of the persisted names are known", () => { + writeLastSelectedAgents(["GhostAgent"]); + expect(readKnownLastSelectedAgents()).toEqual([]); + }); +}); + describe("removeSkills", () => { - it("removes ALL universal agents only when ALL of them are targeted (full canonical wipe)", () => { + it("removes ALL canonical-sharing agents only when ALL of them are targeted (full canonical wipe)", () => { installSkills({ scope: "project", cwd: tempDir, selectedClients: ["Cursor", "Codex", "OpenCode"], }); - const universalSupportedNames = getSkillClients() - .filter((c) => c.supported && c.universal) - .map((c) => c.name); + // All supported clients whose project root resolves to the canonical + // .agents/skills (universal clients + Amp, which is project-canonical + // even though its global path is Amp-specific). + const canonicalProjectRoot = path.resolve( + tempDir, + CANONICAL_AGENTS_DIR, + CANONICAL_SKILLS_SUBDIR, + ); + const canonicalSharers = getSkillClients() + .filter( + (client) => + client.supported && resolveSkillRoot(client, "project", tempDir) === canonicalProjectRoot, + ) + .map((client) => client.name); const results = removeSkills({ scope: "project", cwd: tempDir, - selectedClients: universalSupportedNames, + selectedClients: canonicalSharers, }); - expect(results).toHaveLength(universalSupportedNames.length); + expect(results).toHaveLength(canonicalSharers.length); expect(results.filter((r) => r.removed)).toHaveLength(1); const dedupedResults = results.filter((r) => r.deduped); - expect(dedupedResults).toHaveLength(universalSupportedNames.length - 1); + expect(dedupedResults).toHaveLength(canonicalSharers.length - 1); expect( fs.existsSync(path.join(tempDir, CANONICAL_AGENTS_DIR, CANONICAL_SKILLS_SUBDIR, SKILL_NAME)), ).toBe(false); From 78f1537f0a9acff4835d7c00288b48298628cb27 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Tue, 28 Apr 2026 19:28:59 -0700 Subject: [PATCH 37/37] refactor(cli): hoist SKILL_SCOPES + isSkillScope to shared util Bugbot Low-severity DRY: `SKILL_SCOPES` and `isSkillScope` were defined identically in both `commands/install-skill.ts` and `commands/remove.ts`. Both files already import `SkillScope` from `utils/install-skill.ts`, so co-locate the scope list and guard there to remove the maintenance risk of updating one copy but not the other. Pure refactor, no behavior change. 282 tests still passing. --- packages/cli/src/commands/install-skill.ts | 7 ++----- packages/cli/src/commands/remove.ts | 7 ++----- packages/cli/src/utils/install-skill.ts | 5 +++++ 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/commands/install-skill.ts b/packages/cli/src/commands/install-skill.ts index c8c5cb082..621e0f0f4 100644 --- a/packages/cli/src/commands/install-skill.ts +++ b/packages/cli/src/commands/install-skill.ts @@ -9,7 +9,9 @@ import { getSkillClientNames, getSupportedSkillClientNames, installSkills, + isSkillScope, readKnownLastSelectedAgents, + SKILL_SCOPES, type SkillScope, } from "../utils/install-skill.js"; import { writeLastSelectedAgents } from "../utils/last-selected-agents.js"; @@ -18,8 +20,6 @@ import { prompts } from "../utils/prompts.js"; const VERSION = process.env.VERSION ?? "0.0.1"; -const SKILL_SCOPES: readonly SkillScope[] = ["global", "project"]; - interface InstallSkillCommandOptions { yes?: boolean; agent?: string[]; @@ -27,9 +27,6 @@ interface InstallSkillCommandOptions { cwd: string; } -const isSkillScope = (value: unknown): value is SkillScope => - typeof value === "string" && (SKILL_SCOPES as readonly string[]).includes(value); - const promptForScope = async (): Promise => { const { selectedScope } = await prompts({ type: "select", diff --git a/packages/cli/src/commands/remove.ts b/packages/cli/src/commands/remove.ts index b610d20ef..3d8275562 100644 --- a/packages/cli/src/commands/remove.ts +++ b/packages/cli/src/commands/remove.ts @@ -7,14 +7,14 @@ import { logger } from "../utils/logger.js"; import { prompts } from "../utils/prompts.js"; import { getSupportedSkillClientNames, + isSkillScope, removeSkills, + SKILL_SCOPES, type SkillScope, } from "../utils/install-skill.js"; const VERSION = process.env.VERSION ?? "0.0.1"; -const SKILL_SCOPES: readonly SkillScope[] = ["global", "project"]; - interface RemoveCommandOptions { yes?: boolean; agent?: string[]; @@ -22,9 +22,6 @@ interface RemoveCommandOptions { cwd: string; } -const isSkillScope = (value: unknown): value is SkillScope => - typeof value === "string" && (SKILL_SCOPES as readonly string[]).includes(value); - export const remove = new Command() .name("remove") .description("remove the React Grab skill from your agent(s)") diff --git a/packages/cli/src/utils/install-skill.ts b/packages/cli/src/utils/install-skill.ts index 2ddafe736..950f9771e 100644 --- a/packages/cli/src/utils/install-skill.ts +++ b/packages/cli/src/utils/install-skill.ts @@ -12,6 +12,11 @@ import { readLastSelectedAgents, writeLastSelectedAgents } from "./last-selected export type SkillScope = "global" | "project"; +export const SKILL_SCOPES: readonly SkillScope[] = ["global", "project"]; + +export const isSkillScope = (value: unknown): value is SkillScope => + typeof value === "string" && (SKILL_SCOPES as readonly string[]).includes(value); + export interface SkillClientDefinition { name: string; universal: boolean;