@react-grab/cli 0.1.38 → 0.1.40

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.cjs CHANGED
@@ -393,6 +393,45 @@ const handleError = (error) => {
393
393
  process.exit(1);
394
394
  };
395
395
  //#endregion
396
+ //#region src/utils/detect-agents.ts
397
+ const PATH_BINARIES = {
398
+ "claude-code": ["claude"],
399
+ codex: ["codex"],
400
+ cursor: ["cursor", "cursor-agent"],
401
+ droid: ["droid"],
402
+ "gemini-cli": ["gemini"],
403
+ "github-copilot": ["copilot"],
404
+ opencode: ["opencode"],
405
+ pi: ["pi", "omegon"]
406
+ };
407
+ const isCommandAvailable = (command) => {
408
+ const pathDirectories = (process.env.PATH ?? "").split(node_path.delimiter).filter(Boolean);
409
+ for (const directory of pathDirectories) {
410
+ const binaryPath = (0, node_path.join)(directory, command);
411
+ try {
412
+ if ((0, node_fs.statSync)(binaryPath).isFile()) {
413
+ (0, node_fs.accessSync)(binaryPath, node_fs.constants.X_OK);
414
+ return true;
415
+ }
416
+ } catch {}
417
+ }
418
+ return false;
419
+ };
420
+ const detectAvailableAgents = async () => {
421
+ const installedAgents = new Set(await (0, agent_install_skill.detectInstalledSkillAgents)());
422
+ return (0, agent_install_skill.getSkillAgentTypes)().filter((agent) => {
423
+ if (agent === "universal") return false;
424
+ if (installedAgents.has(agent)) return true;
425
+ return PATH_BINARIES[agent]?.some(isCommandAvailable) ?? false;
426
+ });
427
+ };
428
+ //#endregion
429
+ //#region src/utils/unref-stdin.ts
430
+ const unrefStdin = () => {
431
+ if (process.stdin.isTTY) return;
432
+ process.stdin.unref?.();
433
+ };
434
+ //#endregion
396
435
  //#region src/utils/prompts.ts
