From 62261a31663ee3a0659aff0cd3493f6776ff97fa Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 15 Apr 2026 14:59:21 -0400 Subject: [PATCH] fix: add --portable-hooks flag for WSL/Docker $HOME-relative settings.json paths (#2226) Absolute hook paths in settings.json break when ~/.claude is bind-mounted into a container at a different path, or when running under WSL with a Windows Node.js that resolves a different home directory. Add `--portable-hooks` CLI flag and `GSD_PORTABLE_HOOKS=1` env var opt-in. When set, buildHookCommand() emits `$HOME`-relative paths instead of resolved absolute paths, making the generated hook commands portable across bind mounts. Fixes #2190 Co-authored-by: Claude Sonnet 4.6 --- bin/install.js | 53 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/bin/install.js b/bin/install.js index c843822d5..9a5f39a22 100755 --- a/bin/install.js +++ b/bin/install.js @@ -76,6 +76,7 @@ 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 hasPortableHooks = args.includes('--portable-hooks') || process.env.GSD_PORTABLE_HOOKS === '1'; // Runtime selection - can be set by flags or interactive prompt let selectedRuntimes = []; @@ -436,7 +437,7 @@ if (hasUninstall) { // 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}--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\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 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 / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n`); + 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}--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\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 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 / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n`); process.exit(0); } @@ -453,16 +454,31 @@ function expandTilde(filePath) { /** * Build a hook command path using forward slashes for cross-platform compatibility. * On Windows, $HOME is not expanded by cmd.exe/PowerShell, so we use the actual path. + * + * @param {string} configDir - Resolved absolute config directory path + * @param {string} hookName - Hook filename (e.g. 'gsd-statusline.js') + * @param {{ portableHooks?: boolean }} [opts] - Options + * portableHooks: when true, emit $HOME-relative paths instead of absolute paths. + * Safe for Linux/macOS global installs and WSL/Docker bind-mount scenarios. + * Not suitable for pure Windows (cmd.exe/PowerShell do not expand $HOME). */ -function buildHookCommand(configDir, hookName) { - // Use forward slashes for Node.js compatibility on all platforms - const hooksPath = configDir.replace(/\\/g, '/') + '/hooks/' + hookName; - // .sh hooks use bash; .js hooks use node. Both wrap the path in double quotes - // so that paths with spaces (e.g. Windows "C:/Users/First Last/") work correctly - // (fixes #2045). Routing .sh hooks through this function also ensures they always - // receive an absolute path rather than the bare relative string that the old manual - // concatenation produced (fixes #2046). +function buildHookCommand(configDir, hookName, opts) { + if (!opts) opts = {}; const runner = hookName.endsWith('.sh') ? 'bash' : 'node'; + + if (opts.portableHooks) { + // Replace the home directory prefix with $HOME so the path works when + // ~/.claude is bind-mounted into a container at a different absolute path. + const home = os.homedir().replace(/\\/g, '/'); + const normalized = configDir.replace(/\\/g, '/'); + const relative = normalized.startsWith(home) + ? '$HOME' + normalized.slice(home.length) + : normalized; + return `${runner} "${relative}/hooks/${hookName}"`; + } + + // Default: absolute path with forward slashes (Windows-safe, fixes #2045/#2046). + const hooksPath = configDir.replace(/\\/g, '/') + '/hooks/' + hookName; return `${runner} "${hooksPath}"`; } @@ -5991,20 +6007,21 @@ function install(isGlobal, runtime = 'claude') { // Local installs anchor paths to $CLAUDE_PROJECT_DIR so hooks resolve // correctly regardless of the shell's current working directory (#1906). const localPrefix = '"$CLAUDE_PROJECT_DIR"/' + dirName; + const hookOpts = { portableHooks: hasPortableHooks }; const statuslineCommand = isGlobal - ? buildHookCommand(targetDir, 'gsd-statusline.js') + ? buildHookCommand(targetDir, 'gsd-statusline.js', hookOpts) : 'node ' + localPrefix + '/hooks/gsd-statusline.js'; const updateCheckCommand = isGlobal - ? buildHookCommand(targetDir, 'gsd-check-update.js') + ? buildHookCommand(targetDir, 'gsd-check-update.js', hookOpts) : 'node ' + localPrefix + '/hooks/gsd-check-update.js'; const contextMonitorCommand = isGlobal - ? buildHookCommand(targetDir, 'gsd-context-monitor.js') + ? buildHookCommand(targetDir, 'gsd-context-monitor.js', hookOpts) : 'node ' + localPrefix + '/hooks/gsd-context-monitor.js'; const promptGuardCommand = isGlobal - ? buildHookCommand(targetDir, 'gsd-prompt-guard.js') + ? buildHookCommand(targetDir, 'gsd-prompt-guard.js', hookOpts) : 'node ' + localPrefix + '/hooks/gsd-prompt-guard.js'; const readGuardCommand = isGlobal - ? buildHookCommand(targetDir, 'gsd-read-guard.js') + ? buildHookCommand(targetDir, 'gsd-read-guard.js', hookOpts) : 'node ' + localPrefix + '/hooks/gsd-read-guard.js'; // Enable experimental agents for Gemini CLI (required for custom sub-agents) @@ -6157,7 +6174,7 @@ function install(isGlobal, runtime = 'claude') { // Detects file edits outside GSD workflow context and advises using // /gsd-quick or /gsd-fast for state-tracked changes. Advisory only. const workflowGuardCommand = isGlobal - ? buildHookCommand(targetDir, 'gsd-workflow-guard.js') + ? buildHookCommand(targetDir, 'gsd-workflow-guard.js', hookOpts) : 'node ' + localPrefix + '/hooks/gsd-workflow-guard.js'; const hasWorkflowGuardHook = settings.hooks[preToolEvent].some(entry => entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-workflow-guard')) @@ -6182,7 +6199,7 @@ function install(isGlobal, runtime = 'claude') { // Configure commit validation hook (Conventional Commits enforcement, opt-in) const validateCommitCommand = isGlobal - ? buildHookCommand(targetDir, 'gsd-validate-commit.sh') + ? buildHookCommand(targetDir, 'gsd-validate-commit.sh', hookOpts) : 'bash ' + localPrefix + '/hooks/gsd-validate-commit.sh'; const hasValidateCommitHook = settings.hooks[preToolEvent].some(entry => entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-validate-commit')) @@ -6209,7 +6226,7 @@ function install(isGlobal, runtime = 'claude') { // Configure session state orientation hook (opt-in) const sessionStateCommand = isGlobal - ? buildHookCommand(targetDir, 'gsd-session-state.sh') + ? buildHookCommand(targetDir, 'gsd-session-state.sh', hookOpts) : 'bash ' + localPrefix + '/hooks/gsd-session-state.sh'; const hasSessionStateHook = settings.hooks.SessionStart.some(entry => entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-session-state')) @@ -6231,7 +6248,7 @@ function install(isGlobal, runtime = 'claude') { // Configure phase boundary detection hook (opt-in) const phaseBoundaryCommand = isGlobal - ? buildHookCommand(targetDir, 'gsd-phase-boundary.sh') + ? buildHookCommand(targetDir, 'gsd-phase-boundary.sh', hookOpts) : 'bash ' + localPrefix + '/hooks/gsd-phase-boundary.sh'; const hasPhaseBoundaryHook = settings.hooks[postToolEvent].some(entry => entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-phase-boundary'))