#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const os = require('os'); const readline = require('readline'); const crypto = require('crypto'); // Colors const cyan = '\x1b[36m'; const green = '\x1b[32m'; const yellow = '\x1b[33m'; const red = '\x1b[31m'; const bold = '\x1b[1m'; const dim = '\x1b[2m'; const reset = '\x1b[0m'; // Codex config.toml constants const GSD_CODEX_MARKER = '# GSD Agent Configuration \u2014 managed by get-shit-done installer'; const GSD_CODEX_HOOKS_OWNERSHIP_PREFIX = '# GSD codex_hooks ownership: '; // Copilot instructions marker constants const GSD_COPILOT_INSTRUCTIONS_MARKER = ''; const GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER = ''; const CODEX_AGENT_SANDBOX = { 'gsd-executor': 'workspace-write', 'gsd-planner': 'workspace-write', 'gsd-phase-researcher': 'workspace-write', 'gsd-project-researcher': 'workspace-write', 'gsd-research-synthesizer': 'workspace-write', 'gsd-verifier': 'workspace-write', 'gsd-codebase-mapper': 'workspace-write', 'gsd-roadmapper': 'workspace-write', 'gsd-debugger': 'workspace-write', 'gsd-plan-checker': 'read-only', 'gsd-integration-checker': 'read-only', }; // Copilot tool name mapping — Claude Code tools to GitHub Copilot tools // Tool mapping applies ONLY to agents, NOT to skills (per CONTEXT.md decision) const claudeToCopilotTools = { Read: 'read', Write: 'edit', Edit: 'edit', Bash: 'execute', Grep: 'search', Glob: 'search', Task: 'agent', WebSearch: 'web', WebFetch: 'web', TodoWrite: 'todo', AskUserQuestion: 'ask_user', SlashCommand: 'skill', }; // Get version from package.json const pkg = require('../package.json'); // #2517 — runtime-aware tier resolution shared with core.cjs. // Hoisted to top with absolute __dirname-based paths so `gsd install codex` works // when invoked via npm global install (cwd is the user's project, not the gsd repo // root). Inline `require('../get-shit-done/...')` from inside install functions // works only because Node resolves it relative to the install.js file regardless // of cwd, but keeping the require at the top makes the dependency explicit and // surfaces resolution failures at process start instead of at first install call. const _gsdLibDir = path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib'); const { MODEL_PROFILES: GSD_MODEL_PROFILES } = require(path.join(_gsdLibDir, 'model-profiles.cjs')); const { RUNTIME_PROFILE_MAP: GSD_RUNTIME_PROFILE_MAP, resolveTierEntry: gsdResolveTierEntry, } = require(path.join(_gsdLibDir, 'core.cjs')); const { MINIMAL_SKILL_ALLOWLIST, isMinimalMode, stageSkillsForMode, } = require(path.join(_gsdLibDir, 'install-profiles.cjs')); // Parse args const args = process.argv.slice(2); const hasGlobal = args.includes('--global') || args.includes('-g'); const hasLocal = args.includes('--local') || args.includes('-l'); const hasOpencode = args.includes('--opencode'); const hasClaude = args.includes('--claude'); const hasGemini = args.includes('--gemini'); const hasKilo = args.includes('--kilo'); const hasCodex = args.includes('--codex'); const hasCopilot = args.includes('--copilot'); const hasAntigravity = args.includes('--antigravity'); const hasCursor = args.includes('--cursor'); const hasWindsurf = args.includes('--windsurf'); const hasAugment = args.includes('--augment'); const hasTrae = args.includes('--trae'); const hasQwen = args.includes('--qwen'); const hasHermes = args.includes('--hermes'); const hasCodebuddy = args.includes('--codebuddy'); const hasCline = args.includes('--cline'); const hasBoth = args.includes('--both'); // Legacy flag, keeps working const hasAll = args.includes('--all'); const hasUninstall = args.includes('--uninstall') || args.includes('-u'); const hasSkillsRoot = args.includes('--skills-root'); const hasPortableHooks = args.includes('--portable-hooks') || process.env.GSD_PORTABLE_HOOKS === '1'; const hasMinimal = args.includes('--minimal') || args.includes('--core-only'); const installMode = hasMinimal ? 'minimal' : 'full'; const hasSdk = args.includes('--sdk'); const hasNoSdk = args.includes('--no-sdk'); if (hasSdk && hasNoSdk) { console.error(` ${yellow}Cannot specify both --sdk and --no-sdk${reset}`); process.exit(1); } // Runtime selection - can be set by flags or interactive prompt let selectedRuntimes = []; if (hasAll) { selectedRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline']; } else if (hasBoth) { selectedRuntimes = ['claude', 'opencode']; } else { if (hasClaude) selectedRuntimes.push('claude'); if (hasOpencode) selectedRuntimes.push('opencode'); if (hasGemini) selectedRuntimes.push('gemini'); if (hasKilo) selectedRuntimes.push('kilo'); if (hasCodex) selectedRuntimes.push('codex'); if (hasCopilot) selectedRuntimes.push('copilot'); if (hasAntigravity) selectedRuntimes.push('antigravity'); if (hasCursor) selectedRuntimes.push('cursor'); if (hasWindsurf) selectedRuntimes.push('windsurf'); if (hasAugment) selectedRuntimes.push('augment'); if (hasTrae) selectedRuntimes.push('trae'); if (hasQwen) selectedRuntimes.push('qwen'); if (hasHermes) selectedRuntimes.push('hermes'); if (hasCodebuddy) selectedRuntimes.push('codebuddy'); if (hasCline) selectedRuntimes.push('cline'); } // WSL + Windows Node.js detection // When Windows-native Node runs on WSL, os.homedir() and path.join() produce // backslash paths that don't resolve correctly on the Linux filesystem. if (process.platform === 'win32') { let isWSL = false; try { if (process.env.WSL_DISTRO_NAME) { isWSL = true; } else if (fs.existsSync('/proc/version')) { const procVersion = fs.readFileSync('/proc/version', 'utf8').toLowerCase(); if (procVersion.includes('microsoft') || procVersion.includes('wsl')) { isWSL = true; } } } catch { // Ignore read errors — not WSL } if (isWSL) { console.error(` ${yellow}⚠ Detected WSL with Windows-native Node.js.${reset} This causes path resolution issues that prevent correct installation. Please install a Linux-native Node.js inside WSL: curl -fsSL https://fnm.vercel.app/install | bash fnm install --lts Then re-run: npx get-shit-done-cc@latest `); process.exit(1); } } // Helper to get directory name for a runtime (used for local/project installs) function getDirName(runtime) { if (runtime === 'copilot') return '.github'; if (runtime === 'opencode') return '.opencode'; if (runtime === 'gemini') return '.gemini'; if (runtime === 'kilo') return '.kilo'; if (runtime === 'codex') return '.codex'; if (runtime === 'antigravity') return '.agent'; if (runtime === 'cursor') return '.cursor'; if (runtime === 'windsurf') return '.windsurf'; if (runtime === 'augment') return '.augment'; if (runtime === 'trae') return '.trae'; if (runtime === 'qwen') return '.qwen'; if (runtime === 'hermes') return '.hermes'; if (runtime === 'codebuddy') return '.codebuddy'; if (runtime === 'cline') return '.cline'; return '.claude'; } /** * Get the config directory path relative to home directory for a runtime * Used for templating hooks that use path.join(homeDir, '', ...) * @param {string} runtime - 'claude', 'opencode', 'gemini', 'codex', or 'copilot' * @param {boolean} isGlobal - Whether this is a global install */ function getConfigDirFromHome(runtime, isGlobal) { if (!isGlobal) { // Local installs use the same dir name pattern return `'${getDirName(runtime)}'`; } // Global installs - OpenCode uses XDG path structure if (runtime === 'copilot') return "'.copilot'"; if (runtime === 'opencode') { // OpenCode: ~/.config/opencode -> '.config', 'opencode' // Return as comma-separated for path.join() replacement return "'.config', 'opencode'"; } if (runtime === 'gemini') return "'.gemini'"; if (runtime === 'kilo') return "'.config', 'kilo'"; if (runtime === 'codex') return "'.codex'"; if (runtime === 'antigravity') { if (!isGlobal) return "'.agent'"; return "'.gemini', 'antigravity'"; } if (runtime === 'cursor') return "'.cursor'"; if (runtime === 'windsurf') return "'.windsurf'"; if (runtime === 'augment') return "'.augment'"; if (runtime === 'trae') return "'.trae'"; if (runtime === 'qwen') return "'.qwen'"; if (runtime === 'hermes') return "'.hermes'"; if (runtime === 'codebuddy') return "'.codebuddy'"; if (runtime === 'cline') return "'.cline'"; return "'.claude'"; } /** * Get the global config directory for OpenCode * OpenCode follows XDG Base Directory spec and uses ~/.config/opencode/ * Priority: OPENCODE_CONFIG_DIR > dirname(OPENCODE_CONFIG) > XDG_CONFIG_HOME/opencode > ~/.config/opencode */ function getOpencodeGlobalDir() { // 1. Explicit OPENCODE_CONFIG_DIR env var if (process.env.OPENCODE_CONFIG_DIR) { return expandTilde(process.env.OPENCODE_CONFIG_DIR); } // 2. OPENCODE_CONFIG env var (use its directory) if (process.env.OPENCODE_CONFIG) { return path.dirname(expandTilde(process.env.OPENCODE_CONFIG)); } // 3. XDG_CONFIG_HOME/opencode if (process.env.XDG_CONFIG_HOME) { return path.join(expandTilde(process.env.XDG_CONFIG_HOME), 'opencode'); } // 4. Default: ~/.config/opencode (XDG default) return path.join(os.homedir(), '.config', 'opencode'); } /** * Get the global config directory for Kilo * Kilo follows XDG Base Directory spec and uses ~/.config/kilo/ * Priority: KILO_CONFIG_DIR > dirname(KILO_CONFIG) > XDG_CONFIG_HOME/kilo > ~/.config/kilo */ function getKiloGlobalDir() { // 1. Explicit KILO_CONFIG_DIR env var if (process.env.KILO_CONFIG_DIR) { return expandTilde(process.env.KILO_CONFIG_DIR); } // 2. KILO_CONFIG env var (use its directory) if (process.env.KILO_CONFIG) { return path.dirname(expandTilde(process.env.KILO_CONFIG)); } // 3. XDG_CONFIG_HOME/kilo if (process.env.XDG_CONFIG_HOME) { return path.join(expandTilde(process.env.XDG_CONFIG_HOME), 'kilo'); } // 4. Default: ~/.config/kilo (XDG default) return path.join(os.homedir(), '.config', 'kilo'); } /** * Get the global config directory for a runtime * @param {string} runtime - 'claude', 'opencode', 'gemini', 'codex', or 'copilot' * @param {string|null} explicitDir - Explicit directory from --config-dir flag */ function getGlobalDir(runtime, explicitDir = null) { if (runtime === 'opencode') { // For OpenCode, --config-dir overrides env vars if (explicitDir) { return expandTilde(explicitDir); } return getOpencodeGlobalDir(); } if (runtime === 'kilo') { // For Kilo, --config-dir overrides env vars if (explicitDir) { return expandTilde(explicitDir); } return getKiloGlobalDir(); } if (runtime === 'gemini') { // Gemini: --config-dir > GEMINI_CONFIG_DIR > ~/.gemini if (explicitDir) { return expandTilde(explicitDir); } if (process.env.GEMINI_CONFIG_DIR) { return expandTilde(process.env.GEMINI_CONFIG_DIR); } return path.join(os.homedir(), '.gemini'); } if (runtime === 'codex') { // Codex: --config-dir > CODEX_HOME > ~/.codex if (explicitDir) { return expandTilde(explicitDir); } if (process.env.CODEX_HOME) { return expandTilde(process.env.CODEX_HOME); } return path.join(os.homedir(), '.codex'); } if (runtime === 'copilot') { // Copilot: --config-dir > COPILOT_CONFIG_DIR > ~/.copilot if (explicitDir) { return expandTilde(explicitDir); } if (process.env.COPILOT_CONFIG_DIR) { return expandTilde(process.env.COPILOT_CONFIG_DIR); } return path.join(os.homedir(), '.copilot'); } if (runtime === 'antigravity') { // Antigravity: --config-dir > ANTIGRAVITY_CONFIG_DIR > ~/.gemini/antigravity if (explicitDir) { return expandTilde(explicitDir); } if (process.env.ANTIGRAVITY_CONFIG_DIR) { return expandTilde(process.env.ANTIGRAVITY_CONFIG_DIR); } return path.join(os.homedir(), '.gemini', 'antigravity'); } if (runtime === 'cursor') { // Cursor: --config-dir > CURSOR_CONFIG_DIR > ~/.cursor if (explicitDir) { return expandTilde(explicitDir); } if (process.env.CURSOR_CONFIG_DIR) { return expandTilde(process.env.CURSOR_CONFIG_DIR); } return path.join(os.homedir(), '.cursor'); } if (runtime === 'windsurf') { // Windsurf: --config-dir > WINDSURF_CONFIG_DIR > ~/.codeium/windsurf if (explicitDir) { return expandTilde(explicitDir); } if (process.env.WINDSURF_CONFIG_DIR) { return expandTilde(process.env.WINDSURF_CONFIG_DIR); } return path.join(os.homedir(), '.codeium', 'windsurf'); } if (runtime === 'augment') { // Augment: --config-dir > AUGMENT_CONFIG_DIR > ~/.augment if (explicitDir) { return expandTilde(explicitDir); } if (process.env.AUGMENT_CONFIG_DIR) { return expandTilde(process.env.AUGMENT_CONFIG_DIR); } return path.join(os.homedir(), '.augment'); } if (runtime === 'trae') { // Trae: --config-dir > TRAE_CONFIG_DIR > ~/.trae if (explicitDir) { return expandTilde(explicitDir); } if (process.env.TRAE_CONFIG_DIR) { return expandTilde(process.env.TRAE_CONFIG_DIR); } return path.join(os.homedir(), '.trae'); } if (runtime === 'qwen') { if (explicitDir) { return expandTilde(explicitDir); } if (process.env.QWEN_CONFIG_DIR) { return expandTilde(process.env.QWEN_CONFIG_DIR); } return path.join(os.homedir(), '.qwen'); } if (runtime === 'hermes') { // Hermes Agent: --config-dir > HERMES_HOME > ~/.hermes // Honors HERMES_HOME which Hermes users set for profile mode / Docker // deploys (docs: https://hermes-agent.nousresearch.com/docs). if (explicitDir) { return expandTilde(explicitDir); } if (process.env.HERMES_HOME) { return expandTilde(process.env.HERMES_HOME); } return path.join(os.homedir(), '.hermes'); } if (runtime === 'codebuddy') { // CodeBuddy: --config-dir > CODEBUDDY_CONFIG_DIR > ~/.codebuddy if (explicitDir) { return expandTilde(explicitDir); } if (process.env.CODEBUDDY_CONFIG_DIR) { return expandTilde(process.env.CODEBUDDY_CONFIG_DIR); } return path.join(os.homedir(), '.codebuddy'); } if (runtime === 'cline') { // Cline: --config-dir > CLINE_CONFIG_DIR > ~/.cline if (explicitDir) { return expandTilde(explicitDir); } if (process.env.CLINE_CONFIG_DIR) { return expandTilde(process.env.CLINE_CONFIG_DIR); } return path.join(os.homedir(), '.cline'); } // Claude Code: --config-dir > CLAUDE_CONFIG_DIR > ~/.claude if (explicitDir) { return expandTilde(explicitDir); } if (process.env.CLAUDE_CONFIG_DIR) { return expandTilde(process.env.CLAUDE_CONFIG_DIR); } return path.join(os.homedir(), '.claude'); } const banner = '\n' + cyan + ' ██████╗ ███████╗██████╗\n' + ' ██╔════╝ ██╔════╝██╔══██╗\n' + ' ██║ ███╗███████╗██║ ██║\n' + ' ██║ ██║╚════██║██║ ██║\n' + ' ╚██████╔╝███████║██████╔╝\n' + ' ╚═════╝ ╚══════╝╚═════╝' + reset + '\n' + '\n' + ' Get Shit Done ' + dim + 'v' + pkg.version + reset + '\n' + ' A meta-prompting, context engineering and spec-driven\n' + ' development system for Claude Code, OpenCode, Gemini, Kilo, Codex, Copilot, Antigravity, Cursor, Windsurf, Augment, Trae, Qwen Code, Hermes Agent, Cline and CodeBuddy by TÂCHES.\n'; // Parse --config-dir argument function parseConfigDirArg() { const configDirIndex = args.findIndex(arg => arg === '--config-dir' || arg === '-c'); if (configDirIndex !== -1) { const nextArg = args[configDirIndex + 1]; // Error if --config-dir is provided without a value or next arg is another flag if (!nextArg || nextArg.startsWith('-')) { console.error(` ${yellow}--config-dir requires a path argument${reset}`); process.exit(1); } return nextArg; } // Also handle --config-dir=value format const configDirArg = args.find(arg => arg.startsWith('--config-dir=') || arg.startsWith('-c=')); if (configDirArg) { const value = configDirArg.split('=')[1]; if (!value) { console.error(` ${yellow}--config-dir requires a non-empty path${reset}`); process.exit(1); } return value; } return null; } const explicitConfigDir = parseConfigDirArg(); const hasHelp = args.includes('--help') || args.includes('-h'); const forceStatusline = args.includes('--force-statusline'); if (!hasSkillsRoot) console.log(banner); if (hasUninstall) { console.log(' Mode: Uninstall\n'); } // Show help if requested if (hasHelp) { console.log(` ${yellow}Usage:${reset} npx get-shit-done-cc [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--hermes${reset} Install for Hermes Agent only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir ${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n (for WSL/Docker bind-mount setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--minimal${reset} Install only the main-loop skills (new-project,\n discuss-phase, plan-phase, execute-phase, help, update)\n and zero gsd-* subagents. Cuts cold-start system-prompt\n overhead from ~12k tokens to ~700 — useful for local LLMs\n with 32K–128K context. Re-run \`gsd update\` (without --minimal)\n to expand to the full surface. Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx get-shit-done-cc\n\n ${dim}# Install for Claude Code globally${reset}\n npx get-shit-done-cc --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx get-shit-done-cc --gemini --global\n\n ${dim}# Install for Kilo globally${reset}\n npx get-shit-done-cc --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx get-shit-done-cc --codex --global\n\n ${dim}# Install for Copilot globally${reset}\n npx get-shit-done-cc --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx get-shit-done-cc --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx get-shit-done-cc --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx get-shit-done-cc --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx get-shit-done-cc --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx get-shit-done-cc --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx get-shit-done-cc --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx get-shit-done-cc --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx get-shit-done-cc --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx get-shit-done-cc --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx get-shit-done-cc --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx get-shit-done-cc --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx get-shit-done-cc --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx get-shit-done-cc --hermes --local\n\n ${dim}# Install for Cline locally${reset}\n npx get-shit-done-cc --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx get-shit-done-cc --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx get-shit-done-cc --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx get-shit-done-cc --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx get-shit-done-cc --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx get-shit-done-cc --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx get-shit-done-cc --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / GEMINI_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / COPILOT_CONFIG_DIR / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / HERMES_HOME / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n`); process.exit(0); } /** * Expand ~ to home directory (shell doesn't expand in env vars passed to node) */ function expandTilde(filePath) { if (filePath && filePath.startsWith('~/')) { return path.join(os.homedir(), filePath.slice(2)); } return filePath; } /** * Compute the path prefix used for `@file` references in installed command/skill * markdown. For global installs into a runtime config dir under $HOME, we * normally substitute the home prefix with `$HOME` so paths expand correctly * inside double-quoted shell commands. OpenCode is exempt on every platform: * its `@file` include syntax does NOT shell-expand `$HOME`, so a literal * `@$HOME/...` is treated as a path relative to the config command/ dir, which * resolves to `command/$HOME/...` (file not found). For OpenCode we always emit * the absolute resolved path. (#2376 Windows, #2831 macOS/Linux.) * * @param {object} args * @param {boolean} args.isGlobal - Global runtime install vs local project * @param {boolean} args.isOpencode - Whether the runtime is OpenCode * @param {boolean} args.isWindowsHost - process.platform === 'win32' * @param {string} args.resolvedTarget - Absolute target dir, forward-slashed * @param {string} args.homeDir - User home dir, forward-slashed * @returns {string} pathPrefix ending with '/' */ function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost, resolvedTarget, homeDir }) { if (isGlobal && resolvedTarget.startsWith(homeDir) && !isOpencode) { return '$HOME' + resolvedTarget.slice(homeDir.length) + '/'; } return `${resolvedTarget}/`; } /** * Normalize a raw `process.execPath` to a stable, upgrade-safe node binary * path. On Homebrew installs, `process.execPath` resolves symlinks and returns * the versioned Cellar path (e.g. * `/usr/local/Cellar/node/25.8.1/bin/node`). Baking that path into hook * commands causes `dyld: Library not loaded` errors after `brew upgrade node` * because the shared libraries referenced by the Cellar binary have changed * SOVERSION. (#3181) * * The stable Homebrew symlinks (`/usr/local/bin/node` for Intel, * `/opt/homebrew/bin/node` for Apple Silicon) survive upgrades — Homebrew * re-points them atomically. We prefer those when a Cellar path is detected. * * Non-Homebrew installs (NVM, system node, Windows, etc.) are returned as-is. */ function normalizeNodePath(execPath) { if (!execPath) return execPath; // Intel Homebrew: /usr/local/Cellar/node//bin/node // or /usr/local/Cellar/node@20//bin/node if (/^\/usr\/local\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) { return '/usr/local/bin/node'; } // Apple Silicon Homebrew: /opt/homebrew/Cellar/node//bin/node // or /opt/homebrew/Cellar/node@18//bin/node if (/^\/opt\/homebrew\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) { return '/opt/homebrew/bin/node'; } return execPath; } /** * Resolve the absolute path to the node binary running the installer. * Used as the runner for .js hooks so they execute in GUI/minimal-PATH * runtimes (Gemini, Antigravity, Codex CLIs launched from a Finder * shortcut etc.) where bare `node` is not on `/usr/bin:/bin:/usr/sbin:/sbin` * and the hook would fail with `node: command not found` (#2979). * * Returns a forward-slash-normalized, double-quoted path so the emitted * command is shell-safe across POSIX and Windows. `process.execPath` * gives the absolute path of the node binary actively running the * installer — that is the version the user just installed under, and * the right default runtime for hooks invoked under the same install. * * When `process.execPath` is a versioned Homebrew Cellar path, the stable * Homebrew symlink is returned instead to survive `brew upgrade node` (#3181). */ function resolveNodeRunner() { const execPath = typeof process.execPath === 'string' ? process.execPath : ''; if (!execPath) return null; const stablePath = normalizeNodePath(execPath); // JSON.stringify produces a properly escaped double-quoted shell token, // safe for paths containing spaces or unusual characters. return JSON.stringify(stablePath.replace(/\\/g, '/')); } /** * Rewrite legacy `node .../gsd-*.js` command strings in settings.hooks to use * the absolute Node binary path (#2979 follow-up: CR feedback on #3002). * * The original #2979 fix only emitted absolute paths for *newly registered* * hooks. Pre-existing entries kept their bare `node ` prefix on reinstall, * which left them broken under minimal-PATH GUI runtimes — exactly the * failure mode the original fix was meant to close. This walker normalizes * any managed-hook entry whose command starts with bare `node ` to * `