@react-grab/cli 0.1.40 → 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
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
+ import path, { basename, delimiter, dirname, join, relative, resolve } from "node:path";
3
4
  import pc from "picocolors";
4
5
  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";
@@ -422,8 +422,11 @@ const spinner = (text) => ora({ text });
422
422
  const SKILL_NAME = "react-grab";
423
423
  const SKILL_SOURCE = fileURLToPath(new URL("../skills/react-grab", import.meta.url));
424
424
  const agentLabel = (agent) => getSkillAgentConfig(agent).displayName;
425
- const installedSkillDir = (agent) => join(isUniversalSkillAgent(agent) ? getCanonicalSkillsDir(true) : getSkillAgentDir(agent, { global: true }), SKILL_NAME);
426
- const promptSkillInstall = async ({ yes = false } = {}) => {
425
+ const installedSkillDir = (agent, global, cwd) => join(isUniversalSkillAgent(agent) ? getCanonicalSkillsDir(global, cwd) : getSkillAgentDir(agent, {
426
+ global,
427
+ cwd
428
+ }), SKILL_NAME);
429
+ const promptSkillInstall = async ({ yes = false, global = false, cwd = process.cwd() } = {}) => {
427
430
  const detectedAgents = await detectAvailableAgents();
428
431
  if (detectedAgents.length === 0) {
429
432
  logger.warn("No supported agents detected.");
@@ -434,7 +437,7 @@ const promptSkillInstall = async ({ yes = false } = {}) => {
434
437
  const { agents } = await prompts({
435
438
  type: "multiselect",
436
439
  name: "agents",
437
- message: "Install the React Grab skill for:",
440
+ message: `Install the React Grab skill (${global ? "global" : "this project"}) for:`,
438
441
  choices: detectedAgents.map((agent) => ({
439
442
  title: agentLabel(agent),
440
443
  value: agent,
@@ -450,7 +453,8 @@ const promptSkillInstall = async ({ yes = false } = {}) => {
450
453
  const { installed, failed } = await add({
451
454
  source: SKILL_SOURCE,
452
455
  agents: selectedAgents,
453
- global: true,
456
+ global,
457
+ cwd,
454
458
  mode: "copy"
455
459
  });
456
460
  if (installed.length === 0) {
@@ -461,19 +465,27 @@ const promptSkillInstall = async ({ yes = false } = {}) => {
461
465
  for (const record of failed) logger.log(` ${highlighter.error("✗")} ${agentLabel(record.agent)} ${record.error}`);
462
466
  return true;
463
467
  };
464
- const removeSkill = async () => {
465
- const agentsWithSkill = (await detectAvailableAgents()).filter((agent) => existsSync(installedSkillDir(agent)));
466
- for (const skillDir of new Set(agentsWithSkill.map(installedSkillDir))) rmSync(skillDir, {
468
+ const removeSkill = async ({ cwd = process.cwd(), global = false } = {}) => {
469
+ const agents = await detectAvailableAgents();
470
+ const removedAgents = [];
471
+ const dirsToRemove = /* @__PURE__ */ new Set();
472
+ for (const agent of agents) {
473
+ const skillDir = installedSkillDir(agent, global, cwd);
474
+ if (!existsSync(skillDir)) continue;
475
+ removedAgents.push(agent);
476
+ dirsToRemove.add(skillDir);
477
+ }
478
+ for (const skillDir of dirsToRemove) rmSync(skillDir, {
467
479
  recursive: true,
468
480
  force: true
469
481
  });
470
- for (const agent of agentsWithSkill) logger.log(` ${highlighter.success("✓")} ${agentLabel(agent)}`);
471
- return agentsWithSkill.length;
482
+ for (const agent of removedAgents) logger.log(` ${highlighter.success("✓")} ${agentLabel(agent)}`);
483
+ return removedAgents.length;
472
484
  };
473
485
  //#endregion
474
486
  //#region src/commands/add.ts
475
- const VERSION$5 = "0.1.40";
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) => {
487
+ const VERSION$5 = "0.1.42";
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) => {
477
489
  console.log(`${pc.magenta("✿")} ${pc.bold("React Grab")} ${pc.gray(VERSION$5)}`);
478
490
  console.log();
479
491
  try {
@@ -488,7 +500,11 @@ const add$1 = new Command().name("add").alias("install").description("install th
488
500
  }
489
501
  preflightSpinner.succeed();
490
502
  logger.break();
491
- if (!await promptSkillInstall({ yes: isNonInteractive }) && isNonInteractive) {
503
+ if (!await promptSkillInstall({
504
+ yes: isNonInteractive,
505
+ global: opts.global,
506
+ cwd: resolve(opts.cwd)
507
+ }) && isNonInteractive) {
492
508
  logger.break();
493
509
  process.exit(1);
494
510
  }
@@ -1067,6 +1083,9 @@ const previewCdnTransform = (projectRoot, framework, nextRouterType, targetCdnDo
1067
1083
  //#region src/utils/constants.ts
1068
1084
  const MAX_KEY_HOLD_DURATION_MS = 2e3;
1069
1085
  const DEFAULT_WATCH_DIR = ".react-grab";
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;
1070
1089
  //#endregion
1071
1090
  //#region src/utils/format-activation-key.ts
1072
1091
  const formatActivationKeyDisplay = (activationKey) => {
@@ -1084,7 +1103,7 @@ const formatActivationKeyDisplay = (activationKey) => {
1084
1103
  };
1085
1104
  //#endregion
1086
1105
  //#region src/commands/configure.ts
1087
- const VERSION$4 = "0.1.40";
1106
+ const VERSION$4 = "0.1.42";
1088
1107
  const isMac = process.platform === "darwin";
1089
1108
  const META_LABEL = isMac ? "Cmd" : "Win";
1090
1109
  const ALT_LABEL = isMac ? "Option" : "Alt";
@@ -1703,7 +1722,7 @@ const installPackagesWithFeedback = async (packages, packageManager, projectRoot
1703
1722
  };
1704
1723
  //#endregion
1705
1724
  //#region src/commands/init.ts
1706
- const VERSION$3 = "0.1.40";
1725
+ const VERSION$3 = "0.1.42";
1707
1726
  const REPORT_URL = "https://react-grab.com/api/report-cli";
1708
1727
  const DOCS_URL = "https://github.com/aidenybai/react-grab";
1709
1728
  const reportToCli = (type, config, error) => {
@@ -1770,7 +1789,7 @@ const failWithManualSetup = (failingSpinner, message, { listSupportedFrameworks
1770
1789
  logger.break();
1771
1790
  process.exit(1);
1772
1791
  };
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) => {
1792
+ 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()).option("-g, --global", "install the skill globally instead of in the project", false).action(async (opts) => {
1774
1793
  console.log(`${pc.magenta("✿")} ${pc.bold("React Grab")} ${pc.gray(VERSION$3)}`);
1775
1794
  console.log();
1776
1795
  try {
@@ -1928,7 +1947,11 @@ const init = new Command().name("init").alias("setup").description("initialize R
1928
1947
  }
1929
1948
  }
1930
1949
  logger.break();
1931
- await promptSkillInstall({ yes: isNonInteractive });
1950
+ await promptSkillInstall({
1951
+ yes: isNonInteractive,
1952
+ global: opts.global,
1953
+ cwd
1954
+ });
1932
1955
  logger.break();
1933
1956
  process.exit(0);
1934
1957
  }
@@ -1994,7 +2017,11 @@ const init = new Command().name("init").alias("setup").description("initialize R
1994
2017
  let didInstallSkill = false;
1995
2018
  if (!isNonInteractive) {
1996
2019
  logger.break();
1997
- didInstallSkill = await promptSkillInstall({ yes: isNonInteractive });
2020
+ didInstallSkill = await promptSkillInstall({
2021
+ yes: isNonInteractive,
2022
+ global: opts.global,
2023
+ cwd
2024
+ });
1998
2025
  }
1999
2026
  const result = previewTransform(projectInfo.projectRoot, finalFramework, finalNextRouterType, false, opts.force);
2000
2027
  if (!result.success) {
@@ -2052,6 +2079,8 @@ const sleep = (durationMs) => new Promise((resolve) => setTimeout(resolve, durat
2052
2079
  //#endregion
2053
2080
  //#region src/utils/clipboard.ts
2054
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));
2055
2084
  const READ_TIMEOUT_MS = 2500;
2056
2085
  const MAX_CLIPBOARD_BYTES = 64 * 1024 * 1024;
2057
2086
  const ID_RADIX = 36;
@@ -2145,7 +2174,7 @@ const compileSwiftReader = (readersDir, workDir) => {
2145
2174
  return binary;
2146
2175
  };
2147
2176
  const createDarwinReader = (options) => {
2148
- const binary = options.textOnly ? null : compileSwiftReader(options.readersDir, options.workDir);
2177
+ const binary = options.textOnly ? null : compileSwiftReader(READERS_DIR, options.workDir);
2149
2178
  if (binary) return {
2150
2179
  mode: "darwin-native",
2151
2180
  read: () => {
@@ -2228,7 +2257,7 @@ const detectPowershell = () => hasCommand("pwsh") ? "pwsh" : hasCommand("powersh
2228
2257
  const createWindowsReader = (options) => {
2229
2258
  const shell = detectPowershell();
2230
2259
  if (!shell) return null;
2231
- const scriptPath = path.join(options.readersDir, "read-clipboard.ps1");
2260
+ const scriptPath = path.join(READERS_DIR, "read-clipboard.ps1");
2232
2261
  if (options.textOnly || !fs.existsSync(scriptPath)) return {
2233
2262
  mode: "win-text",
2234
2263
  read: () => {
@@ -2367,77 +2396,291 @@ const runWatchLoop = async (options) => {
2367
2396
  }
2368
2397
  };
2369
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
2370
2484
  //#region src/utils/grab-log.ts
2371
2485
  const CURSOR_FILE_NAME = "cursor.txt";
2486
+ const NEWLINE_BYTE = 10;
2372
2487
  const cursorFilePath = (dir) => path.join(dir, CURSOR_FILE_NAME);
2373
- const readCompleteGrabLines = (dir) => {
2374
- let raw;
2488
+ const historyFilePath = (dir) => path.join(dir, HISTORY_FILE_NAME);
2489
+ const fileSize = (filePath) => {
2375
2490
  try {
2376
- raw = fs.readFileSync(path.join(dir, HISTORY_FILE_NAME), "utf8");
2491
+ return fs.statSync(filePath).size;
2377
2492
  } catch {
2378
- return [];
2493
+ return 0;
2379
2494
  }
2380
- const lastNewline = raw.lastIndexOf("\n");
2381
- if (lastNewline < 0) return [];
2382
- 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;
2383
2536
  };
2384
2537
  const readGrabCursor = (dir) => {
2538
+ let raw;
2539
+ try {
2540
+ raw = fs.readFileSync(cursorFilePath(dir), "utf8").trim();
2541
+ } catch {
2542
+ return 0;
2543
+ }
2544
+ if (raw === "") return 0;
2545
+ let parsed;
2385
2546
  try {
2386
- const value = Number.parseInt(fs.readFileSync(cursorFilePath(dir), "utf8").trim(), 10);
2387
- return Number.isInteger(value) && value >= 0 ? value : 0;
2547
+ parsed = JSON.parse(raw);
2388
2548
  } catch {
2389
2549
  return 0;
2390
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);
2391
2566
  };
2392
2567
  const consumeGrabs = (dir, options) => {
2393
- const lines = readCompleteGrabLines(dir);
2394
- if (options.all) return lines;
2568
+ if (options.all) return readCompleteGrabLines(dir);
2569
+ const size = fileSize(historyFilePath(dir));
2395
2570
  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);
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 [];
2579
+ const now = Date.now();
2580
+ const fresh = [];
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;
2597
+ }
2598
+ fresh.push(line);
2599
+ }
2600
+ const nextCursor = start + consumedBytes;
2601
+ if (nextCursor !== cursor) writeGrabCursor(dir, nextCursor);
2602
+ return fresh;
2400
2603
  };
2401
2604
  //#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) => {
2605
+ //#region src/utils/read-args.ts
2606
+ const parseWaitMs = (raw) => {
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;
2620
+ };
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));
2629
+ };
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) => {
2404
2631
  const dir = path.resolve(options.dir);
2405
2632
  try {
2406
2633
  prepareWorkDir(dir);
2407
2634
  } catch (error) {
2408
- process.stderr.write(`react-grab read: ${String(error?.message ?? error)}\n`);
2409
- process.exit(1);
2410
- }
2411
- unrefStdin();
2412
- const limitRaw = Number(options.limit);
2413
- const limit = Number.isInteger(limitRaw) && limitRaw >= 0 ? limitRaw : 50;
2635
+ fail(String(error?.message ?? error));
2636
+ }
2637
+ const waitMs = parseWaitMs(options.wait);
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)`);
2414
2643
  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);
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;
2426
2662
  while (Date.now() < deadline) {
2427
2663
  await sleep(200);
2428
- if (drain() > 0) break;
2664
+ const batch = consume();
2665
+ if (batch.length > 0) {
2666
+ emitAndExit(batch);
2667
+ return;
2668
+ }
2429
2669
  }
2430
2670
  process.exit(0);
2431
2671
  });
2432
2672
  //#endregion
2433
2673
  //#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 () => {
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) => {
2436
2676
  console.log(`${pc.magenta("✿")} ${pc.bold("React Grab")} ${pc.gray(VERSION$2)}`);
2437
2677
  console.log();
2438
2678
  try {
2439
2679
  logger.break();
2440
- const removedCount = await removeSkill();
2680
+ const removedCount = await removeSkill({
2681
+ cwd: resolve(opts.cwd),
2682
+ global: opts.global
2683
+ });
2441
2684
  logger.break();
2442
2685
  if (removedCount === 0) logger.log("React Grab skill is not installed in any detected agent.");
2443
2686
  else logger.log(`${highlighter.success("Removed")} the React Grab skill from ${removedCount} agent${removedCount === 1 ? "" : "s"}.`);
@@ -2447,8 +2690,16 @@ const remove = new Command().name("remove").description("uninstall the React Gra
2447
2690
  }
2448
2691
  });
2449
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
2450
2701
  //#region src/commands/upgrade.ts
2451
- const VERSION$1 = "0.1.40";
2702
+ const VERSION$1 = "0.1.42";
2452
2703
  const NPM_REGISTRY_URL = "https://registry.npmjs.org/react-grab/latest";
2453
2704
  const fetchLatestVersion = async () => {
2454
2705
  try {
@@ -2522,95 +2773,24 @@ const upgrade = new Command().name("upgrade").alias("update").description("upgra
2522
2773
  }
2523
2774
  });
2524
2775
  //#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
2776
  //#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
2777
  const writeStatus = (message) => {
2606
2778
  process.stderr.write(`react-grab watch: ${message}\n`);
2607
2779
  };
2608
- 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
+ }
2609
2790
  if (!claimDaemon(dir)) process.exit(0);
2610
2791
  process.on("exit", () => releaseDaemon(dir));
2611
2792
  const reader = createReader({
2612
- textOnly,
2613
- readersDir: readersDir(),
2793
+ textOnly: Boolean(options.textOnly),
2614
2794
  workDir: dir
2615
2795
  });
2616
2796
  if (!reader) {
@@ -2622,58 +2802,16 @@ const runForeground = (dir, intervalMs, textOnly, replayLast) => {
2622
2802
  reader,
2623
2803
  dir,
2624
2804
  intervalMs,
2625
- replayLast,
2805
+ replayLast: Boolean(options.replayLast),
2626
2806
  onWarn: writeStatus
2627
2807
  }).catch((error) => {
2628
2808
  writeStatus(String(error?.message ?? error));
2629
2809
  process.exit(1);
2630
2810
  });
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);
2673
2811
  });
2674
2812
  //#endregion
2675
2813
  //#region src/cli.ts
2676
- const VERSION = "0.1.40";
2814
+ const VERSION = "0.1.42";
2677
2815
  const VERSION_API_URL = "https://www.react-grab.com/api/version";
2678
2816
  process.on("SIGINT", () => process.exit(0));
2679
2817
  process.on("SIGTERM", () => process.exit(0));
@@ -2686,8 +2824,9 @@ program.addCommand(add$1);
2686
2824
  program.addCommand(remove);
2687
2825
  program.addCommand(configure);
2688
2826
  program.addCommand(upgrade);
2689
- program.addCommand(watch);
2690
- program.addCommand(read);
2827
+ program.addCommand(pull);
2828
+ program.addCommand(stop);
2829
+ program.addCommand(watch, { hidden: true });
2691
2830
  const main = async () => {
2692
2831
  await program.parseAsync();
2693
2832
  };