@react-grab/cli 0.1.37 → 0.1.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,17 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
3
  import pc from "picocolors";
4
- import fs, { accessSync, constants, existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
4
+ import fs, { accessSync, constants, existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5
5
  import path, { basename, dirname, join, relative, resolve } from "node:path";
6
6
  import { detect } from "package-manager-detector/detect";
7
7
  import ignore from "ignore";
8
- import os from "node:os";
9
- import process$1 from "node:process";
10
- import * as jsonc from "jsonc-parser";
11
- import * as TOML from "smol-toml";
8
+ import { fileURLToPath } from "node:url";
9
+ import { add, detectInstalledSkillAgents, getCanonicalSkillsDir, getSkillAgentConfig, getSkillAgentDir, isUniversalSkillAgent } from "agent-install/skill";
12
10
  import basePrompts from "prompts";
13
11
  import ora from "ora";
14
12
  import { x } from "tinyexec";
13
+ import { spawnSync } from "node:child_process";
14
+ import { createHash } from "node:crypto";
15
15
  //#region src/utils/is-non-interactive.ts
16
16
  const AGENT_ENVIRONMENT_VARIABLES = [
17
17
  "CI",
@@ -40,24 +40,61 @@ const detectPackageManager = async (projectRoot) => {
40
40
  }
41
41
  return "npm";
42
42
  };
43
- const detectFramework = (projectRoot) => {
43
+ const CONFIG_EXTENSIONS = [
44
+ "ts",
45
+ "mts",
46
+ "cts",
47
+ "js",
48
+ "mjs",
49
+ "cjs"
50
+ ];
51
+ const hasConfigFile = (projectRoot, configBaseName) => CONFIG_EXTENSIONS.some((extension) => existsSync(join(projectRoot, `${configBaseName}.${extension}`)));
52
+ const readMergedDependencies = (projectRoot) => {
44
53
  const packageJsonPath = join(projectRoot, "package.json");
45
- if (!existsSync(packageJsonPath)) return "unknown";
54
+ if (!existsSync(packageJsonPath)) return null;
46
55
  try {
47
56
  const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
48
- const allDependencies = {
57
+ return {
49
58
  ...packageJson.dependencies,
50
59
  ...packageJson.devDependencies
51
60
  };
52
- if (allDependencies["next"]) return "next";
53
- if (allDependencies["@tanstack/react-start"]) return "tanstack";
54
- if (allDependencies["vite"]) return "vite";
55
- if (allDependencies["webpack"]) return "webpack";
56
- return "unknown";
57
61
  } catch {
58
- return "unknown";
62
+ return null;
59
63
  }
60
64
  };
65
+ const detectFrameworkFromDependencies = (dependencies) => {
66
+ if (!dependencies) return "unknown";
67
+ if (dependencies["next"]) return "next";
68
+ if (dependencies["@tanstack/react-start"]) return "tanstack";
69
+ if (dependencies["vite"]) return "vite";
70
+ if (dependencies["webpack"]) return "webpack";
71
+ return "unknown";
72
+ };
73
+ const detectFrameworkFromConfigFiles = (projectRoot) => {
74
+ if (hasConfigFile(projectRoot, "next.config")) return "next";
75
+ if (hasConfigFile(projectRoot, "app.config")) return "tanstack";
76
+ if (hasConfigFile(projectRoot, "vite.config")) return "vite";
77
+ if (hasConfigFile(projectRoot, "webpack.config")) return "webpack";
78
+ return "unknown";
79
+ };
80
+ const findEnclosingMonorepoRoot = (projectRoot) => {
81
+ let currentDirectory = dirname(projectRoot);
82
+ while (currentDirectory !== dirname(currentDirectory)) {
83
+ if (detectMonorepo(currentDirectory)) return currentDirectory;
84
+ currentDirectory = dirname(currentDirectory);
85
+ }
86
+ return null;
87
+ };
88
+ const detectFramework = (projectRoot) => {
89
+ const localFramework = detectFrameworkFromDependencies(readMergedDependencies(projectRoot));
90
+ if (localFramework !== "unknown") return localFramework;
91
+ return detectFrameworkFromConfigFiles(projectRoot);
92
+ };
93
+ const detectFrameworkFromMonorepoRoot = (projectRoot) => {
94
+ const monorepoRoot = findEnclosingMonorepoRoot(projectRoot);
95
+ if (!monorepoRoot) return "unknown";
96
+ return detectFrameworkFromDependencies(readMergedDependencies(monorepoRoot));
97
+ };
61
98
  const detectNextRouterType = (projectRoot) => {
62
99
  const hasAppDir = existsSync(join(projectRoot, "app"));
63
100
  const hasSrcAppDir = existsSync(join(projectRoot, "src", "app"));
@@ -130,18 +167,9 @@ const expandWorkspacePattern = (projectRoot, pattern) => {
130
167
  return results;
131
168
  };
132
169
  const hasReactDependency = (projectPath) => {
133
- const packageJsonPath = join(projectPath, "package.json");
134
- if (!existsSync(packageJsonPath)) return false;
135
- try {
136
- const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
137
- const allDeps = {
138
- ...packageJson.dependencies,
139
- ...packageJson.devDependencies
140
- };
141
- return Boolean(allDeps["react"] || allDeps["react-dom"]);
142
- } catch {
143
- return false;
144
- }
170
+ const dependencies = readMergedDependencies(projectPath);
171
+ if (!dependencies) return false;
172
+ return Boolean(dependencies["react"] || dependencies["react-dom"]);
145
173
  };
146
174
  const buildReactProject = (projectPath) => {
147
175
  const framework = detectFramework(projectPath);
@@ -243,14 +271,7 @@ const hasReactGrabInFile = (filePath) => {
243
271
  }
244
272
  };
245
273
  const detectReactGrab = (projectRoot) => {
246
- const packageJsonPath = join(projectRoot, "package.json");
247
- if (existsSync(packageJsonPath)) try {
248
- const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
249
- if ({
250
- ...packageJson.dependencies,
251
- ...packageJson.devDependencies
252
- }["react-grab"]) return true;
253
- } catch {}
274
+ if (readMergedDependencies(projectRoot)?.["react-grab"]) return true;
254
275
  return [
255
276
  join(projectRoot, "app", "layout.tsx"),
256
277
  join(projectRoot, "app", "layout.jsx"),
@@ -275,22 +296,13 @@ const detectReactGrab = (projectRoot) => {
275
296
  ].some(hasReactGrabInFile);
276
297
  };
277
298
  const detectUnsupportedFramework = (projectRoot) => {
278
- const packageJsonPath = join(projectRoot, "package.json");
279
- if (!existsSync(packageJsonPath)) return null;
280
- try {
281
- const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
282
- const allDependencies = {
283
- ...packageJson.dependencies,
284
- ...packageJson.devDependencies
285
- };
286
- if (allDependencies["@remix-run/react"] || allDependencies["remix"]) return "remix";
287
- if (allDependencies["astro"]) return "astro";
288
- if (allDependencies["@sveltejs/kit"]) return "sveltekit";
289
- if (allDependencies["gatsby"]) return "gatsby";
290
- return null;
291
- } catch {
292
- return null;
293
- }
299
+ const dependencies = readMergedDependencies(projectRoot);
300
+ if (!dependencies) return null;
301
+ if (dependencies["@remix-run/react"] || dependencies["remix"]) return "remix";
302
+ if (dependencies["astro"]) return "astro";
303
+ if (dependencies["@sveltejs/kit"]) return "sveltekit";
304
+ if (dependencies["gatsby"]) return "gatsby";
305
+ return null;
294
306
  };
295
307
  const detectReactGrabVersion = (projectRoot) => {
296
308
  const installedPackageJsonPath = join(projectRoot, "node_modules", "react-grab", "package.json");
@@ -300,7 +312,8 @@ const detectReactGrabVersion = (projectRoot) => {
300
312
  return null;
301
313
  };
302
314
  const detectProject = async (projectRoot = process.cwd()) => {
303
- const framework = detectFramework(projectRoot);
315
+ const localFramework = detectFramework(projectRoot);
316
+ const framework = localFramework === "unknown" ? detectFrameworkFromMonorepoRoot(projectRoot) : localFramework;
304
317
  return {
305
318
  packageManager: await detectPackageManager(projectRoot),
306
319
  framework,
@@ -366,204 +379,60 @@ const prompts = (questions) => {
366
379
  //#region src/utils/spinner.ts
367
380
  const spinner = (text) => ora({ text });
368
381
  //#endregion
369
- //#region src/utils/install-mcp.ts
370
- const SERVER_NAME = "react-grab-mcp";
371
- const PACKAGE_NAME = "@react-grab/mcp";
372
- const getXdgConfigHome = () => process$1.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
373
- const getBaseDir = () => {
374
- const homeDir = os.homedir();
375
- if (process$1.platform === "win32") return process$1.env.APPDATA || path.join(homeDir, "AppData", "Roaming");
376
- if (process$1.platform === "darwin") return path.join(homeDir, "Library", "Application Support");
377
- return getXdgConfigHome();
378
- };
379
- const getZedConfigPath = () => {
380
- if (process$1.platform === "win32") return path.join(getBaseDir(), "Zed", "settings.json");
381
- return path.join(os.homedir(), ".config", "zed", "settings.json");
382
- };
383
- const getOpenCodeConfigPath = () => {
384
- const configDir = path.join(getXdgConfigHome(), "opencode");
385
- const jsoncPath = path.join(configDir, "opencode.jsonc");
386
- const jsonPath = path.join(configDir, "opencode.json");
387
- if (fs.existsSync(jsoncPath)) return jsoncPath;
388
- if (fs.existsSync(jsonPath)) return jsonPath;
389
- return jsoncPath;
390
- };
391
- const getClients = () => {
392
- const homeDir = os.homedir();
393
- const baseDir = getBaseDir();
394
- const stdioConfig = {
395
- command: "npx",
396
- args: [
397
- "-y",
398
- PACKAGE_NAME,
399
- "--stdio"
400
- ]
401
- };
402
- return [
403
- {
404
- name: "Claude Code",
405
- configPath: path.join(homeDir, ".claude.json"),
406
- configKey: "mcpServers",
407
- format: "json",
408
- serverConfig: stdioConfig
409
- },
410
- {
411
- name: "Codex",
412
- configPath: path.join(process$1.env.CODEX_HOME || path.join(homeDir, ".codex"), "config.toml"),
413
- configKey: "mcp_servers",
414
- format: "toml",
415
- serverConfig: stdioConfig
416
- },
417
- {
418
- name: "Cursor",
419
- configPath: path.join(homeDir, ".cursor", "mcp.json"),
420
- configKey: "mcpServers",
421
- format: "json",
422
- serverConfig: stdioConfig
423
- },
424
- {
425
- name: "OpenCode",
426
- configPath: getOpenCodeConfigPath(),
427
- configKey: "mcp",
428
- format: "json",
429
- serverConfig: {
430
- type: "local",
431
- command: [
432
- "npx",
433
- "-y",
434
- PACKAGE_NAME,
435
- "--stdio"
436
- ]
437
- }
438
- },
439
- {
440
- name: "VS Code",
441
- configPath: path.join(baseDir, "Code", "User", "mcp.json"),
442
- configKey: "servers",
443
- format: "json",
444
- serverConfig: {
445
- type: "stdio",
446
- ...stdioConfig
447
- }
448
- },
449
- {
450
- name: "Amp",
451
- configPath: path.join(homeDir, ".config", "amp", "settings.json"),
452
- configKey: "amp.mcpServers",
453
- format: "json",
454
- serverConfig: stdioConfig
455
- },
456
- {
457
- name: "Droid",
458
- configPath: path.join(homeDir, ".factory", "mcp.json"),
459
- configKey: "mcpServers",
460
- format: "json",
461
- serverConfig: {
462
- type: "stdio",
463
- ...stdioConfig
464
- }
465
- },
466
- {
467
- name: "Windsurf",
468
- configPath: path.join(homeDir, ".codeium", "windsurf", "mcp_config.json"),
469
- configKey: "mcpServers",
470
- format: "json",
471
- serverConfig: stdioConfig
472
- },
473
- {
474
- name: "Zed",
475
- configPath: getZedConfigPath(),
476
- configKey: "context_servers",
477
- format: "json",
478
- serverConfig: {
479
- source: "custom",
480
- ...stdioConfig,
481
- env: {}
482
- }
483
- }
484
- ];
485
- };
486
- const ensureDirectory = (filePath) => {
487
- const directory = path.dirname(filePath);
488
- if (!fs.existsSync(directory)) fs.mkdirSync(directory, { recursive: true });
489
- };
490
- const JSONC_FORMAT_OPTIONS = {
491
- tabSize: 2,
492
- insertSpaces: true
493
- };
494
- const upsertIntoJsonc = (filePath, content, configKey, serverName, serverConfig) => {
495
- const edits = jsonc.modify(content, [configKey, serverName], serverConfig, { formattingOptions: JSONC_FORMAT_OPTIONS });
496
- fs.writeFileSync(filePath, jsonc.applyEdits(content, edits));
497
- };
498
- const installJsonClient = (client) => {
499
- ensureDirectory(client.configPath);
500
- const content = fs.existsSync(client.configPath) ? fs.readFileSync(client.configPath, "utf8") : "{}";
501
- upsertIntoJsonc(client.configPath, content, client.configKey, SERVER_NAME, client.serverConfig);
502
- };
503
- const installTomlClient = (client) => {
504
- ensureDirectory(client.configPath);
505
- const existingConfig = fs.existsSync(client.configPath) ? TOML.parse(fs.readFileSync(client.configPath, "utf8")) : {};
506
- const serverSection = existingConfig[client.configKey] ?? {};
507
- serverSection[SERVER_NAME] = client.serverConfig;
508
- existingConfig[client.configKey] = serverSection;
509
- fs.writeFileSync(client.configPath, TOML.stringify(existingConfig));
510
- };
511
- const getMcpClientNames = () => getClients().map((client) => client.name);
512
- const installMcpServers = (selectedClients) => {
513
- const allClients = getClients();
514
- const clients = selectedClients ? allClients.filter((client) => selectedClients.includes(client.name)) : allClients;
515
- const results = [];
516
- const installSpinner = spinner("Installing MCP server.").start();
517
- for (const client of clients) try {
518
- if (client.format === "toml") installTomlClient(client);
519
- else installJsonClient(client);
520
- results.push({
521
- client: client.name,
522
- configPath: client.configPath,
523
- success: true
524
- });
525
- } catch (error) {
526
- const message = error instanceof Error ? error.message : String(error);
527
- results.push({
528
- client: client.name,
529
- configPath: client.configPath,
530
- success: false,
531
- error: message
382
+ //#region src/utils/install-skill.ts
383
+ const SKILL_NAME = "react-grab";
384
+ const SKILL_SOURCE = fileURLToPath(new URL("../skills/react-grab", import.meta.url));
385
+ const agentLabel = (agent) => getSkillAgentConfig(agent).displayName;
386
+ const installedSkillDir = (agent) => join(isUniversalSkillAgent(agent) ? getCanonicalSkillsDir(true) : getSkillAgentDir(agent, { global: true }), SKILL_NAME);
387
+ const promptSkillInstall = async ({ yes = false } = {}) => {
388
+ const agents = await detectInstalledSkillAgents();
389
+ if (agents.length === 0) {
390
+ logger.warn("No supported agents detected.");
391
+ return false;
392
+ }
393
+ if (!yes) {
394
+ const { confirmed } = await prompts({
395
+ type: "confirm",
396
+ name: "confirmed",
397
+ message: `Install the React Grab skill for ${highlighter.info(agents.map(agentLabel).join(", "))}?`,
398
+ initial: true
532
399
  });
400
+ if (!confirmed) return false;
533
401
  }
534
- const successCount = results.filter((result) => result.success).length;
535
- if (successCount < results.length) installSpinner.warn(`Installed to ${successCount}/${results.length} agents.`);
536
- else installSpinner.succeed(`Installed to ${successCount} agents.`);
537
- for (const result of results) if (result.success) logger.log(` ${highlighter.success("✓")} ${result.client} ${highlighter.dim("→")} ${highlighter.dim(result.configPath)}`);
538
- else logger.log(` ${highlighter.error("✗")} ${result.client} ${highlighter.dim("→")} ${result.error}`);
539
- return results;
402
+ const installSpinner = spinner("Installing React Grab skill.").start();
403
+ const { installed, failed } = await add({
404
+ source: SKILL_SOURCE,
405
+ agents,
406
+ global: true,
407
+ mode: "copy"
408
+ });
409
+ if (installed.length === 0) {
410
+ installSpinner.fail("Failed to install React Grab skill.");
411
+ return false;
412
+ }
413
+ installSpinner.succeed(`Installed React Grab skill for ${installed.map((record) => agentLabel(record.agent)).join(", ")}.`);
414
+ for (const record of failed) logger.log(` ${highlighter.error("✗")} ${agentLabel(record.agent)} ${record.error}`);
415
+ return true;
540
416
  };
541
- const promptMcpInstall = async () => {
542
- const { selectedAgents } = await prompts({
543
- type: "multiselect",
544
- name: "selectedAgents",
545
- message: "Select agents to install MCP server for:",
546
- choices: getMcpClientNames().map((name) => ({
547
- title: name,
548
- value: name,
549
- selected: true
550
- }))
417
+ const removeSkill = async () => {
418
+ const agentsWithSkill = (await detectInstalledSkillAgents()).filter((agent) => existsSync(installedSkillDir(agent)));
419
+ for (const skillDir of new Set(agentsWithSkill.map(installedSkillDir))) rmSync(skillDir, {
420
+ recursive: true,
421
+ force: true
551
422
  });
552
- if (selectedAgents === void 0 || selectedAgents.length === 0) return false;
553
- logger.break();
554
- return installMcpServers(selectedAgents).some((result) => result.success);
423
+ for (const agent of agentsWithSkill) logger.log(` ${highlighter.success("✓")} ${agentLabel(agent)}`);
424
+ return agentsWithSkill.length;
555
425
  };
556
426
  //#endregion
557
427
  //#region src/commands/add.ts
558
- const VERSION$5 = "0.1.37";
559
- const add = new Command().name("add").alias("install").description("connect React Grab to your agent via MCP").argument("[agent]", "agent to connect (mcp)").option("-y, --yes", "skip confirmation prompts", false).option("-c, --cwd <cwd>", "working directory (defaults to current directory)", process.cwd()).action(async (agentArg, opts) => {
428
+ const VERSION$5 = "0.1.38";
429
+ const add$1 = new Command().name("add").alias("install").description("install the React Grab skill for your agent").option("-y, --yes", "skip confirmation prompts", false).option("-c, --cwd <cwd>", "working directory (defaults to current directory)", process.cwd()).action(async (opts) => {
560
430
  console.log(`${pc.magenta("✿")} ${pc.bold("React Grab")} ${pc.gray(VERSION$5)}`);
561
431
  console.log();
562
432
  try {
563
- const cwd = opts.cwd;
564
433
  const isNonInteractive = detectNonInteractive(opts.yes);
565
434
  const preflightSpinner = spinner("Preflight checks.").start();
566
- if (!(await detectProject(cwd)).hasReactGrab) {
435
+ if (!(await detectProject(opts.cwd)).hasReactGrab) {
567
436
  preflightSpinner.fail("React Grab is not installed.");
568
437
  logger.break();
569
438
  logger.error(`Run ${highlighter.info("react-grab init")} first to install React Grab.`);
@@ -571,39 +440,12 @@ const add = new Command().name("add").alias("install").description("connect Reac
571
440
  process.exit(1);
572
441
  }
573
442
  preflightSpinner.succeed();
574
- if (agentArg && agentArg !== "mcp") {
575
- logger.break();
576
- logger.warn(`Legacy agent packages are deprecated. Use ${highlighter.info("mcp")} instead.`);
577
- logger.log(`Run ${highlighter.info("grab add mcp")} to install the MCP server.`);
443
+ logger.break();
444
+ if (!await promptSkillInstall({ yes: isNonInteractive }) && isNonInteractive) {
578
445
  logger.break();
579
446
  process.exit(1);
580
447
  }
581
- if (agentArg === "mcp" || isNonInteractive) {
582
- if (isNonInteractive) {
583
- if (!installMcpServers().some((result) => result.success)) {
584
- logger.break();
585
- logger.error("Failed to install MCP server.");
586
- logger.break();
587
- process.exit(1);
588
- }
589
- } else if (!await promptMcpInstall()) {
590
- logger.break();
591
- process.exit(0);
592
- }
593
- logger.break();
594
- logger.log(`${highlighter.success("Success!")} MCP server has been configured.`);
595
- logger.log("Restart your agents to activate.");
596
- logger.break();
597
- } else {
598
- if (!await promptMcpInstall()) {
599
- logger.break();
600
- process.exit(0);
601
- }
602
- logger.break();
603
- logger.log(`${highlighter.success("Success!")} MCP server has been configured.`);
604
- logger.log("Restart your agents to activate.");
605
- logger.break();
606
- }
448
+ logger.break();
607
449
  } catch (error) {
608
450
  handleError(error);
609
451
  }
@@ -1185,7 +1027,7 @@ const formatActivationKeyDisplay = (activationKey) => {
1185
1027
  };
1186
1028
  //#endregion
1187
1029
  //#region src/commands/configure.ts
1188
- const VERSION$4 = "0.1.37";
1030
+ const VERSION$4 = "0.1.38";
1189
1031
  const isMac = process.platform === "darwin";
1190
1032
  const META_LABEL = isMac ? "Cmd" : "Win";
1191
1033
  const ALT_LABEL = isMac ? "Option" : "Alt";
@@ -1804,7 +1646,7 @@ const installPackagesWithFeedback = async (packages, packageManager, projectRoot
1804
1646
  };
1805
1647
  //#endregion
1806
1648
  //#region src/commands/init.ts
1807
- const VERSION$3 = "0.1.37";
1649
+ const VERSION$3 = "0.1.38";
1808
1650
  const REPORT_URL = "https://react-grab.com/api/report-cli";
1809
1651
  const DOCS_URL = "https://github.com/aidenybai/react-grab";
1810
1652
  const reportToCli = (type, config, error) => {
@@ -2020,25 +1862,7 @@ const init = new Command().name("init").alias("setup").description("initialize R
2020
1862
  }
2021
1863
  }
2022
1864
  logger.break();
2023
- const { wantAddMcp } = await prompts({
2024
- type: "confirm",
2025
- name: "wantAddMcp",
2026
- message: `Would you like to ${highlighter.info("connect it to your agent via MCP")}?`,
2027
- initial: false
2028
- });
2029
- if (wantAddMcp === void 0) {
2030
- logger.break();
2031
- process.exit(1);
2032
- }
2033
- if (wantAddMcp) {
2034
- if (!await promptMcpInstall()) {
2035
- logger.break();
2036
- process.exit(0);
2037
- }
2038
- logger.break();
2039
- logger.success("MCP server has been configured.");
2040
- logger.log("Restart your agents to activate.");
2041
- }
1865
+ await promptSkillInstall({ yes: isNonInteractive });
2042
1866
  logger.break();
2043
1867
  process.exit(0);
2044
1868
  }
@@ -2090,7 +1914,16 @@ const init = new Command().name("init").alias("setup").description("initialize R
2090
1914
  process.chdir(selectedProject);
2091
1915
  const newProjectInfo = await detectProject(selectedProject);
2092
1916
  Object.assign(projectInfo, newProjectInfo);
2093
- spinner("Verifying framework.").start().succeed(`Verifying framework. Found ${highlighter.info(FRAMEWORK_NAMES[newProjectInfo.framework])}.`);
1917
+ const newFrameworkSpinner = spinner("Verifying framework.").start();
1918
+ if (newProjectInfo.framework === "unknown") {
1919
+ newFrameworkSpinner.fail("Could not detect a supported framework in this project.");
1920
+ logger.break();
1921
+ logger.log("React Grab supports Next.js, Vite, TanStack Start, and Webpack projects.");
1922
+ logger.log(`Visit ${highlighter.info(DOCS_URL)} for manual setup.`);
1923
+ logger.break();
1924
+ process.exit(1);
1925
+ }
1926
+ newFrameworkSpinner.succeed(`Verifying framework. Found ${highlighter.info(FRAMEWORK_NAMES[newProjectInfo.framework])}.`);
2094
1927
  } else {
2095
1928
  frameworkSpinner.fail("Could not detect a supported framework.");
2096
1929
  logger.break();
@@ -2105,30 +1938,10 @@ const init = new Command().name("init").alias("setup").description("initialize R
2105
1938
  const finalFramework = projectInfo.framework;
2106
1939
  const finalPackageManager = projectInfo.packageManager;
2107
1940
  const finalNextRouterType = projectInfo.nextRouterType;
2108
- let didInstallMcp = false;
1941
+ let didInstallSkill = false;
2109
1942
  if (!isNonInteractive) {
2110
1943
  logger.break();
2111
- const { wantAddMcp } = await prompts({
2112
- type: "confirm",
2113
- name: "wantAddMcp",
2114
- message: `Would you like to ${highlighter.info("connect it to your agent via MCP")}?`,
2115
- initial: false
2116
- });
2117
- if (wantAddMcp === void 0) {
2118
- logger.break();
2119
- process.exit(1);
2120
- }
2121
- if (wantAddMcp) {
2122
- didInstallMcp = Boolean(await promptMcpInstall());
2123
- if (!didInstallMcp) {
2124
- logger.break();
2125
- process.exit(0);
2126
- }
2127
- logger.break();
2128
- logger.success("MCP server has been configured.");
2129
- logger.log("Continuing with React Grab installation...");
2130
- logger.break();
2131
- }
1944
+ didInstallSkill = await promptSkillInstall({ yes: isNonInteractive });
2132
1945
  }
2133
1946
  const result = previewTransform(projectInfo.projectRoot, finalFramework, finalNextRouterType, false, opts.force);
2134
1947
  if (!result.success) {
@@ -2172,7 +1985,7 @@ const init = new Command().name("init").alias("setup").description("initialize R
2172
1985
  framework: finalFramework,
2173
1986
  packageManager: finalPackageManager,
2174
1987
  router: finalNextRouterType,
2175
- agent: didInstallMcp ? "mcp" : void 0,
1988
+ agent: didInstallSkill ? "skill" : void 0,
2176
1989
  isMonorepo: projectInfo.isMonorepo
2177
1990
  });
2178
1991
  } catch (error) {
@@ -2182,29 +1995,16 @@ const init = new Command().name("init").alias("setup").description("initialize R
2182
1995
  });
2183
1996
  //#endregion
2184
1997
  //#region src/commands/remove.ts
2185
- const VERSION$2 = "0.1.37";
2186
- 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 <cwd>", "working directory (defaults to current directory)", process.cwd()).action(async (agentArg, opts) => {
1998
+ const VERSION$2 = "0.1.38";
1999
+ const remove = new Command().name("remove").description("uninstall the React Grab skill from your agent").action(async () => {
2187
2000
  console.log(`${pc.magenta("✿")} ${pc.bold("React Grab")} ${pc.gray(VERSION$2)}`);
2188
2001
  console.log();
2189
2002
  try {
2190
- const cwd = opts.cwd;
2191
- const preflightSpinner = spinner("Preflight checks.").start();
2192
- if (!(await detectProject(cwd)).hasReactGrab) {
2193
- preflightSpinner.fail("React Grab is not installed.");
2194
- logger.break();
2195
- logger.error(`Run ${highlighter.info("react-grab init")} first to install React Grab.`);
2196
- logger.break();
2197
- process.exit(1);
2198
- }
2199
- preflightSpinner.succeed();
2200
- if (agentArg && agentArg !== "mcp") {
2201
- logger.break();
2202
- logger.warn(`Legacy agent packages are deprecated. Uninstall ${highlighter.info(`@react-grab/${agentArg}`)} manually with your package manager.`);
2203
- logger.break();
2204
- process.exit(0);
2205
- }
2206
2003
  logger.break();
2207
- logger.warn("To remove the MCP server, delete the react-grab-mcp entry from your agent's MCP config file.");
2004
+ const removedCount = await removeSkill();
2005
+ logger.break();
2006
+ if (removedCount === 0) logger.log("React Grab skill is not installed in any detected agent.");
2007
+ else logger.log(`${highlighter.success("Removed")} the React Grab skill from ${removedCount} agent${removedCount === 1 ? "" : "s"}.`);
2208
2008
  logger.break();
2209
2009
  } catch (error) {
2210
2010
  handleError(error);
@@ -2212,7 +2012,7 @@ const remove = new Command().name("remove").description("disconnect React Grab f
2212
2012
  });
2213
2013
  //#endregion
2214
2014
  //#region src/commands/upgrade.ts
2215
- const VERSION$1 = "0.1.37";
2015
+ const VERSION$1 = "0.1.38";
2216
2016
  const NPM_REGISTRY_URL = "https://registry.npmjs.org/react-grab/latest";
2217
2017
  const fetchLatestVersion = async () => {
2218
2018
  try {
@@ -2286,8 +2086,368 @@ const upgrade = new Command().name("upgrade").alias("update").description("upgra
2286
2086
  }
2287
2087
  });
2288
2088
  //#endregion
2089
+ //#region src/utils/clipboard.ts
2090
+ const READ_TIMEOUT_MS = 2500;
2091
+ const MAX_CLIPBOARD_BYTES = 64 * 1024 * 1024;
2092
+ const ID_RADIX = 36;
2093
+ const HASH_LENGTH = 12;
2094
+ const PICKLE_HEADER_BYTES = 4;
2095
+ const PICKLE_ALIGN_BYTES = 4;
2096
+ const SIGNATURE_SCAN_CHARS = 32 * 1024;
2097
+ const GRAB_MIME = "application/x-react-grab";
2098
+ const CHROMIUM_CUSTOM_FORMAT = "chromium/x-web-custom-data";
2099
+ const GRAB_TEXT_SIGNATURE = /\bin\s+\S+\s+\(at\s+[^\n]{1,400}?:\d+:\d+\)/;
2100
+ const sleep = (durationMs) => new Promise((resolve) => setTimeout(resolve, durationMs));
2101
+ const shortHash = (text) => createHash("sha1").update(text).digest("hex").slice(0, HASH_LENGTH);
2102
+ const alignUp = (value) => value + PICKLE_ALIGN_BYTES - 1 & ~(PICKLE_ALIGN_BYTES - 1);
2103
+ const parseChromiumPickle = (buffer) => {
2104
+ const formats = {};
2105
+ if (!buffer || buffer.length < PICKLE_HEADER_BYTES + 4) return formats;
2106
+ let offset = PICKLE_HEADER_BYTES;
2107
+ const pairCount = buffer.readUInt32LE(offset);
2108
+ offset += 4;
2109
+ for (let pairIndex = 0; pairIndex < pairCount; pairIndex += 1) {
2110
+ if (offset + 4 > buffer.length) break;
2111
+ const formatCodeUnits = buffer.readUInt32LE(offset);
2112
+ offset += 4;
2113
+ if (offset + formatCodeUnits * 2 > buffer.length) break;
2114
+ const format = buffer.toString("utf16le", offset, offset + formatCodeUnits * 2);
2115
+ offset = alignUp(offset + formatCodeUnits * 2);
2116
+ if (offset + 4 > buffer.length) break;
2117
+ const dataCodeUnits = buffer.readUInt32LE(offset);
2118
+ offset += 4;
2119
+ if (offset + dataCodeUnits * 2 > buffer.length) break;
2120
+ const value = buffer.toString("utf16le", offset, offset + dataCodeUnits * 2);
2121
+ offset = alignUp(offset + dataCodeUnits * 2);
2122
+ formats[format] = value;
2123
+ }
2124
+ return formats;
2125
+ };
2126
+ const extractGrab = (raw) => {
2127
+ if (raw.grab) return raw.grab;
2128
+ if (raw.pickleBase64) return parseChromiumPickle(Buffer.from(raw.pickleBase64, "base64"))[GRAB_MIME];
2129
+ };
2130
+ const isGrabText = (text) => GRAB_TEXT_SIGNATURE.test(text.length > SIGNATURE_SCAN_CHARS ? text.slice(0, SIGNATURE_SCAN_CHARS) : text);
2131
+ const extractPrompt = (record) => {
2132
+ const comments = (Array.isArray(record.entries) ? record.entries : []).map((entry) => entry?.commentText?.trim?.()).filter(Boolean);
2133
+ if (comments.length > 0) return comments.join("\n");
2134
+ const lines = (typeof record.content === "string" ? record.content : "").split("\n");
2135
+ const firstReferenceLine = lines.findIndex((line) => line.startsWith("["));
2136
+ if (firstReferenceLine <= 0) return void 0;
2137
+ return lines.slice(0, firstReferenceLine).join("\n").trim() || void 0;
2138
+ };
2139
+ const hasCommand = (name) => {
2140
+ return spawnSync(process.platform === "win32" ? "where" : "which", [name], { stdio: "ignore" }).status === 0;
2141
+ };
2142
+ const runText = (command, args) => {
2143
+ const output = spawnSync(command, args, {
2144
+ encoding: "utf8",
2145
+ maxBuffer: MAX_CLIPBOARD_BYTES,
2146
+ timeout: READ_TIMEOUT_MS
2147
+ });
2148
+ return output.status === 0 ? output.stdout : null;
2149
+ };
2150
+ const runBuffer = (command, args) => {
2151
+ const output = spawnSync(command, args, {
2152
+ maxBuffer: MAX_CLIPBOARD_BYTES,
2153
+ timeout: READ_TIMEOUT_MS
2154
+ });
2155
+ return output.status === 0 && output.stdout?.length ? output.stdout : null;
2156
+ };
2157
+ const runJson = (command, args) => {
2158
+ const output = spawnSync(command, args, {
2159
+ encoding: "utf8",
2160
+ maxBuffer: MAX_CLIPBOARD_BYTES,
2161
+ timeout: READ_TIMEOUT_MS
2162
+ });
2163
+ if (output.status !== 0 || !output.stdout) return null;
2164
+ try {
2165
+ return JSON.parse(output.stdout);
2166
+ } catch {
2167
+ return null;
2168
+ }
2169
+ };
2170
+ const compileSwiftReader = (readersDir, workDir) => {
2171
+ if (!hasCommand("swiftc")) return null;
2172
+ const source = path.join(readersDir, "read-clipboard.swift");
2173
+ if (!fs.existsSync(source)) return null;
2174
+ const binary = path.join(workDir, "pbread");
2175
+ if ((!fs.existsSync(binary) || fs.statSync(source).mtimeMs > fs.statSync(binary).mtimeMs) && spawnSync("swiftc", [
2176
+ "-O",
2177
+ source,
2178
+ "-o",
2179
+ binary
2180
+ ]).status !== 0) return null;
2181
+ return binary;
2182
+ };
2183
+ const createDarwinReader = (options) => {
2184
+ const binary = options.textOnly ? null : compileSwiftReader(options.readersDir, options.workDir);
2185
+ if (binary) return {
2186
+ mode: "darwin-native",
2187
+ read: () => {
2188
+ const raw = runJson(binary, []);
2189
+ if (!raw) return null;
2190
+ return {
2191
+ changeCount: raw.changeCount ?? null,
2192
+ text: raw.text,
2193
+ grab: extractGrab(raw)
2194
+ };
2195
+ }
2196
+ };
2197
+ if (!hasCommand("pbpaste")) return null;
2198
+ return {
2199
+ mode: "darwin-text",
2200
+ read: () => {
2201
+ const text = runText("pbpaste", []);
2202
+ return text == null ? null : {
2203
+ changeCount: null,
2204
+ text,
2205
+ grab: void 0
2206
+ };
2207
+ }
2208
+ };
2209
+ };
2210
+ const detectLinuxTool = () => {
2211
+ if (Boolean(process.env.WAYLAND_DISPLAY) && hasCommand("wl-paste") || hasCommand("wl-paste") && !hasCommand("xclip")) return {
2212
+ name: "wl-paste",
2213
+ readText: () => runText("wl-paste", [
2214
+ "-n",
2215
+ "-t",
2216
+ "text/plain"
2217
+ ]) ?? runText("wl-paste", ["-n"]),
2218
+ readCustom: () => runBuffer("wl-paste", [
2219
+ "-n",
2220
+ "-t",
2221
+ CHROMIUM_CUSTOM_FORMAT
2222
+ ])
2223
+ };
2224
+ if (hasCommand("xclip")) return {
2225
+ name: "xclip",
2226
+ readText: () => runText("xclip", [
2227
+ "-selection",
2228
+ "clipboard",
2229
+ "-o"
2230
+ ]),
2231
+ readCustom: () => runBuffer("xclip", [
2232
+ "-selection",
2233
+ "clipboard",
2234
+ "-t",
2235
+ CHROMIUM_CUSTOM_FORMAT,
2236
+ "-o"
2237
+ ])
2238
+ };
2239
+ if (hasCommand("xsel")) return {
2240
+ name: "xsel",
2241
+ readText: () => runText("xsel", ["--clipboard", "--output"]),
2242
+ readCustom: null
2243
+ };
2244
+ return null;
2245
+ };
2246
+ const createLinuxReader = (options) => {
2247
+ const tool = detectLinuxTool();
2248
+ if (!tool) return null;
2249
+ const useCustom = !options.textOnly && Boolean(tool.readCustom);
2250
+ return {
2251
+ mode: useCustom ? `linux-${tool.name}` : `linux-${tool.name}-text`,
2252
+ read: () => {
2253
+ const text = tool.readText();
2254
+ if (text == null) return null;
2255
+ return {
2256
+ changeCount: null,
2257
+ text,
2258
+ grab: useCustom && tool.readCustom ? parseChromiumPickle(tool.readCustom())[GRAB_MIME] : void 0
2259
+ };
2260
+ }
2261
+ };
2262
+ };
2263
+ const detectPowershell = () => hasCommand("pwsh") ? "pwsh" : hasCommand("powershell") ? "powershell" : null;
2264
+ const createWindowsReader = (options) => {
2265
+ const shell = detectPowershell();
2266
+ if (!shell) return null;
2267
+ const scriptPath = path.join(options.readersDir, "read-clipboard.ps1");
2268
+ if (options.textOnly || !fs.existsSync(scriptPath)) return {
2269
+ mode: "win-text",
2270
+ read: () => {
2271
+ const text = runText(shell, [
2272
+ "-NoProfile",
2273
+ "-Command",
2274
+ "Get-Clipboard -Raw"
2275
+ ]);
2276
+ return text == null ? null : {
2277
+ changeCount: null,
2278
+ text,
2279
+ grab: void 0
2280
+ };
2281
+ }
2282
+ };
2283
+ const args = [
2284
+ "-NoProfile",
2285
+ "-ExecutionPolicy",
2286
+ "Bypass",
2287
+ "-File",
2288
+ scriptPath
2289
+ ];
2290
+ spawnSync(shell, args, {
2291
+ stdio: "ignore",
2292
+ maxBuffer: MAX_CLIPBOARD_BYTES
2293
+ });
2294
+ return {
2295
+ mode: "win-native",
2296
+ read: () => {
2297
+ const raw = runJson(shell, args);
2298
+ if (!raw) return null;
2299
+ return {
2300
+ changeCount: raw.changeCount ?? null,
2301
+ text: raw.text ?? void 0,
2302
+ grab: extractGrab(raw)
2303
+ };
2304
+ }
2305
+ };
2306
+ };
2307
+ const createReader = (options) => {
2308
+ if (process.platform === "darwin") return createDarwinReader(options);
2309
+ if (process.platform === "linux") return createLinuxReader(options);
2310
+ if (process.platform === "win32") return createWindowsReader(options);
2311
+ return null;
2312
+ };
2313
+ const ensureSafeDir = (dir) => {
2314
+ let stats;
2315
+ try {
2316
+ stats = fs.lstatSync(dir);
2317
+ } catch {
2318
+ return;
2319
+ }
2320
+ if (stats.isSymbolicLink()) throw new Error(`Refusing to use ${dir}: it is a symlink.`);
2321
+ if (process.getuid && stats.uid !== process.getuid()) throw new Error(`Refusing to use ${dir}: owned by another user.`);
2322
+ };
2323
+ const prepareWorkDir = (dir) => {
2324
+ ensureSafeDir(dir);
2325
+ fs.mkdirSync(dir, {
2326
+ recursive: true,
2327
+ mode: 448
2328
+ });
2329
+ const gitignore = path.join(dir, ".gitignore");
2330
+ if (!fs.existsSync(gitignore)) fs.writeFileSync(gitignore, "*\n");
2331
+ };
2332
+ const watchForNextGrab = async (options) => {
2333
+ const { reader, dir, intervalMs, replayLast, onWarn, signal } = options;
2334
+ const logPath = path.join(dir, "history.jsonl");
2335
+ const { read } = reader;
2336
+ let lastChangeCount = null;
2337
+ let lastTimestamp = 0;
2338
+ let lastTextHash = "";
2339
+ let lastErrorMessage = "";
2340
+ let sequence = 0;
2341
+ const initial = read();
2342
+ if (initial && !replayLast) {
2343
+ lastChangeCount = initial.changeCount;
2344
+ if (initial.text) lastTextHash = shortHash(initial.text);
2345
+ if (initial.grab) try {
2346
+ lastTimestamp = JSON.parse(initial.grab).timestamp ?? 0;
2347
+ } catch {}
2348
+ }
2349
+ while (!signal?.aborted) {
2350
+ await sleep(intervalMs);
2351
+ if (signal?.aborted) return null;
2352
+ try {
2353
+ const snapshot = read();
2354
+ if (!snapshot) continue;
2355
+ if (snapshot.changeCount !== null && snapshot.changeCount === lastChangeCount) continue;
2356
+ const textHash = snapshot.text ? shortHash(snapshot.text) : "";
2357
+ const didTextChange = textHash !== lastTextHash;
2358
+ let record = null;
2359
+ let nextTimestamp = lastTimestamp;
2360
+ if (snapshot.grab) {
2361
+ let parsed = null;
2362
+ try {
2363
+ parsed = JSON.parse(snapshot.grab);
2364
+ } catch {}
2365
+ if (parsed && typeof parsed.timestamp === "number" && parsed.timestamp > lastTimestamp) {
2366
+ nextTimestamp = parsed.timestamp;
2367
+ record = {
2368
+ source: "custom",
2369
+ timestamp: parsed.timestamp,
2370
+ version: typeof parsed.version === "string" ? parsed.version : void 0,
2371
+ content: typeof parsed.content === "string" ? parsed.content : "",
2372
+ entries: Array.isArray(parsed.entries) ? parsed.entries : []
2373
+ };
2374
+ }
2375
+ } else if (didTextChange && snapshot.text && isGrabText(snapshot.text)) record = {
2376
+ source: "text",
2377
+ timestamp: Date.now(),
2378
+ content: snapshot.text,
2379
+ entries: []
2380
+ };
2381
+ if (!record) {
2382
+ lastChangeCount = snapshot.changeCount;
2383
+ lastTextHash = textHash;
2384
+ continue;
2385
+ }
2386
+ const prompt = extractPrompt(record);
2387
+ if (prompt) record.prompt = prompt;
2388
+ const captured = {
2389
+ id: `${record.timestamp}-${(sequence += 1).toString(ID_RADIX)}`,
2390
+ receivedAt: Date.now(),
2391
+ ...record
2392
+ };
2393
+ fs.appendFileSync(logPath, `${JSON.stringify(captured)}\n`);
2394
+ lastChangeCount = snapshot.changeCount;
2395
+ lastTextHash = textHash;
2396
+ lastTimestamp = nextTimestamp;
2397
+ return captured;
2398
+ } catch (error) {
2399
+ const message = String(error?.message ?? error);
2400
+ if (message !== lastErrorMessage) {
2401
+ lastErrorMessage = message;
2402
+ onWarn?.(message);
2403
+ }
2404
+ }
2405
+ }
2406
+ return null;
2407
+ };
2408
+ //#endregion
2409
+ //#region src/commands/watch.ts
2410
+ const DEFAULT_INTERVAL_MS = 800;
2411
+ const DEFAULT_DIR = ".react-grab";
2412
+ const readersDir = () => path.dirname(fileURLToPath(import.meta.url));
2413
+ const watch = new Command().name("watch").description("watch the clipboard for the next React Grab selection, print it, then exit").option("-d, --dir <dir>", "work dir for history.jsonl + cursor.txt", DEFAULT_DIR).option("-i, --interval <ms>", "clipboard poll interval in ms", String(DEFAULT_INTERVAL_MS)).option("--text-only", "skip the native reader and use the plain-text fallback").option("--replay-last", "also capture the grab already on the clipboard at startup").action((options) => {
2414
+ const dir = path.resolve(options.dir);
2415
+ const intervalMs = Number(options.interval);
2416
+ const textOnly = Boolean(options.textOnly);
2417
+ try {
2418
+ prepareWorkDir(dir);
2419
+ } catch (error) {
2420
+ process.stderr.write(`react-grab watch: ${String(error?.message ?? error)}\n`);
2421
+ process.exit(1);
2422
+ }
2423
+ const reader = createReader({
2424
+ textOnly,
2425
+ readersDir: readersDir(),
2426
+ workDir: dir
2427
+ });
2428
+ if (!reader) {
2429
+ process.stderr.write("react-grab watch: no clipboard reader available. Linux: install xclip or wl-clipboard. macOS: install Xcode CLI tools (swiftc) or rely on pbpaste. Windows: ensure PowerShell is on PATH.\n");
2430
+ process.exit(1);
2431
+ }
2432
+ process.stderr.write(`react-grab watch: watching clipboard via ${reader.mode}; history → ${path.join(dir, "history.jsonl")} (Ctrl+C to stop)\n`);
2433
+ watchForNextGrab({
2434
+ reader,
2435
+ dir,
2436
+ intervalMs: Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : DEFAULT_INTERVAL_MS,
2437
+ replayLast: Boolean(options.replayLast),
2438
+ onWarn: (message) => process.stderr.write(`react-grab watch: ${message}\n`)
2439
+ }).then((grab) => {
2440
+ if (!grab) process.exit(0);
2441
+ process.stdout.write(`${JSON.stringify(grab)}\n`);
2442
+ process.exit(0);
2443
+ }).catch((error) => {
2444
+ process.stderr.write(`react-grab watch: ${String(error?.message ?? error)}\n`);
2445
+ process.exit(1);
2446
+ });
2447
+ });
2448
+ //#endregion
2289
2449
  //#region src/cli.ts
2290
- const VERSION = "0.1.37";
2450
+ const VERSION = "0.1.38";
2291
2451
  const VERSION_API_URL = "https://www.react-grab.com/api/version";
2292
2452
  process.on("SIGINT", () => process.exit(0));
2293
2453
  process.on("SIGTERM", () => process.exit(0));
@@ -2296,10 +2456,11 @@ try {
2296
2456
  } catch {}
2297
2457
  const program = new Command().name("grab").description("add React Grab to your project").version(VERSION, "-v, --version", "display the version number");
2298
2458
  program.addCommand(init);
2299
- program.addCommand(add);
2459
+ program.addCommand(add$1);
2300
2460
  program.addCommand(remove);
2301
2461
  program.addCommand(configure);
2302
2462
  program.addCommand(upgrade);
2463
+ program.addCommand(watch);
2303
2464
  const main = async () => {
2304
2465
  await program.parseAsync();
2305
2466
  };