Files

1984 lines
64 KiB
JavaScript

#!/usr/bin/env node
import { a as previewCdnTransform, c as agentLabel, d as detectAvailableAgents, h as detectProject, i as hasFrameworkEntryPoint, l as installSkill, n as installPackages, o as previewOptionsTransform, r as applyTransform, s as previewTransform, t as getPackagesToInstall, u as removeSkill, y as findReactProjects } from "./install-Car5vKPk.js";
import { Command } from "commander";
import path, { join, relative, resolve } from "node:path";
import pc from "picocolors";
import fs, { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import basePrompts from "prompts";
import ora from "ora";
import { spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
//#region src/utils/is-non-interactive.ts
const AGENT_ENVIRONMENT_VARIABLES = [
"CI",
"CLAUDECODE",
"CURSOR_AGENT",
"CODEX_CI",
"OPENCODE",
"AMP_HOME",
"AMI"
];
const isEnvironmentVariableSet = (variable) => Boolean(process.env[variable]);
const detectNonInteractive = (yesFlag) => yesFlag || AGENT_ENVIRONMENT_VARIABLES.some(isEnvironmentVariableSet) || !process.stdin.isTTY;
//#endregion
//#region src/utils/highlighter.ts
const highlighter = {
error: pc.red,
warn: pc.yellow,
info: pc.cyan,
success: pc.green,
dim: pc.dim
};
//#endregion
//#region src/utils/logger.ts
const logger = {
error(...args) {
console.log(highlighter.error(args.join(" ")));
},
warn(...args) {
console.log(highlighter.warn(args.join(" ")));
},
success(...args) {
console.log(highlighter.success(args.join(" ")));
},
log(...args) {
console.log(args.join(" "));
},
break() {
console.log("");
}
};
//#endregion
//#region src/utils/handle-error.ts
const handleError = (error) => {
logger.break();
logger.error("Something went wrong. Please check the error below for more details.");
logger.error("If the problem persists, please open an issue on GitHub.");
logger.error("");
if (error instanceof Error) logger.error(error.message);
logger.break();
process.exit(1);
};
//#endregion
//#region src/utils/unref-stdin.ts
const unrefStdin = () => {
if (process.stdin.isTTY) return;
process.stdin.unref?.();
};
//#endregion
//#region src/utils/prompts.ts
const onCancel = () => {
logger.break();
logger.log("Cancelled.");
logger.break();
process.exit(0);
};
const prompts = (questions) => {
return basePrompts(questions, { onCancel }).finally(unrefStdin);
};
//#endregion
//#region src/utils/spinner.ts
const spinner = (text) => ora({ text });
//#endregion
//#region src/utils/prompt-skill-install.ts
const promptSkillInstall = async ({ yes = false, global = false, cwd = process.cwd() } = {}) => {
const detectedAgents = await detectAvailableAgents();
if (detectedAgents.length === 0) {
logger.warn("No supported agents detected.");
return false;
}
let selectedAgents = detectedAgents;
if (!yes) {
const { confirmed } = await prompts({
type: "confirm",
name: "confirmed",
message: `Install the React Grab skill (${global ? "global" : "this project"})?`,
initial: true
});
if (!confirmed) return false;
const { agents } = await prompts({
type: "multiselect",
name: "agents",
message: `Install the React Grab skill (${global ? "global" : "this project"}) for:`,
choices: detectedAgents.map((agent) => ({
title: agentLabel(agent),
value: agent,
selected: true
})),
instructions: false,
min: 1
});
selectedAgents = agents ?? [];
if (selectedAgents.length === 0) return false;
}
const installSpinner = spinner("Installing React Grab skill.").start();
const { installed, failed } = await installSkill({
agents: selectedAgents,
global,
cwd
});
if (installed.length === 0) {
installSpinner.fail("Failed to install React Grab skill.");
return false;
}
installSpinner.succeed(`Installed React Grab skill for ${installed.map((record) => agentLabel(record.agent)).join(", ")}.`);
for (const record of failed) logger.log(` ${highlighter.error("✗")} ${agentLabel(record.agent)} ${record.error}`);
return true;
};
//#endregion
//#region src/commands/add.ts
const VERSION$5 = "0.1.50";
const add = 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) => {
console.log(`${pc.magenta("✿")} ${pc.bold("React Grab")} ${pc.gray(VERSION$5)}`);
console.log();
try {
const isNonInteractive = detectNonInteractive(opts.yes);
const preflightSpinner = spinner("Preflight checks.").start();
if (!(await detectProject(opts.cwd)).hasReactGrab) {
preflightSpinner.fail("React Grab is not installed.");
logger.break();
logger.error(`Run ${highlighter.info("react-grab init")} first to install React Grab.`);
logger.break();
process.exit(1);
}
preflightSpinner.succeed();
logger.break();
if (!await promptSkillInstall({
yes: isNonInteractive,
global: opts.global,
cwd: resolve(opts.cwd)
}) && isNonInteractive) {
logger.break();
process.exit(1);
}
logger.break();
} catch (error) {
handleError(error);
}
});
//#endregion
//#region src/utils/diff.ts
const RED = "\x1B[31m";
const GREEN = "\x1B[32m";
const GRAY = "\x1B[90m";
const RESET = "\x1B[0m";
const BOLD = "\x1B[1m";
const generateDiff = (originalContent, newContent) => {
const originalLines = originalContent.split("\n");
const newLines = newContent.split("\n");
const diff = [];
let originalIndex = 0;
let newIndex = 0;
while (originalIndex < originalLines.length || newIndex < newLines.length) {
const originalLine = originalLines[originalIndex];
const newLine = newLines[newIndex];
if (originalLine === newLine) {
diff.push({
type: "unchanged",
content: originalLine,
lineNumber: newIndex + 1
});
originalIndex++;
newIndex++;
} else if (originalLine === void 0) {
diff.push({
type: "added",
content: newLine,
lineNumber: newIndex + 1
});
newIndex++;
} else if (newLine === void 0) {
diff.push({
type: "removed",
content: originalLine
});
originalIndex++;
} else {
const originalInNew = newLines.indexOf(originalLine, newIndex);
const newInOriginal = originalLines.indexOf(newLine, originalIndex);
if (originalInNew !== -1 && (newInOriginal === -1 || originalInNew - newIndex < newInOriginal - originalIndex)) while (newIndex < originalInNew) {
diff.push({
type: "added",
content: newLines[newIndex],
lineNumber: newIndex + 1
});
newIndex++;
}
else if (newInOriginal !== -1) while (originalIndex < newInOriginal) {
diff.push({
type: "removed",
content: originalLines[originalIndex]
});
originalIndex++;
}
else {
diff.push({
type: "removed",
content: originalLine
});
diff.push({
type: "added",
content: newLine,
lineNumber: newIndex + 1
});
originalIndex++;
newIndex++;
}
}
}
return diff;
};
const formatDiff = (diff, contextLines = 3) => {
const lines = [];
let lastPrintedIndex = -1;
let hasChanges = false;
const changedIndices = diff.map((line, index) => line.type !== "unchanged" ? index : -1).filter((index) => index !== -1);
if (changedIndices.length === 0) return `${GRAY}No changes${RESET}`;
for (const changedIndex of changedIndices) {
const startContext = Math.max(0, changedIndex - contextLines);
const endContext = Math.min(diff.length - 1, changedIndex + contextLines);
if (startContext > lastPrintedIndex + 1 && lastPrintedIndex !== -1) lines.push(`${GRAY} ...${RESET}`);
for (let lineIndex = Math.max(startContext, lastPrintedIndex + 1); lineIndex <= endContext; lineIndex++) {
const diffLine = diff[lineIndex];
if (diffLine.type === "added") {
lines.push(`${GREEN}+ ${diffLine.content}${RESET}`);
hasChanges = true;
} else if (diffLine.type === "removed") {
lines.push(`${RED}- ${diffLine.content}${RESET}`);
hasChanges = true;
} else lines.push(`${GRAY} ${diffLine.content}${RESET}`);
lastPrintedIndex = lineIndex;
}
}
return hasChanges ? lines.join("\n") : `${GRAY}No changes${RESET}`;
};
const printDiff = (filePath, originalContent, newContent) => {
console.log(`\n${BOLD}File: ${filePath}${RESET}`);
console.log("─".repeat(60));
const diff = generateDiff(originalContent, newContent);
console.log(formatDiff(diff));
console.log("─".repeat(60));
};
//#endregion
//#region src/utils/cli-helpers.ts
const applyTransformWithFeedback = (result, message) => {
const writeSpinner = spinner(message ?? `Applying changes to ${result.filePath}.`).start();
const writeResult = applyTransform(result);
if (!writeResult.success) {
writeSpinner.fail();
logger.break();
logger.error(writeResult.error || "Failed to write file.");
logger.break();
process.exit(1);
}
writeSpinner.succeed();
};
const installPackagesWithFeedback = async (packages, packageManager, projectRoot) => {
if (packages.length === 0) return;
const installSpinner = spinner(`Installing ${packages.join(", ")}.`).start();
try {
await installPackages(packages, {
packageManager,
cwd: projectRoot
});
installSpinner.succeed();
} catch (error) {
installSpinner.fail();
handleError(error);
}
};
//#endregion
//#region src/utils/constants.ts
const MAX_KEY_HOLD_DURATION_MS = 2e3;
const DEFAULT_WATCH_DIR = ".react-grab";
const DEFAULT_GRAB_AGE_MS = 300 * 1e3;
const MAX_READ_HISTORY_BYTES = 128 * 1024 * 1024;
const MIGRATION_SCAN_CHUNK_BYTES = 1024 * 1024;
//#endregion
//#region src/utils/format-activation-key.ts
const formatActivationKeyDisplay = (activationKey) => {
const defaultLabel = process.platform === "darwin" ? "Option" : "Alt";
if (!activationKey) return `Default (${defaultLabel})`;
return activationKey.split("+").map((part) => {
const lower = part.toLowerCase();
if (lower === "meta") return process.platform === "darwin" ? "⌘" : "Win";
if (lower === "alt") return process.platform === "darwin" ? "⌥" : "Alt";
if (lower === "ctrl") return "Ctrl";
if (lower === "shift") return "Shift";
if (lower === "space" || lower === " ") return "Space";
return part.toUpperCase();
}).join(" + ");
};
//#endregion
//#region src/commands/configure.ts
const VERSION$4 = "0.1.50";
const isMac = process.platform === "darwin";
const META_LABEL = isMac ? "Cmd" : "Win";
const ALT_LABEL = isMac ? "Option" : "Alt";
const MODIFIER_ALIASES = {
cmd: "meta",
command: "meta",
win: "meta",
windows: "meta",
meta: "meta",
ctrl: "ctrl",
control: "ctrl",
shift: "shift",
alt: "alt",
option: "alt",
opt: "alt"
};
const MODIFIERS = [
"meta",
"ctrl",
"shift",
"alt"
];
const BASE_KEYS = [
{
key: " ",
aliases: ["space", "spacebar"]
},
{
key: "Enter",
aliases: ["enter", "return"]
},
{
key: "Escape",
aliases: ["escape", "esc"]
},
{
key: "Tab",
aliases: ["tab"]
},
{
key: "Backspace",
aliases: ["backspace", "back"]
},
{
key: "Delete",
aliases: ["delete", "del"]
},
{
key: "Insert",
aliases: ["insert", "ins"]
},
{
key: "Home",
aliases: ["home"]
},
{
key: "End",
aliases: ["end"]
},
{
key: "PageUp",
aliases: ["pageup", "pgup"]
},
{
key: "PageDown",
aliases: [
"pagedown",
"pgdn",
"pgdown"
]
},
{
key: "ArrowUp",
aliases: ["arrowup", "up"]
},
{
key: "ArrowDown",
aliases: ["arrowdown", "down"]
},
{
key: "ArrowLeft",
aliases: ["arrowleft", "left"]
},
{
key: "ArrowRight",
aliases: ["arrowright", "right"]
},
...Array.from({ length: 12 }, (_, i) => ({
key: `F${i + 1}`,
aliases: [`f${i + 1}`]
})),
...Array.from({ length: 26 }, (_, i) => {
const letter = String.fromCharCode(97 + i);
return {
key: letter,
aliases: [letter]
};
}),
...Array.from({ length: 10 }, (_, i) => ({
key: String(i),
aliases: [String(i)]
})),
{
key: "`",
aliases: [
"backtick",
"grave",
"`"
]
},
{
key: "-",
aliases: [
"minus",
"dash",
"-"
]
},
{
key: "=",
aliases: [
"equals",
"equal",
"="
]
},
{
key: "[",
aliases: [
"leftbracket",
"lbracket",
"["
]
},
{
key: "]",
aliases: [
"rightbracket",
"rbracket",
"]"
]
},
{
key: "\\",
aliases: ["backslash", "\\"]
},
{
key: ";",
aliases: ["semicolon", ";"]
},
{
key: "'",
aliases: [
"quote",
"apostrophe",
"'"
]
},
{
key: ",",
aliases: ["comma", ","]
},
{
key: ".",
aliases: [
"period",
"dot",
"."
]
},
{
key: "/",
aliases: [
"slash",
"forwardslash",
"/"
]
}
];
const formatCombo = (combo) => {
const parts = [];
if (combo.metaKey) parts.push(META_LABEL);
if (combo.ctrlKey) parts.push("Ctrl");
if (combo.shiftKey) parts.push("Shift");
if (combo.altKey) parts.push(ALT_LABEL);
const keyDisplay = combo.key === " " ? "Space" : combo.key.length === 1 ? combo.key.toUpperCase() : combo.key;
parts.push(keyDisplay);
return parts.join("+");
};
const parseInput = (input) => {
const parts = input.toLowerCase().replace(/\s+/g, "").split(/[+-]/);
const modifiers = /* @__PURE__ */ new Set();
let partial = "";
for (const part of parts) {
if (!part) continue;
const modifierKey = MODIFIER_ALIASES[part];
if (modifierKey) modifiers.add(modifierKey);
else partial = part;
}
return {
modifiers,
partial
};
};
const POPULAR_KEYS = [
"g",
"k",
"e",
"d",
"b",
" ",
"Escape",
"Enter"
];
const generateSuggestions = (input) => {
const { modifiers, partial } = parseInput(input);
const suggestions = [];
if (!partial && modifiers.size === 0 && !input) {
for (const mod of MODIFIERS) {
const label = mod === "meta" ? META_LABEL : mod === "alt" ? ALT_LABEL : mod.charAt(0).toUpperCase() + mod.slice(1);
for (const popularKey of POPULAR_KEYS) {
const keyDisplay = popularKey === " " ? "Space" : popularKey.length === 1 ? popularKey.toUpperCase() : popularKey;
suggestions.push({
title: `${label}+${keyDisplay}`,
value: {
key: popularKey,
...mod === "meta" ? { metaKey: true } : {},
...mod === "ctrl" ? { ctrlKey: true } : {},
...mod === "shift" ? { shiftKey: true } : {},
...mod === "alt" ? { altKey: true } : {}
}
});
}
}
for (const baseKey of BASE_KEYS) suggestions.push({
title: baseKey.key === " " ? "Space" : baseKey.key.length === 1 ? baseKey.key.toUpperCase() : baseKey.key,
value: { key: baseKey.key }
});
return suggestions;
}
const buildCombo = (key, mods, extraMod) => ({
key,
...mods.has("meta") || extraMod === "meta" ? { metaKey: true } : {},
...mods.has("ctrl") || extraMod === "ctrl" ? { ctrlKey: true } : {},
...mods.has("shift") || extraMod === "shift" ? { shiftKey: true } : {},
...mods.has("alt") || extraMod === "alt" ? { altKey: true } : {}
});
for (const baseKey of BASE_KEYS) if (partial ? baseKey.aliases.some((alias) => alias.startsWith(partial)) : true) {
const combo = buildCombo(baseKey.key, modifiers);
suggestions.push({
title: formatCombo(combo),
value: combo
});
}
if (!partial) {
const unusedMods = MODIFIERS.filter((m) => !modifiers.has(m));
for (const mod of unusedMods) for (const popularKey of POPULAR_KEYS) {
const combo = buildCombo(popularKey, modifiers, mod);
suggestions.push({
title: formatCombo(combo),
value: combo
});
}
}
return suggestions.slice(0, 30);
};
const CONFIG_OPTIONS = [
{
id: "activationKey",
title: "Shortcut",
description: "The shortcut used to activate React Grab (e.g., g, k, space)"
},
{
id: "activationMode",
title: "Activation Mode",
description: "Toggle (press to activate/deactivate) or Hold (hold key)"
},
{
id: "keyHoldDuration",
title: "Key Hold Duration",
description: "Milliseconds to hold the key before activation (hold mode)"
},
{
id: "allowActivationInsideInput",
title: "Allow Activation Inside Input",
description: "Whether to allow activation when focused on input fields"
},
{
id: "maxContextLines",
title: "Max Context Lines",
description: "Max source-location lines in copied context (raise for large apps)"
}
];
const comboToString = (combo) => {
const parts = [];
if (combo.metaKey) parts.push("Meta");
if (combo.ctrlKey) parts.push("Ctrl");
if (combo.shiftKey) parts.push("Shift");
if (combo.altKey) parts.push("Alt");
if (combo.key) {
const keyDisplay = combo.key === " " ? "Space" : combo.key;
parts.push(keyDisplay);
}
return parts.join("+");
};
const configure = new Command().name("configure").alias("config").description("configure React Grab options").option("-y, --yes", "skip confirmation prompts", false).option("-k, --key <key>", "shortcut (e.g., Meta+K, Ctrl+Shift+G, Space)").option("-m, --mode <mode>", "activation mode (toggle, hold)").option("--hold-duration <ms>", "key hold duration in milliseconds (for hold mode)").option("--allow-input <boolean>", "allow activation inside input fields (true/false)").option("--context-lines <lines>", "max context lines to include").option("--cdn <domain>", "CDN domain (e.g., unpkg.com, custom.react-grab.com)").option("-c, --cwd <cwd>", "working directory (defaults to current directory)", process.cwd()).action(async (opts) => {
console.log(`${pc.magenta("✿")} ${pc.bold("React Grab")} ${pc.gray(VERSION$4)}`);
console.log();
try {
const cwd = opts.cwd;
const preflightSpinner = spinner("Preflight checks.").start();
const projectInfo = await detectProject(cwd);
if (!projectInfo.hasReactGrab) {
preflightSpinner.fail("React Grab is not installed.");
logger.break();
logger.error(`Run ${highlighter.info("react-grab init")} first to install React Grab.`);
logger.break();
process.exit(1);
}
if (!projectInfo.isReactGrabConfigured) {
preflightSpinner.fail("React Grab is installed, but setup is missing.");
logger.break();
logger.error(`Run ${highlighter.info("react-grab init")} to add the setup script/import before configuring options.`);
logger.break();
process.exit(1);
}
preflightSpinner.succeed();
if (opts.cdn) {
const result = previewCdnTransform(projectInfo.projectRoot, projectInfo.framework, projectInfo.nextRouterType, opts.cdn);
if (!result.success) {
logger.break();
logger.error(result.message);
logger.break();
process.exit(1);
}
if (result.noChanges) {
logger.break();
logger.log("No changes needed.");
logger.break();
process.exit(0);
}
logger.break();
printDiff(result.filePath, result.originalContent, result.newContent);
if (!opts.yes) {
logger.break();
const { proceed } = await prompts({
type: "confirm",
name: "proceed",
message: "Apply these changes?",
initial: true
});
if (!proceed) {
logger.break();
logger.log("Changes cancelled.");
logger.break();
process.exit(0);
}
}
applyTransformWithFeedback(result);
logger.break();
logger.log(`${highlighter.success("Success!")} CDN updated.`);
logger.break();
return;
}
const hasFlags = opts.key || opts.mode || opts.holdDuration || opts.allowInput || opts.contextLines;
logger.break();
logger.log(`Configure ${highlighter.info("React Grab")} options:`);
logger.break();
const collectedOptions = {};
if (hasFlags) {
if (opts.key) {
collectedOptions.activationKey = opts.key;
logger.log(` Shortcut: ${highlighter.info(formatActivationKeyDisplay(collectedOptions.activationKey))}`);
}
if (opts.mode) {
if (opts.mode !== "toggle" && opts.mode !== "hold") {
logger.error(`Invalid mode: ${opts.mode}. Use "toggle" or "hold".`);
logger.break();
process.exit(1);
}
collectedOptions.activationMode = opts.mode;
logger.log(` Activation mode: ${highlighter.info(opts.mode)}`);
}
if (opts.holdDuration) {
const duration = parseInt(opts.holdDuration, 10);
if (isNaN(duration) || duration < 0 || duration > 2e3) {
logger.error(`Invalid hold duration. Must be 0-${MAX_KEY_HOLD_DURATION_MS}ms.`);
logger.break();
process.exit(1);
}
collectedOptions.keyHoldDuration = duration;
logger.log(` Key hold duration: ${highlighter.info(`${duration}ms`)}`);
}
if (opts.allowInput !== void 0) {
const allowInput = opts.allowInput === "true" || opts.allowInput === true;
collectedOptions.allowActivationInsideInput = allowInput;
logger.log(` Allow activation inside input: ${highlighter.info(String(allowInput))}`);
}
if (opts.contextLines) {
const lines = parseInt(opts.contextLines, 10);
if (isNaN(lines) || lines < 0 || lines > 50) {
logger.error(`Invalid context lines. Must be 0-50.`);
logger.break();
process.exit(1);
}
collectedOptions.maxContextLines = lines;
logger.log(` Max context lines: ${highlighter.info(String(lines))}`);
}
} else {
const { selectedOption } = await prompts({
type: "autocomplete",
name: "selectedOption",
message: "Search for an option to configure:",
choices: CONFIG_OPTIONS.map((option) => ({
title: option.title,
value: option.id,
description: option.description
})),
suggest: (input, choices) => Promise.resolve(choices.filter((choice) => choice.title.toLowerCase().includes(input.toLowerCase()) || (choice.description?.toLowerCase().includes(input.toLowerCase()) ?? false)))
});
if (selectedOption === void 0) {
logger.break();
process.exit(1);
}
if (selectedOption === "activationKey") {
const { selectedCombo } = await prompts({
type: "autocomplete",
name: "selectedCombo",
message: "Type key combination (e.g. ctrl+shift+g):",
choices: generateSuggestions(""),
suggest: (input) => Promise.resolve(generateSuggestions(input))
});
if (selectedCombo === void 0) {
logger.break();
process.exit(1);
}
collectedOptions.activationKey = comboToString(selectedCombo);
logger.log(` Shortcut: ${highlighter.info(formatActivationKeyDisplay(collectedOptions.activationKey))}`);
}
if (selectedOption === "activationMode") {
const { activationMode } = await prompts({
type: "select",
name: "activationMode",
message: `Select ${highlighter.info("activation mode")}:`,
choices: [{
title: "Toggle (press to activate/deactivate)",
value: "toggle"
}, {
title: "Hold (hold key to keep active)",
value: "hold"
}],
initial: 0
});
if (activationMode === void 0) {
logger.break();
process.exit(1);
}
collectedOptions.activationMode = activationMode;
}
if (selectedOption === "keyHoldDuration") {
const { keyHoldDuration } = await prompts({
type: "number",
name: "keyHoldDuration",
message: `Enter ${highlighter.info("key hold duration")} in milliseconds:`,
initial: 150,
min: 0,
max: 2e3
});
if (keyHoldDuration === void 0) {
logger.break();
process.exit(1);
}
collectedOptions.keyHoldDuration = keyHoldDuration;
}
if (selectedOption === "allowActivationInsideInput") {
const { allowActivationInsideInput } = await prompts({
type: "confirm",
name: "allowActivationInsideInput",
message: `Allow activation ${highlighter.info("inside input fields")}?`,
initial: true
});
if (allowActivationInsideInput === void 0) {
logger.break();
process.exit(1);
}
collectedOptions.allowActivationInsideInput = allowActivationInsideInput;
}
if (selectedOption === "maxContextLines") {
const { maxContextLines } = await prompts({
type: "number",
name: "maxContextLines",
message: `Enter ${highlighter.info("max context lines")} to include:`,
initial: 3,
min: 0,
max: 50
});
if (maxContextLines === void 0) {
logger.break();
process.exit(1);
}
collectedOptions.maxContextLines = maxContextLines;
}
}
const result = previewOptionsTransform(projectInfo.projectRoot, projectInfo.framework, projectInfo.nextRouterType, collectedOptions);
if (!result.success) {
logger.break();
logger.warn(result.message);
logger.break();
const configJson = JSON.stringify(collectedOptions);
logger.log(`Add this to your ${highlighter.info("init()")} call or ${highlighter.info("data-options")} attribute:`);
logger.break();
console.log(` ${pc.cyan(configJson)}`);
logger.break();
process.exit(1);
}
if (!result.noChanges && result.originalContent && result.newContent) {
logger.break();
printDiff(result.filePath, result.originalContent, result.newContent);
if (!opts.yes) {
logger.break();
const { proceed } = await prompts({
type: "confirm",
name: "proceed",
message: "Apply these changes?",
initial: true
});
if (!proceed) {
logger.break();
logger.log("Changes cancelled.");
logger.break();
process.exit(0);
}
}
applyTransformWithFeedback(result);
} else {
logger.break();
logger.log("No changes needed.");
}
logger.break();
logger.log(`${highlighter.success("Success!")} React Grab options have been configured.`);
logger.break();
} catch (error) {
handleError(error);
}
});
//#endregion
//#region src/utils/is-telemetry-enabled.ts
const isTelemetryEnabled = () => {
const doNotTrack = process.env.DO_NOT_TRACK;
return doNotTrack !== "1" && doNotTrack !== "true";
};
//#endregion
//#region src/commands/init.ts
const VERSION$3 = "0.1.50";
const REPORT_URL = "https://react-grab.com/api/report-cli";
const DOCS_URL = "https://github.com/aidenybai/react-grab";
const reportToCli = (type, config, error) => {
if (!isTelemetryEnabled()) return;
fetch(REPORT_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type,
version: VERSION$3,
config,
error: error ? {
message: error.message,
stack: error.stack
} : void 0,
timestamp: (/* @__PURE__ */ new Date()).toISOString()
})
}).catch(() => {});
};
const FRAMEWORK_NAMES = {
next: "Next.js",
vite: "Vite",
tanstack: "TanStack Start",
webpack: "Webpack",
unknown: "Unknown"
};
const PACKAGE_MANAGER_NAMES = {
npm: "npm",
yarn: "Yarn",
pnpm: "pnpm",
bun: "Bun"
};
const UNSUPPORTED_FRAMEWORK_NAMES = {
remix: "Remix",
astro: "Astro",
sveltekit: "SvelteKit",
gatsby: "Gatsby"
};
const sortProjectsByFramework = (projects) => [...projects].sort((projectA, projectB) => {
if (projectA.framework === "unknown" && projectB.framework !== "unknown") return 1;
if (projectA.framework !== "unknown" && projectB.framework === "unknown") return -1;
return 0;
});
const printSubprojects = (searchRoot, sortedProjects) => {
logger.break();
logger.log("Found the following projects:");
logger.break();
for (const project of sortedProjects) {
const frameworkLabel = project.framework !== "unknown" ? ` ${highlighter.dim(`(${FRAMEWORK_NAMES[project.framework]})`)}` : "";
const relativePath = relative(searchRoot, project.path);
logger.log(` ${highlighter.info(project.name)}${frameworkLabel} ${highlighter.dim(relativePath)}`);
}
logger.break();
logger.log(`Re-run with ${highlighter.info("-c <path>")} to specify a project:`);
logger.break();
logger.log(` ${highlighter.dim("$")} npx grab@latest init -c ${relative(searchRoot, sortedProjects[0].path)}`);
logger.break();
};
const SUPPORTED_FRAMEWORKS_LINE = "React Grab supports Next.js, Vite, TanStack Start, and Webpack projects.";
const failWithManualSetup = (failingSpinner, message, { listSupportedFrameworks = false } = {}) => {
failingSpinner.fail(message);
logger.break();
if (listSupportedFrameworks) logger.log(SUPPORTED_FRAMEWORKS_LINE);
logger.log(`Visit ${highlighter.info(DOCS_URL)} for manual setup.`);
logger.break();
process.exit(1);
};
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", "re-run setup checks even when React Grab is already configured", false).option("-k, --key <key>", "shortcut (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) => {
console.log(`${pc.magenta("✿")} ${pc.bold("React Grab")} ${pc.gray(VERSION$3)}`);
console.log();
try {
const cwd = resolve(opts.cwd);
const isNonInteractive = detectNonInteractive(opts.yes);
if (!existsSync(cwd)) {
logger.break();
logger.error(`Directory does not exist: ${highlighter.info(cwd)}`);
logger.break();
process.exit(1);
}
const preflightSpinner = spinner("Preflight checks.").start();
const projectInfo = await detectProject(cwd);
if (projectInfo.isReactGrabConfigured && !opts.force) {
preflightSpinner.succeed();
if (isNonInteractive) {
logger.break();
logger.warn("React Grab is already installed.");
logger.log(`Use ${highlighter.info("--force")} to re-run setup checks, or remove ${highlighter.info("--yes")} for interactive mode.`);
logger.break();
process.exit(0);
}
logger.break();
logger.success("React Grab is already installed.");
logger.break();
const { wantCustomizeOptions } = await prompts({
type: "confirm",
name: "wantCustomizeOptions",
message: `Would you like to customize ${highlighter.info("options")}?`,
initial: false
});
if (wantCustomizeOptions === void 0) {
logger.break();
process.exit(1);
}
if (wantCustomizeOptions || opts.key) {
logger.break();
logger.log(`Configure ${highlighter.info("React Grab")} options:`);
logger.break();
const collectedOptions = {};
if (opts.key) {
collectedOptions.activationKey = opts.key;
logger.log(` Shortcut: ${highlighter.info(formatActivationKeyDisplay(collectedOptions.activationKey))}`);
} else {
const { wantActivationKey } = await prompts({
type: "confirm",
name: "wantActivationKey",
message: `Configure ${highlighter.info("shortcut")}?`,
initial: false
});
if (wantActivationKey === void 0) {
logger.break();
process.exit(1);
}
if (wantActivationKey) {
const { key } = await prompts({
type: "text",
name: "key",
message: "Enter the shortcut (e.g., g, k, space):",
initial: ""
});
if (key === void 0) {
logger.break();
process.exit(1);
}
collectedOptions.activationKey = key ? key.toLowerCase() : void 0;
logger.log(` Shortcut: ${highlighter.info(formatActivationKeyDisplay(collectedOptions.activationKey))}`);
}
}
const { activationMode } = await prompts({
type: "select",
name: "activationMode",
message: `Select ${highlighter.info("activation mode")}:`,
choices: [{
title: "Toggle (press to activate/deactivate)",
value: "toggle"
}, {
title: "Hold (hold key to keep active)",
value: "hold"
}],
initial: 0
});
if (activationMode === void 0) {
logger.break();
process.exit(1);
}
collectedOptions.activationMode = activationMode;
if (activationMode === "hold") {
const { keyHoldDuration } = await prompts({
type: "number",
name: "keyHoldDuration",
message: `Enter ${highlighter.info("key hold duration")} in milliseconds:`,
initial: 150,
min: 0,
max: 2e3
});
if (keyHoldDuration === void 0) {
logger.break();
process.exit(1);
}
collectedOptions.keyHoldDuration = keyHoldDuration;
}
const { allowActivationInsideInput } = await prompts({
type: "confirm",
name: "allowActivationInsideInput",
message: `Allow activation ${highlighter.info("inside input fields")}?`,
initial: true
});
if (allowActivationInsideInput === void 0) {
logger.break();
process.exit(1);
}
collectedOptions.allowActivationInsideInput = allowActivationInsideInput;
const { maxContextLines } = await prompts({
type: "number",
name: "maxContextLines",
message: `Enter ${highlighter.info("max context lines")} to include:`,
initial: 3,
min: 0,
max: 50
});
if (maxContextLines === void 0) {
logger.break();
process.exit(1);
}
collectedOptions.maxContextLines = maxContextLines;
const optionsResult = previewOptionsTransform(projectInfo.projectRoot, projectInfo.framework, projectInfo.nextRouterType, collectedOptions);
if (!optionsResult.success) {
logger.break();
logger.error(optionsResult.message);
logger.break();
process.exit(1);
}
if (!optionsResult.noChanges && optionsResult.originalContent && optionsResult.newContent) {
logger.break();
printDiff(optionsResult.filePath, optionsResult.originalContent, optionsResult.newContent);
logger.break();
const { proceed } = await prompts({
type: "confirm",
name: "proceed",
message: "Apply these changes?",
initial: true
});
if (!proceed) {
logger.break();
logger.log("Options configuration cancelled.");
} else {
applyTransformWithFeedback(optionsResult);
logger.break();
logger.success("React Grab options have been configured.");
}
} else {
logger.break();
logger.log("No option changes needed.");
}
}
logger.break();
await promptSkillInstall({
yes: isNonInteractive,
global: opts.global,
cwd
});
logger.break();
process.exit(0);
}
preflightSpinner.succeed();
const frameworkSpinner = spinner("Verifying framework.").start();
if (projectInfo.unsupportedFramework) {
const frameworkName = UNSUPPORTED_FRAMEWORK_NAMES[projectInfo.unsupportedFramework];
frameworkSpinner.fail(`Found ${highlighter.info(frameworkName)}.`);
logger.break();
logger.log(`${frameworkName} is not yet supported by automatic setup.`);
logger.log(`Visit ${highlighter.info(DOCS_URL)} for manual setup.`);
logger.break();
process.exit(1);
}
if (projectInfo.framework === "unknown" || projectInfo.isMonorepo && !hasFrameworkEntryPoint(projectInfo.projectRoot, projectInfo.framework, projectInfo.nextRouterType)) {
let searchRoot = cwd;
let reactProjects = findReactProjects(searchRoot);
if (reactProjects.length === 0 && cwd !== process.cwd()) {
searchRoot = process.cwd();
reactProjects = findReactProjects(searchRoot);
}
if (reactProjects.length > 0) {
frameworkSpinner.info(`Verifying framework. Found ${reactProjects.length} project${reactProjects.length === 1 ? "" : "s"}.`);
const sortedProjects = sortProjectsByFramework(reactProjects);
if (isNonInteractive) {
printSubprojects(searchRoot, sortedProjects);
process.exit(1);
}
logger.break();
const { selectedProject } = await prompts({
type: "select",
name: "selectedProject",
message: "Select a project to install React Grab:",
choices: [...sortedProjects.map((project) => {
const frameworkLabel = project.framework !== "unknown" ? ` ${highlighter.dim(`(${FRAMEWORK_NAMES[project.framework]})`)}` : "";
return {
title: `${project.name}${frameworkLabel}`,
value: project.path
};
}), {
title: "Skip",
value: "skip"
}]
});
if (!selectedProject || selectedProject === "skip") {
logger.break();
process.exit(0);
}
process.chdir(selectedProject);
const newProjectInfo = await detectProject(selectedProject);
Object.assign(projectInfo, newProjectInfo);
const newFrameworkSpinner = spinner("Verifying framework.").start();
if (newProjectInfo.framework === "unknown") failWithManualSetup(newFrameworkSpinner, "Could not detect a supported framework in this project.", { listSupportedFrameworks: true });
newFrameworkSpinner.succeed(`Verifying framework. Found ${highlighter.info(FRAMEWORK_NAMES[newProjectInfo.framework])}.`);
} else if (projectInfo.framework !== "unknown") failWithManualSetup(frameworkSpinner, `Verifying framework. Found ${highlighter.info(FRAMEWORK_NAMES[projectInfo.framework])}, but could not find an entry file.`);
else failWithManualSetup(frameworkSpinner, "Could not detect a supported framework.", { listSupportedFrameworks: true });
} else frameworkSpinner.succeed(`Verifying framework. Found ${highlighter.info(FRAMEWORK_NAMES[projectInfo.framework])}.`);
if (projectInfo.framework === "next") spinner("Detecting router type.").start().succeed(`Detecting router type. Found ${highlighter.info(projectInfo.nextRouterType === "app" ? "App Router" : "Pages Router")}.`);
spinner("Detecting package manager.").start().succeed(`Detecting package manager. Found ${highlighter.info(PACKAGE_MANAGER_NAMES[projectInfo.packageManager])}.`);
const finalFramework = projectInfo.framework;
const finalPackageManager = projectInfo.packageManager;
const finalNextRouterType = projectInfo.nextRouterType;
let didInstallSkill = false;
if (!isNonInteractive) {
logger.break();
didInstallSkill = await promptSkillInstall({
yes: isNonInteractive,
global: opts.global,
cwd
});
}
const result = previewTransform(projectInfo.projectRoot, finalFramework, finalNextRouterType, projectInfo.isReactGrabConfigured);
if (!result.success) {
logger.break();
logger.error(result.message);
logger.error(`Visit ${highlighter.info(DOCS_URL)} for manual setup.`);
logger.break();
process.exit(1);
}
const hasLayoutChanges = !result.noChanges && result.originalContent && result.newContent;
if (hasLayoutChanges) {
logger.break();
printDiff(result.filePath, result.originalContent, result.newContent);
logger.break();
logger.warn("Auto-detection may not be 100% accurate.");
logger.warn("Please verify the changes before committing.");
if (!isNonInteractive) {
logger.break();
const { proceed } = await prompts({
type: "confirm",
name: "proceed",
message: "Apply these changes?",
initial: true
});
if (!proceed) {
logger.break();
logger.log("Changes cancelled.");
logger.break();
process.exit(0);
}
}
}
const shouldInstallReactGrab = !projectInfo.hasReactGrab;
if (!opts.skipInstall && shouldInstallReactGrab) await installPackagesWithFeedback(getPackagesToInstall(shouldInstallReactGrab), finalPackageManager, projectInfo.projectRoot);
if (hasLayoutChanges) applyTransformWithFeedback(result);
logger.break();
if (hasLayoutChanges) logger.log(`${highlighter.success("Success!")} React Grab has been installed.`);
else logger.log(`${highlighter.success("Success!")} ${result.message}.`);
logger.log("You may now start your development server.");
logger.break();
reportToCli("completed", {
framework: finalFramework,
packageManager: finalPackageManager,
router: finalNextRouterType,
agent: didInstallSkill ? "skill" : void 0,
isMonorepo: projectInfo.isMonorepo
});
} catch (error) {
handleError(error);
reportToCli("error", void 0, error);
}
});
//#endregion
//#region src/utils/sleep.ts
const sleep = (durationMs) => new Promise((resolve) => setTimeout(resolve, durationMs));
//#endregion
//#region src/utils/clipboard.ts
const HISTORY_FILE_NAME = "history.jsonl";
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.";
const READERS_DIR = path.dirname(fileURLToPath(import.meta.url));
const READ_TIMEOUT_MS = 2500;
const MAX_CLIPBOARD_BYTES = 64 * 1024 * 1024;
const ID_RADIX = 36;
const HASH_LENGTH = 12;
const PICKLE_HEADER_BYTES = 4;
const PICKLE_ALIGN_BYTES = 4;
const SIGNATURE_SCAN_CHARS = 32 * 1024;
const GRAB_MIME = "application/x-react-grab";
const CHROMIUM_CUSTOM_FORMAT = "chromium/x-web-custom-data";
const GRAB_TEXT_SIGNATURE = /\bin\s+\S+\s+\(at\s+[^\n]{1,400}?:\d+:\d+\)/;
const shortHash = (text) => createHash("sha1").update(text).digest("hex").slice(0, HASH_LENGTH);
const alignUp = (value) => value + PICKLE_ALIGN_BYTES - 1 & ~(PICKLE_ALIGN_BYTES - 1);
const parseChromiumPickle = (buffer) => {
const formats = {};
if (!buffer || buffer.length < PICKLE_HEADER_BYTES + 4) return formats;
let offset = PICKLE_HEADER_BYTES;
const pairCount = buffer.readUInt32LE(offset);
offset += 4;
for (let pairIndex = 0; pairIndex < pairCount; pairIndex += 1) {
if (offset + 4 > buffer.length) break;
const formatCodeUnits = buffer.readUInt32LE(offset);
offset += 4;
if (offset + formatCodeUnits * 2 > buffer.length) break;
const format = buffer.toString("utf16le", offset, offset + formatCodeUnits * 2);
offset = alignUp(offset + formatCodeUnits * 2);
if (offset + 4 > buffer.length) break;
const dataCodeUnits = buffer.readUInt32LE(offset);
offset += 4;
if (offset + dataCodeUnits * 2 > buffer.length) break;
const value = buffer.toString("utf16le", offset, offset + dataCodeUnits * 2);
offset = alignUp(offset + dataCodeUnits * 2);
formats[format] = value;
}
return formats;
};
const extractGrab = (raw) => {
if (raw.grab) return raw.grab;
if (raw.pickleBase64) return parseChromiumPickle(Buffer.from(raw.pickleBase64, "base64"))[GRAB_MIME];
};
const isGrabText = (text) => GRAB_TEXT_SIGNATURE.test(text.length > SIGNATURE_SCAN_CHARS ? text.slice(0, SIGNATURE_SCAN_CHARS) : text);
const extractPrompt = (record) => {
const comments = (Array.isArray(record.entries) ? record.entries : []).map((entry) => entry?.commentText?.trim?.()).filter(Boolean);
if (comments.length > 0) return comments.join("\n");
const lines = (typeof record.content === "string" ? record.content : "").split("\n");
const firstReferenceLine = lines.findIndex((line) => line.startsWith("["));
if (firstReferenceLine <= 0) return void 0;
return lines.slice(0, firstReferenceLine).join("\n").trim() || void 0;
};
const hasCommand = (name) => {
return spawnSync(process.platform === "win32" ? "where" : "which", [name], { stdio: "ignore" }).status === 0;
};
const runText = (command, args) => {
const output = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: MAX_CLIPBOARD_BYTES,
timeout: READ_TIMEOUT_MS
});
return output.status === 0 ? output.stdout : null;
};
const runBuffer = (command, args) => {
const output = spawnSync(command, args, {
maxBuffer: MAX_CLIPBOARD_BYTES,
timeout: READ_TIMEOUT_MS
});
return output.status === 0 && output.stdout?.length ? output.stdout : null;
};
const runJson = (command, args) => {
const output = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: MAX_CLIPBOARD_BYTES,
timeout: READ_TIMEOUT_MS
});
if (output.status !== 0 || !output.stdout) return null;
try {
return JSON.parse(output.stdout);
} catch {
return null;
}
};
const compileSwiftReader = (readersDir, workDir) => {
if (!hasCommand("swiftc")) return null;
const source = path.join(readersDir, "read-clipboard.swift");
if (!fs.existsSync(source)) return null;
const binary = path.join(workDir, "pbread");
if ((!fs.existsSync(binary) || fs.statSync(source).mtimeMs > fs.statSync(binary).mtimeMs) && spawnSync("swiftc", [
"-O",
source,
"-o",
binary
]).status !== 0) return null;
return binary;
};
const createDarwinReader = (options) => {
const binary = options.textOnly ? null : compileSwiftReader(READERS_DIR, options.workDir);
if (binary) return {
mode: "darwin-native",
read: () => {
const raw = runJson(binary, []);
if (!raw) return null;
return {
changeCount: raw.changeCount ?? null,
text: raw.text,
grab: extractGrab(raw)
};
}
};
if (!hasCommand("pbpaste")) return null;
return {
mode: "darwin-text",
read: () => {
const text = runText("pbpaste", []);
return text == null ? null : {
changeCount: null,
text,
grab: void 0
};
}
};
};
const detectLinuxTool = () => {
if (Boolean(process.env.WAYLAND_DISPLAY) && hasCommand("wl-paste") || hasCommand("wl-paste") && !hasCommand("xclip")) return {
name: "wl-paste",
readText: () => runText("wl-paste", [
"-n",
"-t",
"text/plain"
]) ?? runText("wl-paste", ["-n"]),
readCustom: () => runBuffer("wl-paste", [
"-n",
"-t",
CHROMIUM_CUSTOM_FORMAT
])
};
if (hasCommand("xclip")) return {
name: "xclip",
readText: () => runText("xclip", [
"-selection",
"clipboard",
"-o"
]),
readCustom: () => runBuffer("xclip", [
"-selection",
"clipboard",
"-t",
CHROMIUM_CUSTOM_FORMAT,
"-o"
])
};
if (hasCommand("xsel")) return {
name: "xsel",
readText: () => runText("xsel", ["--clipboard", "--output"]),
readCustom: null
};
return null;
};
const createLinuxReader = (options) => {
const tool = detectLinuxTool();
if (!tool) return null;
const useCustom = !options.textOnly && Boolean(tool.readCustom);
return {
mode: useCustom ? `linux-${tool.name}` : `linux-${tool.name}-text`,
read: () => {
const text = tool.readText();
if (text == null) return null;
return {
changeCount: null,
text,
grab: useCustom && tool.readCustom ? parseChromiumPickle(tool.readCustom())[GRAB_MIME] : void 0
};
}
};
};
const detectPowershell = () => hasCommand("pwsh") ? "pwsh" : hasCommand("powershell") ? "powershell" : null;
const createWindowsReader = (options) => {
const shell = detectPowershell();
if (!shell) return null;
const scriptPath = path.join(READERS_DIR, "read-clipboard.ps1");
if (options.textOnly || !fs.existsSync(scriptPath)) return {
mode: "win-text",
read: () => {
const text = runText(shell, [
"-NoProfile",
"-Command",
"Get-Clipboard -Raw"
]);
return text == null ? null : {
changeCount: null,
text,
grab: void 0
};
}
};
const args = [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
scriptPath
];
spawnSync(shell, args, {
stdio: "ignore",
maxBuffer: MAX_CLIPBOARD_BYTES
});
return {
mode: "win-native",
read: () => {
const raw = runJson(shell, args);
if (!raw) return null;
return {
changeCount: raw.changeCount ?? null,
text: raw.text ?? void 0,
grab: extractGrab(raw)
};
}
};
};
const createReader = (options) => {
if (process.platform === "darwin") return createDarwinReader(options);
if (process.platform === "linux") return createLinuxReader(options);
if (process.platform === "win32") return createWindowsReader(options);
return null;
};
const ensureSafeDir = (dir) => {
let stats;
try {
stats = fs.lstatSync(dir);
} catch {
return;
}
if (stats.isSymbolicLink()) throw new Error(`Refusing to use ${dir}: it is a symlink.`);
if (process.getuid && stats.uid !== process.getuid()) throw new Error(`Refusing to use ${dir}: owned by another user.`);
};
const prepareWorkDir = (dir) => {
ensureSafeDir(dir);
fs.mkdirSync(dir, {
recursive: true,
mode: 448
});
const gitignore = path.join(dir, ".gitignore");
if (!fs.existsSync(gitignore)) fs.writeFileSync(gitignore, "*\n");
};
const runWatchLoop = async (options) => {
const { reader, dir, intervalMs, replayLast, onWarn } = options;
const logPath = path.join(dir, HISTORY_FILE_NAME);
const { read } = reader;
let lastChangeCount = null;
let lastTimestamp = 0;
let lastTextHash = "";
let lastErrorMessage = "";
let sequence = 0;
const initial = read();
if (initial && !replayLast) {
lastChangeCount = initial.changeCount;
if (initial.text) lastTextHash = shortHash(initial.text);
if (initial.grab) try {
lastTimestamp = JSON.parse(initial.grab).timestamp ?? 0;
} catch {}
}
while (true) {
await sleep(intervalMs);
try {
const snapshot = read();
if (!snapshot) continue;
if (snapshot.changeCount !== null && snapshot.changeCount === lastChangeCount) continue;
const textHash = snapshot.text ? shortHash(snapshot.text) : "";
const didTextChange = textHash !== lastTextHash;
let record = null;
let nextTimestamp = lastTimestamp;
if (snapshot.grab) {
let parsed = null;
try {
parsed = JSON.parse(snapshot.grab);
} catch {}
if (parsed && typeof parsed.timestamp === "number" && parsed.timestamp > lastTimestamp) {
nextTimestamp = parsed.timestamp;
record = {
source: "custom",
timestamp: parsed.timestamp,
version: typeof parsed.version === "string" ? parsed.version : void 0,
content: typeof parsed.content === "string" ? parsed.content : "",
entries: Array.isArray(parsed.entries) ? parsed.entries : []
};
}
} else if (didTextChange && snapshot.text && isGrabText(snapshot.text)) record = {
source: "text",
timestamp: Date.now(),
content: snapshot.text,
entries: []
};
if (!record) {
lastChangeCount = snapshot.changeCount;
lastTextHash = textHash;
continue;
}
const prompt = extractPrompt(record);
if (prompt) record.prompt = prompt;
const captured = {
id: `${record.timestamp}-${(sequence += 1).toString(ID_RADIX)}`,
receivedAt: Date.now(),
...record
};
fs.appendFileSync(logPath, `${JSON.stringify(captured)}\n`);
lastChangeCount = snapshot.changeCount;
lastTextHash = textHash;
lastTimestamp = nextTimestamp;
} catch (error) {
const message = String(error?.message ?? error);
if (message !== lastErrorMessage) {
lastErrorMessage = message;
onWarn?.(message);
}
}
}
};
//#endregion
//#region src/utils/daemon.ts
const PID_FILE_NAME = "watch.pid";
const pidFilePath = (dir) => path.join(dir, PID_FILE_NAME);
const cliEntryPath = () => process.argv[1] ?? fileURLToPath(import.meta.url);
const isProcessAlive = (pid) => {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error.code === "EPERM";
}
};
const readDaemonPid = (dir) => {
try {
const pid = Number.parseInt(fs.readFileSync(pidFilePath(dir), "utf8").trim(), 10);
return Number.isInteger(pid) && pid > 0 ? pid : null;
} catch {
return null;
}
};
const isDaemonRunning = (dir) => {
const pid = readDaemonPid(dir);
return pid !== null && isProcessAlive(pid);
};
const claimDaemon = (dir) => {
const file = pidFilePath(dir);
for (let attempt = 0; attempt < 50; attempt += 1) try {
const handle = fs.openSync(file, "wx");
fs.writeFileSync(handle, String(process.pid));
fs.closeSync(handle);
return readDaemonPid(dir) === process.pid;
} catch (error) {
if (error.code !== "EEXIST") throw error;
if (isDaemonRunning(dir)) return false;
try {
fs.rmSync(file, { force: true });
} catch {}
}
return false;
};
const releaseDaemon = (dir) => {
if (readDaemonPid(dir) === process.pid) try {
fs.rmSync(pidFilePath(dir), { force: true });
} catch {}
};
const stopDaemon = (dir) => {
const pid = readDaemonPid(dir);
if (pid === null) return null;
const wasAlive = isProcessAlive(pid);
if (wasAlive) try {
process.kill(pid, "SIGTERM");
} catch {}
if (readDaemonPid(dir) === pid) try {
fs.rmSync(pidFilePath(dir), { force: true });
} catch {}
return wasAlive ? pid : null;
};
const spawnDaemon = (options) => {
const args = [
cliEntryPath(),
"watch",
"--dir",
options.dir,
"--interval",
String(options.intervalMs)
];
if (options.textOnly) args.push("--text-only");
if (options.replayLast) args.push("--replay-last");
spawn(process.execPath, args, {
detached: true,
stdio: "ignore",
windowsHide: true
}).unref();
};
const ensureDaemon = (options) => {
if (isDaemonRunning(options.dir)) return "already-running";
if (!createReader({
textOnly: options.textOnly,
workDir: options.dir
})) return "no-reader";
spawnDaemon(options);
return "started";
};
//#endregion
//#region src/utils/grab-log.ts
const CURSOR_FILE_NAME = "cursor.txt";
const NEWLINE_BYTE = 10;
const cursorFilePath = (dir) => path.join(dir, CURSOR_FILE_NAME);
const historyFilePath = (dir) => path.join(dir, HISTORY_FILE_NAME);
const fileSize = (filePath) => {
try {
return fs.statSync(filePath).size;
} catch {
return 0;
}
};
const readHistoryRange = (dir, start, length) => {
if (length <= 0) return "";
const fd = fs.openSync(historyFilePath(dir), "r");
try {
const buffer = Buffer.allocUnsafe(length);
const bytesRead = fs.readSync(fd, buffer, 0, length, start);
return buffer.toString("utf8", 0, bytesRead);
} finally {
fs.closeSync(fd);
}
};
const writeGrabCursor = (dir, offset) => {
const target = cursorFilePath(dir);
const tempPath = `${target}.${process.pid}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify({ offset }));
fs.renameSync(tempPath, target);
};
const byteOffsetAfterLines = (dir, lineCount) => {
const filePath = historyFilePath(dir);
const size = fileSize(filePath);
if (lineCount <= 0 || size === 0) return 0;
const fd = fs.openSync(filePath, "r");
try {
const buffer = Buffer.allocUnsafe(MIGRATION_SCAN_CHUNK_BYTES);
let position = 0;
let seen = 0;
while (position < size) {
const bytesRead = fs.readSync(fd, buffer, 0, MIGRATION_SCAN_CHUNK_BYTES, position);
if (bytesRead <= 0) break;
for (let index = 0; index < bytesRead; index += 1) {
if (buffer[index] !== NEWLINE_BYTE) continue;
seen += 1;
if (seen === lineCount) return position + index + 1;
}
position += bytesRead;
}
} finally {
fs.closeSync(fd);
}
return size;
};
const readGrabCursor = (dir) => {
let raw;
try {
raw = fs.readFileSync(cursorFilePath(dir), "utf8").trim();
} catch {
return 0;
}
if (raw === "") return 0;
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
return 0;
}
if (typeof parsed === "number") {
if (!Number.isInteger(parsed) || parsed < 0) return 0;
const offset = byteOffsetAfterLines(dir, parsed);
writeGrabCursor(dir, offset);
return offset;
}
return typeof parsed.offset === "number" && Number.isInteger(parsed.offset) && parsed.offset >= 0 ? parsed.offset : 0;
};
const readCompleteGrabLines = (dir) => {
const size = fileSize(historyFilePath(dir));
if (size === 0) return [];
const raw = readHistoryRange(dir, 0, size);
const lastNewline = raw.lastIndexOf("\n");
if (lastNewline < 0) return [];
return raw.slice(0, lastNewline).split("\n").filter(Boolean);
};
const consumeGrabs = (dir, options) => {
if (options.all) return readCompleteGrabLines(dir);
const size = fileSize(historyFilePath(dir));
const cursor = readGrabCursor(dir);
const start = cursor > size ? 0 : cursor;
if (start >= size) {
if (start !== cursor) writeGrabCursor(dir, start);
return [];
}
const chunk = readHistoryRange(dir, start, Math.min(size - start, MAX_READ_HISTORY_BYTES));
const lastNewline = chunk.lastIndexOf("\n");
if (lastNewline < 0) return [];
const now = Date.now();
const fresh = [];
let consumedBytes = 0;
let lineStart = 0;
while (lineStart <= lastNewline && (options.limit <= 0 || fresh.length < options.limit)) {
const newlineIndex = chunk.indexOf("\n", lineStart);
const line = chunk.slice(lineStart, newlineIndex);
consumedBytes += Buffer.byteLength(chunk.slice(lineStart, newlineIndex + 1), "utf8");
lineStart = newlineIndex + 1;
if (line.length === 0) continue;
let parsed;
try {
parsed = JSON.parse(line);
} catch {
continue;
}
if (options.maxAgeMs > 0 && typeof parsed.receivedAt === "number") {
if (now - parsed.receivedAt > options.maxAgeMs) continue;
}
fresh.push(line);
}
const nextCursor = start + consumedBytes;
if (nextCursor !== cursor) writeGrabCursor(dir, nextCursor);
return fresh;
};
//#endregion
//#region src/utils/read-args.ts
const parseWaitMs = (raw) => {
if (raw === void 0) return 0;
const trimmed = raw.trim();
if (trimmed === "") return 0;
if (/^(inf|infinite|infinity|forever)$/i.test(trimmed)) return Number.POSITIVE_INFINITY;
const ms = Number(trimmed);
return Number.isFinite(ms) && ms >= 0 ? ms : null;
};
const parseNonNegativeInt = (raw) => {
if (raw === void 0) return null;
const trimmed = raw.trim();
if (trimmed === "") return null;
const value = Number(trimmed);
return Number.isInteger(value) && value >= 0 ? value : null;
};
//#endregion
//#region src/commands/pull.ts
const fail = (message) => {
process.stderr.write(`react-grab pull: ${message}\n`);
process.exit(1);
};
const emitAndExit = (lines) => {
process.stdout.write(`${lines.join("\n")}\n`, () => process.exit(0));
};
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) => {
const dir = path.resolve(options.dir);
try {
prepareWorkDir(dir);
} catch (error) {
fail(String(error?.message ?? error));
}
const waitMs = parseWaitMs(options.wait);
if (waitMs === null) fail(`invalid --wait "${options.wait}" (use milliseconds or "infinite")`);
const limit = parseNonNegativeInt(options.limit);
if (limit === null) fail(`invalid --limit "${options.limit}" (use a non-negative integer)`);
const maxAgeMs = parseNonNegativeInt(options.maxAge);
if (maxAgeMs === null) fail(`invalid --max-age "${options.maxAge}" (use milliseconds, 0 to disable)`);
const all = Boolean(options.all);
if (ensureDaemon({
dir,
intervalMs: 800,
textOnly: Boolean(options.textOnly),
replayLast: false
}) === "no-reader") fail(NO_READER_MESSAGE);
unrefStdin();
const consume = () => consumeGrabs(dir, {
limit,
all,
maxAgeMs
});
const first = consume();
if (first.length > 0) {
emitAndExit(first);
return;
}
const deadline = Date.now() + waitMs;
while (Date.now() < deadline) {
await sleep(200);
const batch = consume();
if (batch.length > 0) {
emitAndExit(batch);
return;
}
}
process.exit(0);
});
//#endregion
//#region src/commands/remove.ts
const VERSION$2 = "0.1.50";
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) => {
console.log(`${pc.magenta("✿")} ${pc.bold("React Grab")} ${pc.gray(VERSION$2)}`);
console.log();
try {
logger.break();
const removedAgents = await removeSkill({
cwd: resolve(opts.cwd),
global: opts.global
});
for (const agent of removedAgents) logger.log(` ${highlighter.success("✓")} ${agentLabel(agent)}`);
logger.break();
if (removedAgents.length === 0) logger.log("React Grab skill is not installed in any detected agent.");
else logger.log(`${highlighter.success("Removed")} the React Grab skill from ${removedAgents.length} agent${removedAgents.length === 1 ? "" : "s"}.`);
logger.break();
} catch (error) {
handleError(error);
}
});
//#endregion
//#region src/commands/stop.ts
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) => {
const dir = path.resolve(options.dir);
const stoppedPid = stopDaemon(dir);
process.stderr.write(stoppedPid ? `react-grab stop: stopped watcher (pid ${stoppedPid})\n` : `react-grab stop: no watcher running for ${dir}\n`);
process.exit(0);
});
//#endregion
//#region src/commands/upgrade.ts
const VERSION$1 = "0.1.50";
const NPM_REGISTRY_URL = "https://registry.npmjs.org/react-grab/latest";
const fetchLatestVersion = async () => {
try {
return (await (await fetch(NPM_REGISTRY_URL)).json()).version ?? null;
} catch {
return null;
}
};
const isDevDependency = (projectRoot) => {
const packageJsonPath = join(projectRoot, "package.json");
if (!existsSync(packageJsonPath)) return true;
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
if (packageJson.devDependencies?.["react-grab"]) return true;
if (packageJson.dependencies?.["react-grab"]) return false;
} catch {}
return true;
};
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) => {
console.log(`${pc.magenta("✿")} ${pc.bold("React Grab")} ${pc.gray(VERSION$1)}`);
console.log();
try {
const cwd = resolve(opts.cwd);
const detectSpinner = spinner("Detecting project.").start();
const projectInfo = await detectProject(cwd);
if (!projectInfo.hasReactGrab) {
detectSpinner.fail("React Grab is not installed.");
logger.break();
logger.error(`Run ${highlighter.info("npx grab@latest init")} first to install React Grab.`);
logger.break();
process.exit(1);
}
detectSpinner.succeed();
const versionSpinner = spinner("Checking for updates.").start();
const latestVersion = await fetchLatestVersion();
if (!latestVersion) {
versionSpinner.fail("Could not check for updates.");
logger.break();
logger.error("Failed to reach the npm registry. Check your network connection.");
logger.break();
process.exit(1);
}
const installedVersion = projectInfo.reactGrabVersion;
if (installedVersion && installedVersion === latestVersion) {
versionSpinner.succeed(`Already on the latest version ${highlighter.info(`v${latestVersion}`)}.`);
logger.break();
process.exit(0);
}
const fromLabel = installedVersion ? `v${installedVersion}` : "unknown";
versionSpinner.succeed(`Update available: ${highlighter.dim(fromLabel)}${highlighter.info(`v${latestVersion}`)}.`);
const upgradeSpinner = spinner("Upgrading react-grab.").start();
try {
await installPackages(["react-grab@latest"], {
packageManager: projectInfo.packageManager,
cwd: projectInfo.projectRoot,
isDev: isDevDependency(projectInfo.projectRoot)
});
upgradeSpinner.succeed();
} catch {
upgradeSpinner.fail();
logger.break();
logger.error("Failed to upgrade. Check your network connection and try again.");
logger.break();
process.exit(1);
}
logger.break();
logger.log(`${highlighter.success("Success!")} React Grab has been upgraded to ${highlighter.info(`v${latestVersion}`)}.`);
logger.break();
} catch (error) {
handleError(error);
}
});
//#endregion
//#region src/commands/watch.ts
const writeStatus = (message) => {
process.stderr.write(`react-grab watch: ${message}\n`);
};
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) => {
const dir = path.resolve(options.dir);
const intervalRaw = Number(options.interval);
const intervalMs = Number.isFinite(intervalRaw) && intervalRaw > 0 ? intervalRaw : 800;
try {
prepareWorkDir(dir);
} catch (error) {
writeStatus(String(error?.message ?? error));
process.exit(1);
}
if (!claimDaemon(dir)) process.exit(0);
process.on("exit", () => releaseDaemon(dir));
const reader = createReader({
textOnly: Boolean(options.textOnly),
workDir: dir
});
if (!reader) {
writeStatus(NO_READER_MESSAGE);
process.exit(1);
}
writeStatus(`watching clipboard via ${reader.mode}; history → ${path.join(dir, HISTORY_FILE_NAME)}`);
runWatchLoop({
reader,
dir,
intervalMs,
replayLast: Boolean(options.replayLast),
onWarn: writeStatus
}).catch((error) => {
writeStatus(String(error?.message ?? error));
process.exit(1);
});
});
//#endregion
//#region src/cli.ts
const VERSION = "0.1.50";
const VERSION_API_URL = "https://www.react-grab.com/api/version";
process.on("SIGINT", () => process.exit(0));
process.on("SIGTERM", () => process.exit(0));
try {
if (isTelemetryEnabled()) fetch(`${VERSION_API_URL}?source=cli&v=${VERSION}&t=${Date.now()}`).catch(() => {});
} catch {}
const program = new Command().name("grab").description("add React Grab to your project").version(VERSION, "-v, --version", "display the version number");
program.addCommand(init);
program.addCommand(add);
program.addCommand(remove);
program.addCommand(configure);
program.addCommand(upgrade);
program.addCommand(pull);
program.addCommand(stop);
program.addCommand(watch, { hidden: true });
const main = async () => {
await program.parseAsync();
};
main();
//#endregion
export {};
//# sourceMappingURL=cli.js.map