diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 0dc2e61c1..07d310ebc 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 log` 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..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)
@@ -19,18 +19,20 @@ 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:
-- **⌘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/README.md b/packages/cli/README.md
index a4a394620..a99ca0445 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 `log` subcommand that streams React Grab clipboard payloads as NDJSON for AI coding agents.
## Quick Start
@@ -27,31 +27,54 @@ 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) |
+
+For an interactive flow that first verifies React Grab is installed and offers a simpler project-vs-global choice, see `grab add`.
### `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 log`
+
+Stream every React Grab payload as NDJSON, one JSON object per line, until killed. 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 log
```
-| Option | Alias | Description |
-| ------------- | ----- | ---------------------------------------- |
-| `--yes` | `-y` | Skip confirmation prompts |
-| `--cwd ` | `-c` | Working directory (default: current dir) |
+Each line has the shape `{"prompt":"...","content":"..."}` (the `prompt` field is omitted when the user didn't type one in the toolbar). The command takes no flags. It always mirrors every line to `.react-grab/logs` (and writes a `.react-grab/.gitignore` so the log never lands in version control).
+
+Lifecycle depends on stdout:
+
+- **Interactive (TTY)**: streams forever, exits only on SIGINT/SIGTERM or a fundamental clipboard error (exit code `2`, e.g. SSH or missing `xclip`).
+- **Piped or redirected (non-TTY)**: exits cleanly with code `0` after writing the first match. This is what makes `log | head -n 1` and `log > grabs.ndjson` terminate without manual intervention.
+
+To grab a single payload from a script, pipe to `head`:
+
+```bash
+npx -y @react-grab/cli log | head -n 1
+```
### `grab configure`
@@ -84,16 +107,27 @@ 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
+
+# Stream every grab as NDJSON until killed
+npx -y @react-grab/cli log
+
+# Take just the first grab and exit
+npx -y @react-grab/cli log | head -n 1
# 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..097a66a69 100644
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -1,9 +1,13 @@
import { Command } from "commander";
import { add } from "./commands/add.js";
+import { checkInstalled } from "./commands/check-installed.js";
import { configure } from "./commands/configure.js";
import { init } from "./commands/init.js";
+import { installSkill } from "./commands/install-skill.js";
+import { log } from "./commands/log.js";
import { remove } from "./commands/remove.js";
import { upgrade } from "./commands/upgrade.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 +15,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 +31,9 @@ program.addCommand(add);
program.addCommand(remove);
program.addCommand(configure);
program.addCommand(upgrade);
+program.addCommand(installSkill);
+program.addCommand(log);
+program.addCommand(checkInstalled);
const main = async () => {
await program.parseAsync();
diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts
index 2dac4fac6..e5e4caab3 100644
--- a/packages/cli/src/commands/add.ts
+++ b/packages/cli/src/commands/add.ts
@@ -1,11 +1,16 @@
import { Command } from "commander";
import pc from "picocolors";
import { detectNonInteractive } from "../utils/is-non-interactive.js";
-import { detectProject } from "../utils/detect.js";
+import { detectProject, findNearestProjectRoot } 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) => {
@@ -22,7 +27,10 @@ export const add = new Command()
console.log();
try {
- const cwd = opts.cwd;
+ // Walk up from the user-provided cwd to the nearest project root so
+ // running `grab add` inside a subdirectory still anchors detection and
+ // the skill install on the actual project root rather than the subdir.
+ const cwd = findNearestProjectRoot(opts.cwd);
const isNonInteractive = detectNonInteractive(opts.yes);
const preflightSpinner = spinner("Preflight checks.").start();
@@ -39,48 +47,79 @@ 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) {
+ // 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();
+ 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();
- if (!didInstall) {
+ 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(0);
+ process.exit(1);
+ }
+
+ const outcome = await promptSkillInstall(skillScope as SkillScope, projectInfo.projectRoot);
+ if (outcome === "failed") {
+ // Distinguish a real install failure (couldn't write to any agent
+ // skill dir) from a benign user cancellation. The previous boolean
+ // return collapsed both into exit 0 and silently swallowed
+ // permission errors that wrapper scripts / CI need to detect.
+ logger.break();
+ logger.error("React Grab skill install did not write any files.");
+ logger.break();
+ process.exit(1);
+ }
+ if (outcome === "cancelled") {
+ // Exit 1 on user-cancelled prompts so wrapper scripts can
+ // distinguish a cancellation from a successful install.
+ // Consistent with the scope-prompt cancellation branch above and
+ // with `grab install-skill`, which also exits 1 on multiselect
+ // cancel.
+ 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();
}
+
+ 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/check-installed.ts b/packages/cli/src/commands/check-installed.ts
new file mode 100644
index 000000000..d5e422680
--- /dev/null
+++ b/packages/cli/src/commands/check-installed.ts
@@ -0,0 +1,48 @@
+import { resolve } from "node:path";
+import { Command } from "commander";
+import { detectReactGrab, findNearestProjectRoot } from "../utils/detect.js";
+import { handleError } from "../utils/handle-error.js";
+
+interface CheckInstalledOptions {
+ cwd: string;
+ json?: boolean;
+}
+
+const exitWithFlush = (stream: NodeJS.WriteStream, message: string, exitCode: number): void => {
+ stream.write(message, () => process.exit(exitCode));
+};
+
+export const checkInstalled = new Command()
+ .name("check-installed")
+ .alias("is-installed")
+ .description("exit 0 if react-grab is installed in the project, exit 1 otherwise")
+ .option("-c, --cwd ", "working directory (defaults to current directory)", process.cwd())
+ .option("--json", "print structured JSON output instead of human text")
+ .action((rawOptions: CheckInstalledOptions) => {
+ try {
+ const requestedCwd = resolve(rawOptions.cwd);
+ // Match `add`, `install-skill`, `remove`: walk up so the preflight
+ // works from any subdirectory inside a monorepo.
+ const projectRoot = findNearestProjectRoot(requestedCwd);
+ const installed = detectReactGrab(projectRoot);
+
+ if (rawOptions.json) {
+ const payload = JSON.stringify({ installed, projectRoot, requestedCwd });
+ exitWithFlush(process.stdout, `${payload}\n`, installed ? 0 : 1);
+ return;
+ }
+
+ if (installed) {
+ exitWithFlush(process.stdout, `react-grab is installed at ${projectRoot}\n`, 0);
+ return;
+ }
+
+ exitWithFlush(
+ process.stderr,
+ `react-grab is not installed at ${projectRoot}. Run \`npx grab@latest init\` to install.\n`,
+ 1,
+ );
+ } catch (error) {
+ handleError(error);
+ }
+ });
diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts
index 75588a0a2..3f5d3ecf6 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" },
@@ -143,6 +145,23 @@ export const init = new Command()
logger.log(
`Use ${highlighter.info("--force")} to reconfigure, or remove ${highlighter.info("--yes")} for interactive mode.`,
);
+ // Even on a re-run against an already-installed project, scripted
+ // pipelines still want the agent skill installed - mirroring the
+ // pre-CLI MCP behavior where `grab init -y` always wired up agent
+ // integration. Without this, CI scripts that re-run `init -y` after
+ // upgrading the CLI silently lose the skill and never see a prompt
+ // telling them to run `install-skill` separately.
+ logger.break();
+ const results = installDetectedOrAllSkills("project", projectInfo.projectRoot);
+ if (results.some((result) => result.success)) {
+ logger.success(
+ "React Grab skill installed/refreshed. Restart your agent(s) to pick it up.",
+ );
+ } else {
+ logger.warn(
+ `React Grab skill install did not write any files. Run ${highlighter.info("grab install-skill")} after init to retry.`,
+ );
+ }
logger.break();
process.exit(0);
}
@@ -327,27 +346,41 @@ 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 (!didInstall) {
+ if (skillChoice !== "no") {
+ const results = installDetectedOrAllSkills(
+ skillChoice as SkillScope,
+ projectInfo.projectRoot,
+ );
+ const didInstall = results.some((result) => result.success);
+ 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("MCP server has been configured.");
- logger.log("Restart your agents to activate.");
}
logger.break();
@@ -450,30 +483,59 @@ export const init = new Command()
const finalFramework = projectInfo.framework;
const finalPackageManager = projectInfo.packageManager;
const finalNextRouterType = projectInfo.nextRouterType;
- let didInstallMcp = false;
-
- if (!isNonInteractive) {
+ let didInstallSkill = false;
+
+ if (isNonInteractive) {
+ // Match the pre-CLI MCP behavior: `grab init -y` (used in CI /
+ // automation) also installs the React Grab skill so scripted
+ // pipelines retain agent integration after upgrading. Defaults to
+ // project scope so the skill ends up committed alongside the rest of
+ // the React Grab install.
+ const results = installDetectedOrAllSkills("project", projectInfo.projectRoot);
+ didInstallSkill = results.some((result) => result.success);
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,
+ if (didInstallSkill) {
+ logger.success("React Grab skill has been installed.");
+ } else {
+ logger.warn(
+ `React Grab skill install did not write any files. Run ${highlighter.info("grab install-skill")} after init to retry.`,
+ );
+ }
+ logger.log("Continuing with React Grab installation...");
+ logger.break();
+ } else {
+ logger.break();
+ 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) {
- logger.break();
- process.exit(0);
- }
+ if (skillChoice !== "no") {
+ const results = installDetectedOrAllSkills(
+ skillChoice as SkillScope,
+ projectInfo.projectRoot,
+ );
+ didInstallSkill = results.some((result) => result.success);
logger.break();
- logger.success("MCP server has been configured.");
+ 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();
}
@@ -547,7 +609,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..621e0f0f4
--- /dev/null
+++ b/packages/cli/src/commands/install-skill.ts
@@ -0,0 +1,211 @@
+import { Command } from "commander";
+import pc from "picocolors";
+import { findNearestProjectRoot } from "../utils/detect.js";
+import { handleError } from "../utils/handle-error.js";
+import { highlighter } from "../utils/highlighter.js";
+import {
+ buildAgentChoices,
+ detectInstalledSkillClients,
+ getSkillClientNames,
+ getSupportedSkillClientNames,
+ installSkills,
+ isSkillScope,
+ readKnownLastSelectedAgents,
+ SKILL_SCOPES,
+ type SkillScope,
+} from "../utils/install-skill.js";
+import { 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";
+
+interface InstallSkillCommandOptions {
+ yes?: boolean;
+ agent?: string[];
+ scope?: string;
+ cwd: string;
+}
+
+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 supportedNames = getSupportedSkillClientNames();
+ const flagScope: SkillScope | undefined = isSkillScope(opts.scope) ? opts.scope : undefined;
+ // Walk up from cwd to the nearest project root so `install-skill`
+ // invoked from a subdirectory inside a repo writes to the canonical
+ // `/.agents/skills/...` location instead of creating a
+ // sub-`.agents/skills` that agents won't pick up.
+ const projectCwd = findNearestProjectRoot(opts.cwd);
+
+ 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: ${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);
+ }
+ const scope: SkillScope = flagScope ?? "project";
+ const results = installSkills({ scope, cwd: projectCwd, selectedClients: 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.");
+ logger.break();
+ process.exit(1);
+ }
+ 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 : supportedNames;
+ const results = installSkills({ scope, cwd: projectCwd, selectedClients: targets });
+ logger.break();
+ if (results.some((r) => r.success)) {
+ // Only persist when the user signaled an explicit preference via
+ // detection. Wholesale fallback (no detected agents) shouldn't bias
+ // future interactive multiselects toward "every supported agent".
+ if (detected.length > 0) {
+ writeLastSelectedAgents(targets);
+ }
+ 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;
+ }
+
+ const detected = detectInstalledSkillClients();
+ 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.`,
+ );
+ logger.break();
+ const results = installSkills({ scope, cwd: projectCwd, selectedClients: [onlyDetected] });
+ logger.break();
+ if (results.some((r) => r.success)) {
+ // Don't persist when the user didn't make an active choice. The
+ // auto-install branch routes to the only detected agent without
+ // showing a multiselect; persisting it would silently restrict
+ // every future interactive run to that single agent.
+ 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;
+ }
+
+ const { selectedAgents } = await prompts({
+ type: "multiselect",
+ name: "selectedAgents",
+ message: `Select agents to install the React Grab skill for (${scope}):`,
+ choices: buildAgentChoices(scope),
+ });
+
+ if (selectedAgents === undefined || selectedAgents.length === 0) {
+ // Exit 1 on cancellation so wrapper scripts can distinguish a
+ // cancelled multiselect from a successful install. Consistent with
+ // the scope-prompt cancellation branch above and with `grab add`,
+ // both of which exit non-zero on user-aborted prompts.
+ logger.break();
+ logger.log("No agents selected. Nothing to do.");
+ logger.break();
+ process.exit(1);
+ }
+
+ logger.break();
+ const results = installSkills({ scope, cwd: projectCwd, selectedClients: 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.`,
+ );
+ } 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/commands/log.ts b/packages/cli/src/commands/log.ts
new file mode 100644
index 000000000..cc8f541bb
--- /dev/null
+++ b/packages/cli/src/commands/log.ts
@@ -0,0 +1,74 @@
+import { Command } from "commander";
+import { readClipboardPayload } from "../utils/read-clipboard-payload.js";
+import { runLogLoop } from "../utils/run-log-loop.js";
+import { setupLogFileSink } from "../utils/setup-log-file-sink.js";
+
+class ExitSignal extends Error {
+ constructor(public readonly exitCode: number) {
+ super("");
+ }
+}
+
+const fail = (message: string, exitCode: number): never => {
+ // `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.
+ process.stderr.write(`${message}\n`, () => process.exit(exitCode));
+ throw new ExitSignal(exitCode);
+};
+
+export const log = new Command()
+ .name("log")
+ .description("stream every React Grab selection as NDJSON until killed")
+ .action(async () => {
+ try {
+ // EPIPE on stdout means a consumer (e.g. `head -n 1`) closed early.
+ // Without this handler, Node prints an unhandled-error warning before
+ // tearing down. We exit cleanly so the pipeline reports 0.
+ process.stdout.on("error", (caughtError) => {
+ if (caughtError instanceof Error && "code" in caughtError && caughtError.code === "EPIPE") {
+ process.exit(0);
+ }
+ });
+
+ const initialResult = await readClipboardPayload();
+
+ let appendToFile: ((line: string) => void) | undefined;
+ if (initialResult.recoverable) {
+ process.stderr.write("Streaming React Grab clipboard...\n");
+ const sinkSetup = setupLogFileSink();
+ if (sinkSetup.outcome === "ok") {
+ appendToFile = sinkSetup.sink.append;
+ process.stderr.write(`Mirroring to ${sinkSetup.sink.path}\n`);
+ } else {
+ process.stderr.write(`File mirror disabled: ${sinkSetup.reason}\n`);
+ }
+ }
+
+ // When stdout is not a TTY (piped to head, redirected to file, etc.)
+ // we exit cleanly after the first match so the upstream pipeline
+ // doesn't wait on the otherwise-forever poll loop. TTY users still
+ // get continuous streaming.
+ const exitOnFirstMatch = process.stdout.isTTY !== true;
+
+ const result = await runLogLoop({
+ initialResult,
+ read: readClipboardPayload,
+ write: (line) => {
+ process.stdout.write(`${line}\n`);
+ },
+ appendToFile,
+ exitOnFirstMatch,
+ });
+ if (result.outcome === "fail") return fail(result.message, result.exitCode);
+ // outcome: "ok" - just return; Node drains stdout/stderr and exits 0.
+ } 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;
+ }
+ });
diff --git a/packages/cli/src/commands/remove.ts b/packages/cli/src/commands/remove.ts
index 0751e73d6..3d8275562 100644
--- a/packages/cli/src/commands/remove.ts
+++ b/packages/cli/src/commands/remove.ts
@@ -1,53 +1,133 @@
import { Command } from "commander";
import pc from "picocolors";
-import { detectProject } from "../utils/detect.js";
+import { findNearestProjectRoot } 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 {
+ getSupportedSkillClientNames,
+ isSkillScope,
+ removeSkills,
+ SKILL_SCOPES,
+ type SkillScope,
+} from "../utils/install-skill.js";
const VERSION = process.env.VERSION ?? "0.0.1";
+interface RemoveCommandOptions {
+ yes?: boolean;
+ agent?: string[];
+ scope?: string;
+ cwd: string;
+}
+
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 = getSupportedSkillClientNames();
- 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 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);
+ }
+ 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.",
+ // 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
+ // `/.agents/skills/...` location instead of silently
+ // reporting "Nothing to remove."
+ const projectCwd = findNearestProjectRoot(opts.cwd);
+ const aggregated = scopesToTry.flatMap((scope) =>
+ removeSkills({ scope, cwd: projectCwd, selectedClients: targets }).map((result) => ({
+ ...result,
+ scope,
+ })),
);
+
+ logger.break();
+ let sharedSkipCount = 0;
+ 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)`)}`,
+ );
+ } else if (result.sharedWith && result.sharedWith.length > 0) {
+ sharedSkipCount += 1;
+ logger.log(
+ ` ${highlighter.dim("\u2212")} ${result.client} ${highlighter.dim(`(${result.scope}, kept: still used by ${result.sharedWith.join(", ")})`)}`,
+ );
+ }
+ }
+ const removedAny = aggregated.some((result) => result.removed);
+ if (!removedAny) {
+ if (sharedSkipCount > 0) {
+ logger.warn(
+ "Nothing removed: every targeted agent shares its skill file with another agent that wasn't targeted.",
+ );
+ logger.log(
+ `Pass ${highlighter.info("--agent")} for every agent sharing the file, or remove all agents at once.`,
+ );
+ } else {
+ logger.log("Nothing to remove.");
+ }
+ }
logger.break();
} catch (error) {
handleError(error);
diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts
index 0d6e85f61..1299039d3 100644
--- a/packages/cli/src/utils/constants.ts
+++ b/packages/cli/src/utils/constants.ts
@@ -1,3 +1,43 @@
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 CLIPBOARD_POLL_INTERVAL_MS = 250;
+export const NPM_PACKAGE_NAME = "@react-grab/cli";
+export const SKILL_NAME = "react-grab";
+export const PROJECT_REACT_GRAB_DIR = ".react-grab";
+export const PROJECT_LOG_FILE_NAME = "logs";
+export const PROJECT_LOG_GITIGNORE_CONTENT = "logs\n";
+
+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";
+
+// Chromium serializes web-custom-format clipboard data with 4-byte alignment.
+export const CHROMIUM_PICKLE_ALIGNMENT_BYTES = 4;
+// Defensive upper bound for entry count to keep a malicious / corrupt pickle
+// from making the decoder loop for a long time.
+export const MAX_CHROMIUM_PICKLE_ENTRIES = 1024;
+// Sentinel emitted by the macOS JXA bridge when it dumps a Chromium-family
+// web-custom-data pasteboard entry as base64 for the Node side to decode.
+// Wraps the marker in 0x01 / 0x02 control bytes so it can never collide with
+// a direct-path payload (valid JSON cannot start with control bytes).
+export const CHROMIUM_PICKLE_SENTINEL = "\u0001CHROMIUM_PICKLE_B64\u0002";
+
+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/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..6b552e70f
--- /dev/null
+++ b/packages/cli/src/utils/decode-chromium-web-custom-data.ts
@@ -0,0 +1,58 @@
+import { CHROMIUM_PICKLE_ALIGNMENT_BYTES, MAX_CHROMIUM_PICKLE_ENTRIES } from "./constants.js";
+
+// Chromium-family browsers (Chrome, Edge, Cursor, Electron) and WebKit
+// (Safari) on macOS bundle web-custom-format clipboard data into a single
+// pasteboard entry with this layout:
+//
+// 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
+//
+// Chromium's source-of-truth is `ui/base/clipboard/clipboard_format_type_mac.mm`
+// (uses `base::Pickle`). WebKit's exact layout under
+// `org.webkit.web-custom-data` was not verified against a real Safari
+// clipboard at the time of writing - the same parser is reused on a
+// best-effort basis and returns null cleanly if the format differs.
+
+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;
+ if (entryCount > MAX_CHROMIUM_PICKLE_ENTRIES) return null;
+
+ 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, CHROMIUM_PICKLE_ALIGNMENT_BYTES);
+
+ 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, CHROMIUM_PICKLE_ALIGNMENT_BYTES);
+
+ if (mime === targetMime) return data;
+ }
+
+ return null;
+};
diff --git a/packages/cli/src/utils/detect-clipboard-env.ts b/packages/cli/src/utils/detect-clipboard-env.ts
new file mode 100644
index 000000000..d4a6e210e
--- /dev/null
+++ b/packages/cli/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/cli/src/utils/detect.ts b/packages/cli/src/utils/detect.ts
index 71482b35c..28bf1f922 100644
--- a/packages/cli/src/utils/detect.ts
+++ b/packages/cli/src/utils/detect.ts
@@ -1,5 +1,5 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
-import { basename, dirname, join } from "node:path";
+import { basename, dirname, join, resolve } from "node:path";
import { detect } from "@antfu/ni";
import ignore from "ignore";
@@ -347,12 +347,14 @@ const hasReactGrabInFile = (filePath: string): boolean => {
}
};
-export const detectReactGrab = (projectRoot: string): boolean => {
+const detectReactGrabAt = (projectRoot: string): boolean => {
const packageJsonPath = join(projectRoot, "package.json");
if (existsSync(packageJsonPath)) {
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
+ // Dogfood: working inside the react-grab source repo.
+ if (packageJson.name === "react-grab") return true;
const allDependencies = {
...packageJson.dependencies,
...packageJson.devDependencies,
@@ -389,6 +391,22 @@ export const detectReactGrab = (projectRoot: string): boolean => {
return filesToCheck.some(hasReactGrabInFile);
};
+export const detectReactGrab = (projectRoot: string): boolean => {
+ if (detectReactGrabAt(projectRoot)) return true;
+ if (!detectMonorepo(projectRoot)) return false;
+
+ // Monorepo roots often have no react-grab dep themselves while one of
+ // the workspace packages does (or IS react-grab). Without this walk the
+ // preflight misfires from anywhere inside such a repo.
+ for (const pattern of getWorkspacePatterns(projectRoot)) {
+ for (const workspacePath of expandWorkspacePattern(projectRoot, pattern)) {
+ if (detectReactGrabAt(workspacePath)) return true;
+ }
+ }
+
+ return false;
+};
+
export const detectUnsupportedFramework = (projectRoot: string): UnsupportedFramework => {
const packageJsonPath = join(projectRoot, "package.json");
@@ -436,6 +454,72 @@ const detectReactGrabVersion = (projectRoot: string): string | null => {
return null;
};
+// Mirror detectMonorepo's permissive "any truthy `workspaces` counts" rule so
+// the two helpers can never disagree on whether a directory is a workspace
+// root: arrays (npm/yarn classic), `{packages: [...]}` objects (yarn berry),
+// and exotic shapes other tools accept all qualify. Empty arrays /
+// empty-packages objects also count - the user explicitly opted in by
+// setting the field at all. Aligning with detectMonorepo keeps
+// findNearestProjectRoot from returning a different root than detectMonorepo
+// would imply for the same project.
+const hasWorkspacesField = (dir: string): boolean => {
+ const packageJsonPath = join(dir, "package.json");
+ if (!existsSync(packageJsonPath)) return false;
+ try {
+ const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
+ return Boolean(pkg.workspaces);
+ } catch {
+ return false;
+ }
+};
+
+const isWorkspaceRoot = (dir: string): boolean => {
+ if (existsSync(join(dir, "pnpm-workspace.yaml"))) return true;
+ if (existsSync(join(dir, "lerna.json"))) return true;
+ if (hasWorkspacesField(dir)) return true;
+ return false;
+};
+
+// Walks up from `start` looking for the nearest project root for skill
+// installation. Used so commands like `install-skill`, `remove`, and `add`
+// can be invoked from any subdirectory inside a project and still resolve
+// the canonical install location (`/.agents/skills/...`).
+//
+// Resolution priority:
+// 1. The outermost ancestor that is a workspace root (pnpm-workspace.yaml,
+// lerna.json, or package.json with a non-empty `workspaces` field).
+// This handles monorepos where editor agents read from the repo root,
+// not the workspace package the user happens to be inside.
+// 2. The nearest ancestor with a plain `package.json`.
+// 3. `start` itself, as a last-resort fallback.
+//
+// Capped at 64 levels of walking so a malformed cwd can't loop forever.
+export const findNearestProjectRoot = (start: string): string => {
+ const resolvedStart = resolve(start);
+ let dir = resolvedStart;
+ let firstWithPackageJson: string | null = null;
+ let outermostWorkspaceRoot: string | null = null;
+
+ for (let depth = 0; depth < 64; depth += 1) {
+ if (existsSync(join(dir, "package.json"))) {
+ if (firstWithPackageJson === null) firstWithPackageJson = dir;
+ if (isWorkspaceRoot(dir)) outermostWorkspaceRoot = dir;
+ }
+ const parent = dirname(dir);
+ if (parent === dir) break;
+ dir = parent;
+ }
+
+ if (outermostWorkspaceRoot !== null) return outermostWorkspaceRoot;
+ if (firstWithPackageJson !== null) return firstWithPackageJson;
+ // 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 => {
const framework = detectFramework(projectRoot);
const packageManager = await detectPackageManager(projectRoot);
diff --git a/packages/cli/src/utils/extract-prompt-and-content.ts b/packages/cli/src/utils/extract-prompt-and-content.ts
new file mode 100644
index 000000000..a1f509f41
--- /dev/null
+++ b/packages/cli/src/utils/extract-prompt-and-content.ts
@@ -0,0 +1,48 @@
+import type { ReactGrabPayload } from "./parse-react-grab-payload.js";
+
+export interface ExtractedPromptAndContent {
+ prompt?: string;
+ content: string;
+}
+
+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 extractPromptAndContent = (payload: ReactGrabPayload): ExtractedPromptAndContent => {
+ // The producer assigns the prompt to every entry's commentText, so dedupe
+ // via a Set to surface a single prompt 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 consumers.
+ 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 content = stripLeadingPromptPrefix(payload.content, rawPrompts);
+ if (trimmedPrompts.length === 0) return { content };
+ return { prompt: trimmedPrompts.join("\n"), content };
+};
diff --git a/packages/cli/src/utils/format-payload.ts b/packages/cli/src/utils/format-payload.ts
new file mode 100644
index 000000000..b1ae089b2
--- /dev/null
+++ b/packages/cli/src/utils/format-payload.ts
@@ -0,0 +1,8 @@
+import { extractPromptAndContent } from "./extract-prompt-and-content.js";
+import type { ReactGrabPayload } from "./parse-react-grab-payload.js";
+
+export const formatPayload = (payload: ReactGrabPayload): string => {
+ const { prompt, content } = extractPromptAndContent(payload);
+ const elementsSection = `Elements (${payload.entries.length}):\n${content}`;
+ return prompt !== undefined ? `Prompt: ${prompt}\n\n${elementsSection}` : elementsSection;
+};
diff --git a/packages/cli/src/utils/has-error-code.ts b/packages/cli/src/utils/has-error-code.ts
new file mode 100644
index 000000000..c404962a6
--- /dev/null
+++ b/packages/cli/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/cli/src/utils/install-mcp.ts b/packages/cli/src/utils/install-mcp.ts
deleted file mode 100644
index ed9d9e591..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, "--stdio"],
- };
-
- 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, "--stdio"],
- },
- },
- {
- 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..950f9771e
--- /dev/null
+++ b/packages/cli/src/utils/install-skill.ts
@@ -0,0 +1,446 @@
+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 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;
+ 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;
+ sharedWith?: string[];
+}
+
+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"))),
+ // 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: "Amp",
+ universal: false,
+ 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,
+ 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 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.",
+ ),
+ ];
+};
+
+export const getSkillClientNames = (): string[] => getSkillClients().map((client) => client.name);
+
+export const getSupportedSkillClientNames = (): string[] =>
+ getSkillClients()
+ .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())
+ .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 skillFileExists = (skillRoot: string): boolean => {
+ const skillDirectory = path.join(skillRoot, SKILL_NAME);
+ return fs.existsSync(skillDirectory);
+};
+
+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 if (successCount === 0) {
+ // Every selected client was skipped (e.g. unsupported / no install
+ // location). A green "Installed to 0 agents." spinner would contradict
+ // the per-client "skipped" lines printed below and the eventual non-zero
+ // exit, so flag it as a failure up front.
+ installSpinner.fail(
+ skippedCount > 0 ? `No agents installed (${skippedCount} skipped).` : "No agents installed.",
+ );
+ } 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(readKnownLastSelectedAgents());
+ 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 type SkillInstallOutcome = "cancelled" | "succeeded" | "failed";
+
+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 "cancelled";
+ }
+
+ // 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 = readKnownLastSelectedAgents();
+ 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);
+ // Don't persist when the user didn't make an active choice. The
+ // auto-route branch routes to the only detected agent without a
+ // multiselect; persisting would silently restrict every future
+ // interactive run to that single agent (see install-skill.ts which
+ // skips persistence in its symmetrical auto-route branch for the
+ // same reason).
+ return ok ? "succeeded" : "failed";
+ }
+ }
+
+ 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 "cancelled";
+ }
+
+ logger.break();
+ const results = installSkills({ scope, cwd, selectedClients: selectedAgents });
+ const ok = results.some((result) => result.success);
+ if (ok) writeLastSelectedAgents(selectedAgents);
+ return ok ? "succeeded" : "failed";
+};
+
+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 supportedClients = getSkillClients().filter((client) => client.supported);
+ const targetedClients = filterClientsByName(supportedClients, selectedClients);
+ const targetedNameSet = new Set(targetedClients.map((client) => client.name));
+
+ // Build a map from skillRoot -> all supported clients that share it. We
+ // need this so we don't blow away the shared canonical .agents/skills file
+ // when the user only asked to remove one of the agents using it.
+ const rootToClients = new Map();
+ for (const client of supportedClients) {
+ const root = resolveSkillRoot(client, scope, cwd);
+ if (root === null) continue;
+ const sharers = rootToClients.get(root) ?? [];
+ sharers.push(client);
+ rootToClients.set(root, sharers);
+ }
+
+ const removedRoots = new Set();
+ return targetedClients.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 };
+ }
+ // If nothing is actually installed at this root, don't dress up the
+ // result with a misleading "kept: still used by ..." note - the file
+ // doesn't exist, so there's nothing to keep. Fall through to the plain
+ // "Nothing to remove." branch in the caller.
+ const fileExists = skillFileExists(skillRoot);
+ const sharers = rootToClients.get(skillRoot) ?? [];
+ const stillUsing = sharers
+ .filter((sharer) => !targetedNameSet.has(sharer.name))
+ .map((sharer) => sharer.name);
+ if (fileExists && stillUsing.length > 0) {
+ // Refuse to remove a file other (un-targeted) agents are still relying
+ // on. The user can opt back in by also targeting those agents.
+ return {
+ client: client.name,
+ skillRoot,
+ removed: false,
+ sharedWith: stillUsing,
+ };
+ }
+ 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..2dc822ba5
--- /dev/null
+++ b/packages/cli/src/utils/last-selected-agents.ts
@@ -0,0 +1,54 @@
+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();
+ // 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);
+};
+
+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/cli/src/utils/parse-react-grab-payload.ts b/packages/cli/src/utils/parse-react-grab-payload.ts
new file mode 100644
index 000000000..7431f515d
--- /dev/null
+++ b/packages/cli/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/cli/src/utils/read-clipboard-linux.ts b/packages/cli/src/utils/read-clipboard-linux.ts
new file mode 100644
index 000000000..69b3c4fe2
--- /dev/null
+++ b/packages/cli/src/utils/read-clipboard-linux.ts
@@ -0,0 +1,73 @@
+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 };
+ }
+};
+
+// Restrict the "binary missing" detection to ENOENT only. The previous
+// /not found/i regex over `error.message` over-matched runtime stderr -
+// `wl-paste` exits non-zero with messages like "No data found of type X" /
+// "No selection" when the requested MIME just isn't on the clipboard right
+// now, which would incorrectly route to the X11 fallback (or surface the
+// misleading "install xclip" hint on Wayland-only systems).
+const isBinaryMissing = (caughtError: unknown): boolean => hasErrorCode(caughtError, "ENOENT");
+
+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) };
+ }
+ // 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", [
+ "-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, 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
new file mode 100644
index 000000000..ded055db2
--- /dev/null
+++ b/packages/cli/src/utils/read-clipboard-macos.ts
@@ -0,0 +1,93 @@
+import {
+ CHROMIUM_PICKLE_SENTINEL,
+ 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";
+
+// Chromium-family browsers (Chrome, Edge, Cursor, Electron) 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 'org.chromium.web-custom-data' rather than
+// exposing the raw MIME ('application/x-react-grab') directly. A naive
+// dataForType lookup returns nil and we'd never find the payload.
+//
+// 'org.webkit.web-custom-data' is included on a best-effort basis for Safari.
+// Its exact binary layout was not verified at the time of writing; if Safari
+// uses a different format the decoder will return null cleanly and the
+// polling loop will fall through to a normal "no payload" iteration.
+//
+// JXA emits one of three forms to stdout for the Node side:
+// - empty string: nothing on the clipboard
+// - : direct read succeeded (Firefox / Safari direct exposure
+// or a future browser change)
+// - : the pasteboard exposed
+// web-custom-data; Node decodes the base::Pickle and extracts our entry.
+//
+// The sentinel uses 0x01 and 0x02 control bytes so it cannot collide with
+// any direct-path payload (valid JSON cannot start with control bytes, and
+// parseReactGrabPayload validates JSON shape downstream).
+// JXA's Objective-C bridge in macOS Big Sur+ (the floor for `osascript -l
+// JavaScript`) exposes Foundation selectors using camelCase for both
+// single- and multi-argument forms (no trailing underscore for the final
+// colon). The "underscore-per-colon" convention is documented in older
+// Apple references but breaks at runtime on current macOS:
+// `chromium.base64EncodedStringWithOptions_(0)` raises `is not a function`
+// and `NSString.alloc.initWithData_encoding_(...)` returns an empty string,
+// while the camelCase forms below are verified working on the macOS
+// versions we ship to.
+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 '${CHROMIUM_PICKLE_SENTINEL}' + ObjC.unwrap(chromium.base64EncodedStringWithOptions(0));
+ }
+ var webkit = pb.dataForType('org.webkit.web-custom-data');
+ if (!webkit.isNil()) {
+ return '${CHROMIUM_PICKLE_SENTINEL}' + ObjC.unwrap(webkit.base64EncodedStringWithOptions(0));
+ }
+ return '';
+})()`;
+
+const decodeJxaOutput = (raw: string): string | null => {
+ if (raw.length === 0) return null;
+ if (raw.startsWith(CHROMIUM_PICKLE_SENTINEL)) {
+ const base64Pickle = raw.slice(CHROMIUM_PICKLE_SENTINEL.length);
+ const buffer = Buffer.from(base64Pickle, "base64");
+ return decodeChromiumWebCustomData(buffer, REACT_GRAB_MIME_TYPE);
+ }
+ return raw;
+};
+
+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 payload = decodeJxaOutput(stdout.trimEnd());
+ return { payload };
+ } catch (caughtError) {
+ surfaceStderr("osascript", caughtError);
+ if (hasErrorCode(caughtError, "ENOENT")) {
+ return {
+ payload: null,
+ hint: "macOS requires `osascript` (preinstalled). Check $PATH.",
+ recoverable: false,
+ };
+ }
+ return { payload: null };
+ }
+};
diff --git a/packages/cli/src/utils/read-clipboard-outcome.ts b/packages/cli/src/utils/read-clipboard-outcome.ts
new file mode 100644
index 000000000..9546ef698
--- /dev/null
+++ b/packages/cli/src/utils/read-clipboard-outcome.ts
@@ -0,0 +1,5 @@
+export interface ClipboardReadOutcome {
+ payload: string | null;
+ hint?: string;
+ recoverable?: boolean;
+}
diff --git a/packages/cli/src/utils/read-clipboard-payload.ts b/packages/cli/src/utils/read-clipboard-payload.ts
new file mode 100644
index 000000000..d5a5dad28
--- /dev/null
+++ b/packages/cli/src/utils/read-clipboard-payload.ts
@@ -0,0 +1,61 @@
+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;
+ recoverable: boolean;
+ // True iff the platform clipboard reader returned a non-empty raw string,
+ // regardless of whether parseReactGrabPayload then accepted it. Lets the
+ // log loop distinguish a genuinely empty clipboard from a transient
+ // parse failure where a real React Grab payload sits on the clipboard
+ // but the reader returned partial / corrupt output - critical so we
+ // don't return a stale grab as if it were fresh.
+ rawPayloadPresent: boolean;
+}
+
+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 log` 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,
+ };
+ }
+ }
+};
+
+export const readClipboardPayload = async (): Promise => {
+ const env = detectClipboardEnv();
+ const outcome = await readRawByEnv(env);
+ const rawPayloadPresent = typeof outcome.payload === "string" && outcome.payload.length > 0;
+ return {
+ env,
+ payload: parseReactGrabPayload(outcome.payload),
+ hint: outcome.hint,
+ recoverable: outcome.recoverable !== false,
+ rawPayloadPresent,
+ };
+};
diff --git a/packages/cli/src/utils/read-clipboard-windows.ts b/packages/cli/src/utils/read-clipboard-windows.ts
new file mode 100644
index 000000000..480d6c0cf
--- /dev/null
+++ b/packages/cli/src/utils/read-clipboard-windows.ts
@@ -0,0 +1,68 @@
+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}')
+ # 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[]]) {
+ [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())
+ }
+} 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.`,
+ recoverable: false,
+ };
+ }
+ return { payload: null };
+ }
+};
+
+export const readClipboardWindows = (): Promise =>
+ readClipboardViaWindowsPowerShell("powershell.exe");
diff --git a/packages/cli/src/utils/read-clipboard-wsl.ts b/packages/cli/src/utils/read-clipboard-wsl.ts
new file mode 100644
index 000000000..7696a66b9
--- /dev/null
+++ b/packages/cli/src/utils/read-clipboard-wsl.ts
@@ -0,0 +1,50 @@
+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 log` 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;
+};
+
+// Cheap check that a clipboard payload at least *looks* like a JSON object,
+// so we don't fast-return host garbage (partial write, unrelated app putting
+// custom data on the same MIME) and prevent the WSLg fallback from running.
+// The downstream parser still validates against the React Grab schema.
+const looksLikeJsonObject = (value: string): boolean => value.trimStart().startsWith("{");
+
+export const readClipboardWsl = async (): Promise => {
+ const hostOutcome = await readClipboardViaWindowsPowerShell("powershell.exe");
+ if (hostOutcome.payload !== null && looksLikeJsonObject(hostOutcome.payload)) {
+ return hostOutcome;
+ }
+
+ const wslgOutcome = await readClipboardLinux();
+ if (wslgOutcome.payload !== null && looksLikeJsonObject(wslgOutcome.payload)) {
+ return wslgOutcome;
+ }
+ // If only one channel produced something (even garbage), prefer surfacing
+ // it over `null` so the parser can emit its own diagnostic. Host wins ties.
+ if (hostOutcome.payload !== null) return hostOutcome;
+ if (wslgOutcome.payload !== null) return wslgOutcome;
+
+ 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. 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(hostOutcome.hint, WSL_INTEROP_HINT, wslgOutcome.hint),
+ recoverable: !bothChannelsUnrecoverable,
+ };
+ }
+ return wslgOutcome;
+};
diff --git a/packages/cli/src/utils/resolve-log-file-sink-location.ts b/packages/cli/src/utils/resolve-log-file-sink-location.ts
new file mode 100644
index 000000000..d7b2f9fcf
--- /dev/null
+++ b/packages/cli/src/utils/resolve-log-file-sink-location.ts
@@ -0,0 +1,17 @@
+import path from "node:path";
+import { PROJECT_LOG_FILE_NAME, PROJECT_REACT_GRAB_DIR } from "./constants.js";
+
+export interface LogFileSinkLocation {
+ dir: string;
+ logPath: string;
+ gitignorePath: string;
+}
+
+export const resolveLogFileSinkLocation = (cwd: string): LogFileSinkLocation => {
+ const dir = path.join(cwd, PROJECT_REACT_GRAB_DIR);
+ return {
+ dir,
+ logPath: path.join(dir, PROJECT_LOG_FILE_NAME),
+ gitignorePath: path.join(dir, ".gitignore"),
+ };
+};
diff --git a/packages/cli/src/utils/run-exec-file.ts b/packages/cli/src/utils/run-exec-file.ts
new file mode 100644
index 000000000..21a6eb023
--- /dev/null
+++ b/packages/cli/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/cli/src/utils/run-log-loop.ts b/packages/cli/src/utils/run-log-loop.ts
new file mode 100644
index 000000000..376c6d746
--- /dev/null
+++ b/packages/cli/src/utils/run-log-loop.ts
@@ -0,0 +1,102 @@
+import { CLIPBOARD_POLL_INTERVAL_MS } from "./constants.js";
+import { extractPromptAndContent } from "./extract-prompt-and-content.js";
+import type { ReadClipboardPayloadResult } from "./read-clipboard-payload.js";
+import { waitForNextGrab } from "./wait-for-next-grab.js";
+
+export interface RunLogLoopOptions {
+ initialResult: ReadClipboardPayloadResult;
+ read: () => Promise;
+ write: (line: string) => void;
+ appendToFile?: (line: string) => void;
+ // When true, the loop returns `{outcome: "ok"}` after emitting the first
+ // match instead of streaming forever. Used in piped mode (`log | head -n 1`)
+ // so the upstream pipeline doesn't wait on log's poll loop after the
+ // consumer closes the read side.
+ exitOnFirstMatch?: boolean;
+ signal?: AbortSignal;
+ getCurrentMs?: () => number;
+ sleepMs?: (durationMs: number) => Promise;
+}
+
+export interface RunLogLoopOk {
+ outcome: "ok";
+}
+
+export interface RunLogLoopFail {
+ outcome: "fail";
+ message: string;
+ exitCode: number;
+}
+
+export type RunLogLoopExit = RunLogLoopOk | RunLogLoopFail;
+
+const formatUnrecoverableMessage = (result: ReadClipboardPayloadResult): string =>
+ result.hint ?? `Clipboard channel is unavailable in this environment (${result.env}).`;
+
+export const runLogLoop = async (options: RunLogLoopOptions): Promise => {
+ if (!options.initialResult.recoverable) {
+ return {
+ outcome: "fail",
+ message: formatUnrecoverableMessage(options.initialResult),
+ exitCode: 2,
+ };
+ }
+
+ let baselineTimestamp = options.initialResult.payload?.timestamp ?? null;
+ let baselineRawPresent = options.initialResult.rawPayloadPresent;
+
+ while (true) {
+ const waitResult = await waitForNextGrab({
+ initialTimestamp: baselineTimestamp,
+ initialRawPayloadPresent: baselineRawPresent,
+ // 0 = forever. The log loop deliberately has no idle timeout so a
+ // user idling between grabs (sometimes for hours, e.g. running through
+ // a backlog) doesn't see the daemon quietly exit.
+ timeoutMs: 0,
+ pollIntervalMs: CLIPBOARD_POLL_INTERVAL_MS,
+ read: options.read,
+ signal: options.signal,
+ getCurrentMs: options.getCurrentMs,
+ sleepMs: options.sleepMs,
+ });
+
+ switch (waitResult.outcome) {
+ case "match": {
+ const line = JSON.stringify(extractPromptAndContent(waitResult.payload));
+ options.write(line);
+ options.appendToFile?.(line);
+ if (options.exitOnFirstMatch) return { outcome: "ok" };
+ baselineTimestamp = waitResult.payload.timestamp;
+ baselineRawPresent = true;
+ break;
+ }
+ case "unrecoverable":
+ return {
+ outcome: "fail",
+ message: formatUnrecoverableMessage(waitResult.result),
+ exitCode: 2,
+ };
+ case "aborted":
+ return {
+ outcome: "fail",
+ message: "Aborted before a new React Grab payload arrived.",
+ exitCode: 1,
+ };
+ case "timeout":
+ // Unreachable: timeoutMs is 0 (forever). Kept for exhaustiveness.
+ return {
+ outcome: "fail",
+ message: "Unexpected idle timeout in log loop.",
+ exitCode: 2,
+ };
+ default: {
+ const exhaustive: never = waitResult;
+ return {
+ outcome: "fail",
+ message: `Unhandled log outcome: ${JSON.stringify(exhaustive)}`,
+ exitCode: 2,
+ };
+ }
+ }
+ }
+};
diff --git a/packages/cli/src/utils/setup-log-file-sink.ts b/packages/cli/src/utils/setup-log-file-sink.ts
new file mode 100644
index 000000000..c4deae960
--- /dev/null
+++ b/packages/cli/src/utils/setup-log-file-sink.ts
@@ -0,0 +1,75 @@
+import fs from "node:fs";
+import { PROJECT_LOG_GITIGNORE_CONTENT } from "./constants.js";
+import { resolveLogFileSinkLocation } from "./resolve-log-file-sink-location.js";
+
+export interface LogFileSink {
+ append: (line: string) => void;
+ path: string;
+}
+
+export interface LogFileSinkSetupOk {
+ outcome: "ok";
+ sink: LogFileSink;
+}
+
+export interface LogFileSinkSetupSkipped {
+ outcome: "skipped";
+ reason: string;
+}
+
+export type LogFileSinkSetup = LogFileSinkSetupOk | LogFileSinkSetupSkipped;
+
+const errorReason = (caughtError: unknown): string =>
+ caughtError instanceof Error ? caughtError.message : String(caughtError);
+
+export const setupLogFileSink = (cwd: string = process.cwd()): LogFileSinkSetup => {
+ const location = resolveLogFileSinkLocation(cwd);
+ try {
+ fs.mkdirSync(location.dir, { recursive: true });
+ } catch (caughtError) {
+ return {
+ outcome: "skipped",
+ reason: `Could not create ${location.dir}: ${errorReason(caughtError)}`,
+ };
+ }
+
+ // Best-effort gitignore so the log file never lands in version control. We
+ // only write if missing - never overwrite a user-curated gitignore, even
+ // if its contents already happen to match what we would write.
+ if (!fs.existsSync(location.gitignorePath)) {
+ try {
+ fs.writeFileSync(location.gitignorePath, PROJECT_LOG_GITIGNORE_CONTENT, "utf8");
+ } catch {
+ // Non-fatal: the directory exists, the log file will still work, the
+ // user just gets a directory git might pick up. Surface nothing.
+ }
+ }
+
+ // Probe writability up front so a read-only / unwritable parent surfaces
+ // as `skipped` with a usable hint instead of a silent broken sink. The
+ // empty append creates the file if missing without writing any bytes.
+ try {
+ fs.appendFileSync(location.logPath, "");
+ } catch (caughtError) {
+ return {
+ outcome: "skipped",
+ reason: `Could not open ${location.logPath} for append: ${errorReason(caughtError)}`,
+ };
+ }
+
+ return {
+ outcome: "ok",
+ sink: {
+ path: location.logPath,
+ append: (line: string) => {
+ try {
+ fs.appendFileSync(location.logPath, `${line}\n`);
+ } catch {
+ // Best-effort: the file mirror exists for resilience, not durable
+ // bookkeeping. A transient ENOSPC / EROFS / EBADF must not take
+ // down the daemon - stdout consumers still receive the line.
+ }
+ },
+ },
+ };
+};
diff --git a/packages/cli/src/utils/skill-template.md b/packages/cli/src/utils/skill-template.md
new file mode 100644
index 000000000..d15385eef
--- /dev/null
+++ b/packages/cli/src/utils/skill-template.md
@@ -0,0 +1,49 @@
+---
+name: react-grab
+description: >-
+ Use when the user invokes /react-grab or refers to "this", "that", or
+ "the thing/element/component I just clicked/grabbed". If toolbar output
+ is already pasted in the chat, use it directly - do NOT run this skill
+ (it would block waiting for clipboard data that never arrives).
+allowed-tools:
+ - Bash
+---
+
+# React Grab
+
+## Preflight
+
+Run **once** before the loop:
+
+```bash
+npx -y @react-grab/cli check-installed
+```
+
+Exit 0 → installed, continue. Exit 1 → not installed; ask the user "React Grab isn't in this project — want me to run `npx grab@latest init` to set it up?" and only proceed once they confirm and `init` finishes.
+
+## Loop
+
+Repeat until the user says they're done.
+
+1. **Prompt.** "Click an element in the React Grab toolbar (or paste its output here) and I'll pick it up." Don't start step 2 silently.
+2. **Read one grab.** Once per iteration:
+ ```bash
+ npx -y @react-grab/cli log | head -n 1
+ ```
+ Stdout is one line of NDJSON: `{"prompt":"...","content":"..."}` (`prompt` is omitted if the user didn't type one). Parse it.
+3. **Ask** what to do — skip if the parsed JSON has a non-empty `prompt`.
+4. **Do it** against `content` only.
+5. **Offer another:** "Grab another, or done?" Yes → step 1. No → end.
+
+## Failure modes (surface stderr verbatim)
+
+- Exit 2 SSH → run agent on same machine as browser.
+- Linux/WSL clipboard hint → pass install/interop instructions through.
+- No idle timeout: `log` does not exit on its own when piped. If the user never clicks, the `head -n 1` stays blocked until your bash tool times out. Surface that as "still waiting for a click" rather than retrying.
+
+## Constraints
+
+- One `log` per iteration, never concurrent.
+- Never fabricate element details. `log` failed? Ask, don't guess.
+- Step 1 always before step 2.
+- Finish step 4 before step 2 again.
diff --git a/packages/cli/src/utils/skill-template.ts b/packages/cli/src/utils/skill-template.ts
new file mode 100644
index 000000000..a8bd42aa0
--- /dev/null
+++ b/packages/cli/src/utils/skill-template.ts
@@ -0,0 +1,7 @@
+// `__REACT_GRAB_SKILL_TEMPLATE__` is replaced at build time by the contents of
+// `skill-template.md` (see `vite.config.ts`). The same markdown is symlinked
+// to the repo's top-level `skills/react-grab/SKILL.md` so the GitHub-visible
+// copy and the bundled string can never drift apart.
+declare const __REACT_GRAB_SKILL_TEMPLATE__: string;
+
+export const SKILL_TEMPLATE: string = __REACT_GRAB_SKILL_TEMPLATE__;
diff --git a/packages/cli/src/utils/surface-stderr.ts b/packages/cli/src/utils/surface-stderr.ts
new file mode 100644
index 000000000..4808374af
--- /dev/null
+++ b/packages/cli/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] ${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..93f6e394a
--- /dev/null
+++ b/packages/cli/src/utils/wait-for-next-grab.ts
@@ -0,0 +1,88 @@
+import type { ReadClipboardPayloadResult } from "./read-clipboard-payload.js";
+import type { ReactGrabPayload } from "./parse-react-grab-payload.js";
+
+export interface WaitForNextGrabOptions {
+ initialTimestamp: number | null;
+ // True iff the initial read returned non-empty raw clipboard data, even
+ // if parseReactGrabPayload then rejected it. When true and
+ // initialTimestamp is null (parse failed on something that *was* there),
+ // the first successfully-parsed observation is treated as the new
+ // baseline rather than a match - otherwise a stale grab on the clipboard
+ // would be mistakenly returned as if it were a fresh selection.
+ initialRawPayloadPresent: boolean;
+ 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, initialRawPayloadPresent, 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;
+
+ // Mutable baseline. When initialTimestamp is null because the initial
+ // parse failed on a non-empty clipboard, the first successfully-parsed
+ // observation is adopted as the new baseline (instead of being returned
+ // as a "match") so we never surface a stale grab as fresh. When initial
+ // was genuinely empty (rawPayloadPresent false), the first non-null
+ // observation is a real match - the user clicked a grab after the
+ // polling loop started, which is what they want.
+ let baselineTimestamp = initialTimestamp;
+ let baselineLocked = initialTimestamp !== null || !initialRawPayloadPresent;
+
+ while (true) {
+ if (signal?.aborted) return { outcome: "aborted" };
+
+ const result = await read();
+ if (!result.recoverable) {
+ return { outcome: "unrecoverable", result };
+ }
+
+ if (result.payload) {
+ if (!baselineLocked) {
+ baselineTimestamp = result.payload.timestamp;
+ baselineLocked = true;
+ } else if (result.payload.timestamp !== baselineTimestamp) {
+ return { outcome: "match", result, payload: result.payload };
+ }
+ }
+
+ if (getCurrentMs() >= deadlineMs) return { outcome: "timeout" };
+ await sleepMs(pollIntervalMs);
+ }
+};
diff --git a/packages/cli/test/check-installed-cli.test.ts b/packages/cli/test/check-installed-cli.test.ts
new file mode 100644
index 000000000..2b7ccb8d9
--- /dev/null
+++ b/packages/cli/test/check-installed-cli.test.ts
@@ -0,0 +1,163 @@
+import { spawnSync } from "node:child_process";
+import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { afterEach, beforeEach, 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");
+
+interface RunResult {
+ status: number | null;
+ stdout: string;
+ stderr: string;
+}
+
+const runCheck = (cwd: string, args: string[] = []): RunResult | null => {
+ if (!existsSync(CLI_PATH)) return null;
+ const result = spawnSync(process.execPath, [CLI_PATH, "check-installed", "--cwd", cwd, ...args], {
+ encoding: "utf8",
+ timeout: 10_000,
+ });
+ return {
+ status: result.status,
+ stdout: String(result.stdout ?? ""),
+ stderr: String(result.stderr ?? ""),
+ };
+};
+
+let workDir: string;
+
+beforeEach(() => {
+ workDir = mkdtempSync(path.join(tmpdir(), "react-grab-check-"));
+});
+
+afterEach(() => {
+ rmSync(workDir, { recursive: true, force: true });
+});
+
+describe("react-grab check-installed CLI", () => {
+ it("exits 1 with stderr guidance when react-grab is not installed", () => {
+ writeFileSync(
+ path.join(workDir, "package.json"),
+ JSON.stringify({ name: "demo", dependencies: { react: "^18.0.0" } }),
+ );
+
+ const result = runCheck(workDir);
+ if (result === null) return;
+
+ expect(result.status).toBe(1);
+ expect(result.stderr).toContain("react-grab is not installed");
+ expect(result.stderr).toContain("npx grab@latest init");
+ });
+
+ it("exits 0 with stdout confirmation when react-grab is in dependencies", () => {
+ writeFileSync(
+ path.join(workDir, "package.json"),
+ JSON.stringify({ name: "demo", dependencies: { "react-grab": "^0.1.0" } }),
+ );
+
+ const result = runCheck(workDir);
+ if (result === null) return;
+
+ expect(result.status).toBe(0);
+ expect(result.stdout).toContain("react-grab is installed");
+ });
+
+ it("emits structured JSON under --json regardless of installation state", () => {
+ writeFileSync(
+ path.join(workDir, "package.json"),
+ JSON.stringify({ name: "demo", dependencies: { react: "^18.0.0" } }),
+ );
+
+ const result = runCheck(workDir, ["--json"]);
+ if (result === null) return;
+
+ expect(result.status).toBe(1);
+ const parsed = JSON.parse(result.stdout);
+ expect(parsed).toEqual({ installed: false, projectRoot: workDir, requestedCwd: workDir });
+ });
+
+ it("walks up from a subdirectory to find the project's package.json", () => {
+ writeFileSync(
+ path.join(workDir, "package.json"),
+ JSON.stringify({ name: "demo", dependencies: { "react-grab": "^0.1.0" } }),
+ );
+ const subdir = path.join(workDir, "packages", "ui", "src");
+ mkdirSync(subdir, { recursive: true });
+
+ const result = runCheck(subdir, ["--json"]);
+ if (result === null) return;
+
+ expect(result.status).toBe(0);
+ const parsed = JSON.parse(result.stdout);
+ expect(parsed.installed).toBe(true);
+ expect(parsed.projectRoot).toBe(path.resolve(workDir));
+ expect(parsed.requestedCwd).toBe(path.resolve(subdir));
+ });
+
+ it("treats the react-grab source repo as installed (workspace named react-grab)", () => {
+ writeFileSync(
+ path.join(workDir, "package.json"),
+ JSON.stringify({
+ name: "react-grab-monorepo",
+ private: true,
+ workspaces: ["packages/*"],
+ }),
+ );
+ const reactGrabPkgDir = path.join(workDir, "packages", "react-grab");
+ mkdirSync(reactGrabPkgDir, { recursive: true });
+ writeFileSync(
+ path.join(reactGrabPkgDir, "package.json"),
+ JSON.stringify({ name: "react-grab", version: "0.1.0" }),
+ );
+
+ const result = runCheck(workDir, ["--json"]);
+ if (result === null) return;
+
+ expect(result.status).toBe(0);
+ const parsed = JSON.parse(result.stdout);
+ expect(parsed.installed).toBe(true);
+ });
+
+ it("treats a monorepo with react-grab in a workspace package as installed", () => {
+ writeFileSync(
+ path.join(workDir, "package.json"),
+ JSON.stringify({
+ name: "demo-monorepo",
+ private: true,
+ workspaces: ["apps/*"],
+ }),
+ );
+ const appDir = path.join(workDir, "apps", "web");
+ mkdirSync(appDir, { recursive: true });
+ writeFileSync(
+ path.join(appDir, "package.json"),
+ JSON.stringify({ name: "web", dependencies: { "react-grab": "^0.1.0" } }),
+ );
+
+ const result = runCheck(workDir, ["--json"]);
+ if (result === null) return;
+
+ expect(result.status).toBe(0);
+ const parsed = JSON.parse(result.stdout);
+ expect(parsed.installed).toBe(true);
+ });
+
+ it("is reachable via the is-installed alias", () => {
+ if (!existsSync(CLI_PATH)) return;
+ writeFileSync(
+ path.join(workDir, "package.json"),
+ JSON.stringify({ name: "demo", dependencies: { "react-grab": "^0.1.0" } }),
+ );
+
+ const result = spawnSync(process.execPath, [CLI_PATH, "is-installed", "--cwd", workDir], {
+ encoding: "utf8",
+ timeout: 10_000,
+ });
+
+ expect(result.status).toBe(0);
+ expect(result.stdout).toContain("react-grab is installed");
+ });
+});
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..ad1f9a6c7
--- /dev/null
+++ b/packages/cli/test/decode-chromium-web-custom-data.test.ts
@@ -0,0 +1,114 @@
+import { describe, expect, it } from "vite-plus/test";
+import {
+ CHROMIUM_PICKLE_ALIGNMENT_BYTES,
+ MAX_CHROMIUM_PICKLE_ENTRIES,
+} from "../src/utils/constants.js";
+import { decodeChromiumWebCustomData } from "../src/utils/decode-chromium-web-custom-data.js";
+
+interface PickleEntry {
+ mime: string;
+ data: string;
+}
+
+const alignTo = (offset: number, alignment: number): number =>
+ (offset + alignment - 1) & ~(alignment - 1);
+
+const buildChromiumPickle = (entries: PickleEntry[]): Buffer => {
+ const headerBytes = 4;
+ 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");
+ payloadSize +=
+ 4 +
+ alignTo(mimeBytes, CHROMIUM_PICKLE_ALIGNMENT_BYTES) +
+ 4 +
+ alignTo(dataBytes, CHROMIUM_PICKLE_ALIGNMENT_BYTES);
+ }
+
+ 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 = alignTo(offset + mimeUtf16.length, CHROMIUM_PICKLE_ALIGNMENT_BYTES);
+
+ const dataUtf16 = Buffer.from(entry.data, "utf16le");
+ buffer.writeUInt32LE(entry.data.length, offset);
+ offset += 4;
+ dataUtf16.copy(buffer, offset);
+ offset = alignTo(offset + dataUtf16.length, CHROMIUM_PICKLE_ALIGNMENT_BYTES);
+ }
+ 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":[],"timestamp":1}';
+ const pickle = buildChromiumPickle([{ mime: "application/x-react-grab", data: json }]);
+ expect(decodeChromiumWebCustomData(pickle, "application/x-react-grab")).toBe(json);
+ });
+
+ it("returns null when the requested MIME type is not present", () => {
+ const pickle = buildChromiumPickle([{ mime: "text/plain", data: "hello" }]);
+ expect(decodeChromiumWebCustomData(pickle, "application/x-react-grab")).toBeNull();
+ });
+
+ it("scans past unrelated MIME entries to find the target", () => {
+ const target = '{"react-grab":true}';
+ const pickle = buildChromiumPickle([
+ { mime: "text/plain", data: "hello world" },
+ { mime: "application/x-react-grab", data: target },
+ { mime: "text/html", data: "hi
" },
+ ]);
+ expect(decodeChromiumWebCustomData(pickle, "application/x-react-grab")).toBe(target);
+ });
+
+ it("handles odd-length UTF-16 strings (non-multiple-of-4 byte counts) via alignment padding", () => {
+ const pickle = buildChromiumPickle([{ mime: "abc", data: "xyz" }]);
+ expect(decodeChromiumWebCustomData(pickle, "abc")).toBe("xyz");
+ });
+
+ it("returns null on truncated buffers (declared payload size exceeds buffer)", () => {
+ const valid = buildChromiumPickle([{ mime: "a", data: "b" }]);
+ const truncated = valid.subarray(0, valid.length - 4);
+ expect(decodeChromiumWebCustomData(truncated, "a")).toBeNull();
+ });
+
+ it("returns null when the declared payload size is larger than the buffer", () => {
+ const valid = buildChromiumPickle([{ mime: "a", data: "b" }]);
+ // Lie in the header: claim a much larger payload than what's actually here.
+ const lied = Buffer.from(valid);
+ lied.writeUInt32LE(0xffff, 0);
+ expect(decodeChromiumWebCustomData(lied, "a")).toBe("b");
+ });
+
+ it("rejects pickles claiming an entry count above MAX_CHROMIUM_PICKLE_ENTRIES", () => {
+ const buffer = Buffer.alloc(8);
+ buffer.writeUInt32LE(4, 0);
+ buffer.writeUInt32LE(MAX_CHROMIUM_PICKLE_ENTRIES + 1, 4);
+ expect(decodeChromiumWebCustomData(buffer, "application/x-react-grab")).toBeNull();
+ });
+
+ it("accepts a pickle right at the entry-count cap", () => {
+ const buffer = Buffer.alloc(8);
+ buffer.writeUInt32LE(4, 0);
+ buffer.writeUInt32LE(MAX_CHROMIUM_PICKLE_ENTRIES, 4);
+ // Exhausts the buffer before reading any entry; returns null but does not
+ // reject for being over the cap.
+ expect(decodeChromiumWebCustomData(buffer, "application/x-react-grab")).toBeNull();
+ });
+});
diff --git a/packages/cli/test/detect-clipboard-env.test.ts b/packages/cli/test/detect-clipboard-env.test.ts
new file mode 100644
index 000000000..26c4c6de4
--- /dev/null
+++ b/packages/cli/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/cli/test/detect.test.ts b/packages/cli/test/detect.test.ts
index 55fb58b60..c78419e91 100644
--- a/packages/cli/test/detect.test.ts
+++ b/packages/cli/test/detect.test.ts
@@ -223,6 +223,57 @@ describe("detectReactGrab", () => {
expect(detectReactGrab("/test")).toBe(false);
});
+
+ it("should treat a package whose own name is react-grab as installed", () => {
+ mockExistsSync.mockReturnValue(true);
+ mockReadFileSync.mockReturnValue(JSON.stringify({ name: "react-grab", version: "0.1.0" }));
+
+ expect(detectReactGrab("/test")).toBe(true);
+ });
+
+ it("should walk workspace packages to find react-grab in a monorepo", () => {
+ mockExistsSync.mockImplementation((checkedPath) => {
+ const stringPath = String(checkedPath);
+ return (
+ stringPath.endsWith("package.json") ||
+ stringPath.endsWith("/test/apps/web") ||
+ stringPath.endsWith("/test/apps/web/package.json")
+ );
+ });
+ mockReadFileSync.mockImplementation((readPath) => {
+ const stringPath = String(readPath);
+ if (stringPath.endsWith("/test/apps/web/package.json")) {
+ return JSON.stringify({ name: "web", dependencies: { "react-grab": "^0.1.0" } });
+ }
+ return JSON.stringify({ name: "demo", workspaces: ["apps/web"] });
+ });
+
+ expect(detectReactGrab("/test")).toBe(true);
+ });
+
+ it("should walk workspace packages to find a workspace named react-grab (source repo)", () => {
+ mockExistsSync.mockImplementation((checkedPath) => {
+ const stringPath = String(checkedPath);
+ return (
+ stringPath.endsWith("package.json") ||
+ stringPath.endsWith("/test/packages/react-grab") ||
+ stringPath.endsWith("/test/packages/react-grab/package.json")
+ );
+ });
+ mockReadFileSync.mockImplementation((readPath) => {
+ const stringPath = String(readPath);
+ if (stringPath.endsWith("/test/packages/react-grab/package.json")) {
+ return JSON.stringify({ name: "react-grab", version: "0.1.0" });
+ }
+ return JSON.stringify({
+ name: "react-grab-monorepo",
+ private: true,
+ workspaces: ["packages/react-grab"],
+ });
+ });
+
+ expect(detectReactGrab("/test")).toBe(true);
+ });
});
describe("detectMonorepo", () => {
diff --git a/packages/cli/test/extract-prompt-and-content.test.ts b/packages/cli/test/extract-prompt-and-content.test.ts
new file mode 100644
index 000000000..943649741
--- /dev/null
+++ b/packages/cli/test/extract-prompt-and-content.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from "vite-plus/test";
+import { extractPromptAndContent } from "../src/utils/extract-prompt-and-content.js";
+import type { ReactGrabPayload } from "../src/utils/parse-react-grab-payload.js";
+
+const buildPayload = (overrides: Partial = {}): ReactGrabPayload => ({
+ 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: "" });
+ expect(extractPromptAndContent(payload)).toEqual({ content: "" });
+ });
+
+ it("dedupes the prompt across entries (the producer copies it onto every entry)", () => {
+ const payload = buildPayload({
+ content: "Refactor\n\n[1] \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/find-nearest-project-root.test.ts b/packages/cli/test/find-nearest-project-root.test.ts
new file mode 100644
index 000000000..a58c747ad
--- /dev/null
+++ b/packages/cli/test/find-nearest-project-root.test.ts
@@ -0,0 +1,102 @@
+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 { findNearestProjectRoot } from "../src/utils/detect.js";
+
+let tempDir: string;
+
+beforeEach(() => {
+ tempDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "find-root-test-")));
+});
+
+afterEach(() => {
+ if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
+});
+
+describe("findNearestProjectRoot", () => {
+ it("returns the directory itself when it contains package.json", () => {
+ fs.writeFileSync(path.join(tempDir, "package.json"), "{}");
+ expect(findNearestProjectRoot(tempDir)).toBe(tempDir);
+ });
+
+ it("walks up from a subdirectory to the nearest package.json", () => {
+ fs.writeFileSync(path.join(tempDir, "package.json"), "{}");
+ const subdir = path.join(tempDir, "packages", "ui", "src", "components");
+ fs.mkdirSync(subdir, { recursive: true });
+ expect(findNearestProjectRoot(subdir)).toBe(tempDir);
+ });
+
+ it("returns the workspace root over a deeper package.json (pnpm-workspace.yaml)", () => {
+ fs.writeFileSync(path.join(tempDir, "package.json"), "{}");
+ fs.writeFileSync(path.join(tempDir, "pnpm-workspace.yaml"), "packages:\n - 'packages/*'\n");
+ const workspacePackage = path.join(tempDir, "packages", "ui");
+ fs.mkdirSync(workspacePackage, { recursive: true });
+ fs.writeFileSync(path.join(workspacePackage, "package.json"), "{}");
+ const subdir = path.join(workspacePackage, "src");
+ fs.mkdirSync(subdir);
+ expect(findNearestProjectRoot(subdir)).toBe(tempDir);
+ });
+
+ it("returns the workspace root for npm/yarn workspaces (workspaces field)", () => {
+ fs.writeFileSync(
+ path.join(tempDir, "package.json"),
+ JSON.stringify({ workspaces: ["packages/*"] }),
+ );
+ const workspacePackage = path.join(tempDir, "packages", "ui");
+ fs.mkdirSync(workspacePackage, { recursive: true });
+ fs.writeFileSync(path.join(workspacePackage, "package.json"), "{}");
+ expect(findNearestProjectRoot(workspacePackage)).toBe(tempDir);
+ });
+
+ it("returns the workspace root for lerna.json monorepos", () => {
+ fs.writeFileSync(path.join(tempDir, "package.json"), "{}");
+ fs.writeFileSync(
+ path.join(tempDir, "lerna.json"),
+ JSON.stringify({ packages: ["packages/*"] }),
+ );
+ const workspacePackage = path.join(tempDir, "packages", "core");
+ fs.mkdirSync(workspacePackage, { recursive: true });
+ fs.writeFileSync(path.join(workspacePackage, "package.json"), "{}");
+ expect(findNearestProjectRoot(workspacePackage)).toBe(tempDir);
+ });
+
+ it("returns the deepest package.json for non-workspace single repos", () => {
+ // A nested project inside an unrelated parent: parent has package.json
+ // but no workspaces marker. The nested project's package.json wins.
+ fs.writeFileSync(path.join(tempDir, "package.json"), "{}");
+ const nested = path.join(tempDir, "subprojects", "isolated");
+ fs.mkdirSync(nested, { recursive: true });
+ fs.writeFileSync(path.join(nested, "package.json"), "{}");
+ const subdir = path.join(nested, "src");
+ fs.mkdirSync(subdir);
+ expect(findNearestProjectRoot(subdir)).toBe(nested);
+ });
+
+ it("falls back to the original input when no package.json is found", () => {
+ const subdir = path.join(tempDir, "no-project", "deep");
+ 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);
+ }
+ });
+});
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/cli/test/has-error-code.test.ts b/packages/cli/test/has-error-code.test.ts
new file mode 100644
index 000000000..4dee5f8be
--- /dev/null
+++ b/packages/cli/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/cli/test/helpers/mock-exec-file.ts b/packages/cli/test/helpers/mock-exec-file.ts
new file mode 100644
index 000000000..43ee453f8
--- /dev/null
+++ b/packages/cli/test/helpers/mock-exec-file.ts
@@ -0,0 +1,74 @@
+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;
+};
+
+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 8f9500979..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", "--stdio"] },
- ...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", "--stdio"] },
- ...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..f946bdab4
--- /dev/null
+++ b/packages/cli/test/install-skill.test.ts
@@ -0,0 +1,573 @@
+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,
+ readKnownLastSelectedAgents,
+ 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 { writeLastSelectedAgents } from "../src/utils/last-selected-agents.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 (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);
+ expect(client.supported).toBe(true);
+ }
+ });
+
+ 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", "Cline"]));
+ 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("Windsurf");
+ expect(names).toContain("Pi");
+ });
+});
+
+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("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",
+ 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("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 canonical-sharing agents only when ALL of them are targeted (full canonical wipe)", () => {
+ installSkills({
+ scope: "project",
+ cwd: tempDir,
+ selectedClients: ["Cursor", "Codex", "OpenCode"],
+ });
+
+ // 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: canonicalSharers,
+ });
+
+ expect(results).toHaveLength(canonicalSharers.length);
+ expect(results.filter((r) => r.removed)).toHaveLength(1);
+ const dedupedResults = results.filter((r) => r.deduped);
+ expect(dedupedResults).toHaveLength(canonicalSharers.length - 1);
+ expect(
+ fs.existsSync(path.join(tempDir, CANONICAL_AGENTS_DIR, CANONICAL_SKILLS_SUBDIR, SKILL_NAME)),
+ ).toBe(false);
+ });
+
+ it("refuses to delete a shared canonical file when other universal agents still need it", () => {
+ installSkills({
+ scope: "project",
+ cwd: tempDir,
+ selectedClients: ["Cursor", "Codex", "OpenCode"],
+ });
+
+ // Only target Cursor - Codex, OpenCode and other universal agents still
+ // share the same .agents/skills file and should keep it.
+ const results = removeSkills({
+ scope: "project",
+ cwd: tempDir,
+ selectedClients: ["Cursor"],
+ });
+
+ expect(results).toHaveLength(1);
+ expect(results[0]?.removed).toBe(false);
+ expect(results[0]?.sharedWith).toEqual(expect.arrayContaining(["Codex", "OpenCode"]));
+ // File must still exist - other agents are still using it.
+ expect(
+ fs.existsSync(
+ path.join(tempDir, CANONICAL_AGENTS_DIR, CANONICAL_SKILLS_SUBDIR, SKILL_NAME, "SKILL.md"),
+ ),
+ ).toBe(true);
+ });
+
+ it("removes a non-universal agent's own file without touching the shared canonical", () => {
+ installSkills({
+ scope: "project",
+ cwd: tempDir,
+ selectedClients: ["Cursor", "Claude Code"],
+ });
+
+ const results = removeSkills({
+ scope: "project",
+ cwd: tempDir,
+ selectedClients: ["Claude Code"],
+ });
+
+ expect(results[0]?.removed).toBe(true);
+ // Claude Code's path goes away.
+ expect(fs.existsSync(path.join(tempDir, ".claude", "skills", SKILL_NAME))).toBe(false);
+ // Shared canonical stays.
+ expect(
+ fs.existsSync(path.join(tempDir, CANONICAL_AGENTS_DIR, CANONICAL_SKILLS_SUBDIR, SKILL_NAME)),
+ ).toBe(true);
+ });
+
+ it("returns removed: false (not deduped, not sharedWith) when nothing was installed", () => {
+ const results = removeSkills({
+ scope: "project",
+ cwd: tempDir,
+ selectedClients: ["Claude Code"],
+ });
+ expect(results[0]?.removed).toBe(false);
+ expect(results[0]?.deduped).toBeUndefined();
+ expect(results[0]?.sharedWith).toBeUndefined();
+ });
+
+ it("does NOT report sharedWith for a universal agent when no shared file actually exists", () => {
+ // Nothing installed at all - removing only Cursor should fall through
+ // to a plain "not installed" result, not pretend Codex/OpenCode/etc.
+ // are still using a file that doesn't exist.
+ const results = removeSkills({
+ scope: "project",
+ cwd: tempDir,
+ selectedClients: ["Cursor"],
+ });
+ expect(results[0]?.removed).toBe(false);
+ expect(results[0]?.sharedWith).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 log CLI", () => {
+ expect(SKILL_TEMPLATE).toContain("npx -y @react-grab/cli log");
+ });
+
+ 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/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/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: "" }],
+ timestamp,
+ ...overrides,
+});
+
+const buildResult = (
+ payload: ReactGrabPayload | null,
+ overrides: Partial = {},
+): ReadClipboardPayloadResult => ({
+ env: "macos",
+ payload,
+ recoverable: true,
+ rawPayloadPresent: payload !== null,
+ ...overrides,
+});
+
+interface FakeClock {
+ getCurrentMs: () => number;
+ sleepMs: (durationMs: number) => Promise;
+}
+
+const createFakeClock = (): FakeClock => {
+ let currentMs = 0;
+ return {
+ getCurrentMs: () => currentMs,
+ sleepMs: (durationMs: number) => {
+ currentMs += durationMs;
+ return Promise.resolve();
+ },
+ };
+};
+
+describe("runLogLoop", () => {
+ it("emits one NDJSON line per match and advances the baseline so the same grab is not re-emitted", async () => {
+ const grabA = buildPayload(2000, {
+ 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: "", commentText: "Refactor" }],
+ });
+ const stop: ReadClipboardPayloadResult = {
+ env: "ssh",
+ payload: null,
+ hint: "SSH detected",
+ recoverable: false,
+ rawPayloadPresent: false,
+ };
+ const read = vi
+ .fn<() => Promise>()
+ .mockResolvedValueOnce(buildResult(grab))
+ .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");
+ expect(writes).toHaveLength(1);
+ expect(JSON.parse(writes[0])).toEqual({
+ prompt: "Refactor",
+ content: "",
+ });
+ });
+
+ it("returns 'unrecoverable' immediately when the initial read is not recoverable", async () => {
+ const read = vi.fn<() => Promise>();
+ const writes: string[] = [];
+ const initialResult: ReadClipboardPayloadResult = {
+ env: "ssh",
+ payload: null,
+ hint: "Run on the same machine as your browser.",
+ recoverable: false,
+ rawPayloadPresent: false,
+ };
+
+ const result = await runLogLoop({
+ initialResult,
+ read,
+ write: (line) => writes.push(line),
+ });
+
+ expect(result.outcome).toBe("fail");
+ if (result.outcome !== "fail") return;
+ expect(result.exitCode).toBe(2);
+ expect(result.message).toBe("Run on the same machine as your browser.");
+ expect(writes).toHaveLength(0);
+ expect(read).not.toHaveBeenCalled();
+ });
+
+ it("mirrors each emitted line to the appendToFile sink when provided", async () => {
+ const grabA = buildPayload(2000, {
+ content: "",
+ entries: [{ content: "" }],
+ });
+ const grabB = buildPayload(3000, {
+ content: "",
+ entries: [{ content: "" }],
+ });
+ 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))
+ .mockResolvedValue(stop);
+ const stdoutWrites: string[] = [];
+ const fileWrites: string[] = [];
+ const clock = createFakeClock();
+
+ await runLogLoop({
+ initialResult: buildResult(null),
+ read,
+ write: (line) => stdoutWrites.push(line),
+ appendToFile: (line) => fileWrites.push(line),
+ getCurrentMs: clock.getCurrentMs,
+ sleepMs: clock.sleepMs,
+ });
+
+ expect(stdoutWrites).toEqual(fileWrites);
+ expect(stdoutWrites).toHaveLength(2);
+ expect(JSON.parse(stdoutWrites[0])).toEqual({ content: "" });
+ expect(JSON.parse(stdoutWrites[1])).toEqual({ content: "" });
+ });
+
+ it("returns 'ok' after the first match when exitOnFirstMatch is true", async () => {
+ const grab = buildPayload(2000, {
+ content: "",
+ 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-react-grab-payload.test.ts b/packages/cli/test/parse-react-grab-payload.test.ts
new file mode 100644
index 000000000..8c12f1001
--- /dev/null
+++ b/packages/cli/test/parse-react-grab-payload.test.ts
@@ -0,0 +1,79 @@
+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();
+ });
+
+ it("rejects payloads where timestamp is not a number", () => {
+ const payload = {
+ version: "0.1.32",
+ content: "",
+ entries: [{ content: "" }],
+ timestamp: "1700000000000",
+ };
+ expect(parseReactGrabPayload(JSON.stringify(payload))).toBeNull();
+ });
+
+ it("rejects payloads where entries is not an array", () => {
+ const payload = {
+ version: "0.1.32",
+ content: "",
+ entries: { content: "" },
+ timestamp: 1700000000000,
+ };
+ expect(parseReactGrabPayload(JSON.stringify(payload))).toBeNull();
+ });
+
+ it("rejects payloads where an entry has a non-string content", () => {
+ const payload = {
+ version: "0.1.32",
+ content: "",
+ entries: [{ content: 42 }],
+ timestamp: 1700000000000,
+ };
+ expect(parseReactGrabPayload(JSON.stringify(payload))).toBeNull();
+ });
+});
diff --git a/packages/cli/test/read-clipboard-linux.test.ts b/packages/cli/test/read-clipboard-linux.test.ts
new file mode 100644
index 000000000..aee2d1134
--- /dev/null
+++ b/packages/cli/test/read-clipboard-linux.test.ts
@@ -0,0 +1,94 @@
+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,
+ getExecFileCall,
+ 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 } = getExecFileCall(mockExecFile);
+ 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 } = getExecFileCall(mockExecFile);
+ 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(getExecFileCall(mockExecFile, 0).binary).toBe("wl-paste");
+ expect(getExecFileCall(mockExecFile, 1).binary).toBe("xclip");
+ });
+
+ 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 polling loop keeps
+ // polling.
+ process.env.WAYLAND_DISPLAY = "wayland-0";
+ const runtimeError = new Error("wl-paste: clipboard read failed") as NodeJS.ErrnoException;
+ runtimeError.code = "EPIPE";
+ stubExecFile(mockExecFile, { error: runtimeError });
+
+ const result = await readClipboardLinux();
+ expect(result.payload).toBeNull();
+ expect(result.recoverable).not.toBe(false);
+ expect(mockExecFile.mock.calls).toHaveLength(1);
+ expect(getExecFileCall(mockExecFile, 0).binary).toBe("wl-paste");
+ });
+
+ 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 () => {
+ stubExecFile(mockExecFile, { stdout: "" });
+
+ const result = await readClipboardLinux();
+ expect(result.payload).toBeNull();
+ });
+});
diff --git a/packages/cli/test/read-clipboard-macos.test.ts b/packages/cli/test/read-clipboard-macos.test.ts
new file mode 100644
index 000000000..845203f2f
--- /dev/null
+++ b/packages/cli/test/read-clipboard-macos.test.ts
@@ -0,0 +1,109 @@
+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 { getExecFileCall, getExecFileFlagValue, stubExecFile } from "./helpers/mock-exec-file.js";
+
+const mockExecFile = vi.mocked(execFile);
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+import {
+ CHROMIUM_PICKLE_ALIGNMENT_BYTES,
+ CHROMIUM_PICKLE_SENTINEL,
+} from "../src/utils/constants.js";
+
+const alignTo = (offset: number, alignment: number): number =>
+ (offset + alignment - 1) & ~(alignment - 1);
+
+const buildChromiumPickleBase64 = (mime: string, data: string): string => {
+ const mimeBytes = Buffer.byteLength(mime, "utf16le");
+ const dataBytes = Buffer.byteLength(data, "utf16le");
+ const alignedMime = alignTo(mimeBytes, CHROMIUM_PICKLE_ALIGNMENT_BYTES);
+ const alignedData = alignTo(dataBytes, CHROMIUM_PICKLE_ALIGNMENT_BYTES);
+ const payloadSize = 4 + 4 + alignedMime + 4 + alignedData;
+ const buffer = Buffer.alloc(4 + payloadSize);
+ buffer.writeUInt32LE(payloadSize, 0);
+ buffer.writeUInt32LE(1, 4);
+ let offset = 8;
+ buffer.writeUInt32LE(mime.length, offset);
+ offset += 4;
+ Buffer.from(mime, "utf16le").copy(buffer, offset);
+ offset += alignedMime;
+ buffer.writeUInt32LE(data.length, offset);
+ offset += 4;
+ Buffer.from(data, "utf16le").copy(buffer, offset);
+ return buffer.toString("base64");
+};
+
+describe("readClipboardMacos", () => {
+ it("invokes osascript with a JXA script that reads the React Grab MIME type and Chromium fallbacks", async () => {
+ stubExecFile(mockExecFile, { stdout: '{"hello":"world"}\n' });
+
+ const result = await readClipboardMacos();
+ expect(result.payload).toBe('{"hello":"world"}');
+
+ const { binary, args } = getExecFileCall(mockExecFile);
+ expect(binary).toBe("osascript");
+ expect(args).toContain("-l");
+ expect(args).toContain("JavaScript");
+
+ const jxaScript = getExecFileFlagValue(mockExecFile, "-e");
+ expect(jxaScript).toContain("NSPasteboard");
+ expect(jxaScript).toContain("application/x-react-grab");
+ expect(jxaScript).toContain("org.chromium.web-custom-data");
+ expect(jxaScript).toContain("org.webkit.web-custom-data");
+ });
+
+ it("decodes a Chromium web-custom-data pickle when JXA returns the sentinel-prefixed base64", async () => {
+ const json = '{"version":"0.1.32","content":"","entries":[],"timestamp":1}';
+ const pickle = buildChromiumPickleBase64("application/x-react-grab", json);
+ stubExecFile(mockExecFile, { stdout: `${CHROMIUM_PICKLE_SENTINEL}${pickle}\n` });
+
+ const result = await readClipboardMacos();
+ expect(result.payload).toBe(json);
+ });
+
+ it("returns null when the Chromium pickle does not contain our MIME type", async () => {
+ const pickle = buildChromiumPickleBase64("text/plain", "unrelated");
+ stubExecFile(mockExecFile, { stdout: `${CHROMIUM_PICKLE_SENTINEL}${pickle}\n` });
+
+ const result = await readClipboardMacos();
+ expect(result.payload).toBeNull();
+ });
+
+ 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();
+ });
+
+ 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-payload.test.ts b/packages/cli/test/read-clipboard-payload.test.ts
new file mode 100644
index 000000000..993a6d44d
--- /dev/null
+++ b/packages/cli/test/read-clipboard-payload.test.ts
@@ -0,0 +1,121 @@
+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: [{ content: "" }],
+ timestamp: 1700000000000,
+});
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("readClipboardPayload", () => {
+ it("dispatches to the macos reader on darwin", async () => {
+ mockDetectClipboardEnv.mockReturnValue("macos");
+ mockReadClipboardMacos.mockResolvedValue({ payload: validPayloadJson });
+
+ const result = await readClipboardPayload();
+ expect(mockReadClipboardMacos).toHaveBeenCalledOnce();
+ expect(result.env).toBe("macos");
+ expect(result.payload?.version).toBe("0.1.32");
+ });
+
+ it("dispatches to the linux reader on linux", async () => {
+ mockDetectClipboardEnv.mockReturnValue("linux");
+ mockReadClipboardLinux.mockResolvedValue({ payload: validPayloadJson });
+
+ const result = await readClipboardPayload();
+ expect(mockReadClipboardLinux).toHaveBeenCalledOnce();
+ expect(result.env).toBe("linux");
+ });
+
+ it("dispatches to the windows reader on win32", async () => {
+ mockDetectClipboardEnv.mockReturnValue("windows");
+ mockReadClipboardWindows.mockResolvedValue({ payload: validPayloadJson });
+
+ await readClipboardPayload();
+ expect(mockReadClipboardWindows).toHaveBeenCalledOnce();
+ });
+
+ it("dispatches to the wsl reader inside WSL", async () => {
+ mockDetectClipboardEnv.mockReturnValue("wsl");
+ mockReadClipboardWsl.mockResolvedValue({ payload: validPayloadJson });
+
+ await readClipboardPayload();
+ expect(mockReadClipboardWsl).toHaveBeenCalledOnce();
+ });
+
+ it("returns SSH guidance hint without invoking any reader and flags it unrecoverable", async () => {
+ mockDetectClipboardEnv.mockReturnValue("ssh");
+
+ const result = await readClipboardPayload();
+ expect(result.env).toBe("ssh");
+ expect(result.payload).toBeNull();
+ expect(result.hint).toContain("SSH");
+ expect(result.recoverable).toBe(false);
+ expect(mockReadClipboardMacos).not.toHaveBeenCalled();
+ expect(mockReadClipboardLinux).not.toHaveBeenCalled();
+ expect(mockReadClipboardWindows).not.toHaveBeenCalled();
+ expect(mockReadClipboardWsl).not.toHaveBeenCalled();
+ });
+
+ it("propagates platform hints when present and stays recoverable", async () => {
+ mockDetectClipboardEnv.mockReturnValue("linux");
+ mockReadClipboardLinux.mockResolvedValue({
+ payload: null,
+ hint: "Install xclip",
+ });
+
+ const result = await readClipboardPayload();
+ expect(result.payload).toBeNull();
+ expect(result.hint).toBe("Install xclip");
+ expect(result.recoverable).toBe(true);
+ });
+
+ it("returns null payload when raw text fails validation", async () => {
+ mockDetectClipboardEnv.mockReturnValue("macos");
+ mockReadClipboardMacos.mockResolvedValue({ payload: "not json" });
+
+ const result = await readClipboardPayload();
+ expect(result.payload).toBeNull();
+ });
+});
diff --git a/packages/cli/test/read-clipboard-windows.test.ts b/packages/cli/test/read-clipboard-windows.test.ts
new file mode 100644
index 000000000..1b69ad511
--- /dev/null
+++ b/packages/cli/test/read-clipboard-windows.test.ts
@@ -0,0 +1,80 @@
+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 { readClipboardWindows } from "../src/utils/read-clipboard-windows.js";
+import {
+ enoentError,
+ getExecFileCall,
+ getExecFileFlagValue,
+ stubExecFile,
+} from "./helpers/mock-exec-file.js";
+
+const mockExecFile = vi.mocked(execFile);
+
+const getDecodedPowerShellScript = (): string => {
+ const encoded = getExecFileFlagValue(mockExecFile, "-EncodedCommand");
+ return Buffer.from(encoded, "base64").toString("utf16le");
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("readClipboardWindows", () => {
+ it("invokes powershell.exe with -Sta and a base64 EncodedCommand", async () => {
+ stubExecFile(mockExecFile, { stdout: "{}" });
+
+ await readClipboardWindows();
+
+ const { binary, args } = getExecFileCall(mockExecFile);
+ expect(binary).toBe("powershell.exe");
+ expect(args).toContain("-Sta");
+ expect(args).toContain("-NoProfile");
+ expect(args).toContain("-EncodedCommand");
+
+ 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();
+ expect(decodedScript).toContain("[System.IO.Stream]");
+ expect(decodedScript).toContain("CopyTo");
+ expect(decodedScript).toContain("ToArray()");
+ 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();
+ 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 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
new file mode 100644
index 000000000..c6d1c89d6
--- /dev/null
+++ b/packages/cli/test/read-clipboard-wsl.test.ts
@@ -0,0 +1,130 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+vi.mock("../src/utils/read-clipboard-windows.js", () => ({
+ readClipboardViaWindowsPowerShell: vi.fn(),
+}));
+
+vi.mock("../src/utils/read-clipboard-linux.js", () => ({
+ readClipboardLinux: vi.fn(),
+}));
+
+import { readClipboardViaWindowsPowerShell } from "../src/utils/read-clipboard-windows.js";
+import { readClipboardLinux } from "../src/utils/read-clipboard-linux.js";
+import { readClipboardWsl } from "../src/utils/read-clipboard-wsl.js";
+
+const mockReadClipboardViaWindowsPowerShell = vi.mocked(readClipboardViaWindowsPowerShell);
+const mockReadClipboardLinux = vi.mocked(readClipboardLinux);
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("readClipboardWsl", () => {
+ it("returns the Windows host payload when interop succeeds and the value looks like JSON", async () => {
+ mockReadClipboardViaWindowsPowerShell.mockResolvedValue({
+ payload: '{"version":"x","content":"","entries":[],"timestamp":1}',
+ });
+
+ const result = await readClipboardWsl();
+ expect(result.payload).toBe('{"version":"x","content":"","entries":[],"timestamp":1}');
+ expect(mockReadClipboardLinux).not.toHaveBeenCalled();
+ });
+
+ it("falls through to WSLg when Windows host returns non-JSON garbage and WSLg has a real payload", async () => {
+ mockReadClipboardViaWindowsPowerShell.mockResolvedValue({ payload: "not-json-trash" });
+ mockReadClipboardLinux.mockResolvedValue({
+ payload: '{"version":"y","content":"","entries":[],"timestamp":2}',
+ });
+
+ const result = await readClipboardWsl();
+ expect(result.payload).toBe('{"version":"y","content":"","entries":[],"timestamp":2}');
+ expect(mockReadClipboardLinux).toHaveBeenCalledOnce();
+ });
+
+ it("surfaces the host garbage when no channel has a JSON-shaped payload (parser will diagnose)", async () => {
+ mockReadClipboardViaWindowsPowerShell.mockResolvedValue({ payload: "host-junk" });
+ mockReadClipboardLinux.mockResolvedValue({ payload: null });
+
+ const result = await readClipboardWsl();
+ expect(result.payload).toBe("host-junk");
+ });
+
+ it("falls back to WSLg Linux clipboard when Windows host returns nothing", async () => {
+ mockReadClipboardViaWindowsPowerShell.mockResolvedValue({ payload: null });
+ mockReadClipboardLinux.mockResolvedValue({ payload: "from-wslg" });
+
+ const result = await readClipboardWsl();
+ expect(result.payload).toBe("from-wslg");
+ expect(mockReadClipboardViaWindowsPowerShell).toHaveBeenCalledOnce();
+ expect(mockReadClipboardLinux).toHaveBeenCalledOnce();
+ });
+
+ it("surfaces a WSL-specific hint when interop fails and WSLg is empty", async () => {
+ mockReadClipboardViaWindowsPowerShell.mockResolvedValue({
+ payload: null,
+ hint: "Cannot launch powershell.exe.",
+ });
+ mockReadClipboardLinux.mockResolvedValue({ payload: null });
+
+ const result = await readClipboardWsl();
+ expect(result.payload).toBeNull();
+ expect(result.hint).toContain("WSL");
+ expect(result.hint).toContain("interop");
+ });
+
+ it("propagates the Linux install hint when Wayland tools are missing and host has no payload", async () => {
+ mockReadClipboardViaWindowsPowerShell.mockResolvedValue({ payload: null });
+ mockReadClipboardLinux.mockResolvedValue({
+ payload: null,
+ hint: "Install xclip or wl-clipboard.",
+ });
+
+ const result = await readClipboardWsl();
+ expect(result.hint).toContain("xclip");
+ });
+
+ it("combines host-specific, 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();
+ expect(result.payload).toBeNull();
+ // Host-specific guidance must be retained, not silently dropped in
+ // favor of only the generic WSL interop hint - when the actual failure
+ // is e.g. PowerShell missing, the host hint is the most actionable
+ // diagnostic.
+ expect(result.hint).toContain("powershell.exe");
+ expect(result.hint).toContain("interop");
+ 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);
+ });
+});
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/run-exec-file.test.ts b/packages/cli/test/run-exec-file.test.ts
new file mode 100644
index 000000000..80e2ebf09
--- /dev/null
+++ b/packages/cli/test/run-exec-file.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vite-plus/test";
+import { runExecFile, type ExecFileFailure } from "../src/utils/run-exec-file.js";
+
+const isWindows = process.platform === "win32";
+const echoCommand = isWindows ? "cmd" : "node";
+const echoArgs = isWindows
+ ? ["/c", "echo hello"]
+ : ["-e", "process.stdout.write('hello'); process.stderr.write('warn');"];
+const failArgs = isWindows
+ ? ["/c", "echo nope 1>&2 && exit 2"]
+ : ["-e", "process.stderr.write('nope'); process.exit(2);"];
+
+describe("runExecFile", () => {
+ it("resolves with stdout and stderr on exit 0", async () => {
+ const result = await runExecFile(echoCommand, echoArgs, {});
+ expect(result.stdout.trim()).toBe("hello");
+ if (!isWindows) {
+ expect(result.stderr.trim()).toBe("warn");
+ }
+ });
+
+ it("rejects with stderr attached when the child exits non-zero", async () => {
+ let caught: ExecFileFailure | null = null;
+ try {
+ await runExecFile(echoCommand, failArgs, {});
+ } catch (caughtError) {
+ caught = caughtError as ExecFileFailure;
+ }
+ expect(caught).not.toBeNull();
+ expect(caught?.stderr ?? "").toContain("nope");
+ });
+
+ it("rejects with ENOENT when the binary does not exist", async () => {
+ let caught: NodeJS.ErrnoException | null = null;
+ try {
+ await runExecFile("definitely-not-a-real-binary-xyz", [], {});
+ } catch (caughtError) {
+ caught = caughtError as NodeJS.ErrnoException;
+ }
+ expect(caught?.code).toBe("ENOENT");
+ });
+});
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/wait-for-next-grab.test.ts b/packages/cli/test/wait-for-next-grab.test.ts
new file mode 100644
index 000000000..f948b2bb5
--- /dev/null
+++ b/packages/cli/test/wait-for-next-grab.test.ts
@@ -0,0 +1,305 @@
+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: [{ content: "" }],
+ timestamp,
+});
+
+const buildResult = (
+ payload: ReactGrabPayload | null,
+ overrides: Partial = {},
+): ReadClipboardPayloadResult => ({
+ env: "macos",
+ payload,
+ recoverable: true,
+ // Default to "raw was non-empty when payload was non-null". Tests that
+ // exercise the parse-failure-on-stale-data scenario override this.
+ rawPayloadPresent: payload !== null,
+ ...overrides,
+});
+
+interface FakeClock {
+ getCurrentMs: () => number;
+ sleepMs: (durationMs: number) => Promise;
+}
+
+const createFakeClock = (): FakeClock => {
+ let currentMs = 0;
+ return {
+ getCurrentMs: () => currentMs,
+ sleepMs: (durationMs: number) => {
+ currentMs += durationMs;
+ return Promise.resolve();
+ },
+ };
+};
+
+describe("waitForNextGrab", () => {
+ it("returns immediately when the very first read produces a different timestamp", async () => {
+ const fresh = buildPayload(2000);
+ const read = vi.fn().mockResolvedValue(buildResult(fresh));
+ const clock = createFakeClock();
+
+ const outcome = await waitForNextGrab({
+ initialTimestamp: 1000,
+ initialRawPayloadPresent: true,
+ timeoutMs: 5000,
+ pollIntervalMs: 1,
+ read,
+ getCurrentMs: clock.getCurrentMs,
+ sleepMs: clock.sleepMs,
+ });
+
+ expect(outcome.outcome).toBe("match");
+ if (outcome.outcome === "match") {
+ expect(outcome.payload.timestamp).toBe(2000);
+ }
+ expect(read).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not match when the clipboard still holds the snapshot timestamp", async () => {
+ const stale = buildPayload(1000);
+ const fresh = buildPayload(1500);
+ const read = vi
+ .fn<() => Promise>()
+ .mockResolvedValueOnce(buildResult(stale))
+ .mockResolvedValueOnce(buildResult(stale))
+ .mockResolvedValueOnce(buildResult(fresh));
+ const clock = createFakeClock();
+
+ const outcome = await waitForNextGrab({
+ initialTimestamp: 1000,
+ initialRawPayloadPresent: true,
+ timeoutMs: 5000,
+ pollIntervalMs: 1,
+ read,
+ getCurrentMs: clock.getCurrentMs,
+ sleepMs: clock.sleepMs,
+ });
+
+ expect(outcome.outcome).toBe("match");
+ if (outcome.outcome === "match") {
+ expect(outcome.payload.timestamp).toBe(1500);
+ }
+ expect(read).toHaveBeenCalledTimes(3);
+ });
+
+ it("treats a null initial timestamp as 'any payload counts as fresh' when initial clipboard was empty", async () => {
+ const fresh = buildPayload(42);
+ const read = vi.fn().mockResolvedValue(buildResult(fresh));
+ const clock = createFakeClock();
+
+ const outcome = await waitForNextGrab({
+ initialTimestamp: null,
+ initialRawPayloadPresent: false,
+ timeoutMs: 5000,
+ pollIntervalMs: 1,
+ read,
+ getCurrentMs: clock.getCurrentMs,
+ sleepMs: clock.sleepMs,
+ });
+
+ expect(outcome.outcome).toBe("match");
+ });
+
+ it("does NOT return a stale grab when initial parse failed on a non-empty clipboard", async () => {
+ // Bugbot scenario: a real React Grab payload was sitting on the
+ // clipboard from a prior session, but the initial reader transiently
+ // failed to parse it (timeout, partial output). The stale grab should
+ // become the new baseline; we should only match on a different
+ // timestamp arriving after.
+ const stale = buildPayload(1000);
+ const fresh = buildPayload(2000);
+ const read = vi
+ .fn<() => Promise>()
+ .mockResolvedValueOnce(buildResult(stale))
+ .mockResolvedValueOnce(buildResult(stale))
+ .mockResolvedValueOnce(buildResult(fresh));
+ const clock = createFakeClock();
+
+ const outcome = await waitForNextGrab({
+ initialTimestamp: null,
+ // Initial read had raw data but parse failed - subsequent parses
+ // succeed.
+ initialRawPayloadPresent: true,
+ timeoutMs: 5000,
+ pollIntervalMs: 1,
+ read,
+ getCurrentMs: clock.getCurrentMs,
+ sleepMs: clock.sleepMs,
+ });
+
+ expect(outcome.outcome).toBe("match");
+ if (outcome.outcome === "match") {
+ // Must be the FRESH grab (2000), not the stale one (1000) that we
+ // observed first.
+ expect(outcome.payload.timestamp).toBe(2000);
+ }
+ expect(read).toHaveBeenCalledTimes(3);
+ });
+
+ it("times out (not falsely matches) when initial parse failed and clipboard never changes", async () => {
+ const stale = buildPayload(1000);
+ const read = vi.fn().mockResolvedValue(buildResult(stale));
+ const clock = createFakeClock();
+
+ const outcome = await waitForNextGrab({
+ initialTimestamp: null,
+ initialRawPayloadPresent: true,
+ timeoutMs: 50,
+ pollIntervalMs: 5,
+ read,
+ getCurrentMs: clock.getCurrentMs,
+ sleepMs: clock.sleepMs,
+ });
+
+ expect(outcome.outcome).toBe("timeout");
+ });
+
+ it("keeps polling when the clipboard has no payload", async () => {
+ const fresh = buildPayload(2000);
+ const read = vi
+ .fn<() => Promise>()
+ .mockResolvedValueOnce(buildResult(null))
+ .mockResolvedValueOnce(buildResult(null))
+ .mockResolvedValueOnce(buildResult(fresh));
+ const clock = createFakeClock();
+
+ const outcome = await waitForNextGrab({
+ initialTimestamp: null,
+ initialRawPayloadPresent: false,
+ timeoutMs: 5000,
+ pollIntervalMs: 1,
+ read,
+ getCurrentMs: clock.getCurrentMs,
+ sleepMs: clock.sleepMs,
+ });
+
+ expect(outcome.outcome).toBe("match");
+ expect(read).toHaveBeenCalledTimes(3);
+ });
+
+ it("returns timeout when no fresh payload arrives within the deadline", async () => {
+ const stale = buildPayload(1000);
+ const read = vi.fn().mockResolvedValue(buildResult(stale));
+ const clock = createFakeClock();
+
+ const outcome = await waitForNextGrab({
+ initialTimestamp: 1000,
+ initialRawPayloadPresent: true,
+ timeoutMs: 50,
+ pollIntervalMs: 5,
+ read,
+ getCurrentMs: clock.getCurrentMs,
+ sleepMs: clock.sleepMs,
+ });
+
+ expect(outcome.outcome).toBe("timeout");
+ expect(read.mock.calls.length).toBeGreaterThan(0);
+ });
+
+ it("blocks forever when timeoutMs is 0 (until match or abort)", async () => {
+ const fresh = buildPayload(2000);
+ const read = vi
+ .fn<() => Promise>()
+ .mockResolvedValue(buildResult(null))
+ .mockResolvedValueOnce(buildResult(null))
+ .mockResolvedValueOnce(buildResult(null))
+ .mockResolvedValueOnce(buildResult(null))
+ .mockResolvedValueOnce(buildResult(fresh));
+ const clock = createFakeClock();
+
+ const outcome = await waitForNextGrab({
+ initialTimestamp: null,
+ initialRawPayloadPresent: false,
+ timeoutMs: 0,
+ pollIntervalMs: 60_000_000,
+ read,
+ getCurrentMs: clock.getCurrentMs,
+ sleepMs: clock.sleepMs,
+ });
+
+ expect(outcome.outcome).toBe("match");
+ });
+
+ it("returns 'unrecoverable' immediately when the env cannot produce a payload", async () => {
+ const sshHint = "Clipboard channel is unavailable in SSH sessions.";
+ const read = vi.fn().mockResolvedValue({
+ env: "ssh",
+ payload: null,
+ hint: sshHint,
+ recoverable: false,
+ });
+ const clock = createFakeClock();
+
+ const outcome = await waitForNextGrab({
+ initialTimestamp: null,
+ initialRawPayloadPresent: false,
+ timeoutMs: 5000,
+ pollIntervalMs: 5,
+ read,
+ getCurrentMs: clock.getCurrentMs,
+ sleepMs: clock.sleepMs,
+ });
+
+ expect(outcome.outcome).toBe("unrecoverable");
+ if (outcome.outcome === "unrecoverable") {
+ expect(outcome.result.env).toBe("ssh");
+ expect(outcome.result.hint).toBe(sshHint);
+ }
+ expect(read).toHaveBeenCalledTimes(1);
+ });
+
+ it("respects a pre-aborted signal without invoking the reader", async () => {
+ const stale = buildPayload(1000);
+ const read = vi.fn().mockResolvedValue(buildResult(stale));
+ const controller = new AbortController();
+ controller.abort();
+ const clock = createFakeClock();
+
+ const outcome = await waitForNextGrab({
+ initialTimestamp: 1000,
+ initialRawPayloadPresent: true,
+ timeoutMs: 5000,
+ pollIntervalMs: 5,
+ read,
+ signal: controller.signal,
+ getCurrentMs: clock.getCurrentMs,
+ sleepMs: clock.sleepMs,
+ });
+
+ expect(outcome.outcome).toBe("aborted");
+ expect(read).not.toHaveBeenCalled();
+ });
+
+ it("respects a mid-flight abort triggered by the reader itself", async () => {
+ const stale = buildPayload(1000);
+ const controller = new AbortController();
+ let callCount = 0;
+ const read = vi.fn(async (): Promise => {
+ callCount += 1;
+ if (callCount === 3) controller.abort();
+ return buildResult(stale);
+ });
+ const clock = createFakeClock();
+
+ const outcome = await waitForNextGrab({
+ initialTimestamp: 1000,
+ initialRawPayloadPresent: true,
+ timeoutMs: 0,
+ pollIntervalMs: 1,
+ read,
+ signal: controller.signal,
+ getCurrentMs: clock.getCurrentMs,
+ sleepMs: clock.sleepMs,
+ });
+
+ expect(outcome.outcome).toBe("aborted");
+ expect(callCount).toBe(3);
+ });
+});
diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json
index eb51098d5..c56fa1cf7 100644
--- a/packages/cli/tsconfig.json
+++ b/packages/cli/tsconfig.json
@@ -8,7 +8,8 @@
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
- "noEmit": true
+ "noEmit": true,
+ "types": ["node"]
},
- "include": ["src"]
+ "include": ["src", "test"]
}
diff --git a/packages/cli/vite.config.ts b/packages/cli/vite.config.ts
index f85cfb1e4..5b87f43aa 100644
--- a/packages/cli/vite.config.ts
+++ b/packages/cli/vite.config.ts
@@ -1,10 +1,22 @@
import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
import { defineConfig } from "vite-plus";
const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8")) as {
version: string;
};
+// Inline the canonical skill markdown into the bundle. The same file is also
+// 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 sharedDefine = {
+ __REACT_GRAB_SKILL_TEMPLATE__: JSON.stringify(SKILL_TEMPLATE_MD),
+};
+
export default defineConfig({
pack: {
entry: ["src/cli.ts"],
@@ -17,6 +29,7 @@ export default defineConfig({
banner: "#!/usr/bin/env node",
define: {
"process.env.VERSION": JSON.stringify(process.env.VERSION ?? packageJson.version),
+ ...sharedDefine,
},
deps: {
alwaysBundle: [/^zod/],
@@ -27,4 +40,5 @@ export default defineConfig({
include: ["test/**/*.test.ts"],
testTimeout: 10000,
},
+ define: sharedDefine,
});
diff --git a/packages/grab/README.md b/packages/grab/README.md
index 44bc23190..9851c44a4 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 [**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)
@@ -19,18 +19,20 @@ 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:
-- **⌘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/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md
index c5b97b77c..01c429d6c 100644
--- a/packages/mcp/CHANGELOG.md
+++ b/packages/mcp/CHANGELOG.md
@@ -1,5 +1,11 @@
# @react-grab/mcp
+## 0.2.0
+
+### Major Changes
+
+- **DEPRECATED.** The MCP stdio server has been replaced by an agent skill that calls `react-grab watch` from `@react-grab/cli` directly. Run `npx -y @react-grab/cli@latest install-skill` to migrate. This release ships a stub `react-grab-mcp` binary that prints a deprecation notice and exits with code 1 so existing `mcp.json` entries surface a clear next step. See the package README for full migration instructions.
+
## 0.1.32
### Patch Changes
diff --git a/packages/mcp/README.md b/packages/mcp/README.md
new file mode 100644
index 000000000..3a8d3d921
--- /dev/null
+++ b/packages/mcp/README.md
@@ -0,0 +1,30 @@
+# @react-grab/mcp (deprecated)
+
+This package is deprecated. The MCP server has been replaced by an agent skill that calls the React Grab CLI directly.
+
+## Migration
+
+1. Install the new skill:
+
+ ```bash
+ npx -y @react-grab/cli@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` in chat and click an element in the React Grab toolbar — the agent will receive the file name, React component, and HTML snippet.
+
+## Why
+
+Skills auto-trigger on `/react-grab` or when the user references a grabbed element ("this thing", "the component I clicked"), and they shell out to a single short-lived CLI invocation instead of running a long-lived MCP stdio server. One package, one binary, no MCP config to maintain.
+
+## What replaced what
+
+| Old | New |
+| ----------------------------------- | --------------------------------------------------- |
+| `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. |
+
+See https://github.com/aidenybai/react-grab for full docs.
diff --git a/packages/mcp/package.json b/packages/mcp/package.json
index 3132c9d11..dfe8bb291 100644
--- a/packages/mcp/package.json
+++ b/packages/mcp/package.json
@@ -1,6 +1,7 @@
{
"name": "@react-grab/mcp",
- "version": "0.1.32",
+ "version": "0.2.0",
+ "description": "Deprecated. Use @react-grab/cli (run `npx -y @react-grab/cli install-skill`).",
"bin": {
"react-grab-mcp": "./dist/cli.cjs"
},
@@ -8,36 +9,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",
+ "typecheck": "tsc --noEmit",
"lint": "vp lint",
"format": "vp fmt",
"format:check": "vp fmt --check",
"check": "vp check"
},
- "dependencies": {
- "@modelcontextprotocol/sdk": "^1.25.0",
- "fkill": "^9.0.0",
- "react-grab": "workspace:*",
- "zod": "^3.25.0"
- },
"devDependencies": {
"@types/node": "^22.10.7"
- }
+ },
+ "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/mcp/src/cli.ts b/packages/mcp/src/cli.ts
index e85927513..57cc53136 100644
--- a/packages/mcp/src/cli.ts
+++ b/packages/mcp/src/cli.ts
@@ -1,7 +1,18 @@
-#!/usr/bin/env node
-import { startMcpServer } from "./server.js";
+const DEPRECATION_NOTICE = `@react-grab/mcp is deprecated.
-startMcpServer({
- port: Number(process.env.PORT) || undefined,
- stdio: process.argv.includes("--stdio"),
-});
+The MCP server has been replaced by an agent skill that calls the React Grab
+CLI directly. To migrate:
+
+ 1. Run: npx -y @react-grab/cli@latest install-skill
+ 2. Remove the "react-grab-mcp" entry from your agent's mcp.json (Claude Code,
+ Cursor, Codex, OpenCode, Windsurf, etc.).
+ 3. Restart your agent. The /react-grab skill will be auto-invoked.
+
+See https://github.com/aidenybai/react-grab for details.
+`;
+
+process.stderr.write(DEPRECATION_NOTICE);
+// 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;
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..cc9ebd93a 100644
--- a/packages/mcp/src/constants.ts
+++ b/packages/mcp/src/constants.ts
@@ -1,4 +1 @@
-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 DEPRECATED = true;
diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts
deleted file mode 100644
index ebabf3feb..000000000
--- a/packages/mcp/src/server.ts
+++ /dev/null
@@ -1,243 +0,0 @@
-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,
- DEFAULT_MCP_PORT,
- HEALTH_CHECK_TIMEOUT_MS,
- POST_KILL_DELAY_MS,
-} from "./constants.js";
-
-const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms));
-
-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"),
-});
-
-type AgentContext = z.infer;
-
-interface StoredContext {
- context: AgentContext;
- submittedAt: number;
-}
-
-let latestContext: StoredContext | null = null;
-
-const textResult = (text: string) => ({
- content: [{ type: "text" as const, text }],
-});
-
-const formatContext = (context: AgentContext): string => {
- const parts: string[] = [];
- if (context.prompt) {
- parts.push(`Prompt: ${context.prompt}`);
- }
- parts.push(`Elements:\n${context.content.join("\n\n")}`);
- return parts.join("\n\n");
-};
-
-const createMcpServer = (): McpServer => {
- const server = new McpServer(
- { name: "react-grab-mcp", version: "0.1.0" },
- { capabilities: { logging: {} } },
- );
-
- server.registerTool(
- "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;
- },
- );
-
- 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`);
-};
diff --git a/packages/mcp/test/deprecation-stub.test.ts b/packages/mcp/test/deprecation-stub.test.ts
new file mode 100644
index 000000000..ac393acd5
--- /dev/null
+++ b/packages/mcp/test/deprecation-stub.test.ts
@@ -0,0 +1,28 @@
+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 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", () => {
+ if (!existsSync(STUB_PATH)) {
+ // The stub is built by `vp pack`. CI runs `pnpm build` before `pnpm test`
+ // (per turbo.json `test: { dependsOn: ["build"] }`), so this should be
+ // populated. Skip cleanly if a developer ran tests without building.
+ return;
+ }
+
+ const result = spawnSync(process.execPath, [STUB_PATH], {
+ encoding: "utf8",
+ timeout: 5000,
+ });
+
+ expect(result.status).toBe(1);
+ expect(result.stderr).toContain("@react-grab/mcp is deprecated");
+ expect(result.stderr).toContain("install-skill");
+ });
+});
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 2c6f16321..685e06e5c 100644
--- a/packages/mcp/vite.config.ts
+++ b/packages/mcp/vite.config.ts
@@ -7,40 +7,23 @@ 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/cli.ts"],
+ format: ["cjs", "esm"],
+ dts: true,
+ clean: false,
+ sourcemap: false,
+ platform: "node",
+ fixedExtension: false,
+ banner: "#!/usr/bin/env node",
+ 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/README.md b/packages/react-grab/README.md
index a3947d42c..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)
@@ -19,18 +19,20 @@ 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:
-- **⌘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/react-grab/docs/architecture.md b/packages/react-grab/docs/architecture.md
index 74a7ac918..ecab012b2 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 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.
+`@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.
diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts
index da2761ca5..d58014abc 100644
--- a/packages/react-grab/src/core/copy.ts
+++ b/packages/react-grab/src/core/copy.ts
@@ -63,6 +63,10 @@ export const tryCopyWithFallback = async (
didCopy = copyContent(copiedContent, {
componentName: options.componentName,
+ // The getContent path leaves entries undefined; commentText
+ // populates the default entry so downstream formatters can still
+ // surface a Prompt: section.
+ commentText: extraPrompt,
entries,
});
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cc8f55a0f..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,19 +248,6 @@ importers:
version: link:../cli
packages/mcp:
- dependencies:
- '@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
devDependencies:
'@types/node':
specifier: ^22.10.7
@@ -1356,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'}
@@ -3050,10 +3030,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 +3390,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 +3866,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 +3974,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 +4102,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 +4287,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 +4469,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 +4864,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 +5057,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 +5122,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 +5286,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 +5357,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 +5410,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 +5914,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 +6014,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'}
@@ -6611,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:
@@ -6632,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
@@ -7796,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)
@@ -9243,11 +9133,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 +9463,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 +9938,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 +10116,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 +10266,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 +10536,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 +10669,6 @@ snapshots:
is-stream@2.0.1: {}
- is-stream@3.0.0: {}
-
is-stream@4.0.1: {}
is-subdir@1.2.0:
@@ -11171,8 +11011,6 @@ snapshots:
mimic-fn@2.1.0: {}
- mimic-fn@4.0.0: {}
-
mimic-function@5.0.1: {}
mimic-response@3.1.0:
@@ -11351,10 +11189,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 +11231,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 +11431,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 +11517,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 +11602,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 +12253,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 +12355,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 120000
index 000000000..11176f2d8
--- /dev/null
+++ b/skills/react-grab/SKILL.md
@@ -0,0 +1 @@
+../../packages/cli/src/utils/skill-template.md
\ No newline at end of file