1987 lines
65 KiB
JavaScript
1987 lines
65 KiB
JavaScript
#!/usr/bin/env node
|
|
const require_install = require("./install-DlD3sLtm.cjs");
|
|
let commander = require("commander");
|
|
let node_path = require("node:path");
|
|
node_path = require_install.__toESM(node_path, 1);
|
|
let picocolors = require("picocolors");
|
|
picocolors = require_install.__toESM(picocolors, 1);
|
|
let node_fs = require("node:fs");
|
|
node_fs = require_install.__toESM(node_fs, 1);
|
|
let node_url = require("node:url");
|
|
let prompts = require("prompts");
|
|
prompts = require_install.__toESM(prompts, 1);
|
|
let ora = require("ora");
|
|
ora = require_install.__toESM(ora, 1);
|
|
let node_child_process = require("node:child_process");
|
|
let node_crypto = require("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: picocolors.default.red,
|
|
warn: picocolors.default.yellow,
|
|
info: picocolors.default.cyan,
|
|
success: picocolors.default.green,
|
|
dim: picocolors.default.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$1 = (questions) => {
|
|
return (0, prompts.default)(questions, { onCancel }).finally(unrefStdin);
|
|
};
|
|
//#endregion
|
|
//#region src/utils/spinner.ts
|
|
const spinner = (text) => (0, ora.default)({ text });
|
|
//#endregion
|
|
//#region src/utils/prompt-skill-install.ts
|
|
const promptSkillInstall = async ({ yes = false, global = false, cwd = process.cwd() } = {}) => {
|
|
const detectedAgents = await require_install.detectAvailableAgents();
|
|
if (detectedAgents.length === 0) {
|
|
logger.warn("No supported agents detected.");
|
|
return false;
|
|
}
|
|
let selectedAgents = detectedAgents;
|
|
if (!yes) {
|
|
const { confirmed } = await prompts$1({
|
|
type: "confirm",
|
|
name: "confirmed",
|
|
message: `Install the React Grab skill (${global ? "global" : "this project"})?`,
|
|
initial: true
|
|
});
|
|
if (!confirmed) return false;
|
|
const { agents } = await prompts$1({
|
|
type: "multiselect",
|
|
name: "agents",
|
|
message: `Install the React Grab skill (${global ? "global" : "this project"}) for:`,
|
|
choices: detectedAgents.map((agent) => ({
|
|
title: require_install.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 require_install.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) => require_install.agentLabel(record.agent)).join(", ")}.`);
|
|
for (const record of failed) logger.log(` ${highlighter.error("✗")} ${require_install.agentLabel(record.agent)} ${record.error}`);
|
|
return true;
|
|
};
|
|
//#endregion
|
|
//#region src/commands/add.ts
|
|
const VERSION$5 = "0.1.50";
|
|
const add = new commander.Command().name("add").alias("install").description("install the React Grab skill for your agent").option("-y, --yes", "skip confirmation prompts", false).option("-c, --cwd <cwd>", "working directory (defaults to current directory)", process.cwd()).option("-g, --global", "install the skill globally instead of in the project", false).action(async (opts) => {
|
|
console.log(`${picocolors.default.magenta("✿")} ${picocolors.default.bold("React Grab")} ${picocolors.default.gray(VERSION$5)}`);
|
|
console.log();
|
|
try {
|
|
const isNonInteractive = detectNonInteractive(opts.yes);
|
|
const preflightSpinner = spinner("Preflight checks.").start();
|
|
if (!(await require_install.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: (0, node_path.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 = require_install.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 require_install.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 commander.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(`${picocolors.default.magenta("✿")} ${picocolors.default.bold("React Grab")} ${picocolors.default.gray(VERSION$4)}`);
|
|
console.log();
|
|
try {
|
|
const cwd = opts.cwd;
|
|
const preflightSpinner = spinner("Preflight checks.").start();
|
|
const projectInfo = await require_install.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 = require_install.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$1({
|
|
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$1({
|
|
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$1({
|
|
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$1({
|
|
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$1({
|
|
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$1({
|
|
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$1({
|
|
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 = require_install.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(` ${picocolors.default.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$1({
|
|
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 = (0, node_path.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 ${(0, node_path.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 commander.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(`${picocolors.default.magenta("✿")} ${picocolors.default.bold("React Grab")} ${picocolors.default.gray(VERSION$3)}`);
|
|
console.log();
|
|
try {
|
|
const cwd = (0, node_path.resolve)(opts.cwd);
|
|
const isNonInteractive = detectNonInteractive(opts.yes);
|
|
if (!(0, node_fs.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 require_install.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$1({
|
|
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$1({
|
|
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$1({
|
|
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$1({
|
|
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$1({
|
|
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$1({
|
|
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$1({
|
|
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 = require_install.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$1({
|
|
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 && !require_install.hasFrameworkEntryPoint(projectInfo.projectRoot, projectInfo.framework, projectInfo.nextRouterType)) {
|
|
let searchRoot = cwd;
|
|
let reactProjects = require_install.findReactProjects(searchRoot);
|
|
if (reactProjects.length === 0 && cwd !== process.cwd()) {
|
|
searchRoot = process.cwd();
|
|
reactProjects = require_install.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$1({
|
|
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 require_install.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 = require_install.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$1({
|
|
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(require_install.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 = node_path.default.dirname((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
|
|
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) => (0, node_crypto.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 (0, node_child_process.spawnSync)(process.platform === "win32" ? "where" : "which", [name], { stdio: "ignore" }).status === 0;
|
|
};
|
|
const runText = (command, args) => {
|
|
const output = (0, node_child_process.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 = (0, node_child_process.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 = (0, node_child_process.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 = node_path.default.join(readersDir, "read-clipboard.swift");
|
|
if (!node_fs.default.existsSync(source)) return null;
|
|
const binary = node_path.default.join(workDir, "pbread");
|
|
if ((!node_fs.default.existsSync(binary) || node_fs.default.statSync(source).mtimeMs > node_fs.default.statSync(binary).mtimeMs) && (0, node_child_process.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 = node_path.default.join(READERS_DIR, "read-clipboard.ps1");
|
|
if (options.textOnly || !node_fs.default.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
|
|
];
|
|
(0, node_child_process.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 = node_fs.default.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);
|
|
node_fs.default.mkdirSync(dir, {
|
|
recursive: true,
|
|
mode: 448
|
|
});
|
|
const gitignore = node_path.default.join(dir, ".gitignore");
|
|
if (!node_fs.default.existsSync(gitignore)) node_fs.default.writeFileSync(gitignore, "*\n");
|
|
};
|
|
const runWatchLoop = async (options) => {
|
|
const { reader, dir, intervalMs, replayLast, onWarn } = options;
|
|
const logPath = node_path.default.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
|
|
};
|
|
node_fs.default.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) => node_path.default.join(dir, PID_FILE_NAME);
|
|
const cliEntryPath = () => process.argv[1] ?? (0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href);
|
|
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(node_fs.default.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 = node_fs.default.openSync(file, "wx");
|
|
node_fs.default.writeFileSync(handle, String(process.pid));
|
|
node_fs.default.closeSync(handle);
|
|
return readDaemonPid(dir) === process.pid;
|
|
} catch (error) {
|
|
if (error.code !== "EEXIST") throw error;
|
|
if (isDaemonRunning(dir)) return false;
|
|
try {
|
|
node_fs.default.rmSync(file, { force: true });
|
|
} catch {}
|
|
}
|
|
return false;
|
|
};
|
|
const releaseDaemon = (dir) => {
|
|
if (readDaemonPid(dir) === process.pid) try {
|
|
node_fs.default.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 {
|
|
node_fs.default.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");
|
|
(0, node_child_process.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) => node_path.default.join(dir, CURSOR_FILE_NAME);
|
|
const historyFilePath = (dir) => node_path.default.join(dir, HISTORY_FILE_NAME);
|
|
const fileSize = (filePath) => {
|
|
try {
|
|
return node_fs.default.statSync(filePath).size;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
};
|
|
const readHistoryRange = (dir, start, length) => {
|
|
if (length <= 0) return "";
|
|
const fd = node_fs.default.openSync(historyFilePath(dir), "r");
|
|
try {
|
|
const buffer = Buffer.allocUnsafe(length);
|
|
const bytesRead = node_fs.default.readSync(fd, buffer, 0, length, start);
|
|
return buffer.toString("utf8", 0, bytesRead);
|
|
} finally {
|
|
node_fs.default.closeSync(fd);
|
|
}
|
|
};
|
|
const writeGrabCursor = (dir, offset) => {
|
|
const target = cursorFilePath(dir);
|
|
const tempPath = `${target}.${process.pid}.tmp`;
|
|
node_fs.default.writeFileSync(tempPath, JSON.stringify({ offset }));
|
|
node_fs.default.renameSync(tempPath, target);
|
|
};
|
|
const byteOffsetAfterLines = (dir, lineCount) => {
|
|
const filePath = historyFilePath(dir);
|
|
const size = fileSize(filePath);
|
|
if (lineCount <= 0 || size === 0) return 0;
|
|
const fd = node_fs.default.openSync(filePath, "r");
|
|
try {
|
|
const buffer = Buffer.allocUnsafe(MIGRATION_SCAN_CHUNK_BYTES);
|
|
let position = 0;
|
|
let seen = 0;
|
|
while (position < size) {
|
|
const bytesRead = node_fs.default.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 {
|
|
node_fs.default.closeSync(fd);
|
|
}
|
|
return size;
|
|
};
|
|
const readGrabCursor = (dir) => {
|
|
let raw;
|
|
try {
|
|
raw = node_fs.default.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 commander.Command().name("pull").description("start the watcher if needed, then wait for and print the next React Grab grab(s)").option("-d, --dir <dir>", "work dir for history.jsonl + watch.pid", DEFAULT_WATCH_DIR).option("-w, --wait <ms>", "how long to wait for a grab: ms, 'infinite', or 0 for none", "infinite").option("-n, --limit <count>", "max grabs to print per call (0 = no limit)", String(50)).option("--max-age <ms>", "skip grabs captured longer ago than <ms> (0 = never)", String(DEFAULT_GRAB_AGE_MS)).option("--text-only", "watcher uses the plain-text clipboard reader (ignored if already running)").option("--all", "print the whole history without advancing the cursor").action(async (options) => {
|
|
const dir = node_path.default.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 commander.Command().name("remove").description("uninstall the React Grab skill from your agent").option("-c, --cwd <cwd>", "working directory (defaults to current directory)", process.cwd()).option("-g, --global", "remove the globally-installed skill instead of the project's", false).action(async (opts) => {
|
|
console.log(`${picocolors.default.magenta("✿")} ${picocolors.default.bold("React Grab")} ${picocolors.default.gray(VERSION$2)}`);
|
|
console.log();
|
|
try {
|
|
logger.break();
|
|
const removedAgents = await require_install.removeSkill({
|
|
cwd: (0, node_path.resolve)(opts.cwd),
|
|
global: opts.global
|
|
});
|
|
for (const agent of removedAgents) logger.log(` ${highlighter.success("✓")} ${require_install.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 commander.Command().name("stop").description("stop the React Grab watcher for this dir").option("-d, --dir <dir>", "work dir holding watch.pid", DEFAULT_WATCH_DIR).action((options) => {
|
|
const dir = node_path.default.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 = (0, node_path.join)(projectRoot, "package.json");
|
|
if (!(0, node_fs.existsSync)(packageJsonPath)) return true;
|
|
try {
|
|
const packageJson = JSON.parse((0, node_fs.readFileSync)(packageJsonPath, "utf-8"));
|
|
if (packageJson.devDependencies?.["react-grab"]) return true;
|
|
if (packageJson.dependencies?.["react-grab"]) return false;
|
|
} catch {}
|
|
return true;
|
|
};
|
|
const upgrade = new commander.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(`${picocolors.default.magenta("✿")} ${picocolors.default.bold("React Grab")} ${picocolors.default.gray(VERSION$1)}`);
|
|
console.log();
|
|
try {
|
|
const cwd = (0, node_path.resolve)(opts.cwd);
|
|
const detectSpinner = spinner("Detecting project.").start();
|
|
const projectInfo = await require_install.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 require_install.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 commander.Command().name("watch").description("run the React Grab capture daemon in the foreground (used internally by `pull`)").option("-d, --dir <dir>", "work dir for history.jsonl + watch.pid", DEFAULT_WATCH_DIR).option("-i, --interval <ms>", "clipboard poll interval in ms", String(800)).option("--text-only", "skip the native reader and use the plain-text fallback").option("--replay-last", "also capture the grab already on the clipboard at startup").action((options) => {
|
|
const dir = node_path.default.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 → ${node_path.default.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 commander.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
|