@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.js CHANGED
@@ -1,16 +1,16 @@
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, rmSync, writeFileSync } from "node:fs";
5
- import path, { basename, dirname, join, relative, resolve } from "node:path";
4
+ import fs, { accessSync, constants, existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
5
+ import path, { basename, delimiter, dirname, join, relative, resolve } from "node:path";
6
6
  import { detect } from "package-manager-detector/detect";
7
7
  import ignore from "ignore";
8
8
  import { fileURLToPath } from "node:url";
9
- import { add, detectInstalledSkillAgents, getCanonicalSkillsDir, getSkillAgentConfig, getSkillAgentDir, isUniversalSkillAgent } from "agent-install/skill";
9
+ import { add, detectInstalledSkillAgents, getCanonicalSkillsDir, getSkillAgentConfig, getSkillAgentDir, getSkillAgentTypes, isUniversalSkillAgent } from "agent-install/skill";
10
10
  import basePrompts from "prompts";
11
11
  import ora from "ora";
12
12
  import { x } from "tinyexec";
13
- import { spawnSync } from "node:child_process";
13
+ import { spawn, spawnSync } from "node:child_process";
14
14
  import { createHash } from "node:crypto";
15
15
  //#region src/utils/is-non-interactive.ts
16
16
  const AGENT_ENVIRONMENT_VARIABLES = [
@@ -365,6 +365,45 @@ const handleError = (error) => {
365
365
  process.exit(1);
366
366
  };
367
367
  //#endregion
368
+ //#region src/utils/detect-agents.ts
369
+ const PATH_BINARIES = {
370
+ "claude-code": ["claude"],
371
+ codex: ["codex"],
372
+ cursor: ["cursor", "cursor-agent"],
373
+ droid: ["droid"],
374
+ "gemini-cli": ["gemini"],
375
+ "github-copilot": ["copilot"],
376
+ opencode: ["opencode"],
377
+ pi: ["pi", "omegon"]
378
+ };
379
+ const isCommandAvailable = (command) => {
380
+ const pathDirectories = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
381
+ for (const directory of pathDirectories) {
382
+ const binaryPath = join(directory, command);
383
+ try {
384
+ if (statSync(binaryPath).isFile()) {
385
+ accessSync(binaryPath, constants.X_OK);
386
+ return true;
387
+ }
388
+ } catch {}
389
+ }
390
+ return false;
391
+ };
392
+ const detectAvailableAgents = async () => {
393
+ const installedAgents = new Set(await detectInstalledSkillAgents());
394
+ return getSkillAgentTypes().filter((agent) => {
395
+ if (agent === "universal") return false;
396
+ if (installedAgents.has(agent)) return true;
397
+ return PATH_BINARIES[agent]?.some(isCommandAvailable) ?? false;
398
+ });
399
+ };
400
+ //#endregion
401
+ //#region src/utils/unref-stdin.ts
402
+ const unrefStdin = () => {
403
+ if (process.stdin.isTTY) return;
404
+ process.stdin.unref?.();
405
+ };
406
+ //#endregion
368
407
  //#region src/utils/prompts.ts
369
408
  const onCancel = () => {
370
409
  logger.break();
@@ -373,7 +412,7 @@ const onCancel = () => {
373
412
  process.exit(0);
374
413
  };
375
414
  const prompts = (questions) => {
376
- return basePrompts(questions, { onCancel });
415
+ return basePrompts(questions, { onCancel }).finally(unrefStdin);
377
416
  };
378
417
  //#endregion
379
418
  //#region src/utils/spinner.ts
@@ -385,24 +424,32 @@ const SKILL_SOURCE = fileURLToPath(new URL("../skills/react-grab", import.meta.u
385
424
  const agentLabel = (agent) => getSkillAgentConfig(agent).displayName;
386
425
  const installedSkillDir = (agent) => join(isUniversalSkillAgent(agent) ? getCanonicalSkillsDir(true) : getSkillAgentDir(agent, { global: true }), SKILL_NAME);
387
426
  const promptSkillInstall = async ({ yes = false } = {}) => {
388
- const agents = await detectInstalledSkillAgents();
389
- if (agents.length === 0) {
427
+ const detectedAgents = await detectAvailableAgents();
428
+ if (detectedAgents.length === 0) {
390
429
  logger.warn("No supported agents detected.");
391
430
  return false;
392
431
  }
432
+ let selectedAgents = detectedAgents;
393
433
  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
434
+ const { agents } = await prompts({
435
+ type: "multiselect",
436
+ name: "agents",
437
+ message: "Install the React Grab skill for:",
438
+ choices: detectedAgents.map((agent) => ({
439
+ title: agentLabel(agent),
440
+ value: agent,
441
+ selected: true
442
+ })),
443
+ instructions: false,
444
+ min: 1
399
445
  });
400
- if (!confirmed) return false;
446
+ selectedAgents = agents ?? [];
447
+ if (selectedAgents.length === 0) return false;
401
448
  }
402
449
  const installSpinner = spinner("Installing React Grab skill.").start();
403
450
  const { installed, failed } = await add({
404
451
  source: SKILL_SOURCE,
405
- agents,
452
+ agents: selectedAgents,
406
453
  global: true,
407
454
  mode: "copy"
408
455
  });
@@ -415,7 +462,7 @@ const promptSkillInstall = async ({ yes = false } = {}) => {
415
462
  return true;
416
463
  };
417
464
  const removeSkill = async () => {
418
- const agentsWithSkill = (await detectInstalledSkillAgents()).filter((agent) => existsSync(installedSkillDir(agent)));
465
+ const agentsWithSkill = (await detectAvailableAgents()).filter((agent) => existsSync(installedSkillDir(agent)));
419
466
  for (const skillDir of new Set(agentsWithSkill.map(installedSkillDir))) rmSync(skillDir, {
420
467
  recursive: true,
421
468
  force: true
@@ -425,7 +472,7 @@ const removeSkill = async () => {
425
472
  };
426
473
  //#endregion
427
474
  //#region src/commands/add.ts
428
- const VERSION$5 = "0.1.38";
475
+ const VERSION$5 = "0.1.40";
429
476
  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) => {
430
477
  console.log(`${pc.magenta("✿")} ${pc.bold("React Grab")} ${pc.gray(VERSION$5)}`);
431
478
  console.log();
@@ -828,6 +875,15 @@ const transformTanStack = (projectRoot, reactGrabAlreadyConfigured, force = fals
828
875
  newContent
829
876
  };
830
877
  };
878
+ const hasFrameworkEntryPoint = (projectRoot, framework, nextRouterType) => {
879
+ switch (framework) {
880
+ case "next": return nextRouterType === "app" ? findLayoutFile(projectRoot) !== null : findDocumentFile(projectRoot) !== null;
881
+ case "vite":
882
+ case "webpack": return findEntryFile(projectRoot) !== null;
883
+ case "tanstack": return findTanStackRootFile(projectRoot) !== null;
884
+ default: return false;
885
+ }
886
+ };
831
887
  const previewTransform = (projectRoot, framework, nextRouterType, reactGrabAlreadyConfigured = false, force = false) => {
832
888
  switch (framework) {
833
889
  case "next":
@@ -1010,6 +1066,7 @@ const previewCdnTransform = (projectRoot, framework, nextRouterType, targetCdnDo
1010
1066
  //#endregion
1011
1067
  //#region src/utils/constants.ts
1012
1068
  const MAX_KEY_HOLD_DURATION_MS = 2e3;
1069
+ const DEFAULT_WATCH_DIR = ".react-grab";
1013
1070
  //#endregion
1014
1071
  //#region src/utils/format-activation-key.ts
1015
1072
  const formatActivationKeyDisplay = (activationKey) => {
@@ -1027,7 +1084,7 @@ const formatActivationKeyDisplay = (activationKey) => {
1027
1084
  };
1028
1085
  //#endregion
1029
1086
  //#region src/commands/configure.ts
1030
- const VERSION$4 = "0.1.38";
1087
+ const VERSION$4 = "0.1.40";
1031
1088
  const isMac = process.platform === "darwin";
1032
1089
  const META_LABEL = isMac ? "Cmd" : "Win";
1033
1090
  const ALT_LABEL = isMac ? "Option" : "Alt";
@@ -1646,7 +1703,7 @@ const installPackagesWithFeedback = async (packages, packageManager, projectRoot
1646
1703
  };
1647
1704
  //#endregion
1648
1705
  //#region src/commands/init.ts
1649
- const VERSION$3 = "0.1.38";
1706
+ const VERSION$3 = "0.1.40";
1650
1707
  const REPORT_URL = "https://react-grab.com/api/report-cli";
1651
1708
  const DOCS_URL = "https://github.com/aidenybai/react-grab";
1652
1709
  const reportToCli = (type, config, error) => {
@@ -1704,6 +1761,15 @@ const printSubprojects = (searchRoot, sortedProjects) => {
1704
1761
  logger.log(` ${highlighter.dim("$")} npx grab@latest init -c ${relative(searchRoot, sortedProjects[0].path)}`);
1705
1762
  logger.break();
1706
1763
  };
1764
+ const SUPPORTED_FRAMEWORKS_LINE = "React Grab supports Next.js, Vite, TanStack Start, and Webpack projects.";
1765
+ const failWithManualSetup = (failingSpinner, message, { listSupportedFrameworks = false } = {}) => {
1766
+ failingSpinner.fail(message);
1767
+ logger.break();
1768
+ if (listSupportedFrameworks) logger.log(SUPPORTED_FRAMEWORKS_LINE);
1769
+ logger.log(`Visit ${highlighter.info(DOCS_URL)} for manual setup.`);
1770
+ logger.break();
1771
+ process.exit(1);
1772
+ };
1707
1773
  const init = new 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) => {
1708
1774
  console.log(`${pc.magenta("✿")} ${pc.bold("React Grab")} ${pc.gray(VERSION$3)}`);
1709
1775
  console.log();
@@ -1877,7 +1943,7 @@ const init = new Command().name("init").alias("setup").description("initialize R
1877
1943
  logger.break();
1878
1944
  process.exit(1);
1879
1945
  }
1880
- if (projectInfo.framework === "unknown") {
1946
+ if (projectInfo.framework === "unknown" || projectInfo.isMonorepo && !hasFrameworkEntryPoint(projectInfo.projectRoot, projectInfo.framework, projectInfo.nextRouterType)) {
1881
1947
  let searchRoot = cwd;
1882
1948
  let reactProjects = findReactProjects(searchRoot);
1883
1949
  if (reactProjects.length === 0 && cwd !== process.cwd()) {
@@ -1915,23 +1981,10 @@ const init = new Command().name("init").alias("setup").description("initialize R
1915
1981
  const newProjectInfo = await detectProject(selectedProject);
1916
1982
  Object.assign(projectInfo, newProjectInfo);
1917
1983
  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
- }
1984
+ if (newProjectInfo.framework === "unknown") failWithManualSetup(newFrameworkSpinner, "Could not detect a supported framework in this project.", { listSupportedFrameworks: true });
1926
1985
  newFrameworkSpinner.succeed(`Verifying framework. Found ${highlighter.info(FRAMEWORK_NAMES[newProjectInfo.framework])}.`);
1927
- } else {
1928
- frameworkSpinner.fail("Could not detect a supported framework.");
1929
- logger.break();
1930
- logger.log("React Grab supports Next.js, Vite, TanStack Start, and Webpack projects.");
1931
- logger.log(`Visit ${highlighter.info(DOCS_URL)} for manual setup.`);
1932
- logger.break();
1933
- process.exit(1);
1934
- }
1986
+ } else if (projectInfo.framework !== "unknown") failWithManualSetup(frameworkSpinner, `Verifying framework. Found ${highlighter.info(FRAMEWORK_NAMES[projectInfo.framework])}, but could not find an entry file.`);
1987
+ else failWithManualSetup(frameworkSpinner, "Could not detect a supported framework.", { listSupportedFrameworks: true });
1935
1988
  } else frameworkSpinner.succeed(`Verifying framework. Found ${highlighter.info(FRAMEWORK_NAMES[projectInfo.framework])}.`);
1936
1989
  if (projectInfo.framework === "next") spinner("Detecting router type.").start().succeed(`Detecting router type. Found ${highlighter.info(projectInfo.nextRouterType === "app" ? "App Router" : "Pages Router")}.`);
1937
1990
  spinner("Detecting package manager.").start().succeed(`Detecting package manager. Found ${highlighter.info(PACKAGE_MANAGER_NAMES[projectInfo.packageManager])}.`);
@@ -1994,99 +2047,11 @@ const init = new Command().name("init").alias("setup").description("initialize R
1994
2047
  }
1995
2048
  });
1996
2049
  //#endregion
1997
- //#region src/commands/remove.ts
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 () => {
2000
- console.log(`${pc.magenta("✿")} ${pc.bold("React Grab")} ${pc.gray(VERSION$2)}`);
2001
- console.log();
2002
- try {
2003
- logger.break();
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"}.`);
2008
- logger.break();
2009
- } catch (error) {
2010
- handleError(error);
2011
- }
2012
- });
2013
- //#endregion
2014
- //#region src/commands/upgrade.ts
2015
- const VERSION$1 = "0.1.38";
2016
- const NPM_REGISTRY_URL = "https://registry.npmjs.org/react-grab/latest";
2017
- const fetchLatestVersion = async () => {
2018
- try {
2019
- return (await (await fetch(NPM_REGISTRY_URL)).json()).version ?? null;
2020
- } catch {
2021
- return null;
2022
- }
2023
- };
2024
- const isDevDependency = (projectRoot) => {
2025
- const packageJsonPath = join(projectRoot, "package.json");
2026
- if (!existsSync(packageJsonPath)) return true;
2027
- try {
2028
- const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
2029
- if (packageJson.devDependencies?.["react-grab"]) return true;
2030
- if (packageJson.dependencies?.["react-grab"]) return false;
2031
- } catch {}
2032
- return true;
2033
- };
2034
- const upgrade = new 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) => {
2035
- console.log(`${pc.magenta("✿")} ${pc.bold("React Grab")} ${pc.gray(VERSION$1)}`);
2036
- console.log();
2037
- try {
2038
- const cwd = resolve(opts.cwd);
2039
- const detectSpinner = spinner("Detecting project.").start();
2040
- const projectInfo = await detectProject(cwd);
2041
- if (!projectInfo.hasReactGrab) {
2042
- detectSpinner.fail("React Grab is not installed.");
2043
- logger.break();
2044
- logger.error(`Run ${highlighter.info("npx grab@latest init")} first to install React Grab.`);
2045
- logger.break();
2046
- process.exit(1);
2047
- }
2048
- detectSpinner.succeed();
2049
- const versionSpinner = spinner("Checking for updates.").start();
2050
- const latestVersion = await fetchLatestVersion();
2051
- if (!latestVersion) {
2052
- versionSpinner.fail("Could not check for updates.");
2053
- logger.break();
2054
- logger.error("Failed to reach the npm registry. Check your network connection.");
2055
- logger.break();
2056
- process.exit(1);
2057
- }
2058
- const installedVersion = projectInfo.reactGrabVersion;
2059
- if (installedVersion && installedVersion === latestVersion) {
2060
- versionSpinner.succeed(`Already on the latest version ${highlighter.info(`v${latestVersion}`)}.`);
2061
- logger.break();
2062
- process.exit(0);
2063
- }
2064
- const fromLabel = installedVersion ? `v${installedVersion}` : "unknown";
2065
- versionSpinner.succeed(`Update available: ${highlighter.dim(fromLabel)} → ${highlighter.info(`v${latestVersion}`)}.`);
2066
- const upgradeSpinner = spinner("Upgrading react-grab.").start();
2067
- try {
2068
- await installPackages(["react-grab@latest"], {
2069
- packageManager: projectInfo.packageManager,
2070
- cwd: projectInfo.projectRoot,
2071
- isDev: isDevDependency(projectInfo.projectRoot)
2072
- });
2073
- upgradeSpinner.succeed();
2074
- } catch {
2075
- upgradeSpinner.fail();
2076
- logger.break();
2077
- logger.error("Failed to upgrade. Check your network connection and try again.");
2078
- logger.break();
2079
- process.exit(1);
2080
- }
2081
- logger.break();
2082
- logger.log(`${highlighter.success("Success!")} React Grab has been upgraded to ${highlighter.info(`v${latestVersion}`)}.`);
2083
- logger.break();
2084
- } catch (error) {
2085
- handleError(error);
2086
- }
2087
- });
2050
+ //#region src/utils/sleep.ts
2051
+ const sleep = (durationMs) => new Promise((resolve) => setTimeout(resolve, durationMs));
2088
2052
  //#endregion
2089
2053
  //#region src/utils/clipboard.ts
2054
+ const HISTORY_FILE_NAME = "history.jsonl";
2090
2055
  const READ_TIMEOUT_MS = 2500;
2091
2056
  const MAX_CLIPBOARD_BYTES = 64 * 1024 * 1024;
2092
2057
  const ID_RADIX = 36;
@@ -2097,7 +2062,6 @@ const SIGNATURE_SCAN_CHARS = 32 * 1024;
2097
2062
  const GRAB_MIME = "application/x-react-grab";
2098
2063
  const CHROMIUM_CUSTOM_FORMAT = "chromium/x-web-custom-data";
2099
2064
  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
2065
  const shortHash = (text) => createHash("sha1").update(text).digest("hex").slice(0, HASH_LENGTH);
2102
2066
  const alignUp = (value) => value + PICKLE_ALIGN_BYTES - 1 & ~(PICKLE_ALIGN_BYTES - 1);
2103
2067
  const parseChromiumPickle = (buffer) => {
@@ -2329,9 +2293,9 @@ const prepareWorkDir = (dir) => {
2329
2293
  const gitignore = path.join(dir, ".gitignore");
2330
2294
  if (!fs.existsSync(gitignore)) fs.writeFileSync(gitignore, "*\n");
2331
2295
  };
2332
- const watchForNextGrab = async (options) => {
2333
- const { reader, dir, intervalMs, replayLast, onWarn, signal } = options;
2334
- const logPath = path.join(dir, "history.jsonl");
2296
+ const runWatchLoop = async (options) => {
2297
+ const { reader, dir, intervalMs, replayLast, onWarn } = options;
2298
+ const logPath = path.join(dir, HISTORY_FILE_NAME);
2335
2299
  const { read } = reader;
2336
2300
  let lastChangeCount = null;
2337
2301
  let lastTimestamp = 0;
@@ -2346,9 +2310,8 @@ const watchForNextGrab = async (options) => {
2346
2310
  lastTimestamp = JSON.parse(initial.grab).timestamp ?? 0;
2347
2311
  } catch {}
2348
2312
  }
2349
- while (!signal?.aborted) {
2313
+ while (true) {
2350
2314
  await sleep(intervalMs);
2351
- if (signal?.aborted) return null;
2352
2315
  try {
2353
2316
  const snapshot = read();
2354
2317
  if (!snapshot) continue;
@@ -2394,7 +2357,6 @@ const watchForNextGrab = async (options) => {
2394
2357
  lastChangeCount = snapshot.changeCount;
2395
2358
  lastTextHash = textHash;
2396
2359
  lastTimestamp = nextTimestamp;
2397
- return captured;
2398
2360
  } catch (error) {
2399
2361
  const message = String(error?.message ?? error);
2400
2362
  if (message !== lastErrorMessage) {
@@ -2403,51 +2365,315 @@ const watchForNextGrab = async (options) => {
2403
2365
  }
2404
2366
  }
2405
2367
  }
2406
- return null;
2407
2368
  };
2408
2369
  //#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) => {
2370
+ //#region src/utils/grab-log.ts
2371
+ const CURSOR_FILE_NAME = "cursor.txt";
2372
+ const cursorFilePath = (dir) => path.join(dir, CURSOR_FILE_NAME);
2373
+ const readCompleteGrabLines = (dir) => {
2374
+ let raw;
2375
+ try {
2376
+ raw = fs.readFileSync(path.join(dir, HISTORY_FILE_NAME), "utf8");
2377
+ } catch {
2378
+ return [];
2379
+ }
2380
+ const lastNewline = raw.lastIndexOf("\n");
2381
+ if (lastNewline < 0) return [];
2382
+ return raw.slice(0, lastNewline).split("\n").filter(Boolean);
2383
+ };
2384
+ const readGrabCursor = (dir) => {
2385
+ try {
2386
+ const value = Number.parseInt(fs.readFileSync(cursorFilePath(dir), "utf8").trim(), 10);
2387
+ return Number.isInteger(value) && value >= 0 ? value : 0;
2388
+ } catch {
2389
+ return 0;
2390
+ }
2391
+ };
2392
+ const consumeGrabs = (dir, options) => {
2393
+ const lines = readCompleteGrabLines(dir);
2394
+ if (options.all) return lines;
2395
+ const cursor = readGrabCursor(dir);
2396
+ const start = Math.min(cursor, lines.length);
2397
+ const end = options.limit > 0 ? Math.min(start + options.limit, lines.length) : lines.length;
2398
+ if (end !== cursor) fs.writeFileSync(cursorFilePath(dir), String(end));
2399
+ return lines.slice(start, end);
2400
+ };
2401
+ //#endregion
2402
+ //#region src/commands/read.ts
2403
+ const read = new 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) => {
2414
2404
  const dir = path.resolve(options.dir);
2415
- const intervalMs = Number(options.interval);
2416
- const textOnly = Boolean(options.textOnly);
2417
2405
  try {
2418
2406
  prepareWorkDir(dir);
2419
2407
  } catch (error) {
2420
- process.stderr.write(`react-grab watch: ${String(error?.message ?? error)}\n`);
2408
+ process.stderr.write(`react-grab read: ${String(error?.message ?? error)}\n`);
2421
2409
  process.exit(1);
2422
2410
  }
2411
+ unrefStdin();
2412
+ const limitRaw = Number(options.limit);
2413
+ const limit = Number.isInteger(limitRaw) && limitRaw >= 0 ? limitRaw : 50;
2414
+ const all = Boolean(options.all);
2415
+ const drain = () => {
2416
+ const fresh = consumeGrabs(dir, {
2417
+ limit,
2418
+ all
2419
+ });
2420
+ if (fresh.length > 0) process.stdout.write(`${fresh.join("\n")}\n`);
2421
+ return fresh.length;
2422
+ };
2423
+ const waitRaw = options.wait ? Number(options.wait) : 0;
2424
+ const deadline = Date.now() + (Number.isFinite(waitRaw) && waitRaw > 0 ? waitRaw : 0);
2425
+ if (drain() > 0) process.exit(0);
2426
+ while (Date.now() < deadline) {
2427
+ await sleep(200);
2428
+ if (drain() > 0) break;
2429
+ }
2430
+ process.exit(0);
2431
+ });
2432
+ //#endregion
2433
+ //#region src/commands/remove.ts
2434
+ const VERSION$2 = "0.1.40";
2435
+ const remove = new Command().name("remove").description("uninstall the React Grab skill from your agent").action(async () => {
2436
+ console.log(`${pc.magenta("✿")} ${pc.bold("React Grab")} ${pc.gray(VERSION$2)}`);
2437
+ console.log();
2438
+ try {
2439
+ logger.break();
2440
+ const removedCount = await removeSkill();
2441
+ logger.break();
2442
+ if (removedCount === 0) logger.log("React Grab skill is not installed in any detected agent.");
2443
+ else logger.log(`${highlighter.success("Removed")} the React Grab skill from ${removedCount} agent${removedCount === 1 ? "" : "s"}.`);
2444
+ logger.break();
2445
+ } catch (error) {
2446
+ handleError(error);
2447
+ }
2448
+ });
2449
+ //#endregion
2450
+ //#region src/commands/upgrade.ts
2451
+ const VERSION$1 = "0.1.40";
2452
+ const NPM_REGISTRY_URL = "https://registry.npmjs.org/react-grab/latest";
2453
+ const fetchLatestVersion = async () => {
2454
+ try {
2455
+ return (await (await fetch(NPM_REGISTRY_URL)).json()).version ?? null;
2456
+ } catch {
2457
+ return null;
2458
+ }
2459
+ };
2460
+ const isDevDependency = (projectRoot) => {
2461
+ const packageJsonPath = join(projectRoot, "package.json");
2462
+ if (!existsSync(packageJsonPath)) return true;
2463
+ try {
2464
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
2465
+ if (packageJson.devDependencies?.["react-grab"]) return true;
2466
+ if (packageJson.dependencies?.["react-grab"]) return false;
2467
+ } catch {}
2468
+ return true;
2469
+ };
2470
+ const upgrade = new 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) => {
2471
+ console.log(`${pc.magenta("✿")} ${pc.bold("React Grab")} ${pc.gray(VERSION$1)}`);
2472
+ console.log();
2473
+ try {
2474
+ const cwd = resolve(opts.cwd);
2475
+ const detectSpinner = spinner("Detecting project.").start();
2476
+ const projectInfo = await detectProject(cwd);
2477
+ if (!projectInfo.hasReactGrab) {
2478
+ detectSpinner.fail("React Grab is not installed.");
2479
+ logger.break();
2480
+ logger.error(`Run ${highlighter.info("npx grab@latest init")} first to install React Grab.`);
2481
+ logger.break();
2482
+ process.exit(1);
2483
+ }
2484
+ detectSpinner.succeed();
2485
+ const versionSpinner = spinner("Checking for updates.").start();
2486
+ const latestVersion = await fetchLatestVersion();
2487
+ if (!latestVersion) {
2488
+ versionSpinner.fail("Could not check for updates.");
2489
+ logger.break();
2490
+ logger.error("Failed to reach the npm registry. Check your network connection.");
2491
+ logger.break();
2492
+ process.exit(1);
2493
+ }
2494
+ const installedVersion = projectInfo.reactGrabVersion;
2495
+ if (installedVersion && installedVersion === latestVersion) {
2496
+ versionSpinner.succeed(`Already on the latest version ${highlighter.info(`v${latestVersion}`)}.`);
2497
+ logger.break();
2498
+ process.exit(0);
2499
+ }
2500
+ const fromLabel = installedVersion ? `v${installedVersion}` : "unknown";
2501
+ versionSpinner.succeed(`Update available: ${highlighter.dim(fromLabel)} → ${highlighter.info(`v${latestVersion}`)}.`);
2502
+ const upgradeSpinner = spinner("Upgrading react-grab.").start();
2503
+ try {
2504
+ await installPackages(["react-grab@latest"], {
2505
+ packageManager: projectInfo.packageManager,
2506
+ cwd: projectInfo.projectRoot,
2507
+ isDev: isDevDependency(projectInfo.projectRoot)
2508
+ });
2509
+ upgradeSpinner.succeed();
2510
+ } catch {
2511
+ upgradeSpinner.fail();
2512
+ logger.break();
2513
+ logger.error("Failed to upgrade. Check your network connection and try again.");
2514
+ logger.break();
2515
+ process.exit(1);
2516
+ }
2517
+ logger.break();
2518
+ logger.log(`${highlighter.success("Success!")} React Grab has been upgraded to ${highlighter.info(`v${latestVersion}`)}.`);
2519
+ logger.break();
2520
+ } catch (error) {
2521
+ handleError(error);
2522
+ }
2523
+ });
2524
+ //#endregion
2525
+ //#region src/utils/daemon.ts
2526
+ const PID_FILE_NAME = "watch.pid";
2527
+ const pidFilePath = (dir) => path.join(dir, PID_FILE_NAME);
2528
+ const cliEntryPath = () => process.argv[1] ?? fileURLToPath(import.meta.url);
2529
+ const isProcessAlive = (pid) => {
2530
+ if (!Number.isInteger(pid) || pid <= 0) return false;
2531
+ try {
2532
+ process.kill(pid, 0);
2533
+ return true;
2534
+ } catch (error) {
2535
+ return error.code === "EPERM";
2536
+ }
2537
+ };
2538
+ const readDaemonPid = (dir) => {
2539
+ try {
2540
+ const pid = Number.parseInt(fs.readFileSync(pidFilePath(dir), "utf8").trim(), 10);
2541
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
2542
+ } catch {
2543
+ return null;
2544
+ }
2545
+ };
2546
+ const isDaemonRunning = (dir) => {
2547
+ const pid = readDaemonPid(dir);
2548
+ return pid !== null && isProcessAlive(pid);
2549
+ };
2550
+ const claimDaemon = (dir) => {
2551
+ const file = pidFilePath(dir);
2552
+ for (let attempt = 0; attempt < 50; attempt += 1) try {
2553
+ const handle = fs.openSync(file, "wx");
2554
+ fs.writeFileSync(handle, String(process.pid));
2555
+ fs.closeSync(handle);
2556
+ return readDaemonPid(dir) === process.pid;
2557
+ } catch (error) {
2558
+ if (error.code !== "EEXIST") throw error;
2559
+ if (isDaemonRunning(dir)) return false;
2560
+ try {
2561
+ fs.rmSync(file, { force: true });
2562
+ } catch {}
2563
+ }
2564
+ return false;
2565
+ };
2566
+ const releaseDaemon = (dir) => {
2567
+ if (readDaemonPid(dir) === process.pid) try {
2568
+ fs.rmSync(pidFilePath(dir), { force: true });
2569
+ } catch {}
2570
+ };
2571
+ const stopDaemon = (dir) => {
2572
+ const pid = readDaemonPid(dir);
2573
+ if (pid === null) return null;
2574
+ const wasAlive = isProcessAlive(pid);
2575
+ if (wasAlive) try {
2576
+ process.kill(pid, "SIGTERM");
2577
+ } catch {}
2578
+ if (readDaemonPid(dir) === pid) try {
2579
+ fs.rmSync(pidFilePath(dir), { force: true });
2580
+ } catch {}
2581
+ return wasAlive ? pid : null;
2582
+ };
2583
+ const spawnDaemon = (options) => {
2584
+ const args = [
2585
+ cliEntryPath(),
2586
+ "watch",
2587
+ "--foreground",
2588
+ "--dir",
2589
+ options.dir,
2590
+ "--interval",
2591
+ String(options.intervalMs)
2592
+ ];
2593
+ if (options.textOnly) args.push("--text-only");
2594
+ if (options.replayLast) args.push("--replay-last");
2595
+ spawn(process.execPath, args, {
2596
+ detached: true,
2597
+ stdio: "ignore",
2598
+ windowsHide: true
2599
+ }).unref();
2600
+ };
2601
+ //#endregion
2602
+ //#region src/commands/watch.ts
2603
+ const readersDir = () => path.dirname(fileURLToPath(import.meta.url));
2604
+ 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.";
2605
+ const writeStatus = (message) => {
2606
+ process.stderr.write(`react-grab watch: ${message}\n`);
2607
+ };
2608
+ const runForeground = (dir, intervalMs, textOnly, replayLast) => {
2609
+ if (!claimDaemon(dir)) process.exit(0);
2610
+ process.on("exit", () => releaseDaemon(dir));
2423
2611
  const reader = createReader({
2424
2612
  textOnly,
2425
2613
  readersDir: readersDir(),
2426
2614
  workDir: dir
2427
2615
  });
2428
2616
  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");
2617
+ writeStatus(NO_READER_MESSAGE);
2430
2618
  process.exit(1);
2431
2619
  }
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({
2620
+ writeStatus(`watching clipboard via ${reader.mode}; history → ${path.join(dir, HISTORY_FILE_NAME)}`);
2621
+ runWatchLoop({
2434
2622
  reader,
2435
2623
  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);
2624
+ intervalMs,
2625
+ replayLast,
2626
+ onWarn: writeStatus
2443
2627
  }).catch((error) => {
2444
- process.stderr.write(`react-grab watch: ${String(error?.message ?? error)}\n`);
2628
+ writeStatus(String(error?.message ?? error));
2445
2629
  process.exit(1);
2446
2630
  });
2631
+ };
2632
+ const watch = new 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) => {
2633
+ const dir = path.resolve(options.dir);
2634
+ const intervalRaw = Number(options.interval);
2635
+ const intervalMs = Number.isFinite(intervalRaw) && intervalRaw > 0 ? intervalRaw : 800;
2636
+ const textOnly = Boolean(options.textOnly);
2637
+ const replayLast = Boolean(options.replayLast);
2638
+ try {
2639
+ prepareWorkDir(dir);
2640
+ } catch (error) {
2641
+ writeStatus(String(error?.message ?? error));
2642
+ process.exit(1);
2643
+ }
2644
+ if (options.stop) {
2645
+ const stoppedPid = stopDaemon(dir);
2646
+ writeStatus(stoppedPid ? `stopped daemon (pid ${stoppedPid})` : `no daemon running for ${dir}`);
2647
+ process.exit(0);
2648
+ }
2649
+ if (options.foreground) {
2650
+ runForeground(dir, intervalMs, textOnly, replayLast);
2651
+ return;
2652
+ }
2653
+ if (isDaemonRunning(dir)) {
2654
+ writeStatus(`already watching ${dir} (pid ${readDaemonPid(dir)})`);
2655
+ process.exit(0);
2656
+ }
2657
+ if (!createReader({
2658
+ textOnly,
2659
+ readersDir: readersDir(),
2660
+ workDir: dir
2661
+ })) {
2662
+ writeStatus(NO_READER_MESSAGE);
2663
+ process.exit(1);
2664
+ }
2665
+ spawnDaemon({
2666
+ dir,
2667
+ intervalMs,
2668
+ textOnly,
2669
+ replayLast
2670
+ });
2671
+ writeStatus(`started; capturing grabs → ${path.join(dir, HISTORY_FILE_NAME)} (run \`grab read\` to consume, \`grab watch --stop\` to stop)`);
2672
+ process.exit(0);
2447
2673
  });
2448
2674
  //#endregion
2449
2675
  //#region src/cli.ts
2450
- const VERSION = "0.1.38";
2676
+ const VERSION = "0.1.40";
2451
2677
  const VERSION_API_URL = "https://www.react-grab.com/api/version";
2452
2678
  process.on("SIGINT", () => process.exit(0));
2453
2679
  process.on("SIGTERM", () => process.exit(0));
@@ -2461,6 +2687,7 @@ program.addCommand(remove);
2461
2687
  program.addCommand(configure);
2462
2688
  program.addCommand(upgrade);
2463
2689
  program.addCommand(watch);
2690
+ program.addCommand(read);
2464
2691
  const main = async () => {
2465
2692
  await program.parseAsync();
2466
2693
  };