#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const os = require('os'); const readline = require('readline'); const crypto = require('crypto'); const { isManagedHookBasename, isManagedHookCommand, projectLocalHookPrefix, projectLegacySettingsHookCommand, projectManagedHookCommand, projectPathActionProjection, projectPortableHookBaseDir, projectPersistentPathExportActions, projectShellCommandText, projectCodexHookTomlCommand, shellHookOmitsBashRunner, buildLocalShellHookCommand, } = require('../gsd-core/bin/lib/shell-command-projection.cjs'); // Bidirectional GSD slash-command namespace transformer (#3583). // Required at module scope so the command list can be computed once per install // and passed down to convertClaudeCommandToClaudeSkill, avoiding repeated // fs.readdirSync + RegExp work for every skill. const { transformContentToHyphen, readCmdNames: readGsdCommandNames, } = require(path.join(__dirname, '..', 'scripts', 'fix-slash-commands.cjs')); const { resolveAntigravityGlobalDir, getGlobalConfigDir, getGlobalSkillsBase, } = require('../gsd-core/bin/lib/runtime-homes.cjs'); const { applyWorktreeBaseRef, readBaseRefFromSettings, } = require('../gsd-core/bin/lib/worktree-base-ref.cjs'); const { resolveRuntimeConfigIntent } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs'); /** * Runtimes that register hyphen-form `name:` per #2808 AND copy agent bodies * verbatim (only branding swaps, no namespace conversion), so retired * `/gsd:` colon refs leak into installed agent prose. Sibling fixes * #3583 / #3629 covered SKILL.md bodies, #3584 / #3606 covered runtime * emissions — this is the agent-body surface (#3677). * * Explicit allow-list rather than deny-list so unknown / future runtimes * default to "no rewrite" (better to leak than to mangle a runtime whose * namespace behavior we haven't verified). */ const HYPHEN_NAME_AGENT_RUNTIMES = new Set(['claude', 'qwen', 'hermes']); /** * #3677 predicate — true when an agent body needs `/gsd:` → `/gsd-` * normalization at install time. */ function shouldNormalizeHyphenNamespaceInAgentBody(runtime) { if (typeof runtime !== 'string' || runtime === '') return false; return HYPHEN_NAME_AGENT_RUNTIMES.has(runtime); } /** * #3677 helper — applies the hyphen-namespace transform iff the predicate * says so. Pure function; safe to call unconditionally from the install * loop. Returns the input unchanged for runtimes that self-convert or * intentionally keep colon refs. */ function normalizeAgentBodyForRuntime(content, runtime, cmdNames) { if (!shouldNormalizeHyphenNamespaceInAgentBody(runtime)) return content; return transformContentToHyphen(content, cmdNames); } // 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 gsd-core installer'; const GSD_CODEX_HOOKS_OWNERSHIP_PREFIX = '# GSD codex_hooks ownership: '; // Codex's hook-enabling feature flag (issue #3566). Codex itself marks // `codex_hooks` as a `legacy_key` in codex-rs/features/src/legacy.rs; the // canonical current key under [features] is `hooks`. The installer always // emits the canonical key going forward, recognizes legacy aliases as // equivalent during reinstall, and migrates them forward on rewrite. The // audit-marker string above is intentionally unchanged so existing // installs' ownership lines continue to round-trip. const CODEX_HOOKS_FEATURE_KEY = 'hooks'; const CODEX_HOOKS_FEATURE_LEGACY_KEYS = ['codex_hooks']; const CODEX_HOOKS_FEATURE_ALL_KEYS = [CODEX_HOOKS_FEATURE_KEY, ...CODEX_HOOKS_FEATURE_LEGACY_KEYS]; function isCodexHooksFeatureKey(key) { return CODEX_HOOKS_FEATURE_ALL_KEYS.includes(key); } // #768 \u2014 Claude Code permissions.allow / permissions.deny entries. // Pre-populated during Claude installs to eliminate first-run approval friction // for gsd-core's own known-safe tool calls, and to add defense-in-depth deny // entries for common credential files. // // Format: each string uses Claude Code's documented permission rule syntax \u2014 // "Tool(pattern)" e.g. "Bash(npx gsd-core *)", "Read(.planning/*)" // "Tool" (bare tool name, no pattern) // // Merge policy: additive, non-destructive \u2014 existing user entries are preserved; // GSD entries are appended only when not already present (idempotent). const GSD_CLAUDE_ALLOW_PERMISSIONS = Object.freeze([ 'Bash(npx gsd-core *)', 'Read(.planning/*)', 'Write(.planning/*)', 'Read(STATE.md)', 'Write(STATE.md)', ]); const GSD_CLAUDE_DENY_PERMISSIONS = Object.freeze([ 'Read(.env)', 'Read(.env.*)', 'Read(.secrets)', ]); /** * Merge GSD-owned permission entries into a Claude Code settings object. * * Additive and idempotent: existing allow/deny entries are preserved; GSD * entries are appended only if not already present. No other permission sub-keys * (ask, disableBypassPermissionsMode, etc.) are touched. * * Defensive: if settings is not a plain object, returns immediately without * throwing. If permissions.allow / permissions.deny exist but are not arrays * (malformed settings), they are replaced with valid arrays. * * @param {object} settings - The parsed settings.json object to mutate in-place. */ function mergeClaudePermissions(settings) { if (settings === null || typeof settings !== 'object' || Array.isArray(settings)) return; if (!settings.permissions || typeof settings.permissions !== 'object' || Array.isArray(settings.permissions)) { settings.permissions = {}; } if (!Array.isArray(settings.permissions.allow)) { settings.permissions.allow = []; } if (!Array.isArray(settings.permissions.deny)) { settings.permissions.deny = []; } for (const entry of GSD_CLAUDE_ALLOW_PERMISSIONS) { if (!settings.permissions.allow.includes(entry)) { settings.permissions.allow.push(entry); } } for (const entry of GSD_CLAUDE_DENY_PERMISSIONS) { if (!settings.permissions.deny.includes(entry)) { settings.permissions.deny.push(entry); } } } // Copilot instructions marker constants const GSD_COPILOT_INSTRUCTIONS_MARKER = ''; const GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER = ''; // #786 \u2014 GitHub Copilot CLI lifecycle hook constants. // Copilot reads hook configs from /hooks/*.json (repo scope: .github/hooks/, // user scope: ~/.copilot/hooks/) with the shape { version, hooks: { : [...] } }. // Events use camelCase (sessionStart, preToolUse, postToolUse, ...). A `command` // hook runs an INLINE shell command (bash / powershell), so the GSD hook is fully // self-contained \u2014 there is no separate hook script to install, and therefore // nothing that can dangle if a script copy is skipped. See // https://docs.github.com/en/copilot/reference/hooks-configuration const GSD_COPILOT_HOOK_FILE = 'gsd-session.json'; // Copilot parses a command hook's stdout as the hook-output JSON. For sessionStart // the schema is `{ additionalContext?: string }` (the text is prepended to the // session as context). So the hook must emit that JSON envelope — not bare text. // The two messages contain no JSON-special characters, so they embed verbatim. const GSD_COPILOT_SESSION_MSG_PRESENT = 'GSD: .planning/STATE.md present - review the current phase and any blockers before acting.'; const GSD_COPILOT_SESSION_MSG_ABSENT = 'GSD: no .planning/ workflow found - run /gsd-new-project to start a tracked workflow.'; const GSD_COPILOT_SESSION_HOOK_BASH = 'if [ -f .planning/STATE.md ]; then ' + `printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}'; else ` + `printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}'; fi`; const GSD_COPILOT_SESSION_HOOK_PWSH = 'if (Test-Path .planning/STATE.md) ' + `{ '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}' } ` + `else { '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}' }`; // #777 — Cursor CLI lifecycle hook constants. // Cursor reads hook configs from /.cursor/hooks.json (local) or // ~/.cursor/hooks.json (global) with the shape { version: 1, hooks: { : [...] } }. // Events use camelCase: sessionStart, postToolUse, preToolUse, etc. // A `command` hook entry runs an external script. GSD registers two managed hooks: // sessionStart → gsd-cursor-session-start.js (context injection) // postToolUse → gsd-cursor-post-tool.js (STATE.md update monitor) // Cursor docs: https://cursor.com/docs/hooks const GSD_CURSOR_SESSION_HOOK_SCRIPT = 'gsd-cursor-session-start.js'; const GSD_CURSOR_POST_TOOL_HOOK_SCRIPT = 'gsd-cursor-post-tool.js'; // Marker comment embedded in managed hook entries so GSD can find+remove them. const GSD_CURSOR_HOOK_MARKER = 'gsd-managed'; // GSD-managed files under hooks/lib/ (helpers required by gsd-*.sh hooks). // git-cmd.js does not start with "gsd-" (shared classifier for #3129), gsd-graphify-rebuild.sh does. const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh']; 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('../gsd-core/...')` 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, '..', 'gsd-core', '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, EFFORT_SET: GSD_EFFORT_SET, } = require(path.join(_gsdLibDir, 'core.cjs')); // #443 — model-catalog and config-defaults.manifest.json exports needed only // by effort-resolution code paths (resolveInstallTimeEffort / // generateCodexAgentToml / Claude .md effort injection). Loaded lazily the // first time they are needed so that requiring install.js in test contexts that // never trigger an install does NOT produce module-load-time side effects (the // manifest read + hard throw) that could alter subprocess exit codes or stderr. let _gsdEffortCatalogCache = null; function _getGsdEffortCatalog() { if (_gsdEffortCatalogCache) return _gsdEffortCatalogCache; const { AGENT_DEFAULT_TIERS, renderEffortForRuntime } = require(path.join(_gsdLibDir, 'model-catalog.cjs')); const manifestPath = path.join( __dirname, '..', 'gsd-core', 'bin', 'shared', 'config-defaults.manifest.json' ); let manifestData; try { manifestData = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); } catch (_err) { // Fail loudly — a missing manifest is a broken install, not a soft degradation. throw new Error( `gsd install: cannot load config-defaults.manifest.json at ${manifestPath}: ${_err.message}` ); } const tierDefaults = (manifestData.effort && manifestData.effort.routing_tier_defaults && typeof manifestData.effort.routing_tier_defaults === 'object' && !Array.isArray(manifestData.effort.routing_tier_defaults)) ? manifestData.effort.routing_tier_defaults : { light: 'low', standard: 'high', heavy: 'xhigh' }; // guard: unreachable if manifest is valid const effortDefault = (manifestData.effort && typeof manifestData.effort.default === 'string') ? manifestData.effort.default : 'high'; // guard: unreachable if manifest is valid _gsdEffortCatalogCache = { AGENT_DEFAULT_TIERS, renderEffortForRuntime, EFFORT_MANIFEST_TIER_DEFAULTS: tierDefaults, EFFORT_MANIFEST_DEFAULT: effortDefault, }; return _gsdEffortCatalogCache; } const { MINIMAL_SKILL_ALLOWLIST, PROFILES, isMinimalMode, stageSkillsForMode, readActiveProfile, writeActiveProfile, resolveEffectiveProfile, mostRestrictiveProfile, resolveProfile, loadSkillsManifest, stageSkillsForProfile, stageAgentsForProfile, stageSkillsForRuntimeAsSkills, } = require(path.join(_gsdLibDir, 'install-profiles.cjs')); // ADR-857 phase 4c: load capability registry (optional; missing → falls back to undefined) let _capabilityRegistry; try { _capabilityRegistry = require(path.join(_gsdLibDir, 'capability-registry.cjs')); } catch (_) { _capabilityRegistry = undefined; } const { applyInstallerMigrationPlan, discoverInstallerMigrations, runInstallerMigrations, } = require(path.join(_gsdLibDir, 'installer-migrations.cjs')); const { assertInstallerMigrationsUnblocked, resolveInstallerMigrationPromptsForNonTty, summarizeInstallerMigrationResult, } = require(path.join(_gsdLibDir, 'installer-migration-report.cjs')); const { resolveRuntimeArtifactLayout, } = require(path.join(_gsdLibDir, 'runtime-artifact-layout.cjs')); const { planLegacyCleanup, applyLegacyCleanup, } = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'legacy-cleanup.cjs')); const { updateCacheFileName, } = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'package-identity.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 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 hasDryRun = args.includes('--dry-run'); // --profile= or --profile=, (composable); mutually exclusive with --minimal const _profileArgRaw = (() => { for (const arg of args) { if (arg.startsWith('--profile=')) return arg.slice('--profile='.length); } return null; })(); // Resolve active profile name: // 1. --minimal / --core-only → 'core' (back-compat alias) // 2. --profile= → named profile // 3. neither → 'full' (default, back-compat) // Note: when re-running as `gsd update` the marker is read later (after // configDir is resolved) and may override 'full' — see writeActiveProfile call below. const _profileIsCore = _profileArgRaw === 'core'; const _requestedProfileName = (hasMinimal || _profileIsCore) ? 'core' : (_profileArgRaw || null); if (hasMinimal && _profileArgRaw) { console.error(` ${yellow}Cannot specify both --minimal/--core-only and --profile${reset}`); process.exit(1); } function selectRuntimesFromArgs(runtimeArgs) { if (runtimeArgs.includes('--all')) { return ['claude', 'kimi', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline']; } if (runtimeArgs.includes('--both')) { return ['claude', 'opencode']; } const selected = []; if (runtimeArgs.includes('--claude')) selected.push('claude'); if (runtimeArgs.includes('--opencode')) selected.push('opencode'); if (runtimeArgs.includes('--gemini')) selected.push('gemini'); if (runtimeArgs.includes('--kilo')) selected.push('kilo'); if (runtimeArgs.includes('--codex')) selected.push('codex'); if (runtimeArgs.includes('--copilot')) selected.push('copilot'); if (runtimeArgs.includes('--antigravity')) selected.push('antigravity'); if (runtimeArgs.includes('--cursor')) selected.push('cursor'); if (runtimeArgs.includes('--windsurf')) selected.push('windsurf'); if (runtimeArgs.includes('--augment')) selected.push('augment'); if (runtimeArgs.includes('--trae')) selected.push('trae'); if (runtimeArgs.includes('--qwen')) selected.push('qwen'); if (runtimeArgs.includes('--hermes')) selected.push('hermes'); if (runtimeArgs.includes('--kimi')) selected.push('kimi'); if (runtimeArgs.includes('--codebuddy')) selected.push('codebuddy'); if (runtimeArgs.includes('--cline')) selected.push('cline'); return selected; } // Runtime selection - can be set by flags or interactive prompt let selectedRuntimes = selectRuntimesFromArgs(args); // 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 ${pkg.name}@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 === 'kimi') return '.kimi-code'; 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'"; const antigravityDir = resolveAntigravityGlobalDir(); const rel = path.relative(os.homedir(), antigravityDir); const segments = rel.split(path.sep).filter(Boolean); if (segments.length > 0 && !rel.startsWith('..') && !path.isAbsolute(rel)) { return segments.map((seg) => `'${seg}'`).join(', '); } // If resolution points outside HOME (e.g. via env override), keep the // stable legacy template so generated path.join() calls remain valid. 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'"; if (runtime === 'kimi') return "'.config', 'agents'"; return "'.claude'"; } /** * Compatibility seam for tests and older installer consumers. * Runtime home resolution now lives in runtime-homes.cjs. */ function getGlobalDir(runtime, explicitDir = null) { return getGlobalConfigDir(runtime, explicitDir); } const banner = '\n' + cyan + ' ██████╗ ███████╗██████╗\n' + ' ██╔════╝ ██╔════╝██╔══██╗\n' + ' ██║ ███╗███████╗██║ ██║\n' + ' ██║ ██║╚════██║██║ ██║\n' + ' ╚██████╔╝███████║██████╔╝\n' + ' ╚═════╝ ╚══════╝╚═════╝' + reset + '\n' + '\n' + ' GSD Core ' + dim + 'v' + pkg.version + reset + '\n' + ' Git. Ship. Done.\n' + ' A meta-prompting, context engineering and spec-driven\n' + ' development workflows for Claude Code, OpenCode, Gemini, Kimi CLI, Kilo, Codex, Copilot, Antigravity, Cursor, Windsurf, Augment, Trae, Qwen Code, Hermes Agent, Cline and CodeBuddy.\n'; // Pure seam: parse --config-dir / -c from an arbitrary args array. // Returns the path string, '' for an empty equals-form value, or null when the // flag is absent. Space-separated form returns null (not an error string) when // the next token is missing or flag-looking — callers that want process.exit // behaviour must check after calling this function. // Exported via module.exports so unit tests can exercise it directly. function parseConfigDirFromArgs(argsArray) { const configDirIndex = argsArray.findIndex(arg => arg === '--config-dir' || arg === '-c'); if (configDirIndex !== -1) { const nextArg = argsArray[configDirIndex + 1]; // No value / next token is a flag → signal "missing" by returning null if (!nextArg || nextArg.startsWith('-')) { return null; } return nextArg; } // Handle --config-dir=value and -c=value format. // Use indexOf('=') + 1 so that = signs inside the path value are preserved. const configDirArg = argsArray.find(arg => arg.startsWith('--config-dir=') || arg.startsWith('-c=')); if (configDirArg) { return configDirArg.slice(configDirArg.indexOf('=') + 1); } return null; } // Parse --config-dir argument function parseConfigDirArg() { const result = parseConfigDirFromArgs(args); if (result === null) { // Check if the space-separated form was present but missing a value const configDirIndex = args.findIndex(arg => arg === '--config-dir' || arg === '-c'); if (configDirIndex !== -1) { console.error(` ${yellow}--config-dir requires a path argument${reset}`); process.exit(1); } return null; } if (result === '') { console.error(` ${yellow}--config-dir requires a non-empty path${reset}`); process.exit(1); } return result; } 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 ${pkg.name} [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}--kimi${reset} Install for Kimi CLI 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}--profile=${reset} Install a named skill profile. Profiles:\n core — ${PROFILES.core.length} main-loop skills incl. phase (~130 desc tokens)\n standard — ${PROFILES.standard.length} skills incl. phase, review, config (~700)\n full — all skills (default)\n Composable: --profile=core,audit installs union of closures.\n Profile is persisted and respected by \`gsd update\`.\n ${cyan}--minimal${reset} Alias for --profile=core (back-compat).\n Cuts cold-start overhead from ~12k tokens to ~700.\n Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx ${pkg.name}\n\n ${dim}# Install for Claude Code globally${reset}\n npx ${pkg.name} --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx ${pkg.name} --gemini --global\n\n ${dim}# Install for Kilo globally${reset}\n npx ${pkg.name} --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx ${pkg.name} --codex --global\n\n ${dim}# Install for Kimi CLI globally${reset}\n npx ${pkg.name} --kimi --global\n\n ${dim}# Install for Kimi CLI under ~/.kimi-code${reset}\n npx ${pkg.name} --kimi --global --config-dir ~/.kimi-code\n\n ${dim}# Install for Copilot globally${reset}\n npx ${pkg.name} --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx ${pkg.name} --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx ${pkg.name} --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx ${pkg.name} --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx ${pkg.name} --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx ${pkg.name} --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx ${pkg.name} --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx ${pkg.name} --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx ${pkg.name} --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx ${pkg.name} --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx ${pkg.name} --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx ${pkg.name} --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx ${pkg.name} --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx ${pkg.name} --hermes --local\n\n ${dim}# Install for Cline globally${reset}\n npx ${pkg.name} --cline --global\n\n ${dim}# Install for Cline locally${reset}\n npx ${pkg.name} --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx ${pkg.name} --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx ${pkg.name} --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx ${pkg.name} --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx ${pkg.name} --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx ${pkg.name} --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx ${pkg.name} --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 / KIMI_CONFIG_DIR / COPILOT_CONFIG_DIR / COPILOT_HOME / 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 Kimi CLI defaults to the first existing generic skills root: ${cyan}~/.config/agents/skills${reset}, then ${cyan}~/.agents/skills${reset}; if neither exists, GSD creates ${cyan}~/.config/agents${reset}.\n Use ${cyan}--config-dir ~/.kimi-code${reset} or ${cyan}KIMI_CONFIG_DIR=~/.kimi-code${reset} for brand-specific Kimi installs.\n`); process.exit(0); } /** * 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 * `