@react-grab/cli 0.1.41 → 0.1.42

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
@@ -493,15 +493,15 @@ const promptSkillInstall = async ({ yes = false, global = false, cwd = process.c
493
493
  for (const record of failed) logger.log(` ${highlighter.error("✗")} ${agentLabel(record.agent)} ${record.error}`);
494
494
  return true;
495
495
  };
496
- const removeSkill = async (cwd = process.cwd()) => {
496
+ const removeSkill = async ({ cwd = process.cwd(), global = false } = {}) => {
497
497
  const agents = await detectAvailableAgents();
498
498
  const removedAgents = [];
499
499
  const dirsToRemove = /* @__PURE__ */ new Set();
500
500
  for (const agent of agents) {
501
- const present = [installedSkillDir(agent, false, cwd), installedSkillDir(agent, true, cwd)].filter((dir) => (0, node_fs.existsSync)(dir));
502
- if (present.length === 0) continue;
501
+ const skillDir = installedSkillDir(agent, global, cwd);
502
+ if (!(0, node_fs.existsSync)(skillDir)) continue;
503
503
  removedAgents.push(agent);
504
- for (const dir of present) dirsToRemove.add(dir);
504
+ dirsToRemove.add(skillDir);
505
505
  }
506
506
  for (const skillDir of dirsToRemove) (0, node_fs.rmSync)(skillDir, {
507
507
  recursive: true,
@@ -512,7 +512,7 @@ const removeSkill = async (cwd = process.cwd()) => {
512
512
  };
513
513
  //#endregion
514
514
  //#region src/commands/add.ts
515
- const VERSION$5 = "0.1.41";
515
+ const VERSION$5 = "0.1.42";
516
516
  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()).option("-g, --global", "install the skill globally instead of in the project", false).action(async (opts) => {
517
517
  console.log(`${picocolors.default.magenta("✿")} ${picocolors.default.bold("React Grab")} ${picocolors.default.gray(VERSION$5)}`);
518
518
  console.log();
@@ -1111,7 +1111,9 @@ const previewCdnTransform = (projectRoot, framework, nextRouterType, targetCdnDo
1111
1111
  //#region src/utils/constants.ts
1112
1112
  const MAX_KEY_HOLD_DURATION_MS = 2e3;
1113
1113
  const DEFAULT_WATCH_DIR = ".react-grab";
1114
- const MAX_GRAB_AGE_MS = 300 * 1e3;
1114
+ const DEFAULT_GRAB_AGE_MS = 300 * 1e3;
1115
+ const MAX_READ_HISTORY_BYTES = 128 * 1024 * 1024;
1116
+ const MIGRATION_SCAN_CHUNK_BYTES = 1024 * 1024;
1115
1117
  //#endregion
1116
1118
  //#region src/utils/format-activation-key.ts
1117
1119
  const formatActivationKeyDisplay = (activationKey) => {
@@ -1129,7 +1131,7 @@ const formatActivationKeyDisplay = (activationKey) => {
1129
1131
  };
1130
1132
  //#endregion
1131
1133
  //#region src/commands/configure.ts
1132
- const VERSION$4 = "0.1.41";
1134
+ const VERSION$4 = "0.1.42";
1133
1135
  const isMac = process.platform === "darwin";
1134
1136
  const META_LABEL = isMac ? "Cmd" : "Win";
1135
1137
  const ALT_LABEL = isMac ? "Option" : "Alt";
@@ -1748,7 +1750,7 @@ const installPackagesWithFeedback = async (packages, packageManager, projectRoot
1748
1750
  };
1749
1751
  //#endregion
1750
1752
  //#region src/commands/init.ts
1751
- const VERSION$3 = "0.1.41";
1753
+ const VERSION$3 = "0.1.42";
1752
1754
  const REPORT_URL = "https://react-grab.com/api/report-cli";
1753
1755
  const DOCS_URL = "https://github.com/aidenybai/react-grab";
1754
1756
  const reportToCli = (type, config, error) => {
@@ -2105,6 +2107,8 @@ const sleep = (durationMs) => new Promise((resolve) => setTimeout(resolve, durat
2105
2107
  //#endregion
2106
2108
  //#region src/utils/clipboard.ts
2107
2109
  const HISTORY_FILE_NAME = "history.jsonl";
2110
+ 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.";
2111
+ const READERS_DIR = node_path.default.dirname((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
2108
2112
  const READ_TIMEOUT_MS = 2500;
2109
2113
  const MAX_CLIPBOARD_BYTES = 64 * 1024 * 1024;
2110
2114
  const ID_RADIX = 36;
@@ -2198,7 +2202,7 @@ const compileSwiftReader = (readersDir, workDir) => {
2198
2202
  return binary;
2199
2203
  };
2200
2204
  const createDarwinReader = (options) => {
2201
- const binary = options.textOnly ? null : compileSwiftReader(options.readersDir, options.workDir);
2205
+ const binary = options.textOnly ? null : compileSwiftReader(READERS_DIR, options.workDir);
2202
2206
  if (binary) return {
2203
2207
  mode: "darwin-native",
2204
2208
  read: () => {
@@ -2281,7 +2285,7 @@ const detectPowershell = () => hasCommand("pwsh") ? "pwsh" : hasCommand("powersh
2281
2285
  const createWindowsReader = (options) => {
2282
2286
  const shell = detectPowershell();
2283
2287
  if (!shell) return null;
2284
- const scriptPath = node_path.default.join(options.readersDir, "read-clipboard.ps1");
2288
+ const scriptPath = node_path.default.join(READERS_DIR, "read-clipboard.ps1");
2285
2289
  if (options.textOnly || !node_fs.default.existsSync(scriptPath)) return {
2286
2290
  mode: "win-text",
2287
2291
  read: () => {
@@ -2420,108 +2424,291 @@ const runWatchLoop = async (options) => {
2420
2424
  }
2421
2425
  };
2422
2426
  //#endregion
2427
+ //#region src/utils/daemon.ts
2428
+ const PID_FILE_NAME = "watch.pid";
2429
+ const pidFilePath = (dir) => node_path.default.join(dir, PID_FILE_NAME);
2430
+ const cliEntryPath = () => process.argv[1] ?? (0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href);
2431
+ const isProcessAlive = (pid) => {
2432
+ if (!Number.isInteger(pid) || pid <= 0) return false;
2433
+ try {
2434
+ process.kill(pid, 0);
2435
+ return true;
2436
+ } catch (error) {
2437
+ return error.code === "EPERM";
2438
+ }
2439
+ };
2440
+ const readDaemonPid = (dir) => {
2441
+ try {
2442
+ const pid = Number.parseInt(node_fs.default.readFileSync(pidFilePath(dir), "utf8").trim(), 10);
2443
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
2444
+ } catch {
2445
+ return null;
2446
+ }
2447
+ };
2448
+ const isDaemonRunning = (dir) => {
2449
+ const pid = readDaemonPid(dir);
2450
+ return pid !== null && isProcessAlive(pid);
2451
+ };
2452
+ const claimDaemon = (dir) => {
2453
+ const file = pidFilePath(dir);
2454
+ for (let attempt = 0; attempt < 50; attempt += 1) try {
2455
+ const handle = node_fs.default.openSync(file, "wx");
2456
+ node_fs.default.writeFileSync(handle, String(process.pid));
2457
+ node_fs.default.closeSync(handle);
2458
+ return readDaemonPid(dir) === process.pid;
2459
+ } catch (error) {
2460
+ if (error.code !== "EEXIST") throw error;
2461
+ if (isDaemonRunning(dir)) return false;
2462
+ try {
2463
+ node_fs.default.rmSync(file, { force: true });
2464
+ } catch {}
2465
+ }
2466
+ return false;
2467
+ };
2468
+ const releaseDaemon = (dir) => {
2469
+ if (readDaemonPid(dir) === process.pid) try {
2470
+ node_fs.default.rmSync(pidFilePath(dir), { force: true });
2471
+ } catch {}
2472
+ };
2473
+ const stopDaemon = (dir) => {
2474
+ const pid = readDaemonPid(dir);
2475
+ if (pid === null) return null;
2476
+ const wasAlive = isProcessAlive(pid);
2477
+ if (wasAlive) try {
2478
+ process.kill(pid, "SIGTERM");
2479
+ } catch {}
2480
+ if (readDaemonPid(dir) === pid) try {
2481
+ node_fs.default.rmSync(pidFilePath(dir), { force: true });
2482
+ } catch {}
2483
+ return wasAlive ? pid : null;
2484
+ };
2485
+ const spawnDaemon = (options) => {
2486
+ const args = [
2487
+ cliEntryPath(),
2488
+ "watch",
2489
+ "--dir",
2490
+ options.dir,
2491
+ "--interval",
2492
+ String(options.intervalMs)
2493
+ ];
2494
+ if (options.textOnly) args.push("--text-only");
2495
+ if (options.replayLast) args.push("--replay-last");
2496
+ (0, node_child_process.spawn)(process.execPath, args, {
2497
+ detached: true,
2498
+ stdio: "ignore",
2499
+ windowsHide: true
2500
+ }).unref();
2501
+ };
2502
+ const ensureDaemon = (options) => {
2503
+ if (isDaemonRunning(options.dir)) return "already-running";
2504
+ if (!createReader({
2505
+ textOnly: options.textOnly,
2506
+ workDir: options.dir
2507
+ })) return "no-reader";
2508
+ spawnDaemon(options);
2509
+ return "started";
2510
+ };
2511
+ //#endregion
2423
2512
  //#region src/utils/grab-log.ts
2424
2513
  const CURSOR_FILE_NAME = "cursor.txt";
2514
+ const NEWLINE_BYTE = 10;
2425
2515
  const cursorFilePath = (dir) => node_path.default.join(dir, CURSOR_FILE_NAME);
2426
- const readCompleteGrabLines = (dir) => {
2427
- let raw;
2516
+ const historyFilePath = (dir) => node_path.default.join(dir, HISTORY_FILE_NAME);
2517
+ const fileSize = (filePath) => {
2428
2518
  try {
2429
- raw = node_fs.default.readFileSync(node_path.default.join(dir, HISTORY_FILE_NAME), "utf8");
2519
+ return node_fs.default.statSync(filePath).size;
2430
2520
  } catch {
2431
- return [];
2521
+ return 0;
2432
2522
  }
2433
- const lastNewline = raw.lastIndexOf("\n");
2434
- if (lastNewline < 0) return [];
2435
- return raw.slice(0, lastNewline).split("\n").filter(Boolean);
2523
+ };
2524
+ const readHistoryRange = (dir, start, length) => {
2525
+ if (length <= 0) return "";
2526
+ const fd = node_fs.default.openSync(historyFilePath(dir), "r");
2527
+ try {
2528
+ const buffer = Buffer.allocUnsafe(length);
2529
+ const bytesRead = node_fs.default.readSync(fd, buffer, 0, length, start);
2530
+ return buffer.toString("utf8", 0, bytesRead);
2531
+ } finally {
2532
+ node_fs.default.closeSync(fd);
2533
+ }
2534
+ };
2535
+ const writeGrabCursor = (dir, offset) => {
2536
+ const target = cursorFilePath(dir);
2537
+ const tempPath = `${target}.${process.pid}.tmp`;
2538
+ node_fs.default.writeFileSync(tempPath, JSON.stringify({ offset }));
2539
+ node_fs.default.renameSync(tempPath, target);
2540
+ };
2541
+ const byteOffsetAfterLines = (dir, lineCount) => {
2542
+ const filePath = historyFilePath(dir);
2543
+ const size = fileSize(filePath);
2544
+ if (lineCount <= 0 || size === 0) return 0;
2545
+ const fd = node_fs.default.openSync(filePath, "r");
2546
+ try {
2547
+ const buffer = Buffer.allocUnsafe(MIGRATION_SCAN_CHUNK_BYTES);
2548
+ let position = 0;
2549
+ let seen = 0;
2550
+ while (position < size) {
2551
+ const bytesRead = node_fs.default.readSync(fd, buffer, 0, MIGRATION_SCAN_CHUNK_BYTES, position);
2552
+ if (bytesRead <= 0) break;
2553
+ for (let index = 0; index < bytesRead; index += 1) {
2554
+ if (buffer[index] !== NEWLINE_BYTE) continue;
2555
+ seen += 1;
2556
+ if (seen === lineCount) return position + index + 1;
2557
+ }
2558
+ position += bytesRead;
2559
+ }
2560
+ } finally {
2561
+ node_fs.default.closeSync(fd);
2562
+ }
2563
+ return size;
2436
2564
  };
2437
2565
  const readGrabCursor = (dir) => {
2566
+ let raw;
2438
2567
  try {
2439
- const value = Number.parseInt(node_fs.default.readFileSync(cursorFilePath(dir), "utf8").trim(), 10);
2440
- return Number.isInteger(value) && value >= 0 ? value : 0;
2568
+ raw = node_fs.default.readFileSync(cursorFilePath(dir), "utf8").trim();
2441
2569
  } catch {
2442
2570
  return 0;
2443
2571
  }
2444
- };
2445
- const grabReceivedAt = (line) => {
2572
+ if (raw === "") return 0;
2573
+ let parsed;
2446
2574
  try {
2447
- const value = JSON.parse(line).receivedAt;
2448
- return typeof value === "number" ? value : null;
2575
+ parsed = JSON.parse(raw);
2449
2576
  } catch {
2450
- return null;
2577
+ return 0;
2451
2578
  }
2579
+ if (typeof parsed === "number") {
2580
+ if (!Number.isInteger(parsed) || parsed < 0) return 0;
2581
+ const offset = byteOffsetAfterLines(dir, parsed);
2582
+ writeGrabCursor(dir, offset);
2583
+ return offset;
2584
+ }
2585
+ return typeof parsed.offset === "number" && Number.isInteger(parsed.offset) && parsed.offset >= 0 ? parsed.offset : 0;
2586
+ };
2587
+ const readCompleteGrabLines = (dir) => {
2588
+ const size = fileSize(historyFilePath(dir));
2589
+ if (size === 0) return [];
2590
+ const raw = readHistoryRange(dir, 0, size);
2591
+ const lastNewline = raw.lastIndexOf("\n");
2592
+ if (lastNewline < 0) return [];
2593
+ return raw.slice(0, lastNewline).split("\n").filter(Boolean);
2452
2594
  };
2453
2595
  const consumeGrabs = (dir, options) => {
2454
- const lines = readCompleteGrabLines(dir);
2455
- if (options.all) return lines;
2456
- const maxAgeMs = options.maxAgeMs ?? 0;
2596
+ if (options.all) return readCompleteGrabLines(dir);
2597
+ const size = fileSize(historyFilePath(dir));
2457
2598
  const cursor = readGrabCursor(dir);
2458
- const total = lines.length;
2599
+ const start = cursor > size ? 0 : cursor;
2600
+ if (start >= size) {
2601
+ if (start !== cursor) writeGrabCursor(dir, start);
2602
+ return [];
2603
+ }
2604
+ const chunk = readHistoryRange(dir, start, Math.min(size - start, MAX_READ_HISTORY_BYTES));
2605
+ const lastNewline = chunk.lastIndexOf("\n");
2606
+ if (lastNewline < 0) return [];
2459
2607
  const now = Date.now();
2460
2608
  const fresh = [];
2461
- let index = Math.min(cursor, total);
2462
- while (index < total && (options.limit <= 0 || fresh.length < options.limit)) {
2463
- const line = lines[index];
2464
- index += 1;
2465
- if (maxAgeMs > 0) {
2466
- const receivedAt = grabReceivedAt(line);
2467
- if (receivedAt !== null && now - receivedAt > maxAgeMs) continue;
2609
+ let consumedBytes = 0;
2610
+ let lineStart = 0;
2611
+ while (lineStart <= lastNewline && (options.limit <= 0 || fresh.length < options.limit)) {
2612
+ const newlineIndex = chunk.indexOf("\n", lineStart);
2613
+ const line = chunk.slice(lineStart, newlineIndex);
2614
+ consumedBytes += Buffer.byteLength(chunk.slice(lineStart, newlineIndex + 1), "utf8");
2615
+ lineStart = newlineIndex + 1;
2616
+ if (line.length === 0) continue;
2617
+ let parsed;
2618
+ try {
2619
+ parsed = JSON.parse(line);
2620
+ } catch {
2621
+ continue;
2622
+ }
2623
+ if (options.maxAgeMs > 0 && typeof parsed.receivedAt === "number") {
2624
+ if (now - parsed.receivedAt > options.maxAgeMs) continue;
2468
2625
  }
2469
2626
  fresh.push(line);
2470
2627
  }
2471
- if (index !== cursor) node_fs.default.writeFileSync(cursorFilePath(dir), String(index));
2628
+ const nextCursor = start + consumedBytes;
2629
+ if (nextCursor !== cursor) writeGrabCursor(dir, nextCursor);
2472
2630
  return fresh;
2473
2631
  };
2474
2632
  //#endregion
2475
- //#region src/commands/read.ts
2633
+ //#region src/utils/read-args.ts
2476
2634
  const parseWaitMs = (raw) => {
2477
- if (!raw) return 0;
2478
- if (/^(inf|infinite|forever)$/i.test(raw.trim())) return Number.POSITIVE_INFINITY;
2479
- const ms = Number(raw);
2480
- return Number.isFinite(ms) && ms > 0 ? ms : 0;
2635
+ if (raw === void 0) return 0;
2636
+ const trimmed = raw.trim();
2637
+ if (trimmed === "") return 0;
2638
+ if (/^(inf|infinite|infinity|forever)$/i.test(trimmed)) return Number.POSITIVE_INFINITY;
2639
+ const ms = Number(trimmed);
2640
+ return Number.isFinite(ms) && ms >= 0 ? ms : null;
2641
+ };
2642
+ const parseNonNegativeInt = (raw) => {
2643
+ if (raw === void 0) return null;
2644
+ const trimmed = raw.trim();
2645
+ if (trimmed === "") return null;
2646
+ const value = Number(trimmed);
2647
+ return Number.isInteger(value) && value >= 0 ? value : null;
2481
2648
  };
2482
- const parseNonNegativeInt = (raw, fallback) => {
2483
- const value = Number(raw);
2484
- return Number.isInteger(value) && value >= 0 ? value : fallback;
2649
+ //#endregion
2650
+ //#region src/commands/pull.ts
2651
+ const fail = (message) => {
2652
+ process.stderr.write(`react-grab pull: ${message}\n`);
2653
+ process.exit(1);
2654
+ };
2655
+ const emitAndExit = (lines) => {
2656
+ process.stdout.write(`${lines.join("\n")}\n`, () => process.exit(0));
2485
2657
  };
2486
- 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> (or 'infinite') for a 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("--max-age <ms>", "skip grabs captured longer ago than <ms> (0 = never evict)", String(MAX_GRAB_AGE_MS)).option("--all", "print the entire history without advancing the cursor").action(async (options) => {
2658
+ const pull = new commander.Command().name("pull").description("start the watcher if needed, then wait for and print the next React Grab grab(s)").option("-d, --dir <dir>", "work dir for history.jsonl + watch.pid", DEFAULT_WATCH_DIR).option("-w, --wait <ms>", "how long to wait for a grab: ms, 'infinite', or 0 for none", "infinite").option("-n, --limit <count>", "max grabs to print per call (0 = no limit)", String(50)).option("--max-age <ms>", "skip grabs captured longer ago than <ms> (0 = never)", String(DEFAULT_GRAB_AGE_MS)).option("--text-only", "watcher uses the plain-text clipboard reader (ignored if already running)").option("--all", "print the whole history without advancing the cursor").action(async (options) => {
2487
2659
  const dir = node_path.default.resolve(options.dir);
2488
2660
  try {
2489
2661
  prepareWorkDir(dir);
2490
2662
  } catch (error) {
2491
- process.stderr.write(`react-grab read: ${String(error?.message ?? error)}\n`);
2492
- process.exit(1);
2663
+ fail(String(error?.message ?? error));
2493
2664
  }
2494
- unrefStdin();
2495
- const limit = parseNonNegativeInt(options.limit, 50);
2496
- const maxAgeMs = parseNonNegativeInt(options.maxAge, MAX_GRAB_AGE_MS);
2497
- const all = Boolean(options.all);
2498
- const drain = () => {
2499
- const fresh = consumeGrabs(dir, {
2500
- limit,
2501
- all,
2502
- maxAgeMs
2503
- });
2504
- if (fresh.length > 0) process.stdout.write(`${fresh.join("\n")}\n`);
2505
- return fresh.length;
2506
- };
2507
2665
  const waitMs = parseWaitMs(options.wait);
2508
- const deadline = waitMs === Number.POSITIVE_INFINITY ? Number.POSITIVE_INFINITY : Date.now() + waitMs;
2509
- if (drain() > 0) process.exit(0);
2666
+ if (waitMs === null) fail(`invalid --wait "${options.wait}" (use milliseconds or "infinite")`);
2667
+ const limit = parseNonNegativeInt(options.limit);
2668
+ if (limit === null) fail(`invalid --limit "${options.limit}" (use a non-negative integer)`);
2669
+ const maxAgeMs = parseNonNegativeInt(options.maxAge);
2670
+ if (maxAgeMs === null) fail(`invalid --max-age "${options.maxAge}" (use milliseconds, 0 to disable)`);
2671
+ const all = Boolean(options.all);
2672
+ if (ensureDaemon({
2673
+ dir,
2674
+ intervalMs: 800,
2675
+ textOnly: Boolean(options.textOnly),
2676
+ replayLast: false
2677
+ }) === "no-reader") fail(NO_READER_MESSAGE);
2678
+ unrefStdin();
2679
+ const consume = () => consumeGrabs(dir, {
2680
+ limit,
2681
+ all,
2682
+ maxAgeMs
2683
+ });
2684
+ const first = consume();
2685
+ if (first.length > 0) {
2686
+ emitAndExit(first);
2687
+ return;
2688
+ }
2689
+ const deadline = Date.now() + waitMs;
2510
2690
  while (Date.now() < deadline) {
2511
2691
  await sleep(200);
2512
- if (drain() > 0) break;
2692
+ const batch = consume();
2693
+ if (batch.length > 0) {
2694
+ emitAndExit(batch);
2695
+ return;
2696
+ }
2513
2697
  }
2514
2698
  process.exit(0);
2515
2699
  });
2516
2700
  //#endregion
2517
2701
  //#region src/commands/remove.ts
2518
- const VERSION$2 = "0.1.41";
2519
- const remove = new commander.Command().name("remove").description("uninstall the React Grab skill from your agent").action(async () => {
2702
+ const VERSION$2 = "0.1.42";
2703
+ const remove = new commander.Command().name("remove").description("uninstall the React Grab skill from your agent").option("-c, --cwd <cwd>", "working directory (defaults to current directory)", process.cwd()).option("-g, --global", "remove the globally-installed skill instead of the project's", false).action(async (opts) => {
2520
2704
  console.log(`${picocolors.default.magenta("✿")} ${picocolors.default.bold("React Grab")} ${picocolors.default.gray(VERSION$2)}`);
2521
2705
  console.log();
2522
2706
  try {
2523
2707
  logger.break();
2524
- const removedCount = await removeSkill();
2708
+ const removedCount = await removeSkill({
2709
+ cwd: (0, node_path.resolve)(opts.cwd),
2710
+ global: opts.global
2711
+ });
2525
2712
  logger.break();
2526
2713
  if (removedCount === 0) logger.log("React Grab skill is not installed in any detected agent.");
2527
2714
  else logger.log(`${highlighter.success("Removed")} the React Grab skill from ${removedCount} agent${removedCount === 1 ? "" : "s"}.`);
@@ -2531,8 +2718,16 @@ const remove = new commander.Command().name("remove").description("uninstall the
2531
2718
  }
2532
2719
  });
2533
2720
  //#endregion
2721
+ //#region src/commands/stop.ts
2722
+ const stop = new commander.Command().name("stop").description("stop the React Grab watcher for this dir").option("-d, --dir <dir>", "work dir holding watch.pid", DEFAULT_WATCH_DIR).action((options) => {
2723
+ const dir = node_path.default.resolve(options.dir);
2724
+ const stoppedPid = stopDaemon(dir);
2725
+ process.stderr.write(stoppedPid ? `react-grab stop: stopped watcher (pid ${stoppedPid})\n` : `react-grab stop: no watcher running for ${dir}\n`);
2726
+ process.exit(0);
2727
+ });
2728
+ //#endregion
2534
2729
  //#region src/commands/upgrade.ts
2535
- const VERSION$1 = "0.1.41";
2730
+ const VERSION$1 = "0.1.42";
2536
2731
  const NPM_REGISTRY_URL = "https://registry.npmjs.org/react-grab/latest";
2537
2732
  const fetchLatestVersion = async () => {
2538
2733
  try {
@@ -2606,95 +2801,24 @@ const upgrade = new commander.Command().name("upgrade").alias("update").descript
2606
2801
  }
2607
2802
  });
2608
2803
  //#endregion
2609
- //#region src/utils/daemon.ts
2610
- const PID_FILE_NAME = "watch.pid";
2611
- const pidFilePath = (dir) => node_path.default.join(dir, PID_FILE_NAME);
2612
- const cliEntryPath = () => process.argv[1] ?? (0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href);
2613
- const isProcessAlive = (pid) => {
2614
- if (!Number.isInteger(pid) || pid <= 0) return false;
2615
- try {
2616
- process.kill(pid, 0);
2617
- return true;
2618
- } catch (error) {
2619
- return error.code === "EPERM";
2620
- }
2621
- };
2622
- const readDaemonPid = (dir) => {
2623
- try {
2624
- const pid = Number.parseInt(node_fs.default.readFileSync(pidFilePath(dir), "utf8").trim(), 10);
2625
- return Number.isInteger(pid) && pid > 0 ? pid : null;
2626
- } catch {
2627
- return null;
2628
- }
2629
- };
2630
- const isDaemonRunning = (dir) => {
2631
- const pid = readDaemonPid(dir);
2632
- return pid !== null && isProcessAlive(pid);
2633
- };
2634
- const claimDaemon = (dir) => {
2635
- const file = pidFilePath(dir);
2636
- for (let attempt = 0; attempt < 50; attempt += 1) try {
2637
- const handle = node_fs.default.openSync(file, "wx");
2638
- node_fs.default.writeFileSync(handle, String(process.pid));
2639
- node_fs.default.closeSync(handle);
2640
- return readDaemonPid(dir) === process.pid;
2641
- } catch (error) {
2642
- if (error.code !== "EEXIST") throw error;
2643
- if (isDaemonRunning(dir)) return false;
2644
- try {
2645
- node_fs.default.rmSync(file, { force: true });
2646
- } catch {}
2647
- }
2648
- return false;
2649
- };
2650
- const releaseDaemon = (dir) => {
2651
- if (readDaemonPid(dir) === process.pid) try {
2652
- node_fs.default.rmSync(pidFilePath(dir), { force: true });
2653
- } catch {}
2654
- };
2655
- const stopDaemon = (dir) => {
2656
- const pid = readDaemonPid(dir);
2657
- if (pid === null) return null;
2658
- const wasAlive = isProcessAlive(pid);
2659
- if (wasAlive) try {
2660
- process.kill(pid, "SIGTERM");
2661
- } catch {}
2662
- if (readDaemonPid(dir) === pid) try {
2663
- node_fs.default.rmSync(pidFilePath(dir), { force: true });
2664
- } catch {}
2665
- return wasAlive ? pid : null;
2666
- };
2667
- const spawnDaemon = (options) => {
2668
- const args = [
2669
- cliEntryPath(),
2670
- "watch",
2671
- "--foreground",
2672
- "--dir",
2673
- options.dir,
2674
- "--interval",
2675
- String(options.intervalMs)
2676
- ];
2677
- if (options.textOnly) args.push("--text-only");
2678
- if (options.replayLast) args.push("--replay-last");
2679
- (0, node_child_process.spawn)(process.execPath, args, {
2680
- detached: true,
2681
- stdio: "ignore",
2682
- windowsHide: true
2683
- }).unref();
2684
- };
2685
- //#endregion
2686
2804
  //#region src/commands/watch.ts
2687
- const readersDir = () => node_path.default.dirname((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
2688
- 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.";
2689
2805
  const writeStatus = (message) => {
2690
2806
  process.stderr.write(`react-grab watch: ${message}\n`);
2691
2807
  };
2692
- const runForeground = (dir, intervalMs, textOnly, replayLast) => {
2808
+ const watch = new commander.Command().name("watch").description("run the React Grab capture daemon in the foreground (used internally by `pull`)").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").action((options) => {
2809
+ const dir = node_path.default.resolve(options.dir);
2810
+ const intervalRaw = Number(options.interval);
2811
+ const intervalMs = Number.isFinite(intervalRaw) && intervalRaw > 0 ? intervalRaw : 800;
2812
+ try {
2813
+ prepareWorkDir(dir);
2814
+ } catch (error) {
2815
+ writeStatus(String(error?.message ?? error));
2816
+ process.exit(1);
2817
+ }
2693
2818
  if (!claimDaemon(dir)) process.exit(0);
2694
2819
  process.on("exit", () => releaseDaemon(dir));
2695
2820
  const reader = createReader({
2696
- textOnly,
2697
- readersDir: readersDir(),
2821
+ textOnly: Boolean(options.textOnly),
2698
2822
  workDir: dir
2699
2823
  });
2700
2824
  if (!reader) {
@@ -2706,58 +2830,16 @@ const runForeground = (dir, intervalMs, textOnly, replayLast) => {
2706
2830
  reader,
2707
2831
  dir,
2708
2832
  intervalMs,
2709
- replayLast,
2833
+ replayLast: Boolean(options.replayLast),
2710
2834
  onWarn: writeStatus
2711
2835
  }).catch((error) => {
2712
2836
  writeStatus(String(error?.message ?? error));
2713
2837
  process.exit(1);
2714
2838
  });
2715
- };
2716
- 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) => {
2717
- const dir = node_path.default.resolve(options.dir);
2718
- const intervalRaw = Number(options.interval);
2719
- const intervalMs = Number.isFinite(intervalRaw) && intervalRaw > 0 ? intervalRaw : 800;
2720
- const textOnly = Boolean(options.textOnly);
2721
- const replayLast = Boolean(options.replayLast);
2722
- try {
2723
- prepareWorkDir(dir);
2724
- } catch (error) {
2725
- writeStatus(String(error?.message ?? error));
2726
- process.exit(1);
2727
- }
2728
- if (options.stop) {
2729
- const stoppedPid = stopDaemon(dir);
2730
- writeStatus(stoppedPid ? `stopped daemon (pid ${stoppedPid})` : `no daemon running for ${dir}`);
2731
- process.exit(0);
2732
- }
2733
- if (options.foreground) {
2734
- runForeground(dir, intervalMs, textOnly, replayLast);
2735
- return;
2736
- }
2737
- if (isDaemonRunning(dir)) {
2738
- writeStatus(`already watching ${dir} (pid ${readDaemonPid(dir)})`);
2739
- process.exit(0);
2740
- }
2741
- if (!createReader({
2742
- textOnly,
2743
- readersDir: readersDir(),
2744
- workDir: dir
2745
- })) {
2746
- writeStatus(NO_READER_MESSAGE);
2747
- process.exit(1);
2748
- }
2749
- spawnDaemon({
2750
- dir,
2751
- intervalMs,
2752
- textOnly,
2753
- replayLast
2754
- });
2755
- writeStatus(`started; capturing grabs → ${node_path.default.join(dir, HISTORY_FILE_NAME)} (run \`grab read\` to consume, \`grab watch --stop\` to stop)`);
2756
- process.exit(0);
2757
2839
  });
2758
2840
  //#endregion
2759
2841
  //#region src/cli.ts
2760
- const VERSION = "0.1.41";
2842
+ const VERSION = "0.1.42";
2761
2843
  const VERSION_API_URL = "https://www.react-grab.com/api/version";
2762
2844
  process.on("SIGINT", () => process.exit(0));
2763
2845
  process.on("SIGTERM", () => process.exit(0));
@@ -2770,8 +2852,9 @@ program.addCommand(add);
2770
2852
  program.addCommand(remove);
2771
2853
  program.addCommand(configure);
2772
2854
  program.addCommand(upgrade);
2773
- program.addCommand(watch);
2774
- program.addCommand(read);
2855
+ program.addCommand(pull);
2856
+ program.addCommand(stop);
2857
+ program.addCommand(watch, { hidden: true });
2775
2858
  const main = async () => {
2776
2859
  await program.parseAsync();
2777
2860
  };