@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.js CHANGED
@@ -465,15 +465,15 @@ const promptSkillInstall = async ({ yes = false, global = false, cwd = process.c
465
465
  for (const record of failed) logger.log(` ${highlighter.error("✗")} ${agentLabel(record.agent)} ${record.error}`);
466
466
  return true;
467
467
  };
468
- const removeSkill = async (cwd = process.cwd()) => {
468
+ const removeSkill = async ({ cwd = process.cwd(), global = false } = {}) => {
469
469
  const agents = await detectAvailableAgents();
470
470
  const removedAgents = [];
471
471
  const dirsToRemove = /* @__PURE__ */ new Set();
472
472
  for (const agent of agents) {
473
- const present = [installedSkillDir(agent, false, cwd), installedSkillDir(agent, true, cwd)].filter((dir) => existsSync(dir));
474
- if (present.length === 0) continue;
473
+ const skillDir = installedSkillDir(agent, global, cwd);
474
+ if (!existsSync(skillDir)) continue;
475
475
  removedAgents.push(agent);
476
- for (const dir of present) dirsToRemove.add(dir);
476
+ dirsToRemove.add(skillDir);
477
477
  }
478
478
  for (const skillDir of dirsToRemove) rmSync(skillDir, {
479
479
  recursive: true,
@@ -484,7 +484,7 @@ const removeSkill = async (cwd = process.cwd()) => {
484
484
  };
485
485
  //#endregion
486
486
  //#region src/commands/add.ts
487
- const VERSION$5 = "0.1.41";
487
+ const VERSION$5 = "0.1.42";
488
488
  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()).option("-g, --global", "install the skill globally instead of in the project", false).action(async (opts) => {
489
489
  console.log(`${pc.magenta("✿")} ${pc.bold("React Grab")} ${pc.gray(VERSION$5)}`);
490
490
  console.log();
@@ -1083,7 +1083,9 @@ const previewCdnTransform = (projectRoot, framework, nextRouterType, targetCdnDo
1083
1083
  //#region src/utils/constants.ts
1084
1084
  const MAX_KEY_HOLD_DURATION_MS = 2e3;
1085
1085
  const DEFAULT_WATCH_DIR = ".react-grab";
1086
- const MAX_GRAB_AGE_MS = 300 * 1e3;
1086
+ const DEFAULT_GRAB_AGE_MS = 300 * 1e3;
1087
+ const MAX_READ_HISTORY_BYTES = 128 * 1024 * 1024;
1088
+ const MIGRATION_SCAN_CHUNK_BYTES = 1024 * 1024;
1087
1089
  //#endregion
1088
1090
  //#region src/utils/format-activation-key.ts
1089
1091
  const formatActivationKeyDisplay = (activationKey) => {
@@ -1101,7 +1103,7 @@ const formatActivationKeyDisplay = (activationKey) => {
1101
1103
  };
1102
1104
  //#endregion
1103
1105
  //#region src/commands/configure.ts
1104
- const VERSION$4 = "0.1.41";
1106
+ const VERSION$4 = "0.1.42";
1105
1107
  const isMac = process.platform === "darwin";
1106
1108
  const META_LABEL = isMac ? "Cmd" : "Win";
1107
1109
  const ALT_LABEL = isMac ? "Option" : "Alt";
@@ -1720,7 +1722,7 @@ const installPackagesWithFeedback = async (packages, packageManager, projectRoot
1720
1722
  };
1721
1723
  //#endregion
1722
1724
  //#region src/commands/init.ts
1723
- const VERSION$3 = "0.1.41";
1725
+ const VERSION$3 = "0.1.42";
1724
1726
  const REPORT_URL = "https://react-grab.com/api/report-cli";
1725
1727
  const DOCS_URL = "https://github.com/aidenybai/react-grab";
1726
1728
  const reportToCli = (type, config, error) => {
@@ -2077,6 +2079,8 @@ const sleep = (durationMs) => new Promise((resolve) => setTimeout(resolve, durat
2077
2079
  //#endregion
2078
2080
  //#region src/utils/clipboard.ts
2079
2081
  const HISTORY_FILE_NAME = "history.jsonl";
2082
+ 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.";
2083
+ const READERS_DIR = path.dirname(fileURLToPath(import.meta.url));
2080
2084
  const READ_TIMEOUT_MS = 2500;
2081
2085
  const MAX_CLIPBOARD_BYTES = 64 * 1024 * 1024;
2082
2086
  const ID_RADIX = 36;
@@ -2170,7 +2174,7 @@ const compileSwiftReader = (readersDir, workDir) => {
2170
2174
  return binary;
2171
2175
  };
2172
2176
  const createDarwinReader = (options) => {
2173
- const binary = options.textOnly ? null : compileSwiftReader(options.readersDir, options.workDir);
2177
+ const binary = options.textOnly ? null : compileSwiftReader(READERS_DIR, options.workDir);
2174
2178
  if (binary) return {
2175
2179
  mode: "darwin-native",
2176
2180
  read: () => {
@@ -2253,7 +2257,7 @@ const detectPowershell = () => hasCommand("pwsh") ? "pwsh" : hasCommand("powersh
2253
2257
  const createWindowsReader = (options) => {
2254
2258
  const shell = detectPowershell();
2255
2259
  if (!shell) return null;
2256
- const scriptPath = path.join(options.readersDir, "read-clipboard.ps1");
2260
+ const scriptPath = path.join(READERS_DIR, "read-clipboard.ps1");
2257
2261
  if (options.textOnly || !fs.existsSync(scriptPath)) return {
2258
2262
  mode: "win-text",
2259
2263
  read: () => {
@@ -2392,108 +2396,291 @@ const runWatchLoop = async (options) => {
2392
2396
  }
2393
2397
  };
2394
2398
  //#endregion
2399
+ //#region src/utils/daemon.ts
2400
+ const PID_FILE_NAME = "watch.pid";
2401
+ const pidFilePath = (dir) => path.join(dir, PID_FILE_NAME);
2402
+ const cliEntryPath = () => process.argv[1] ?? fileURLToPath(import.meta.url);
2403
+ const isProcessAlive = (pid) => {
2404
+ if (!Number.isInteger(pid) || pid <= 0) return false;
2405
+ try {
2406
+ process.kill(pid, 0);
2407
+ return true;
2408
+ } catch (error) {
2409
+ return error.code === "EPERM";
2410
+ }
2411
+ };
2412
+ const readDaemonPid = (dir) => {
2413
+ try {
2414
+ const pid = Number.parseInt(fs.readFileSync(pidFilePath(dir), "utf8").trim(), 10);
2415
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
2416
+ } catch {
2417
+ return null;
2418
+ }
2419
+ };
2420
+ const isDaemonRunning = (dir) => {
2421
+ const pid = readDaemonPid(dir);
2422
+ return pid !== null && isProcessAlive(pid);
2423
+ };
2424
+ const claimDaemon = (dir) => {
2425
+ const file = pidFilePath(dir);
2426
+ for (let attempt = 0; attempt < 50; attempt += 1) try {
2427
+ const handle = fs.openSync(file, "wx");
2428
+ fs.writeFileSync(handle, String(process.pid));
2429
+ fs.closeSync(handle);
2430
+ return readDaemonPid(dir) === process.pid;
2431
+ } catch (error) {
2432
+ if (error.code !== "EEXIST") throw error;
2433
+ if (isDaemonRunning(dir)) return false;
2434
+ try {
2435
+ fs.rmSync(file, { force: true });
2436
+ } catch {}
2437
+ }
2438
+ return false;
2439
+ };
2440
+ const releaseDaemon = (dir) => {
2441
+ if (readDaemonPid(dir) === process.pid) try {
2442
+ fs.rmSync(pidFilePath(dir), { force: true });
2443
+ } catch {}
2444
+ };
2445
+ const stopDaemon = (dir) => {
2446
+ const pid = readDaemonPid(dir);
2447
+ if (pid === null) return null;
2448
+ const wasAlive = isProcessAlive(pid);
2449
+ if (wasAlive) try {
2450
+ process.kill(pid, "SIGTERM");
2451
+ } catch {}
2452
+ if (readDaemonPid(dir) === pid) try {
2453
+ fs.rmSync(pidFilePath(dir), { force: true });
2454
+ } catch {}
2455
+ return wasAlive ? pid : null;
2456
+ };
2457
+ const spawnDaemon = (options) => {
2458
+ const args = [
2459
+ cliEntryPath(),
2460
+ "watch",
2461
+ "--dir",
2462
+ options.dir,
2463
+ "--interval",
2464
+ String(options.intervalMs)
2465
+ ];
2466
+ if (options.textOnly) args.push("--text-only");
2467
+ if (options.replayLast) args.push("--replay-last");
2468
+ spawn(process.execPath, args, {
2469
+ detached: true,
2470
+ stdio: "ignore",
2471
+ windowsHide: true
2472
+ }).unref();
2473
+ };
2474
+ const ensureDaemon = (options) => {
2475
+ if (isDaemonRunning(options.dir)) return "already-running";
2476
+ if (!createReader({
2477
+ textOnly: options.textOnly,
2478
+ workDir: options.dir
2479
+ })) return "no-reader";
2480
+ spawnDaemon(options);
2481
+ return "started";
2482
+ };
2483
+ //#endregion
2395
2484
  //#region src/utils/grab-log.ts
2396
2485
  const CURSOR_FILE_NAME = "cursor.txt";
2486
+ const NEWLINE_BYTE = 10;
2397
2487
  const cursorFilePath = (dir) => path.join(dir, CURSOR_FILE_NAME);
2398
- const readCompleteGrabLines = (dir) => {
2399
- let raw;
2488
+ const historyFilePath = (dir) => path.join(dir, HISTORY_FILE_NAME);
2489
+ const fileSize = (filePath) => {
2400
2490
  try {
2401
- raw = fs.readFileSync(path.join(dir, HISTORY_FILE_NAME), "utf8");
2491
+ return fs.statSync(filePath).size;
2402
2492
  } catch {
2403
- return [];
2493
+ return 0;
2404
2494
  }
2405
- const lastNewline = raw.lastIndexOf("\n");
2406
- if (lastNewline < 0) return [];
2407
- return raw.slice(0, lastNewline).split("\n").filter(Boolean);
2495
+ };
2496
+ const readHistoryRange = (dir, start, length) => {
2497
+ if (length <= 0) return "";
2498
+ const fd = fs.openSync(historyFilePath(dir), "r");
2499
+ try {
2500
+ const buffer = Buffer.allocUnsafe(length);
2501
+ const bytesRead = fs.readSync(fd, buffer, 0, length, start);
2502
+ return buffer.toString("utf8", 0, bytesRead);
2503
+ } finally {
2504
+ fs.closeSync(fd);
2505
+ }
2506
+ };
2507
+ const writeGrabCursor = (dir, offset) => {
2508
+ const target = cursorFilePath(dir);
2509
+ const tempPath = `${target}.${process.pid}.tmp`;
2510
+ fs.writeFileSync(tempPath, JSON.stringify({ offset }));
2511
+ fs.renameSync(tempPath, target);
2512
+ };
2513
+ const byteOffsetAfterLines = (dir, lineCount) => {
2514
+ const filePath = historyFilePath(dir);
2515
+ const size = fileSize(filePath);
2516
+ if (lineCount <= 0 || size === 0) return 0;
2517
+ const fd = fs.openSync(filePath, "r");
2518
+ try {
2519
+ const buffer = Buffer.allocUnsafe(MIGRATION_SCAN_CHUNK_BYTES);
2520
+ let position = 0;
2521
+ let seen = 0;
2522
+ while (position < size) {
2523
+ const bytesRead = fs.readSync(fd, buffer, 0, MIGRATION_SCAN_CHUNK_BYTES, position);
2524
+ if (bytesRead <= 0) break;
2525
+ for (let index = 0; index < bytesRead; index += 1) {
2526
+ if (buffer[index] !== NEWLINE_BYTE) continue;
2527
+ seen += 1;
2528
+ if (seen === lineCount) return position + index + 1;
2529
+ }
2530
+ position += bytesRead;
2531
+ }
2532
+ } finally {
2533
+ fs.closeSync(fd);
2534
+ }
2535
+ return size;
2408
2536
  };
2409
2537
  const readGrabCursor = (dir) => {
2538
+ let raw;
2410
2539
  try {
2411
- const value = Number.parseInt(fs.readFileSync(cursorFilePath(dir), "utf8").trim(), 10);
2412
- return Number.isInteger(value) && value >= 0 ? value : 0;
2540
+ raw = fs.readFileSync(cursorFilePath(dir), "utf8").trim();
2413
2541
  } catch {
2414
2542
  return 0;
2415
2543
  }
2416
- };
2417
- const grabReceivedAt = (line) => {
2544
+ if (raw === "") return 0;
2545
+ let parsed;
2418
2546
  try {
2419
- const value = JSON.parse(line).receivedAt;
2420
- return typeof value === "number" ? value : null;
2547
+ parsed = JSON.parse(raw);
2421
2548
  } catch {
2422
- return null;
2549
+ return 0;
2423
2550
  }
2551
+ if (typeof parsed === "number") {
2552
+ if (!Number.isInteger(parsed) || parsed < 0) return 0;
2553
+ const offset = byteOffsetAfterLines(dir, parsed);
2554
+ writeGrabCursor(dir, offset);
2555
+ return offset;
2556
+ }
2557
+ return typeof parsed.offset === "number" && Number.isInteger(parsed.offset) && parsed.offset >= 0 ? parsed.offset : 0;
2558
+ };
2559
+ const readCompleteGrabLines = (dir) => {
2560
+ const size = fileSize(historyFilePath(dir));
2561
+ if (size === 0) return [];
2562
+ const raw = readHistoryRange(dir, 0, size);
2563
+ const lastNewline = raw.lastIndexOf("\n");
2564
+ if (lastNewline < 0) return [];
2565
+ return raw.slice(0, lastNewline).split("\n").filter(Boolean);
2424
2566
  };
2425
2567
  const consumeGrabs = (dir, options) => {
2426
- const lines = readCompleteGrabLines(dir);
2427
- if (options.all) return lines;
2428
- const maxAgeMs = options.maxAgeMs ?? 0;
2568
+ if (options.all) return readCompleteGrabLines(dir);
2569
+ const size = fileSize(historyFilePath(dir));
2429
2570
  const cursor = readGrabCursor(dir);
2430
- const total = lines.length;
2571
+ const start = cursor > size ? 0 : cursor;
2572
+ if (start >= size) {
2573
+ if (start !== cursor) writeGrabCursor(dir, start);
2574
+ return [];
2575
+ }
2576
+ const chunk = readHistoryRange(dir, start, Math.min(size - start, MAX_READ_HISTORY_BYTES));
2577
+ const lastNewline = chunk.lastIndexOf("\n");
2578
+ if (lastNewline < 0) return [];
2431
2579
  const now = Date.now();
2432
2580
  const fresh = [];
2433
- let index = Math.min(cursor, total);
2434
- while (index < total && (options.limit <= 0 || fresh.length < options.limit)) {
2435
- const line = lines[index];
2436
- index += 1;
2437
- if (maxAgeMs > 0) {
2438
- const receivedAt = grabReceivedAt(line);
2439
- if (receivedAt !== null && now - receivedAt > maxAgeMs) continue;
2581
+ let consumedBytes = 0;
2582
+ let lineStart = 0;
2583
+ while (lineStart <= lastNewline && (options.limit <= 0 || fresh.length < options.limit)) {
2584
+ const newlineIndex = chunk.indexOf("\n", lineStart);
2585
+ const line = chunk.slice(lineStart, newlineIndex);
2586
+ consumedBytes += Buffer.byteLength(chunk.slice(lineStart, newlineIndex + 1), "utf8");
2587
+ lineStart = newlineIndex + 1;
2588
+ if (line.length === 0) continue;
2589
+ let parsed;
2590
+ try {
2591
+ parsed = JSON.parse(line);
2592
+ } catch {
2593
+ continue;
2594
+ }
2595
+ if (options.maxAgeMs > 0 && typeof parsed.receivedAt === "number") {
2596
+ if (now - parsed.receivedAt > options.maxAgeMs) continue;
2440
2597
  }
2441
2598
  fresh.push(line);
2442
2599
  }
2443
- if (index !== cursor) fs.writeFileSync(cursorFilePath(dir), String(index));
2600
+ const nextCursor = start + consumedBytes;
2601
+ if (nextCursor !== cursor) writeGrabCursor(dir, nextCursor);
2444
2602
  return fresh;
2445
2603
  };
2446
2604
  //#endregion
2447
- //#region src/commands/read.ts
2605
+ //#region src/utils/read-args.ts
2448
2606
  const parseWaitMs = (raw) => {
2449
- if (!raw) return 0;
2450
- if (/^(inf|infinite|forever)$/i.test(raw.trim())) return Number.POSITIVE_INFINITY;
2451
- const ms = Number(raw);
2452
- return Number.isFinite(ms) && ms > 0 ? ms : 0;
2607
+ if (raw === void 0) return 0;
2608
+ const trimmed = raw.trim();
2609
+ if (trimmed === "") return 0;
2610
+ if (/^(inf|infinite|infinity|forever)$/i.test(trimmed)) return Number.POSITIVE_INFINITY;
2611
+ const ms = Number(trimmed);
2612
+ return Number.isFinite(ms) && ms >= 0 ? ms : null;
2613
+ };
2614
+ const parseNonNegativeInt = (raw) => {
2615
+ if (raw === void 0) return null;
2616
+ const trimmed = raw.trim();
2617
+ if (trimmed === "") return null;
2618
+ const value = Number(trimmed);
2619
+ return Number.isInteger(value) && value >= 0 ? value : null;
2453
2620
  };
2454
- const parseNonNegativeInt = (raw, fallback) => {
2455
- const value = Number(raw);
2456
- return Number.isInteger(value) && value >= 0 ? value : fallback;
2621
+ //#endregion
2622
+ //#region src/commands/pull.ts
2623
+ const fail = (message) => {
2624
+ process.stderr.write(`react-grab pull: ${message}\n`);
2625
+ process.exit(1);
2626
+ };
2627
+ const emitAndExit = (lines) => {
2628
+ process.stdout.write(`${lines.join("\n")}\n`, () => process.exit(0));
2457
2629
  };
2458
- 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> (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) => {
2630
+ const pull = new 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) => {
2459
2631
  const dir = path.resolve(options.dir);
2460
2632
  try {
2461
2633
  prepareWorkDir(dir);
2462
2634
  } catch (error) {
2463
- process.stderr.write(`react-grab read: ${String(error?.message ?? error)}\n`);
2464
- process.exit(1);
2635
+ fail(String(error?.message ?? error));
2465
2636
  }
2466
- unrefStdin();
2467
- const limit = parseNonNegativeInt(options.limit, 50);
2468
- const maxAgeMs = parseNonNegativeInt(options.maxAge, MAX_GRAB_AGE_MS);
2469
- const all = Boolean(options.all);
2470
- const drain = () => {
2471
- const fresh = consumeGrabs(dir, {
2472
- limit,
2473
- all,
2474
- maxAgeMs
2475
- });
2476
- if (fresh.length > 0) process.stdout.write(`${fresh.join("\n")}\n`);
2477
- return fresh.length;
2478
- };
2479
2637
  const waitMs = parseWaitMs(options.wait);
2480
- const deadline = waitMs === Number.POSITIVE_INFINITY ? Number.POSITIVE_INFINITY : Date.now() + waitMs;
2481
- if (drain() > 0) process.exit(0);
2638
+ if (waitMs === null) fail(`invalid --wait "${options.wait}" (use milliseconds or "infinite")`);
2639
+ const limit = parseNonNegativeInt(options.limit);
2640
+ if (limit === null) fail(`invalid --limit "${options.limit}" (use a non-negative integer)`);
2641
+ const maxAgeMs = parseNonNegativeInt(options.maxAge);
2642
+ if (maxAgeMs === null) fail(`invalid --max-age "${options.maxAge}" (use milliseconds, 0 to disable)`);
2643
+ const all = Boolean(options.all);
2644
+ if (ensureDaemon({
2645
+ dir,
2646
+ intervalMs: 800,
2647
+ textOnly: Boolean(options.textOnly),
2648
+ replayLast: false
2649
+ }) === "no-reader") fail(NO_READER_MESSAGE);
2650
+ unrefStdin();
2651
+ const consume = () => consumeGrabs(dir, {
2652
+ limit,
2653
+ all,
2654
+ maxAgeMs
2655
+ });
2656
+ const first = consume();
2657
+ if (first.length > 0) {
2658
+ emitAndExit(first);
2659
+ return;
2660
+ }
2661
+ const deadline = Date.now() + waitMs;
2482
2662
  while (Date.now() < deadline) {
2483
2663
  await sleep(200);
2484
- if (drain() > 0) break;
2664
+ const batch = consume();
2665
+ if (batch.length > 0) {
2666
+ emitAndExit(batch);
2667
+ return;
2668
+ }
2485
2669
  }
2486
2670
  process.exit(0);
2487
2671
  });
2488
2672
  //#endregion
2489
2673
  //#region src/commands/remove.ts
2490
- const VERSION$2 = "0.1.41";
2491
- const remove = new Command().name("remove").description("uninstall the React Grab skill from your agent").action(async () => {
2674
+ const VERSION$2 = "0.1.42";
2675
+ const remove = new 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) => {
2492
2676
  console.log(`${pc.magenta("✿")} ${pc.bold("React Grab")} ${pc.gray(VERSION$2)}`);
2493
2677
  console.log();
2494
2678
  try {
2495
2679
  logger.break();
2496
- const removedCount = await removeSkill();
2680
+ const removedCount = await removeSkill({
2681
+ cwd: resolve(opts.cwd),
2682
+ global: opts.global
2683
+ });
2497
2684
  logger.break();
2498
2685
  if (removedCount === 0) logger.log("React Grab skill is not installed in any detected agent.");
2499
2686
  else logger.log(`${highlighter.success("Removed")} the React Grab skill from ${removedCount} agent${removedCount === 1 ? "" : "s"}.`);
@@ -2503,8 +2690,16 @@ const remove = new Command().name("remove").description("uninstall the React Gra
2503
2690
  }
2504
2691
  });
2505
2692
  //#endregion
2693
+ //#region src/commands/stop.ts
2694
+ const stop = new 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) => {
2695
+ const dir = path.resolve(options.dir);
2696
+ const stoppedPid = stopDaemon(dir);
2697
+ process.stderr.write(stoppedPid ? `react-grab stop: stopped watcher (pid ${stoppedPid})\n` : `react-grab stop: no watcher running for ${dir}\n`);
2698
+ process.exit(0);
2699
+ });
2700
+ //#endregion
2506
2701
  //#region src/commands/upgrade.ts
2507
- const VERSION$1 = "0.1.41";
2702
+ const VERSION$1 = "0.1.42";
2508
2703
  const NPM_REGISTRY_URL = "https://registry.npmjs.org/react-grab/latest";
2509
2704
  const fetchLatestVersion = async () => {
2510
2705
  try {
@@ -2578,95 +2773,24 @@ const upgrade = new Command().name("upgrade").alias("update").description("upgra
2578
2773
  }
2579
2774
  });
2580
2775
  //#endregion
2581
- //#region src/utils/daemon.ts
2582
- const PID_FILE_NAME = "watch.pid";
2583
- const pidFilePath = (dir) => path.join(dir, PID_FILE_NAME);
2584
- const cliEntryPath = () => process.argv[1] ?? fileURLToPath(import.meta.url);
2585
- const isProcessAlive = (pid) => {
2586
- if (!Number.isInteger(pid) || pid <= 0) return false;
2587
- try {
2588
- process.kill(pid, 0);
2589
- return true;
2590
- } catch (error) {
2591
- return error.code === "EPERM";
2592
- }
2593
- };
2594
- const readDaemonPid = (dir) => {
2595
- try {
2596
- const pid = Number.parseInt(fs.readFileSync(pidFilePath(dir), "utf8").trim(), 10);
2597
- return Number.isInteger(pid) && pid > 0 ? pid : null;
2598
- } catch {
2599
- return null;
2600
- }
2601
- };
2602
- const isDaemonRunning = (dir) => {
2603
- const pid = readDaemonPid(dir);
2604
- return pid !== null && isProcessAlive(pid);
2605
- };
2606
- const claimDaemon = (dir) => {
2607
- const file = pidFilePath(dir);
2608
- for (let attempt = 0; attempt < 50; attempt += 1) try {
2609
- const handle = fs.openSync(file, "wx");
2610
- fs.writeFileSync(handle, String(process.pid));
2611
- fs.closeSync(handle);
2612
- return readDaemonPid(dir) === process.pid;
2613
- } catch (error) {
2614
- if (error.code !== "EEXIST") throw error;
2615
- if (isDaemonRunning(dir)) return false;
2616
- try {
2617
- fs.rmSync(file, { force: true });
2618
- } catch {}
2619
- }
2620
- return false;
2621
- };
2622
- const releaseDaemon = (dir) => {
2623
- if (readDaemonPid(dir) === process.pid) try {
2624
- fs.rmSync(pidFilePath(dir), { force: true });
2625
- } catch {}
2626
- };
2627
- const stopDaemon = (dir) => {
2628
- const pid = readDaemonPid(dir);
2629
- if (pid === null) return null;
2630
- const wasAlive = isProcessAlive(pid);
2631
- if (wasAlive) try {
2632
- process.kill(pid, "SIGTERM");
2633
- } catch {}
2634
- if (readDaemonPid(dir) === pid) try {
2635
- fs.rmSync(pidFilePath(dir), { force: true });
2636
- } catch {}
2637
- return wasAlive ? pid : null;
2638
- };
2639
- const spawnDaemon = (options) => {
2640
- const args = [
2641
- cliEntryPath(),
2642
- "watch",
2643
- "--foreground",
2644
- "--dir",
2645
- options.dir,
2646
- "--interval",
2647
- String(options.intervalMs)
2648
- ];
2649
- if (options.textOnly) args.push("--text-only");
2650
- if (options.replayLast) args.push("--replay-last");
2651
- spawn(process.execPath, args, {
2652
- detached: true,
2653
- stdio: "ignore",
2654
- windowsHide: true
2655
- }).unref();
2656
- };
2657
- //#endregion
2658
2776
  //#region src/commands/watch.ts
2659
- const readersDir = () => path.dirname(fileURLToPath(import.meta.url));
2660
- 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.";
2661
2777
  const writeStatus = (message) => {
2662
2778
  process.stderr.write(`react-grab watch: ${message}\n`);
2663
2779
  };
2664
- const runForeground = (dir, intervalMs, textOnly, replayLast) => {
2780
+ const watch = new 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) => {
2781
+ const dir = path.resolve(options.dir);
2782
+ const intervalRaw = Number(options.interval);
2783
+ const intervalMs = Number.isFinite(intervalRaw) && intervalRaw > 0 ? intervalRaw : 800;
2784
+ try {
2785
+ prepareWorkDir(dir);
2786
+ } catch (error) {
2787
+ writeStatus(String(error?.message ?? error));
2788
+ process.exit(1);
2789
+ }
2665
2790
  if (!claimDaemon(dir)) process.exit(0);
2666
2791
  process.on("exit", () => releaseDaemon(dir));
2667
2792
  const reader = createReader({
2668
- textOnly,
2669
- readersDir: readersDir(),
2793
+ textOnly: Boolean(options.textOnly),
2670
2794
  workDir: dir
2671
2795
  });
2672
2796
  if (!reader) {
@@ -2678,58 +2802,16 @@ const runForeground = (dir, intervalMs, textOnly, replayLast) => {
2678
2802
  reader,
2679
2803
  dir,
2680
2804
  intervalMs,
2681
- replayLast,
2805
+ replayLast: Boolean(options.replayLast),
2682
2806
  onWarn: writeStatus
2683
2807
  }).catch((error) => {
2684
2808
  writeStatus(String(error?.message ?? error));
2685
2809
  process.exit(1);
2686
2810
  });
2687
- };
2688
- 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) => {
2689
- const dir = path.resolve(options.dir);
2690
- const intervalRaw = Number(options.interval);
2691
- const intervalMs = Number.isFinite(intervalRaw) && intervalRaw > 0 ? intervalRaw : 800;
2692
- const textOnly = Boolean(options.textOnly);
2693
- const replayLast = Boolean(options.replayLast);
2694
- try {
2695
- prepareWorkDir(dir);
2696
- } catch (error) {
2697
- writeStatus(String(error?.message ?? error));
2698
- process.exit(1);
2699
- }
2700
- if (options.stop) {
2701
- const stoppedPid = stopDaemon(dir);
2702
- writeStatus(stoppedPid ? `stopped daemon (pid ${stoppedPid})` : `no daemon running for ${dir}`);
2703
- process.exit(0);
2704
- }
2705
- if (options.foreground) {
2706
- runForeground(dir, intervalMs, textOnly, replayLast);
2707
- return;
2708
- }
2709
- if (isDaemonRunning(dir)) {
2710
- writeStatus(`already watching ${dir} (pid ${readDaemonPid(dir)})`);
2711
- process.exit(0);
2712
- }
2713
- if (!createReader({
2714
- textOnly,
2715
- readersDir: readersDir(),
2716
- workDir: dir
2717
- })) {
2718
- writeStatus(NO_READER_MESSAGE);
2719
- process.exit(1);
2720
- }
2721
- spawnDaemon({
2722
- dir,
2723
- intervalMs,
2724
- textOnly,
2725
- replayLast
2726
- });
2727
- writeStatus(`started; capturing grabs → ${path.join(dir, HISTORY_FILE_NAME)} (run \`grab read\` to consume, \`grab watch --stop\` to stop)`);
2728
- process.exit(0);
2729
2811
  });
2730
2812
  //#endregion
2731
2813
  //#region src/cli.ts
2732
- const VERSION = "0.1.41";
2814
+ const VERSION = "0.1.42";
2733
2815
  const VERSION_API_URL = "https://www.react-grab.com/api/version";
2734
2816
  process.on("SIGINT", () => process.exit(0));
2735
2817
  process.on("SIGTERM", () => process.exit(0));
@@ -2742,8 +2824,9 @@ program.addCommand(add$1);
2742
2824
  program.addCommand(remove);
2743
2825
  program.addCommand(configure);
2744
2826
  program.addCommand(upgrade);
2745
- program.addCommand(watch);
2746
- program.addCommand(read);
2827
+ program.addCommand(pull);
2828
+ program.addCommand(stop);
2829
+ program.addCommand(watch, { hidden: true });
2747
2830
  const main = async () => {
2748
2831
  await program.parseAsync();
2749
2832
  };