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: [{ content: "" }], + timestamp: 0, + ...overrides, +}); + +describe("extractPromptAndContent", () => { + it("returns content only when no entry has a commentText", () => { + const payload = buildPayload({ content: "\n[2] ", + entries: [ + { content: "", commentText: "Refactor" }, + { content: "", commentText: "Refactor" }, + ], + }); + expect(extractPromptAndContent(payload)).toEqual({ + prompt: "Refactor", + content: "[1] \n[2] ", + }); + }); + + it("strips the leading prompt prefix from content using the raw (untrimmed) prompt", () => { + const rawPrompt = " click me "; + const payload = buildPayload({ + content: `${rawPrompt}\n\n`, + entries: [{ content: "", commentText: rawPrompt }], + }); + expect(extractPromptAndContent(payload)).toEqual({ + prompt: "click me", + content: "", + }); + }); + + it("does not strip when content does not actually start with the raw prompt + '\\n\\n'", () => { + const payload = buildPayload({ + content: "", + entries: [{ content: "", commentText: "click me" }], + }); + expect(extractPromptAndContent(payload)).toEqual({ + prompt: "click me", + content: "", + }); + }); + + it("ignores empty / whitespace-only commentTexts", () => { + const payload = buildPayload({ + entries: [ + { content: "", commentText: "" }, + { content: "", commentText: " " }, + ], + }); + expect(extractPromptAndContent(payload)).toEqual({ content: "" }); + }); + + it("joins multiple distinct prompts with newlines", () => { + const payload = buildPayload({ + entries: [ + { content: "", commentText: "rename" }, + { content: "", commentText: "rename" }, + { content: "", commentText: "fix spacing" }, + ], + }); + const { prompt } = extractPromptAndContent(payload); + expect(prompt).toBe("rename\nfix spacing"); + }); +}); diff --git a/packages/cli/test/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: "" }], + }); + const grabB = buildPayload(3000, { + content: "", + entries: [{ content: "" }], + }); + const grabC = buildPayload(4000, { + content: "", + entries: [{ content: "" }], + }); + // SSH-style unrecoverable read after three matches forces the loop to + // exit so the test terminates deterministically. + const stop: ReadClipboardPayloadResult = { + env: "ssh", + payload: null, + hint: "SSH detected", + recoverable: false, + rawPayloadPresent: false, + }; + const read = vi + .fn<() => Promise>() + .mockResolvedValueOnce(buildResult(grabA)) + .mockResolvedValueOnce(buildResult(grabB)) + .mockResolvedValueOnce(buildResult(grabC)) + .mockResolvedValue(stop); + const writes: string[] = []; + const clock = createFakeClock(); + + const result = await runLogLoop({ + initialResult: buildResult(null), + read, + write: (line) => writes.push(line), + getCurrentMs: clock.getCurrentMs, + sleepMs: clock.sleepMs, + }); + + expect(result.outcome).toBe("fail"); + if (result.outcome !== "fail") return; + expect(result.exitCode).toBe(2); + expect(result.message).toBe("SSH detected"); + expect(writes).toHaveLength(3); + expect(JSON.parse(writes[0])).toEqual({ content: "" }); + expect(JSON.parse(writes[1])).toEqual({ content: "" }); + expect(JSON.parse(writes[2])).toEqual({ content: "" }); + }); + + it("includes prompt when the user typed one in the toolbar", async () => { + const grab = buildPayload(2000, { + content: "Refactor\n\n", + entries: [{ content: "" }], + }); + const read = vi + .fn<() => Promise>() + .mockResolvedValue(buildResult(grab)); + const writes: string[] = []; + const fileWrites: string[] = []; + const clock = createFakeClock(); + + const result = await runLogLoop({ + initialResult: buildResult(null), + read, + write: (line) => writes.push(line), + appendToFile: (line) => fileWrites.push(line), + exitOnFirstMatch: true, + getCurrentMs: clock.getCurrentMs, + sleepMs: clock.sleepMs, + }); + + expect(result.outcome).toBe("ok"); + expect(writes).toHaveLength(1); + expect(fileWrites).toHaveLength(1); + expect(JSON.parse(writes[0])).toEqual({ content: "" }); + // Should not have polled again - one read, one match, then exit ok. + expect(read).toHaveBeenCalledTimes(1); + }); + + it("respects an abort signal between iterations", async () => { + const grab = buildPayload(2000); + const controller = new AbortController(); + const read = vi.fn(async (): Promise => { + controller.abort(); + return buildResult(grab); + }); + const writes: string[] = []; + const clock = createFakeClock(); + + const result = await runLogLoop({ + initialResult: buildResult(null), + read, + write: (line) => writes.push(line), + signal: controller.signal, + getCurrentMs: clock.getCurrentMs, + sleepMs: clock.sleepMs, + }); + + expect(result.outcome).toBe("fail"); + if (result.outcome !== "fail") return; + expect(result.exitCode).toBe(1); + expect(result.message).toContain("Aborted"); + }); +}); diff --git a/packages/cli/test/parse-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: "