#!/usr/bin/env node import { basename, delimiter, dirname, join, resolve } from "node:path"; import { accessSync, constants, existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import { detect } from "package-manager-detector/detect"; import ignore from "ignore"; import { add, detectInstalledSkillAgents, getCanonicalSkillsDir, getSkillAgentConfig, getSkillAgentDir, getSkillAgentTypes, isUniversalSkillAgent } from "agent-install/skill"; import { fileURLToPath } from "node:url"; import { x } from "tinyexec"; //#region src/utils/react-grab-code.ts const REACT_GRAB_SPECIFIER_PATTERN = String.raw`react-grab(?:\/[^"']+)?`; const stripComments = (content) => content.replace(//g, "").replace(/\{\/\*[\s\S]*?\*\/\}/g, "").replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1"); const stripTypeOnlyReactGrabImports = (content) => { return content.replace(new RegExp(String.raw`import\s+type\s+[^;]+from\s+["']${REACT_GRAB_SPECIFIER_PATTERN}["'];?`, "g"), "").replace(new RegExp(String.raw`import\s*\{\s*type\s+[^,}]+(?:\s*,\s*type\s+[^,}]+)*\s*,?\s*\}\s*from\s+["']${REACT_GRAB_SPECIFIER_PATTERN}["'];?`, "g"), ""); }; const hasReactGrabSetupCode = (content) => { const setupCandidateContent = stripTypeOnlyReactGrabImports(stripComments(content)); return [ new RegExp(String.raw`import\s*\(\s*["']${REACT_GRAB_SPECIFIER_PATTERN}["']\s*\)`), new RegExp(String.raw`import\s+(?!type\b)(?:[^"';]+from\s+)?["']${REACT_GRAB_SPECIFIER_PATTERN}["']`), new RegExp(String.raw`require\s*\(\s*["']${REACT_GRAB_SPECIFIER_PATTERN}["']\s*\)`), / pattern.test(setupCandidateContent)); }; //#endregion //#region src/utils/react-grab-setup-files.ts const COMPONENT_EXTENSIONS = [ "tsx", "jsx", "ts", "js" ]; const INSTRUMENTATION_EXTENSIONS = [ "ts", "tsx", "js", "jsx", "mts", "cts", "mjs", "cjs" ]; const ROUTE_EXTENSIONS = ["tsx", "jsx"]; const createFileCandidates = (projectRoot, directories, baseName, extensions) => { const fileCandidates = []; for (const directory of directories) for (const extension of extensions) fileCandidates.push(join(projectRoot, directory, `${baseName}.${extension}`)); return fileCandidates; }; const findExistingFile = (fileCandidates) => { for (const filePath of fileCandidates) if (existsSync(filePath)) return filePath; return null; }; const getLayoutFileCandidates = (projectRoot) => createFileCandidates(projectRoot, ["app", "src/app"], "layout", COMPONENT_EXTENSIONS); const getDocumentFileCandidates = (projectRoot) => createFileCandidates(projectRoot, ["pages", "src/pages"], "_document", COMPONENT_EXTENSIONS); const getInstrumentationFileCandidates = (projectRoot) => createFileCandidates(projectRoot, ["", "src"], "instrumentation-client", INSTRUMENTATION_EXTENSIONS); const getIndexHtmlCandidates = (projectRoot) => [join(projectRoot, "index.html"), join(projectRoot, "public", "index.html")]; const getEntryFileCandidates = (projectRoot) => [...createFileCandidates(projectRoot, ["src"], "index", COMPONENT_EXTENSIONS), ...createFileCandidates(projectRoot, ["src"], "main", COMPONENT_EXTENSIONS)]; const getTanStackRootFileCandidates = (projectRoot) => createFileCandidates(projectRoot, ["src/routes", "app/routes"], "__root", ROUTE_EXTENSIONS); const getReactGrabSetupFileCandidates = (projectRoot) => [ ...getLayoutFileCandidates(projectRoot), ...getDocumentFileCandidates(projectRoot), ...getInstrumentationFileCandidates(projectRoot), ...getIndexHtmlCandidates(projectRoot), ...getEntryFileCandidates(projectRoot), ...getTanStackRootFileCandidates(projectRoot) ]; const findLayoutFile = (projectRoot) => findExistingFile(getLayoutFileCandidates(projectRoot)); const findDocumentFile = (projectRoot) => findExistingFile(getDocumentFileCandidates(projectRoot)); const findIndexHtml = (projectRoot) => findExistingFile(getIndexHtmlCandidates(projectRoot)); const findEntryFile = (projectRoot) => findExistingFile(getEntryFileCandidates(projectRoot)); const findTanStackRootFile = (projectRoot) => findExistingFile(getTanStackRootFileCandidates(projectRoot)); const isInstrumentationFile = (filePath) => /(?:^|[/\\])instrumentation-client\.[cm]?[jt]sx?$/.test(filePath); //#endregion //#region src/utils/detect.ts const VALID_PACKAGE_MANAGERS = new Set([ "npm", "yarn", "pnpm", "bun" ]); const detectPackageManager = async (projectRoot) => { const result = await detect({ cwd: projectRoot }); if (result?.agent) { const managerName = result.agent.split("@")[0]; if (VALID_PACKAGE_MANAGERS.has(managerName)) return managerName; } return "npm"; }; const CONFIG_EXTENSIONS = [ "ts", "mts", "cts", "js", "mjs", "cjs" ]; const hasConfigFile = (projectRoot, configBaseName) => CONFIG_EXTENSIONS.some((extension) => existsSync(join(projectRoot, `${configBaseName}.${extension}`))); const readMergedDependencies = (projectRoot) => { const packageJsonPath = join(projectRoot, "package.json"); if (!existsSync(packageJsonPath)) return null; try { const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); return { ...packageJson.dependencies, ...packageJson.devDependencies }; } catch { return null; } }; const detectFrameworkFromDependencies = (dependencies) => { if (!dependencies) return "unknown"; if (dependencies["next"]) return "next"; if (dependencies["@tanstack/react-start"]) return "tanstack"; if (dependencies["vite"]) return "vite"; if (dependencies["webpack"]) return "webpack"; return "unknown"; }; const detectFrameworkFromConfigFiles = (projectRoot) => { if (hasConfigFile(projectRoot, "next.config")) return "next"; if (hasConfigFile(projectRoot, "app.config")) return "tanstack"; if (hasConfigFile(projectRoot, "vite.config")) return "vite"; if (hasConfigFile(projectRoot, "webpack.config")) return "webpack"; return "unknown"; }; const findEnclosingMonorepoRoot = (projectRoot) => { let currentDirectory = dirname(projectRoot); while (currentDirectory !== dirname(currentDirectory)) { if (detectMonorepo(currentDirectory)) return currentDirectory; currentDirectory = dirname(currentDirectory); } return null; }; const detectFramework = (projectRoot) => { const localFramework = detectFrameworkFromDependencies(readMergedDependencies(projectRoot)); if (localFramework !== "unknown") return localFramework; return detectFrameworkFromConfigFiles(projectRoot); }; const detectFrameworkFromMonorepoRoot = (projectRoot) => { const monorepoRoot = findEnclosingMonorepoRoot(projectRoot); if (!monorepoRoot) return "unknown"; return detectFrameworkFromDependencies(readMergedDependencies(monorepoRoot)); }; const detectNextRouterType = (projectRoot) => { const hasAppDir = existsSync(join(projectRoot, "app")); const hasSrcAppDir = existsSync(join(projectRoot, "src", "app")); const hasPagesDir = existsSync(join(projectRoot, "pages")); const hasSrcPagesDir = existsSync(join(projectRoot, "src", "pages")); if (hasAppDir || hasSrcAppDir) return "app"; if (hasPagesDir || hasSrcPagesDir) return "pages"; return "unknown"; }; const detectMonorepo = (projectRoot) => { if (existsSync(join(projectRoot, "pnpm-workspace.yaml"))) return true; if (existsSync(join(projectRoot, "lerna.json"))) return true; const packageJsonPath = join(projectRoot, "package.json"); if (existsSync(packageJsonPath)) try { if (JSON.parse(readFileSync(packageJsonPath, "utf-8")).workspaces) return true; } catch { return false; } return false; }; const getWorkspacePatterns = (projectRoot) => { const patterns = []; const pnpmWorkspacePath = join(projectRoot, "pnpm-workspace.yaml"); if (existsSync(pnpmWorkspacePath)) { const lines = readFileSync(pnpmWorkspacePath, "utf-8").split("\n"); let inPackages = false; for (const line of lines) { if (line.match(/^packages:\s*$/)) { inPackages = true; continue; } if (inPackages) { if (line.match(/^[a-zA-Z]/) || line.trim() === "") { if (line.match(/^[a-zA-Z]/)) inPackages = false; continue; } const match = line.match(/^\s*-\s*['"]?([^'"#\n]+?)['"]?\s*$/); if (match) patterns.push(match[1].trim()); } } } const lernaJsonPath = join(projectRoot, "lerna.json"); if (existsSync(lernaJsonPath)) try { const lernaJson = JSON.parse(readFileSync(lernaJsonPath, "utf-8")); if (Array.isArray(lernaJson.packages)) patterns.push(...lernaJson.packages); } catch {} const packageJsonPath = join(projectRoot, "package.json"); if (existsSync(packageJsonPath)) try { const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); if (Array.isArray(packageJson.workspaces)) patterns.push(...packageJson.workspaces); else if (packageJson.workspaces?.packages) patterns.push(...packageJson.workspaces.packages); } catch {} return [...new Set(patterns)]; }; const expandWorkspacePattern = (projectRoot, pattern) => { const isGlob = pattern.endsWith("/*"); const basePath = join(projectRoot, pattern.replace(/\/\*$/, "")); if (!existsSync(basePath)) return []; if (!isGlob) return existsSync(join(basePath, "package.json")) ? [basePath] : []; const results = []; try { const entries = readdirSync(basePath, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; if (existsSync(join(basePath, entry.name, "package.json"))) results.push(join(basePath, entry.name)); } } catch { return results; } return results; }; const hasReactDependency = (projectPath) => { const dependencies = readMergedDependencies(projectPath); if (!dependencies) return false; return Boolean(dependencies["react"] || dependencies["react-dom"]); }; const buildReactProject = (projectPath) => { const framework = detectFramework(projectPath); if (!hasReactDependency(projectPath) && framework === "unknown") return null; let name = basename(projectPath); const packageJsonPath = join(projectPath, "package.json"); try { name = JSON.parse(readFileSync(packageJsonPath, "utf-8")).name || name; } catch {} return { name, path: projectPath, framework }; }; const findWorkspaceProjects = (projectRoot) => { const patterns = getWorkspacePatterns(projectRoot); const projects = []; for (const pattern of patterns) for (const projectPath of expandWorkspacePattern(projectRoot, pattern)) { const project = buildReactProject(projectPath); if (project) projects.push(project); } return projects; }; const ALWAYS_IGNORED_DIRECTORIES = [ "node_modules", ".git", ".next", ".cache", ".turbo", "dist", "build", "coverage", "test-results" ]; const loadGitignore = (projectRoot) => { const ignorer = ignore().add(ALWAYS_IGNORED_DIRECTORIES); const gitignorePath = join(projectRoot, ".gitignore"); if (existsSync(gitignorePath)) try { ignorer.add(readFileSync(gitignorePath, "utf-8")); } catch {} return ignorer; }; const scanDirectoryForProjects = (rootDirectory, ignorer, maxDepth, currentDepth = 0) => { if (currentDepth >= maxDepth) return []; if (!existsSync(rootDirectory)) return []; const projects = []; try { const entries = readdirSync(rootDirectory, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; if (ignorer.ignores(entry.name)) continue; const entryPath = join(rootDirectory, entry.name); if (existsSync(join(entryPath, "package.json"))) { const project = buildReactProject(entryPath); if (project) { projects.push(project); continue; } } projects.push(...scanDirectoryForProjects(entryPath, ignorer, maxDepth, currentDepth + 1)); } } catch { return projects; } return projects; }; const MAX_SCAN_DEPTH = 2; const normalizePathForComparison = (filePath) => filePath.replace(/\\/g, "/"); const findReactProjects = (projectRoot) => { const monorepoRoot = detectMonorepo(projectRoot) ? projectRoot : findEnclosingMonorepoRoot(projectRoot); if (monorepoRoot) { const workspaceProjects = findWorkspaceProjects(monorepoRoot); const localProject = projectRoot === monorepoRoot ? null : buildReactProject(projectRoot); const projects = localProject ? [localProject, ...workspaceProjects.filter((project) => normalizePathForComparison(project.path) !== normalizePathForComparison(localProject.path))] : workspaceProjects; if (projects.length > 0) return projects; } const scannedProjects = scanDirectoryForProjects(projectRoot, loadGitignore(projectRoot), MAX_SCAN_DEPTH); if (scannedProjects.length > 0) return scannedProjects; let currentDirectory = dirname(projectRoot); while (currentDirectory !== dirname(currentDirectory)) { const parentProject = buildReactProject(currentDirectory); if (parentProject) return [parentProject]; currentDirectory = dirname(currentDirectory); } return []; }; const hasReactGrabSetupInFile = (filePath) => { if (!existsSync(filePath)) return false; try { return hasReactGrabSetupCode(readFileSync(filePath, "utf-8")); } catch { return false; } }; const detectReactGrabDependency = (projectRoot) => { const dependencies = readMergedDependencies(projectRoot); return Boolean(dependencies?.["react-grab"]); }; const detectReactGrabConfigured = (projectRoot) => { return getReactGrabSetupFileCandidates(projectRoot).some(hasReactGrabSetupInFile); }; const detectReactGrab = (projectRoot) => detectReactGrabDependency(projectRoot) || detectReactGrabConfigured(projectRoot); const detectUnsupportedFramework = (projectRoot) => { const dependencies = readMergedDependencies(projectRoot); if (!dependencies) return null; if (dependencies["@remix-run/react"] || dependencies["remix"]) return "remix"; if (dependencies["astro"]) return "astro"; if (dependencies["@sveltejs/kit"]) return "sveltekit"; if (dependencies["gatsby"]) return "gatsby"; return null; }; const detectReactGrabVersion = (projectRoot) => { const installedPackageJsonPath = join(projectRoot, "node_modules", "react-grab", "package.json"); if (existsSync(installedPackageJsonPath)) try { return JSON.parse(readFileSync(installedPackageJsonPath, "utf-8")).version ?? null; } catch {} return null; }; const detectProject = async (projectRoot = process.cwd()) => { const localFramework = detectFramework(projectRoot); const framework = localFramework === "unknown" ? detectFrameworkFromMonorepoRoot(projectRoot) : localFramework; const packageManager = await detectPackageManager(projectRoot); const isMonorepo = detectMonorepo(projectRoot) || findEnclosingMonorepoRoot(projectRoot) !== null; const isReactGrabConfigured = detectReactGrabConfigured(projectRoot); return { packageManager, framework, nextRouterType: framework === "next" ? detectNextRouterType(projectRoot) : "unknown", isMonorepo, projectRoot, hasReactGrab: detectReactGrabDependency(projectRoot) || isReactGrabConfigured, isReactGrabConfigured, reactGrabVersion: detectReactGrabVersion(projectRoot), unsupportedFramework: detectUnsupportedFramework(projectRoot) }; }; //#endregion //#region src/utils/detect-agents.ts const PATH_BINARIES = { "claude-code": ["claude"], codex: ["codex"], cursor: ["cursor", "cursor-agent"], droid: ["droid"], "gemini-cli": ["gemini"], "github-copilot": ["copilot"], opencode: ["opencode"], pi: ["pi", "omegon"] }; const isCommandAvailable = (command) => { const pathDirectories = (process.env.PATH ?? "").split(delimiter).filter(Boolean); for (const directory of pathDirectories) { const binaryPath = join(directory, command); try { if (statSync(binaryPath).isFile()) { accessSync(binaryPath, constants.X_OK); return true; } } catch {} } return false; }; const detectAvailableAgents = async () => { const installedAgents = new Set(await detectInstalledSkillAgents()); return getSkillAgentTypes().filter((agent) => { if (agent === "universal") return false; if (installedAgents.has(agent)) return true; return PATH_BINARIES[agent]?.some(isCommandAvailable) ?? false; }); }; //#endregion //#region src/utils/install-skill.ts const SKILL_NAME = "react-grab"; const SKILL_SOURCE = fileURLToPath(new URL("../skills/react-grab", import.meta.url)); const agentLabel = (agent) => getSkillAgentConfig(agent).displayName; const installedSkillDir = (agent, global, cwd) => join(isUniversalSkillAgent(agent) ? getCanonicalSkillsDir(global, cwd) : getSkillAgentDir(agent, { global, cwd }), SKILL_NAME); const installSkill = async ({ agents, global = false, cwd = process.cwd() } = {}) => { return add({ source: SKILL_SOURCE, agents: agents ?? await detectAvailableAgents(), global, cwd, mode: "copy" }); }; const removeSkill = async ({ cwd = process.cwd(), global = false } = {}) => { const agents = await detectAvailableAgents(); const removedAgents = []; const dirsToRemove = /* @__PURE__ */ new Set(); for (const agent of agents) { const skillDir = installedSkillDir(agent, global, cwd); if (!existsSync(skillDir)) continue; removedAgents.push(agent); dirsToRemove.add(skillDir); } for (const skillDir of dirsToRemove) rmSync(skillDir, { recursive: true, force: true }); return removedAgents; }; //#endregion //#region src/utils/templates.ts const NEXT_APP_ROUTER_SCRIPT = `{process.env.NODE_ENV === "development" && (