397
436
  const onCancel = () => {
398
437
  logger.break();
@@ -401,7 +440,7 @@ const onCancel = () => {
401
440
  process.exit(0);
402
441
  };
403
442
  const prompts$1 = (questions) => {
404
- return (0, prompts.default)(questions, { onCancel });
443
+ return (0, prompts.default)(questions, { onCancel }).finally(unrefStdin);
405
444
  };
406
445
  //#endregion
407
446
  //#region src/utils/spinner.ts
@@ -413,24 +452,32 @@ const SKILL_SOURCE = (0, node_url.fileURLToPath)(new URL("../skills/react-grab",
413
452
  const agentLabel = (agent) => (0, agent_install_skill.getSkillAgentConfig)(agent).displayName;
414
453
  const installedSkillDir = (agent) => (0, node_path.join)((0, agent_install_skill.isUniversalSkillAgent)(agent) ? (0, agent_install_skill.getCanonicalSkillsDir)(true) : (0, agent_install_skill.getSkillAgentDir)(agent, { global: true }), SKILL_NAME);
415
454
  const promptSkillInstall = async ({ yes = false } = {}) => {
416
- const agents = await (0, agent_install_skill.detectInstalledSkillAgents)();
417
- if (agents.length === 0) {
455
+ const detectedAgents = await detectAvailableAgents();
456
+ if (detectedAgents.length === 0) {
418
457
  logger.warn("No supported agents detected.");
419
458
  return false;
420
459
  }
460
+ let selectedAgents = detectedAgents;
421
461
  if (!yes) {
422
- const { confirmed } = await prompts$1({
423
- type: "confirm",
424
- name: "confirmed",
425
- message: `Install the React Grab skill for ${highlighter.info(agents.map(agentLabel).join(", "))}?`,
426
- initial: true
462
+ const { agents } = await prompts$1({
463
+ type: "multiselect",
464
+ name: "agents",
465
+ message: "Install the React Grab skill for:",
466
+ choices: detectedAgents.map((agent) => ({
467
+ title: agentLabel(agent),
468
+ value: agent,
469
+ selected: true
470
+ })),
471
+ instructions: false,
472
+ min: 1
427
473
  });
428
- if (!confirmed) return false;
474
+ selectedAgents = agents ?? [];
475
+ if (selectedAgents.length === 0) return false;
429
476
  }
430
477
  const installSpinner = spinner("Installing React Grab skill.").start();
431
478
  const { installed, failed } = await (0, agent_install_skill.add)({
432
479
  source: SKILL_SOURCE,
433
- agents,
480
+ agents: selectedAgents,
434
481
  global: true,
435
482
  mode: "copy"
436
483
  });
@@ -443,7 +490,7 @@ const promptSkillInstall = async ({ yes = false } = {}) => {
443
490
  return true;
444
491
  };
445
492
  const removeSkill = async () => {
446
- const agentsWithSkill = (await (0, agent_install_skill.detectInstalledSkillAgents)()).filter((agent) => (0, node_fs.existsSync)(installedSkillDir(agent)));
493
+ const agentsWithSkill = (await detectAvailableAgents()).filter((agent) => (0, node_fs.existsSync)(installedSkillDir(agent)));
447
494
  for (const skillDir of new Set(agentsWithSkill.map(installedSkillDir))) (0, node_fs.rmSync)(skillDir, {
448
495
  recursive: true,
449
496
  force: true
@@ -453,7 +500,7 @@ const removeSkill = async () => {
453
500
  };
454
501
  //#endregion
455
502
  //#region src/commands/add.ts
456
- const VERSION$5 = "0.1.38";
503
+ const VERSION$5 = "0.1.40";
457
504
  const add = new commander.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) => {
458
505
  console.log(`${picocolors.default.magenta("✿")} ${picocolors.default.bold("React Grab")} ${picocolors.default.gray(VERSION$5)}`);
459
506
  console.log();
@@ -856,6 +903,15 @@ const transformTanStack = (projectRoot, reactGrabAlreadyConfigured, force = fals
856
903
  newContent
857
904
  };
858
905
  };
906
+ const hasFrameworkEntryPoint = (projectRoot, framework, nextRouterType) => {
907
+ switch (framework) {
908
+ case "next": return nextRouterType === "app" ? findLayoutFile(projectRoot) !== null : findDocumentFile(projectRoot) !== null;
909
+ case "vite":
910
+ case "webpack": return findEntryFile(projectRoot) !== null;
911
+ case "tanstack": return findTanStackRootFile(projectRoot) !== null;
912
+ default: return false;
913
+ }
914
+ };
859
915
  const previewTransform = (projectRoot, framework, nextRouterType, reactGrabAlreadyConfigured = false, force = false) => {
860
916
  switch (framework) {
861
917
  case "next":
@@ -1038,6 +1094,7 @@ const previewCdnTransform = (projectRoot, framework, nextRouterType, targetCdnDo
1038
1094
  //#endregion
1039
1095
  //#region src/utils/constants.ts
1040
1096
  const MAX_KEY_HOLD_DURATION_MS = 2e3;
1097
+ const DEFAULT_WATCH_DIR = ".react-grab";
1041
1098
  //#endregion
1042
1099
  //#region src/utils/format-activation-key.ts
1043
1100
  const formatActivationKeyDisplay = (activationKey) => {
@@ -1055,7 +1112,7 @@ const formatActivationKeyDisplay = (activationKey) => {
1055
1112
  };
1056
1113
  //#endregion
1057
1114
  //#region src/commands/configure.ts
1058
- const VERSION$4 = "0.1.38";
1115
+ const VERSION$4 = "0.1.40";
1059
1116
  const isMac = process.platform === "darwin";
1060
1117
  const META_LABEL = isMac ? "Cmd" : "Win";
1061
1118
  const ALT_LABEL = isMac ? "Option" : "Alt";
@@ -1674,7 +1731,7 @@ const installPackagesWithFeedback = async (packages, packageManager, projectRoot
1674
1731
  };
1675
1732
  //#endregion
1676
1733
  //#region src/commands/init.ts
1677
- const VERSION$3 = "0.1.38";
1734
+ const VERSION$3 = "0.1.40";
1678
1735
  const REPORT_URL = "https://react-grab.com/api/report-cli";
1679
1736
  const DOCS_URL = "https://github.com/aidenybai/react-grab";
1680
1737
  const reportToCli = (type, config, error) => {
@@ -1732,6 +1789,15 @@ const printSubprojects = (searchRoot, sortedProjects) => {
1732
1789
  logger.log(` ${highlighter.dim("$")} npx grab@latest init -c ${(0, node_path.relative)(searchRoot, sortedProjects[0].path)}`);
1733
1790
  logger.break();
1734
1791
  };
1792
+ const SUPPORTED_FRAMEWORKS_LINE = "React Grab supports Next.js, Vite, TanStack Start, and Webpack projects.";
1793
+ const failWithManualSetup = (failingSpinner, message, { listSupportedFrameworks = false } = {}) => {
1794
+ failingSpinner.fail(message);
1795
+ logger.break();
1796
+ if (listSupportedFrameworks) logger.log(SUPPORTED_FRAMEWORKS_LINE);
1797
+ logger.log(`Visit ${highlighter.info(DOCS_URL)} for manual setup.`);
1798
+ logger.break();
1799
+ process.exit(1);
1800
+ };
1735
1801
  const init = new commander.Command().name("init").alias("setup").description("initialize React Grab in your project").option("-y, --yes", "skip confirmation prompts", false).option("-f, --force", "force overwrite existing config", false).option("-k, --key <key>", "activation key (e.g., Meta+K, Ctrl+Shift+G, Space)").option("--skip-install", "skip package installation", false).option("--pkg <pkg>", "custom package URL for CLI (e.g., grab)").option("-c, --cwd <cwd>", "working directory (defaults to current directory)", process.cwd()).action(async (opts) => {
1736
1802
  console.log(`${picocolors.default.magenta("✿")} ${picocolors.default.bold("React Grab")} ${picocolors.default.gray(VERSION$3)}`);
1737
1803
  console.log();
@@ -1905,7 +1971,7 @@ const init = new commander.Command().name("init").alias("setup").description("in
1905
1971
  logger.break();
1906
1972
  process.exit(1);
1907
1973
  }
1908
- if (projectInfo.framework === "unknown") {
1974
+ if (projectInfo.framework === "unknown" || projectInfo.isMonorepo && !hasFrameworkEntryPoint(projectInfo.projectRoot, projectInfo.framework, projectInfo.nextRouterType)) {
1909
1975
  let searchRoot = cwd;
1910
1976
  let reactProjects = findReactProjects(searchRoot);
1911
1977
  if (reactProjects.length === 0 && cwd !== process.cwd()) {
@@ -1943,23 +2009,10 @@ const init = new commander.Command().name("init").alias("setup").description("in
1943
2009
  const newProjectInfo = await detectProject(selectedProject);
1944
2010
  Object.assign(projectInfo, newProjectInfo);
1945
2011
  const newFrameworkSpinner = spinner("Verifying framework.").start();
1946
- if (newProjectInfo.framework === "unknown") {
1947
- newFrameworkSpinner.fail("Could not detect a supported framework in this project.");
1948
- logger.break();
1949
- logger.log("React Grab supports Next.js, Vite, TanStack Start, and Webpack projects.");
1950
- logger.log(`Visit ${highlighter.info(DOCS_URL)} for manual setup.`);
1951
- logger.break();
1952
- process.exit(1);
1953
- }
2012
+ if (newProjectInfo.framework === "unknown") failWithManualSetup(newFrameworkSpinner, "Could not detect a supported framework in this project.", { listSupportedFrameworks: true });
1954
2013
  newFrameworkSpinner.succeed(`Verifying framework. Found ${highlighter.info(FRAMEWORK_NAMES[newProjectInfo.framework])}.`);
1955
- } else {
1956
- frameworkSpinner.fail("Could not detect a supported framework.");
1957
- logger.break();
1958
- logger.log("React Grab supports Next.js, Vite, TanStack Start, and Webpack projects.");
1959
- logger.log(`Visit ${highlighter.info(DOCS_URL)} for manual setup.`);
1960
- logger.break();
1961
- process.exit(1);
1962
- }
2014
+ } else if (projectInfo.framework !== "unknown") failWithManualSetup(frameworkSpinner, `Verifying framework. Found ${highlighter.info(FRAMEWORK_NAMES[projectInfo.framework])}, but could not find an entry file.`);
2015
+ else failWithManualSetup(frameworkSpinner, "Could not detect a supported framework.", { listSupportedFrameworks: true });
1963
2016
  } else frameworkSpinner.succeed(`Verifying framework. Found ${highlighter.info(FRAMEWORK_NAMES[projectInfo.framework])}.`);
1964
2017
  if (projectInfo.framework === "next") spinner("Detecting router type.").start().succeed(`Detecting router type. Found ${highlighter.info(projectInfo.nextRouterType === "app" ? "App Router" : "Pages Router")}.`);
1965
2018
  spinner("Detecting package manager.").start().succeed(`Detecting package manager. Found ${highlighter.info(PACKAGE_MANAGER_NAMES[projectInfo.packageManager])}.`);
@@ -2022,99 +2075,11 @@ const init = new commander.Command().name("init").alias("setup").description("in
2022
2075
  }
2023
2076
  });
2024
2077
  //#endregion
2025
- //#region src/commands/remove.ts
2026
- const VERSION$2 = "0.1.38";
2027
- const remove = new commander.Command().name("remove").description("uninstall the React Grab skill from your agent").action(async () => {
2028
- console.log(`${picocolors.default.magenta("✿")} ${picocolors.default.bold("React Grab")} ${picocolors.default.gray(VERSION$2)}`);
2029
- console.log();
2030
- try {
2031
- logger.break();
2032
- const removedCount = await removeSkill();
2033
- logger.break();
2034
- if (removedCount === 0) logger.log("React Grab skill is not installed in any detected agent.");
2035
- else logger.log(`${highlighter.success("Removed")} the React Grab skill from ${removedCount} agent${removedCount === 1 ? "" : "s"}.`);
2036
- logger.break();
2037
- } catch (error) {
2038
- handleError(error);
2039
- }
2040
- });
2041
- //#endregion
2042
- //#region src/commands/upgrade.ts
2043
- const VERSION$1 = "0.1.38";
2044
- const NPM_REGISTRY_URL = "https://registry.npmjs.org/react-grab/latest";
2045
- const fetchLatestVersion = async () => {
2046
- try {
2047
- return (await (await fetch(NPM_REGISTRY_URL)).json()).version ?? null;
2048
- } catch {
2049
- return null;
2050
- }
2051
- };
2052
- const isDevDependency = (projectRoot) => {
2053
- const packageJsonPath = (0, node_path.join)(projectRoot, "package.json");
2054
- if (!(0, node_fs.existsSync)(packageJsonPath)) return true;
2055
- try {
2056
- const packageJson = JSON.parse((0, node_fs.readFileSync)(packageJsonPath, "utf-8"));
2057
- if (packageJson.devDependencies?.["react-grab"]) return true;
2058
- if (packageJson.dependencies?.["react-grab"]) return false;
2059
- } catch {}
2060
- return true;
2061
- };
2062
- const upgrade = new commander.Command().name("upgrade").alias("update").description("upgrade react-grab to the latest version").option("-c, --cwd <cwd>", "working directory (defaults to current directory)", process.cwd()).action(async (opts) => {
2063
- console.log(`${picocolors.default.magenta("✿")} ${picocolors.default.bold("React Grab")} ${picocolors.default.gray(VERSION$1)}`);
2064
- console.log();
2065
- try {
2066
- const cwd = (0, node_path.resolve)(opts.cwd);
2067
- const detectSpinner = spinner("Detecting project.").start();
2068
- const projectInfo = await detectProject(cwd);
2069
- if (!projectInfo.hasReactGrab) {
2070
- detectSpinner.fail("React Grab is not installed.");
2071
- logger.break();
2072
- logger.error(`Run ${highlighter.info("npx grab@latest init")} first to install React Grab.`);
2073
- logger.break();
2074
- process.exit(1);
2075
- }
2076
- detectSpinner.succeed();
2077
- const versionSpinner = spinner("Checking for updates.").start();
2078
- const latestVersion = await fetchLatestVersion();
2079
- if (!latestVersion) {
2080
- versionSpinner.fail("Could not check for updates.");
2081
- logger.break();
2082
- logger.error("Failed to reach the npm registry. Check your network connection.");
2083
- logger.break();
2084
- process.exit(1);
2085
- }
2086
- const installedVersion = projectInfo.reactGrabVersion;
2087
- if (installedVersion && installedVersion === latestVersion) {
2088
- versionSpinner.succeed(`Already on the latest version ${highlighter.info(`v${latestVersion}`)}.`);
2089
- logger.break();
2090
- process.exit(0);
2091
- }
2092
- const fromLabel = installedVersion ? `v${installedVersion}` : "unknown";
2093
- versionSpinner.succeed(`Update available: ${highlighter.dim(fromLabel)} → ${highlighter.info(`v${latestVersion}`)}.`);
2094
- const upgradeSpinner = spinner("Upgrading react-grab.").start();
2095
- try {
2096
- await installPackages(["react-grab@latest"], {
2097
- packageManager: projectInfo.packageManager,
2098
- cwd: projectInfo.projectRoot,
2099
- isDev: isDevDependency(projectInfo.projectRoot)
2100
- });
2101
- upgradeSpinner.succeed();
2102
- } catch {
2103
- upgradeSpinner.fail();
2104
- logger.break();
2105
- logger.error("Failed to upgrade. Check your network connection and try again.");
2106
- logger.break();
2107
- process.exit(1);
2108
- }
2109
- logger.break();
2110
- logger.log(`${highlighter.success("Success!")} React Grab has been upgraded to ${highlighter.info(`v${latestVersion}`)}.`);
2111
- logger.break();
2112
- } catch (error) {
2113
- handleError(error);
2114
- }
2115
- });
2078
+ //#region src/utils/sleep.ts
2079
+ const sleep = (durationMs) => new Promise((resolve) => setTimeout(resolve, durationMs));
2116
2080
  //#endregion
2117
2081
  //#region src/utils/clipboard.ts
2082
+ const HISTORY_FILE_NAME = "history.jsonl";
2118
2083
  const READ_TIMEOUT_MS = 2500;
2119
2084
  const MAX_CLIPBOARD_BYTES = 64 * 1024 * 1024;
2120
2085
  const ID_RADIX = 36;
@@ -2125,7 +2090,6 @@ const SIGNATURE_SCAN_CHARS = 32 * 1024;
2125
2090
  const GRAB_MIME = "application/x-react-grab";
2126
2091
  const CHROMIUM_CUSTOM_FORMAT = "chromium/x-web-custom-data";
2127
2092
  const GRAB_TEXT_SIGNATURE = /\bin\s+\S+\s+\(at\s+[^\n]{1,400}?:\d+:\d+\)/;
2128
- const sleep = (durationMs) => new Promise((resolve) => setTimeout(resolve, durationMs));
2129
2093
  const shortHash = (text) => (0, node_crypto.createHash)("sha1").update(text).digest("hex").slice(0, HASH_LENGTH);
2130
2094
  const alignUp = (value) => value + PICKLE_ALIGN_BYTES - 1 & ~(PICKLE_ALIGN_BYTES - 1);
2131
2095
  const parseChromiumPickle = (buffer) => {
@@ -2357,9 +2321,9 @@ const prepareWorkDir = (dir) => {
2357
2321
  const gitignore = node_path.default.join(dir, ".gitignore");
2358
2322
  if (!node_fs.default.existsSync(gitignore)) node_fs.default.writeFileSync(gitignore, "*\n");
2359
2323
  };
2360
- const watchForNextGrab = async (options) => {
2361
- const { reader, dir, intervalMs, replayLast, onWarn, signal } = options;
2362
- const logPath = node_path.default.join(dir, "history.jsonl");
2324
+ const runWatchLoop = async (options) => {
2325
+ const { reader, dir, intervalMs, replayLast, onWarn } = options;
2326
+ const logPath = node_path.default.join(dir, HISTORY_FILE_NAME);
2363
2327
  const { read } = reader;
2364
2328
  let lastChangeCount = null;
2365
2329
  let lastTimestamp = 0;
@@ -2374,9 +2338,8 @@ const watchForNextGrab = async (options) => {
2374
2338
  lastTimestamp = JSON.parse(initial.grab).timestamp ?? 0;
2375
2339
  } catch {}
2376
2340
  }
2377
- while (!signal?.aborted) {
2341
+ while (true) {
2378
2342
  await sleep(intervalMs);
2379
- if (signal?.aborted) return null;
2380
2343
  try {
2381
2344
  const snapshot = read();
2382
2345
  if (!snapshot) continue;
@@ -2422,7 +2385,6 @@ const watchForNextGrab = async (options) => {
2422
2385
  lastChangeCount = snapshot.changeCount;
2423
2386
  lastTextHash = textHash;
2424
2387
  lastTimestamp = nextTimestamp;
2425
- return captured;
2426
2388
  } catch (error) {
2427
2389
  const message = String(error?.message ?? error);
2428
2390
  if (message !== lastErrorMessage) {
@@ -2431,51 +2393,315 @@ const watchForNextGrab = async (options) => {
2431
2393
  }
2432
2394
  }
2433
2395
  }
2434
- return null;
2435
2396
  };
2436
2397
  //#endregion
2437
- //#region src/commands/watch.ts
2438
- const DEFAULT_INTERVAL_MS = 800;
2439
- const DEFAULT_DIR = ".react-grab";
2440
- const readersDir = () => node_path.default.dirname((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
2441
- const watch = new commander.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) => {
2398
+ //#region src/utils/grab-log.ts
2399
+ const CURSOR_FILE_NAME = "cursor.txt";
2400
+ const cursorFilePath = (dir) => node_path.default.join(dir, CURSOR_FILE_NAME);
2401
+ const readCompleteGrabLines = (dir) => {
2402
+ let raw;
2403
+ try {
2404
+ raw = node_fs.default.readFileSync(node_path.default.join(dir, HISTORY_FILE_NAME), "utf8");
2405
+ } catch {
2406
+ return [];
2407
+ }
2408
+ const lastNewline = raw.lastIndexOf("\n");
2409
+ if (lastNewline < 0) return [];
2410
+ return raw.slice(0, lastNewline).split("\n").filter(Boolean);
2411
+ };
2412
+ const readGrabCursor = (dir) => {
2413
+ try {
2414
+ const value = Number.parseInt(node_fs.default.readFileSync(cursorFilePath(dir), "utf8").trim(), 10);
2415
+ return Number.isInteger(value) && value >= 0 ? value : 0;
2416
+ } catch {
2417
+ return 0;
2418
+ }
2419
+ };
2420
+ const consumeGrabs = (dir, options) => {
2421
+ const lines = readCompleteGrabLines(dir);
2422
+ if (options.all) return lines;
2423
+ const cursor = readGrabCursor(dir);
2424
+ const start = Math.min(cursor, lines.length);
2425
+ const end = options.limit > 0 ? Math.min(start + options.limit, lines.length) : lines.length;
2426
+ if (end !== cursor) node_fs.default.writeFileSync(cursorFilePath(dir), String(end));
2427
+ return lines.slice(start, end);
2428
+ };
2429
+ //#endregion
2430
+ //#region src/commands/read.ts
2431
+ const read = new commander.Command().name("read").description("print React Grab selections captured since the last read, then advance the cursor").option("-d, --dir <dir>", "work dir holding history.jsonl + cursor.txt", DEFAULT_WATCH_DIR).option("-w, --wait <ms>", "block up to <ms> for at least one new grab (default: no wait)").option("-n, --limit <count>", "max grabs to print per call (0 = no limit); the cursor only advances past what is printed", String(50)).option("--all", "print the entire history without advancing the cursor").action(async (options) => {
2442
2432
  const dir = node_path.default.resolve(options.dir);
2443
- const intervalMs = Number(options.interval);
2444
- const textOnly = Boolean(options.textOnly);
2445
2433
  try {
2446
2434
  prepareWorkDir(dir);
2447
2435
  } catch (error) {
2448
- process.stderr.write(`react-grab watch: ${String(error?.message ?? error)}\n`);
2436
+ process.stderr.write(`react-grab read: ${String(error?.message ?? error)}\n`);
2449
2437
  process.exit(1);
2450
2438
  }
2439
+ unrefStdin();
2440
+ const limitRaw = Number(options.limit);
2441
+ const limit = Number.isInteger(limitRaw) && limitRaw >= 0 ? limitRaw : 50;
2442
+ const all = Boolean(options.all);
2443
+ const drain = () => {
2444
+ const fresh = consumeGrabs(dir, {
2445
+ limit,
2446
+ all
2447
+ });
2448
+ if (fresh.length > 0) process.stdout.write(`${fresh.join("\n")}\n`);
2449
+ return fresh.length;
2450
+ };
2451
+ const waitRaw = options.wait ? Number(options.wait) : 0;
2452
+ const deadline = Date.now() + (Number.isFinite(waitRaw) && waitRaw > 0 ? waitRaw : 0);
2453
+ if (drain() > 0) process.exit(0);
2454
+ while (Date.now() < deadline) {
2455
+ await sleep(200);
2456
+ if (drain() > 0) break;
2457
+ }
2458
+ process.exit(0);
2459
+ });
2460
+ //#endregion
2461
+ //#region src/commands/remove.ts
2462
+ const VERSION$2 = "0.1.40";
2463
+ const remove = new commander.Command().name("remove").description("uninstall the React Grab skill from your agent").action(async () => {
2464
+ console.log(`${picocolors.default.magenta("✿")} ${picocolors.default.bold("React Grab")} ${picocolors.default.gray(VERSION$2)}`);
2465
+ console.log();
2466
+ try {
2467
+ logger.break();
2468
+ const removedCount = await removeSkill();
2469
+ logger.break();
2470
+ if (removedCount === 0) logger.log("React Grab skill is not installed in any detected agent.");
2471
+ else logger.log(`${highlighter.success("Removed")} the React Grab skill from ${removedCount} agent${removedCount === 1 ? "" : "s"}.`);
2472
+ logger.break();
2473
+ } catch (error) {
2474
+ handleError(error);
2475
+ }
2476
+ });
2477
+ //#endregion
2478
+ //#region src/commands/upgrade.ts
2479
+ const VERSION$1 = "0.1.40";
2480
+ const NPM_REGISTRY_URL = "https://registry.npmjs.org/react-grab/latest";
2481
+ const fetchLatestVersion = async () => {
2482
+ try {
2483
+ return (await (await fetch(NPM_REGISTRY_URL)).json()).version ?? null;
2484
+ } catch {
2485
+ return null;
2486
+ }
2487
+ };
2488
+ const isDevDependency = (projectRoot) => {
2489
+ const packageJsonPath = (0, node_path.join)(projectRoot, "package.json");
2490
+ if (!(0, node_fs.existsSync)(packageJsonPath)) return true;
2491
+ try {
2492
+ const packageJson = JSON.parse((0, node_fs.readFileSync)(packageJsonPath, "utf-8"));
2493
+ if (packageJson.devDependencies?.["react-grab"]) return true;
2494
+ if (packageJson.dependencies?.["react-grab"]) return false;
2495
+ } catch {}
2496
+ return true;
2497
+ };
2498
+ const upgrade = new commander.Command().name("upgrade").alias("update").description("upgrade react-grab to the latest version").option("-c, --cwd <cwd>", "working directory (defaults to current directory)", process.cwd()).action(async (opts) => {
2499
+ console.log(`${picocolors.default.magenta("✿")} ${picocolors.default.bold("React Grab")} ${picocolors.default.gray(VERSION$1)}`);
2500
+ console.log();
2501
+ try {
2502
+ const cwd = (0, node_path.resolve)(opts.cwd);
2503
+ const detectSpinner = spinner("Detecting project.").start();
2504
+ const projectInfo = await detectProject(cwd);
2505
+ if (!projectInfo.hasReactGrab) {
2506
+ detectSpinner.fail("React Grab is not installed.");
2507
+ logger.break();
2508
+ logger.error(`Run ${highlighter.info("npx grab@latest init")} first to install React Grab.`);
2509
+ logger.break();
2510
+ process.exit(1);
2511
+ }
2512
+ detectSpinner.succeed();
2513
+ const versionSpinner = spinner("Checking for updates.").start();
2514
+ const latestVersion = await fetchLatestVersion();
2515
+ if (!latestVersion) {
2516
+ versionSpinner.fail("Could not check for updates.");
2517
+ logger.break();
2518
+ logger.error("Failed to reach the npm registry. Check your network connection.");
2519
+ logger.break();
2520
+ process.exit(1);
2521
+ }
2522
+ const installedVersion = projectInfo.reactGrabVersion;
2523
+ if (installedVersion && installedVersion === latestVersion) {
2524
+ versionSpinner.succeed(`Already on the latest version ${highlighter.info(`v${latestVersion}`)}.`);
2525
+ logger.break();
2526
+ process.exit(0);
2527
+ }
2528
+ const fromLabel = installedVersion ? `v${installedVersion}` : "unknown";
2529
+ versionSpinner.succeed(`Update available: ${highlighter.dim(fromLabel)} → ${highlighter.info(`v${latestVersion}`)}.`);
2530
+ const upgradeSpinner = spinner("Upgrading react-grab.").start();
2531
+ try {
2532
+ await installPackages(["react-grab@latest"], {
2533
+ packageManager: projectInfo.packageManager,
2534
+ cwd: projectInfo.projectRoot,
2535
+ isDev: isDevDependency(projectInfo.projectRoot)
2536
+ });
2537
+ upgradeSpinner.succeed();
2538
+ } catch {
2539
+ upgradeSpinner.fail();
2540
+ logger.break();
2541
+ logger.error("Failed to upgrade. Check your network connection and try again.");
2542
+ logger.break();
2543
+ process.exit(1);
2544
+ }
2545
+ logger.break();
2546
+ logger.log(`${highlighter.success("Success!")} React Grab has been upgraded to ${highlighter.info(`v${latestVersion}`)}.`);
2547
+ logger.break();
2548
+ } catch (error) {
2549
+ handleError(error);
2550
+ }
2551
+ });
2552
+ //#endregion
2553
+ //#region src/utils/daemon.ts
2554
+ const PID_FILE_NAME = "watch.pid";
2555
+ const pidFilePath = (dir) => node_path.default.join(dir, PID_FILE_NAME);
2556
+ const cliEntryPath = () => process.argv[1] ?? (0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href);
2557
+ const isProcessAlive = (pid) => {
2558
+ if (!Number.isInteger(pid) || pid <= 0) return false;
2559
+ try {
2560
+ process.kill(pid, 0);
2561
+ return true;
2562
+ } catch (error) {
2563
+ return error.code === "EPERM";
2564
+ }
2565
+ };
2566
+ const readDaemonPid = (dir) => {
2567
+ try {
2568
+ const pid = Number.parseInt(node_fs.default.readFileSync(pidFilePath(dir), "utf8").trim(), 10);
2569
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
2570
+ } catch {
2571
+ return null;
2572
+ }
2573
+ };
2574
+ const isDaemonRunning = (dir) => {
2575
+ const pid = readDaemonPid(dir);
2576
+ return pid !== null && isProcessAlive(pid);
2577
+ };
2578
+ const claimDaemon = (dir) => {
2579
+ const file = pidFilePath(dir);
2580
+ for (let attempt = 0; attempt < 50; attempt += 1) try {
2581
+ const handle = node_fs.default.openSync(file, "wx");
2582
+ node_fs.default.writeFileSync(handle, String(process.pid));
2583
+ node_fs.default.closeSync(handle);
2584
+ return readDaemonPid(dir) === process.pid;
2585
+ } catch (error) {
2586
+ if (error.code !== "EEXIST") throw error;
2587
+ if (isDaemonRunning(dir)) return false;
2588
+ try {
2589
+ node_fs.default.rmSync(file, { force: true });
2590
+ } catch {}
2591
+ }
2592
+ return false;
2593
+ };
2594
+ const releaseDaemon = (dir) => {
2595
+ if (readDaemonPid(dir) === process.pid) try {
2596
+ node_fs.default.rmSync(pidFilePath(dir), { force: true });
2597
+ } catch {}
2598
+ };
2599
+ const stopDaemon = (dir) => {
2600
+ const pid = readDaemonPid(dir);
2601
+ if (pid === null) return null;
2602
+ const wasAlive = isProcessAlive(pid);
2603
+ if (wasAlive) try {
2604
+ process.kill(pid, "SIGTERM");
2605
+ } catch {}
2606
+ if (readDaemonPid(dir) === pid) try {
2607
+ node_fs.default.rmSync(pidFilePath(dir), { force: true });
2608
+ } catch {}
2609
+ return wasAlive ? pid : null;
2610
+ };
2611
+ const spawnDaemon = (options) => {
2612
+ const args = [
2613
+ cliEntryPath(),
2614
+ "watch",
2615
+ "--foreground",
2616
+ "--dir",
2617
+ options.dir,
2618
+ "--interval",
2619
+ String(options.intervalMs)
2620
+ ];
2621
+ if (options.textOnly) args.push("--text-only");
2622
+ if (options.replayLast) args.push("--replay-last");
2623
+ (0, node_child_process.spawn)(process.execPath, args, {
2624
+ detached: true,
2625
+ stdio: "ignore",
2626
+ windowsHide: true
2627
+ }).unref();
2628
+ };
2629
+ //#endregion
2630
+ //#region src/commands/watch.ts
2631
+ const readersDir = () => node_path.default.dirname((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
2632
+ const NO_READER_MESSAGE = "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.";
2633
+ const writeStatus = (message) => {
2634
+ process.stderr.write(`react-grab watch: ${message}\n`);
2635
+ };
2636
+ const runForeground = (dir, intervalMs, textOnly, replayLast) => {
2637
+ if (!claimDaemon(dir)) process.exit(0);
2638
+ process.on("exit", () => releaseDaemon(dir));
2451
2639
  const reader = createReader({
2452
2640
  textOnly,
2453
2641
  readersDir: readersDir(),
2454
2642
  workDir: dir
2455
2643
  });
2456
2644
  if (!reader) {
2457
- 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");
2645
+ writeStatus(NO_READER_MESSAGE);
2458
2646
  process.exit(1);
2459
2647
  }
2460
- process.stderr.write(`react-grab watch: watching clipboard via ${reader.mode}; history → ${node_path.default.join(dir, "history.jsonl")} (Ctrl+C to stop)\n`);
2461
- watchForNextGrab({
2648
+ writeStatus(`watching clipboard via ${reader.mode}; history → ${node_path.default.join(dir, HISTORY_FILE_NAME)}`);
2649
+ runWatchLoop({
2462
2650
  reader,
2463
2651
  dir,
2464
- intervalMs: Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : DEFAULT_INTERVAL_MS,
2465
- replayLast: Boolean(options.replayLast),
2466
- onWarn: (message) => process.stderr.write(`react-grab watch: ${message}\n`)
2467
- }).then((grab) => {
2468
- if (!grab) process.exit(0);
2469
- process.stdout.write(`${JSON.stringify(grab)}\n`);
2470
- process.exit(0);
2652
+ intervalMs,
2653
+ replayLast,
2654
+ onWarn: writeStatus
2471
2655
  }).catch((error) => {
2472
- process.stderr.write(`react-grab watch: ${String(error?.message ?? error)}\n`);
2656
+ writeStatus(String(error?.message ?? error));
2473
2657
  process.exit(1);
2474
2658
  });
2659
+ };
2660
+ const watch = new commander.Command().name("watch").description("start a background daemon that captures React Grab selections to history.jsonl").option("-d, --dir <dir>", "work dir for history.jsonl + watch.pid", DEFAULT_WATCH_DIR).option("-i, --interval <ms>", "clipboard poll interval in ms", String(800)).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").option("--foreground", "run the capture loop in this process instead of detaching a daemon").option("--stop", "stop the daemon watching this dir").action((options) => {
2661
+ const dir = node_path.default.resolve(options.dir);
2662
+ const intervalRaw = Number(options.interval);
2663
+ const intervalMs = Number.isFinite(intervalRaw) && intervalRaw > 0 ? intervalRaw : 800;
2664
+ const textOnly = Boolean(options.textOnly);
2665
+ const replayLast = Boolean(options.replayLast);
2666
+ try {
2667
+ prepareWorkDir(dir);
2668
+ } catch (error) {
2669
+ writeStatus(String(error?.message ?? error));
2670
+ process.exit(1);
2671
+ }
2672
+ if (options.stop) {
2673
+ const stoppedPid = stopDaemon(dir);
2674
+ writeStatus(stoppedPid ? `stopped daemon (pid ${stoppedPid})` : `no daemon running for ${dir}`);
2675
+ process.exit(0);
2676
+ }
2677
+ if (options.foreground) {
2678
+ runForeground(dir, intervalMs, textOnly, replayLast);
2679
+ return;
2680
+ }
2681
+ if (isDaemonRunning(dir)) {
2682
+ writeStatus(`already watching ${dir} (pid ${readDaemonPid(dir)})`);
2683
+ process.exit(0);
2684
+ }
2685
+ if (!createReader({
2686
+ textOnly,
2687
+ readersDir: readersDir(),
2688
+ workDir: dir
2689
+ })) {
2690
+ writeStatus(NO_READER_MESSAGE);
2691
+ process.exit(1);
2692
+ }
2693
+ spawnDaemon({
2694
+ dir,
2695
+ intervalMs,
2696
+ textOnly,
2697
+ replayLast
2698
+ });
2699
+ writeStatus(`started; capturing grabs → ${node_path.default.join(dir, HISTORY_FILE_NAME)} (run \`grab read\` to consume, \`grab watch --stop\` to stop)`);
2700
+ process.exit(0);
2475
2701
  });
2476
2702
  //#endregion
2477
2703
  //#region src/cli.ts
2478
- const VERSION = "0.1.38";
2704
+ const VERSION = "0.1.40";
2479
2705
  const VERSION_API_URL = "https://www.react-grab.com/api/version";
2480
2706
  process.on("SIGINT", () => process.exit(0));
2481
2707
  process.on("SIGTERM", () => process.exit(0));
@@ -2489,6 +2715,7 @@ program.addCommand(remove);
2489
2715
  program.addCommand(configure);
2490
2716
  program.addCommand(upgrade);
2491
2717
  program.addCommand(watch);
2718
+ program.addCommand(read);
2492
2719
  const main = async () => {
2493
2720
  await program.parseAsync();
2494
2721
  };