diff --git a/.changeset/steady-shell-projection.md b/.changeset/steady-shell-projection.md new file mode 100644 index 000000000..81ca7839d --- /dev/null +++ b/.changeset/steady-shell-projection.md @@ -0,0 +1,6 @@ +--- +type: Added +pr: 3445 +--- + +**Shell command projection is now centralized for key installer/runtime surfaces** — managed-hook rewrites, local hook command construction, Windows shim rendering, and SDK PATH diagnostics now share one projection seam to reduce cross-shell drift and runtime-specific regressions. (#3445) diff --git a/CLAUDE.md b/CLAUDE.md index 8cb2d4094..64deac0a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,18 +1,8 @@ ## GitHub access -**Always** set `GITHUB_TOKEN` from `.envrc` before any `gh` CLI call: - -```bash -export GITHUB_TOKEN=$(grep GITHUB_TOKEN .envrc | cut -d\' -f2) -``` - -Or prefix every `gh` command: - -```bash -GITHUB_TOKEN=$(grep GITHUB_TOKEN .envrc | cut -d\' -f2) gh issue create ... -``` - -**Never** use the ambient `gh auth` session — it resolves to enterprise credentials that cannot access this repo. The `.envrc` token is the only credential authorised for `gsd-build/get-shit-done`. +Use the configured GitHub CLI session for this checkout. Always pass +`--repo gsd-build/get-shit-done` on `gh` commands so issue and PR operations +stay scoped to the canonical repository. --- @@ -20,7 +10,7 @@ GITHUB_TOKEN=$(grep GITHUB_TOKEN .envrc | cut -d\' -f2) gh issue create ... ### Issue tracker -Issues live in GitHub Issues (`gsd-build/get-shit-done`). Always use the `.envrc` token — never the ambient `gh auth` session. See `docs/agents/issue-tracker.md`. +Issues live in GitHub Issues (`gsd-build/get-shit-done`). See `docs/agents/issue-tracker.md`. ### Triage labels diff --git a/CONTEXT.md b/CONTEXT.md index ea0f8ece3..e5b04443a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -243,6 +243,21 @@ After stripping prose @-refs, some command `` blocks retained bolded "* `RULESET.CONTRIB.CLASSIFY.enhancement=requires approved-enhancement before implementation` `RULESET.CONTRIB.CLASSIFY.feature=requires approved-feature before implementation` +## AI Ops Memory (2026-05-10, machine-oriented) + +`RULESET.GH.AUTH.DEFAULT=source .envrc GITHUB_TOKEN before gh; exception=ambient allowed only when user explicitly says machine-only fallback` +`RULESET.CODERABBIT.GUARD.OPEN_PRS=gh pr list --repo gsd-build/get-shit-done --author @me --state open; repeat near end because open PR set can change mid-run` +`RULESET.CODERABBIT.GUARD.COMPLETE=required_checks_green && coderabbit_check_pass && graphQL(reviewThreads.unresolved_count)==0` +`RULESET.CODERABBIT.GUARD.GRAPHQL=reviewThreads(first:100){nodes{id isResolved comments{nodes{author body path line originalLine url}}}}; use unresolved threads as authoritative, not badge text alone` +`RULESET.CODERABBIT.GUARD.RERUN=after every push wait for CodeRabbit completion, then re-query unresolved threads; CodeRabbit can add new findings after earlier threads were resolved` +`RULESET.CODERABBIT.GUARD.RESOLVE=fix validated finding -> focused tests -> commit/push -> resolveReviewThread(threadId) -> wait CI/CodeRabbit -> final unresolved_count query` +`RULESET.CODERABBIT.GUARD.SCOPE=if a new @me open PR appears during final list, include it in the same guard pass before declaring all-open-PRs complete` +`RULESET.TESTS.CODERABBIT_FIX=prefer exported-function behavioral tests over source-grep; lint-no-source-grep rejects readFileSync source assertions without allow-test-rule` +`RULESET.WORKFLOW_MARKDOWN.FENCES=when editing shell snippets inside workflow markdown, preserve the opening language fence; malformed fence can create fresh CodeRabbit threads` +`RULESET.WORKFLOW_SIZE_BUDGET=workflow-size-budget can fail otherwise-valid review fixes; keep XL workflows <=1800 lines or trim prose in same PR before final checks` +`RULESET.GEMINI.TOOLS.ask_user=Gemini CLI has no ask_user tool; filter both AskUserQuestion and lowercase ask_user from tools frontmatter and neutralize both names in Gemini body text` +`RULESET.GEMINI.TEST_SENTINEL=convertClaudeToGeminiAgent regression should assert tools excludes ask_user, body excludes AskUserQuestion/ask_user, and Read still maps to read_file` + `CI.GATE.issue-link-required=hard-fail if PR body lacks closes/fixes/resolves #` `CI.GATE.changeset-lint=hard-fail for user-facing code diffs unless .changeset/* or PR has no-changelog label` `CI.GATE.repair-sequence(PR)=create issue -> apply approval label -> edit PR body w/ closing keyword -> apply no-changelog if appropriate -> re-run checks` diff --git a/bin/install.js b/bin/install.js index e899ea4b8..69f56fb9c 100755 --- a/bin/install.js +++ b/bin/install.js @@ -6,8 +6,14 @@ const os = require('os'); const readline = require('readline'); const crypto = require('crypto'); const { - formatHookCommandForRuntime: formatHookCommandForShell, - formatManagedHookScriptToken, + buildWindowsShimTriple: buildWindowsShimTripleFromProjection, + formatSdkPathDiagnostic: formatSdkPathDiagnosticFromProjection, + isManagedHookBasename, + projectLocalHookPrefix, + projectLegacySettingsHookCommand, + projectManagedHookCommand, + projectPortableHookBaseDir, + projectShellCommandText, projectCodexHookTomlCommand, } = require('../get-shit-done/bin/lib/shell-command-projection.cjs'); @@ -634,16 +640,6 @@ function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) { if (!settings || !settings.hooks || !absoluteRunner) return false; if (!opts) opts = {}; const platform = opts.platform || process.platform; - const MANAGED_HOOK_FILES = new Set([ - 'gsd-check-update.js', - 'gsd-statusline.js', - 'gsd-context-monitor.js', - 'gsd-prompt-guard.js', - 'gsd-read-guard.js', - 'gsd-read-injection-scanner.js', - 'gsd-update-banner.js', - 'gsd-workflow-guard.js', - ]); let changed = false; for (const entries of Object.values(settings.hooks)) { if (!Array.isArray(entries)) continue; @@ -696,11 +692,16 @@ function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) { // Take the basename — match against MANAGED_HOOK_FILES by exact // equality, not substring containment. Handles both forward and // backslash separators (Windows). - const scriptBase = scriptPath.split(/[\\/]/).pop() || ''; - if (!MANAGED_HOOK_FILES.has(scriptBase)) continue; + if (!isManagedHookBasename(scriptPath, { surface: 'settings-json' })) continue; - const safeScriptToken = formatManagedHookScriptToken(scriptPath, opts) || scriptToken; - const projectedCommand = formatHookCommandForShell(`${absoluteRunner} ${safeScriptToken}`, opts); + const projectedCommand = projectLegacySettingsHookCommand({ + absoluteRunner, + scriptPath, + scriptToken, + runtime: opts.runtime || 'generic', + platform, + }); + if (!projectedCommand) continue; // Skip only when the existing managed command already matches the // desired runtime-aware projected shape. This preserves Gemini's @@ -716,15 +717,6 @@ function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) { return changed; } -/** - * Codex managed-hook filenames eligible for legacy-bare-node migration. - * Mirrors the settings.json allowlist in rewriteLegacyManagedNodeHookCommands. - * Centralized so the codex toml branch and the settings.json branch can't drift. - */ -const CODEX_MANAGED_HOOK_BASENAMES = new Set([ - 'gsd-check-update.js', -]); - /** * Build the GSD-managed Codex SessionStart hook block for config.toml. * @@ -806,8 +798,7 @@ function rewriteLegacyCodexHookBlock(content, absoluteRunner, opts) { scriptPath = quoted[1]; } } - const base = scriptPath.split(/[\\/]/).pop() || ''; - if (!CODEX_MANAGED_HOOK_BASENAMES.has(base)) return full; + if (!isManagedHookBasename(scriptPath, { surface: 'codex-toml' })) return full; const desiredCommand = projectCodexHookTomlCommand({ absoluteRunner, scriptPath, @@ -853,19 +844,26 @@ function buildHookCommand(configDir, hookName, opts) { if (runner === null) return null; 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 formatHookCommandForShell(`${runner} "${relative}/hooks/${hookName}"`, opts); + const portableBaseDir = projectPortableHookBaseDir({ + configDir, + homeDir: os.homedir(), + }); + return projectManagedHookCommand({ + absoluteRunner: runner, + scriptPath: `${portableBaseDir}/hooks/${hookName}`, + runtime: opts.runtime || 'generic', + platform: opts.platform || process.platform, + }); } // Default: absolute path with forward slashes (Windows-safe, fixes #2045/#2046). const hooksPath = configDir.replace(/\\/g, '/') + '/hooks/' + hookName; - return formatHookCommandForShell(`${runner} "${hooksPath}"`, opts); + return projectManagedHookCommand({ + absoluteRunner: runner, + scriptPath: hooksPath, + runtime: opts.runtime || 'generic', + platform: opts.platform || process.platform, + }); } /** @@ -8851,21 +8849,33 @@ function install(isGlobal, runtime = 'claude', options = {}) { // Claude Code sets $CLAUDE_PROJECT_DIR; Gemini/Antigravity do not — and on // Windows their own substitution logic doubles the path (#2557). Those runtimes // run project hooks with the project dir as cwd, so bare relative paths work. - const localPrefix = (runtime === 'gemini' || runtime === 'antigravity') - ? dirName - : '"$CLAUDE_PROJECT_DIR"/' + dirName; + const localPrefix = projectLocalHookPrefix({ runtime, dirName }); const hookOpts = { portableHooks: hasPortableHooks, runtime }; // #2979: local-install hook commands also use the absolute node path so // GUI/minimal-PATH runtimes can resolve them. Bare `node` fails when the // host launches the runtime with a stripped PATH (Finder/Antigravity/etc). const localNodeRunner = resolveNodeRunner(); + const localBashRunner = resolveBashRunner({ platform: process.platform }); // If we cannot resolve an absolute node path AND this is a local install, // skip managed-hook registration. Returning null from buildHookCommand on // global installs has the same effect. Better to skip than to emit a bare // `node` command that recreates the #2979 failure. const localCmd = (hookFile) => localNodeRunner === null ? null - : formatHookCommandForShell(localNodeRunner + ' ' + localPrefix + '/hooks/' + hookFile, hookOpts); + : projectShellCommandText({ + runnerToken: localNodeRunner, + argTokens: [`${localPrefix}/hooks/${hookFile}`], + runtime, + platform: process.platform, + }); + const localShellCmd = (hookFile) => localBashRunner === null + ? null + : projectShellCommandText({ + runnerToken: localBashRunner, + argTokens: [`${localPrefix}/hooks/${hookFile}`], + runtime, + platform: process.platform, + }); const statuslineCommand = isGlobal ? buildHookCommand(targetDir, 'gsd-statusline.js', hookOpts) : localCmd('gsd-statusline.js'); @@ -9100,7 +9110,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { // Configure commit validation hook (Conventional Commits enforcement, opt-in) const validateCommitCommand = isGlobal ? buildHookCommand(targetDir, 'gsd-validate-commit.sh', hookOpts) - : 'bash ' + localPrefix + '/hooks/gsd-validate-commit.sh'; + : localShellCmd('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')) ); @@ -9129,7 +9139,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { // Configure session state orientation hook (opt-in) const sessionStateCommand = isGlobal ? buildHookCommand(targetDir, 'gsd-session-state.sh', hookOpts) - : 'bash ' + localPrefix + '/hooks/gsd-session-state.sh'; + : localShellCmd('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')) ); @@ -9153,7 +9163,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { // Configure phase boundary detection hook (opt-in) const phaseBoundaryCommand = isGlobal ? buildHookCommand(targetDir, 'gsd-phase-boundary.sh', hookOpts) - : 'bash ' + localPrefix + '/hooks/gsd-phase-boundary.sh'; + : localShellCmd('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')) ); @@ -10496,32 +10506,7 @@ function trySelfLinkGsdSdk(shimSrc) { * over rendered shim text. */ function buildWindowsShimTriple(shimSrc) { - const path = require('path'); - const shimAbs = path.resolve(shimSrc); - // JSON.stringify produces a double-quoted string with backslash+quote - // escaping — the safe quoting form for cmd.exe and PowerShell paths alike. - const shimQuoted = JSON.stringify(shimAbs); - - const invocation = { - interpreter: 'node', - target: shimAbs, - }; - - // Renderers are template literals — the only place text is constructed. - // Tests do not parse these strings; they assert on the typed fields above. - const renderCmd = () => - '@ECHO OFF\r\n@SETLOCAL\r\n@node ' + shimQuoted + ' %*\r\n'; - const renderPs1 = () => - '#!/usr/bin/env pwsh\n& node ' + shimQuoted + ' $args\nexit $LASTEXITCODE\n'; - const renderSh = () => - '#!/usr/bin/env sh\nexec node ' + shimQuoted + ' "$@"\n'; - - return { - invocation, - eol: { cmd: '\r\n', ps1: '\n', sh: '\n' }, - fileNames: { cmd: 'gsd-sdk.cmd', ps1: 'gsd-sdk.ps1', sh: 'gsd-sdk' }, - render: { cmd: renderCmd, ps1: renderPs1, sh: renderSh }, - }; + return buildWindowsShimTripleFromProjection(shimSrc); } /** @@ -10541,56 +10526,7 @@ function buildWindowsShimTriple(shimSrc) { * console output. Pure function — no fs, no spawn, no console. */ function formatSdkPathDiagnostic({ shimDir, platform, runDir }) { - const path = require('path'); - const isWin32 = platform === 'win32'; - // Detect either path separator — the test fixtures pass Windows-style - // paths while running on POSIX, and real users hit either depending on - // their npm/npx setup. Anchor on `_npx` between separators. - const isNpx = typeof runDir === 'string' && - (runDir.includes('/_npx/') || runDir.includes('\\_npx\\')); - - const shimLocationLine = shimDir ? `Shim written to: ${shimDir}` : ''; - const actionLines = []; - - if (shimDir) { - // Escape shimDir for each shell context. A path containing a single - // quote (e.g. C:\Users\O'Neil\AppData\...) would otherwise generate - // broken commands the user can't paste: - // - PowerShell single-quoted string: '' escapes a literal single quote - // - bash inside outer single quotes: '\'' (close, escaped quote, reopen) - // - POSIX export inside double quotes: escape \ $ " ` so the path is - // copied verbatim and $PATH (which is OUTSIDE the escaped substring) - // still expands at paste time. - const psShimDir = shimDir.replace(/'/g, "''"); - const bashShimDir = shimDir.replace(/\\/g, '/').replace(/'/g, "'\\''"); - const posixShimDir = shimDir.replace(/[\\$"`]/g, '\\$&'); - actionLines.push('Add that directory to your PATH and restart your shell.'); - if (isWin32) { - actionLines.push(`PowerShell: [Environment]::SetEnvironmentVariable('PATH', '${psShimDir};' + [Environment]::GetEnvironmentVariable('PATH', 'User'), 'User')`); - // setx PATH "...;%PATH%" silently truncates above 1024 chars and - // expands %PATH% / %SystemRoot% to literals (turning REG_EXPAND_SZ - // into REG_SZ), permanently breaking lazy variable references. - // Invoke PowerShell from cmd.exe with the same SetEnvironmentVariable - // call as the PowerShell line so cmd.exe users get a safe command. - actionLines.push(`cmd.exe : powershell -Command "[Environment]::SetEnvironmentVariable('PATH', '${psShimDir};' + [Environment]::GetEnvironmentVariable('PATH', 'User'), 'User')"`); - actionLines.push(`Git Bash : echo 'export PATH="${bashShimDir}:$PATH"' >> ~/.bashrc`); - } else { - actionLines.push(`export PATH="${posixShimDir}:$PATH"`); - } - } else { - actionLines.push('Could not locate a writable PATH directory to install the shim.'); - actionLines.push('Install globally to materialize the bin symlink:'); - actionLines.push('npm install -g get-shit-done-cc'); - } - - const npxNoteLines = isNpx - ? [ - "Note: you're running via npx. For a persistent shim,", - 'install globally instead: npm install -g get-shit-done-cc', - ] - : []; - - return { shimLocationLine, actionLines, npxNoteLines, isNpx, isWin32 }; + return formatSdkPathDiagnosticFromProjection({ shimDir, platform, runDir }); } function trySelfLinkGsdSdkWindows(shimSrc) { diff --git a/docs/adr/0009-shell-command-projection-module.md b/docs/adr/0009-shell-command-projection-module.md index 40b5c0d9c..5ea7b9d8e 100644 --- a/docs/adr/0009-shell-command-projection-module.md +++ b/docs/adr/0009-shell-command-projection-module.md @@ -16,7 +16,7 @@ We propose introducing a Shell Command Projection Module that owns projection fr - generated shim / wrapper script text written to disk - Keep internal subprocess execution (`spawnSync`, `execFileSync`, SDK query dispatch) outside this seam. The module does **not** become a generic command runner. - Make runtime-specific wrappers explicit policy at the seam (for example, emit PowerShell call-operator prefixes only for shells/runtimes that require them). -- Make path-style projection explicit policy at the seam (`native Windows`, `POSIX slash`, `$HOME`-relative`, project-dir-relative`, etc.). +- Make path-style projection explicit policy at the seam (`native Windows`, `POSIX slash`, `$HOME`-relative, `project-dir-relative`, etc.). - Prefer typed IR outputs that tests can assert against directly, then render text at the final Adapter. ## Initial Scope diff --git a/docs/adr/README.md b/docs/adr/README.md index 7aeb0595d..245180670 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -25,3 +25,6 @@ ADR 0005 is the top-level SDK seam index. It references per-seam ADRs and states ADR 0006 documents how SDK query handlers project planning paths (`cwd → effectiveRoot → .planning//...`). Cross-reference with the Planning Workspace Module (ADR 0004) for workstream pointer policy. ADR 0008 documents the Installer Migration Module for safe install-time moves, removals, config rewrites, and user-data preservation. + +ADR 0009 documents the Shell Command Projection Module seam for runtime-aware +projection of installer-owned command text and projection IR. diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md index 9cc280f48..d327c5d39 100644 --- a/docs/agents/issue-tracker.md +++ b/docs/agents/issue-tracker.md @@ -4,13 +4,8 @@ Issues for this repo live in **GitHub Issues** at `gsd-build/get-shit-done`. ## Auth -Always read the token from `.envrc` — never use the ambient `gh auth` session (it resolves to enterprise credentials that cannot access this repo): - -```bash -export GITHUB_TOKEN=$(grep GITHUB_TOKEN .envrc | cut -d\' -f2) -# or inline: -GITHUB_TOKEN=$(grep GITHUB_TOKEN .envrc | cut -d\' -f2) gh issue create ... -``` +Use the configured GitHub CLI session for this checkout. Do not require a +repo-local `.envrc` before running `gh`. ## Conventions diff --git a/docs/installer-migrations.md b/docs/installer-migrations.md index 57422a380..fd175ed41 100644 --- a/docs/installer-migrations.md +++ b/docs/installer-migrations.md @@ -221,9 +221,10 @@ citing `docs/installer-migrations.md#runtime-configuration-contract-registry`. ### preserve-user Declare that a path is user-owned and must survive surrounding directory -replacement. This action is informational in dry-run output and blocks -non-interactive apply until a later interactive baseline migration can ask for -an explicit user choice. +replacement. This action is informational in dry-run output. During apply it +becomes a copy-through/restore operation when baseline ownership is known; when +ownership is not yet established, non-interactive apply must block until an +interactive baseline migration records an explicit user choice. Use for profile, preferences, hand-authored instructions, and future workflow outputs. @@ -360,7 +361,7 @@ for the new shape before changing migration behavior. | Gemini CLI | TOML slash commands in `commands/gsd/*.toml`; agents in `agents/gsd-*.md`; `settings.json` feature flag, hooks, and statusline | Global `GEMINI_CONFIG_DIR` or `~/.gemini`; local `./.gemini` | GSD owns generated commands/agents/hooks and only GSD settings entries; local command copy may be skipped when global GSD commands already exist | [Custom commands](https://google-gemini.github.io/gemini-cli/docs/cli/custom-commands.html), [configuration](https://google-gemini.github.io/gemini-cli/docs/cli/configuration.html); docs checked 2026-05-11 | | Codex | Skills in `skills/gsd-*/SKILL.md`; agents as source markdown plus per-agent TOML in `agents/`; `[agents.gsd-*]` and hooks in `config.toml` | Global `CODEX_HOME` or `~/.codex`; local `./.codex` | GSD owns generated skills, generated agent TOML, `agents.gsd-*` config sections, `[features].codex_hooks` when added by GSD, and GSD hook entries | [Codex config schema](https://developers.openai.com/codex/config-schema.json), [Codex developer docs](https://developers.openai.com/codex/); docs not versioned, checked 2026-05-11; installer compatibility sentinel: Codex 0.124.0 agent table shape | | GitHub Copilot | Skills in `skills/gsd-*/SKILL.md`; agents as `.agent.md`; repository instructions in `copilot-instructions.md` | Global `COPILOT_CONFIG_DIR` or `~/.copilot`; local `./.github` | GSD owns generated skill/agent files and GSD-authored instruction files; no hook/statusline ownership | [Repository custom instructions](https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions), [Copilot CLI custom instructions](https://docs.github.com/en/copilot/how-tos/copilot-cli/add-custom-instructions); GitHub Docs product docs, checked 2026-05-11 | -| Antigravity | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; Gemini-style `settings.json` hooks when installed by GSD | Global `ANTIGRAVITY_CONFIG_DIR` or `~/.gemini/antigravity`; local `./.agent` | GSD owns generated skills/agents/hooks and GSD settings entries only | Checked 2026-05-11 against available Antigravity install/config material; this row records GSD's Gemini-compatible settings contract as the compatibility baseline for installer migrations. | +| Antigravity | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; Gemini-style `settings.json` hooks when installed by GSD | Global `ANTIGRAVITY_CONFIG_DIR` or `~/.gemini/antigravity`; local `./.agent` | GSD owns generated skills/agents/hooks and GSD settings entries only | Public Antigravity install/config docs for this file layout were not stable or complete as of 2026-05-11; installer compatibility therefore uses GSD's Gemini-compatible settings policy, documented shim baseline. | | Cursor | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; rule references under `rules/` | Global `CURSOR_CONFIG_DIR` or `~/.cursor`; local `./.cursor` | GSD owns generated skills/agents and GSD rule files or references; no hook/statusline ownership | [Cursor rules](https://docs.cursor.com/context/rules); docs not versioned, checked 2026-05-11 | | Windsurf | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; rule references under `rules/` | Global `WINDSURF_CONFIG_DIR` or `~/.codeium/windsurf`; local `./.windsurf` | GSD owns generated skills/agents and GSD rule files or references; no hook/statusline ownership | Windsurf public rule docs were source-limited in search results as of 2026-05-11; installer targets the common workspace rules convention `./.windsurf/rules` and must be rechecked before migrations rewrite rules | | Augment Code | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/` | Global `AUGMENT_CONFIG_DIR` or `~/.augment`; local `./.augment` | GSD owns generated skills/agents only; no hook/statusline ownership | [Augment Agent Skills](https://docs.augmentcode.com/cli/skills), [Augment IDE skills](https://docs.augmentcode.com/using-augment/skills); IDE skills public beta in VS Code 0.789.0+, checked 2026-05-11 | diff --git a/get-shit-done/bin/lib/shell-command-projection.cjs b/get-shit-done/bin/lib/shell-command-projection.cjs index 94c516123..9696a7dc4 100644 --- a/get-shit-done/bin/lib/shell-command-projection.cjs +++ b/get-shit-done/bin/lib/shell-command-projection.cjs @@ -45,12 +45,95 @@ function formatManagedHookScriptToken(scriptPath, opts = {}) { return JSON.stringify(scriptPath.replace(/\\/g, '/')); } +function projectLocalHookPrefix({ runtime = 'claude', dirName }) { + if (!dirName) return dirName; + return (runtime === 'gemini' || runtime === 'antigravity') + ? dirName + : `"$CLAUDE_PROJECT_DIR"/${dirName}`; +} + +function projectPortableHookBaseDir({ configDir, homeDir }) { + const normalizedConfigDir = String(configDir || '').replace(/\\/g, '/'); + const normalizedHome = String(homeDir || '').replace(/\\/g, '/'); + if (!normalizedConfigDir || !normalizedHome) return normalizedConfigDir; + return normalizedConfigDir.startsWith(normalizedHome) + ? '$HOME' + normalizedConfigDir.slice(normalizedHome.length) + : normalizedConfigDir; +} + +function projectShellCommandText({ + runnerToken, + argTokens = [], + runtime = 'generic', + platform = process.platform, +}) { + if (!runnerToken) return null; + const parts = [runnerToken, ...argTokens.filter(Boolean)]; + return formatHookCommandForRuntime(parts.join(' '), { platform, runtime }); +} + function projectManagedHookCommand({ absoluteRunner, scriptPath, runtime = 'generic', platform = process.platform }) { if (!absoluteRunner || !scriptPath) return null; const normalizedScriptPath = platform === 'win32' ? scriptPath.replace(/\\/g, '/') : scriptPath; - return formatHookCommandForRuntime(`${absoluteRunner} ${JSON.stringify(normalizedScriptPath)}`, { - platform, + return projectShellCommandText({ + runnerToken: absoluteRunner, + argTokens: [JSON.stringify(normalizedScriptPath)], runtime, + platform, + }); +} + +const MANAGED_HOOK_BASENAMES_BY_SURFACE = { + 'settings-json': new Set([ + 'gsd-check-update.js', + 'gsd-statusline.js', + 'gsd-context-monitor.js', + 'gsd-prompt-guard.js', + 'gsd-read-guard.js', + 'gsd-read-injection-scanner.js', + 'gsd-update-banner.js', + 'gsd-workflow-guard.js', + ]), + 'codex-toml': new Set([ + 'gsd-check-update.js', + ]), +}; + +function managedHookSurfaceSet(surface = 'settings-json') { + return MANAGED_HOOK_BASENAMES_BY_SURFACE[surface] || MANAGED_HOOK_BASENAMES_BY_SURFACE['settings-json']; +} + +function isManagedHookBasename(scriptPathOrBasename, opts = {}) { + if (!scriptPathOrBasename) return false; + const surface = opts.surface || 'settings-json'; + const basename = String(scriptPathOrBasename).split(/[\\/]/).pop() || ''; + return managedHookSurfaceSet(surface).has(basename); +} + +/** + * Projection helper for legacy settings.json hook rewrites. + * + * Non-Windows keeps the original script token shape when provided (single + * quote / bareword / quoted), while Windows normalizes to double-quoted + * forward-slash path tokens for stable cross-shell behavior. + */ +function projectLegacySettingsHookCommand({ + absoluteRunner, + scriptPath, + scriptToken, + runtime = 'generic', + platform = process.platform, +}) { + if (!absoluteRunner || !scriptPath) return null; + const normalizedScriptPath = platform === 'win32' ? scriptPath.replace(/\\/g, '/') : scriptPath; + const commandScriptToken = platform === 'win32' + ? JSON.stringify(normalizedScriptPath) + : (scriptToken || JSON.stringify(normalizedScriptPath)); + return projectShellCommandText({ + runnerToken: absoluteRunner, + argTokens: [commandScriptToken], + runtime, + platform, }); } @@ -68,11 +151,72 @@ function projectCodexHookTomlCommand({ absoluteRunner, scriptPath, platform = pr return command === null ? null : escapeTomlDoubleQuotedString(command); } +function buildWindowsShimTriple(shimSrc) { + const path = require('path'); + const shimAbs = path.resolve(shimSrc); + const shimQuoted = JSON.stringify(shimAbs); + const invocation = { + interpreter: 'node', + target: shimAbs, + }; + const renderCmd = () => + '@ECHO OFF\r\n@SETLOCAL\r\n@node ' + shimQuoted + ' %*\r\n'; + const renderPs1 = () => + '#!/usr/bin/env pwsh\n& node ' + shimQuoted + ' $args\nexit $LASTEXITCODE\n'; + const renderSh = () => + '#!/usr/bin/env sh\nexec node ' + shimQuoted + ' "$@"\n'; + return { + invocation, + eol: { cmd: '\r\n', ps1: '\n', sh: '\n' }, + fileNames: { cmd: 'gsd-sdk.cmd', ps1: 'gsd-sdk.ps1', sh: 'gsd-sdk' }, + render: { cmd: renderCmd, ps1: renderPs1, sh: renderSh }, + }; +} + +function formatSdkPathDiagnostic({ shimDir, platform, runDir }) { + const isWin32 = platform === 'win32'; + const isNpx = typeof runDir === 'string' && + (runDir.includes('/_npx/') || runDir.includes('\\_npx\\')); + const shimLocationLine = shimDir ? `Shim written to: ${shimDir}` : ''; + const actionLines = []; + if (shimDir) { + const psShimDir = shimDir.replace(/'/g, "''"); + const bashShimDir = shimDir.replace(/\\/g, '/').replace(/'/g, "'\\''"); + const posixShimDir = shimDir.replace(/[\\$"`]/g, '\\$&'); + actionLines.push('Add that directory to your PATH and restart your shell.'); + if (isWin32) { + actionLines.push(`PowerShell: [Environment]::SetEnvironmentVariable('PATH', '${psShimDir};' + [Environment]::GetEnvironmentVariable('PATH', 'User'), 'User')`); + actionLines.push(`cmd.exe : powershell -Command "[Environment]::SetEnvironmentVariable('PATH', '${psShimDir};' + [Environment]::GetEnvironmentVariable('PATH', 'User'), 'User')"`); + actionLines.push(`Git Bash : echo 'export PATH="${bashShimDir}:$PATH"' >> ~/.bashrc`); + } else { + actionLines.push(`export PATH="${posixShimDir}:$PATH"`); + } + } else { + actionLines.push('Could not locate a writable PATH directory to install the shim.'); + actionLines.push('Install globally to materialize the bin symlink:'); + actionLines.push('npm install -g get-shit-done-cc'); + } + const npxNoteLines = isNpx + ? [ + "Note: you're running via npx. For a persistent shim,", + 'install globally instead: npm install -g get-shit-done-cc', + ] + : []; + return { shimLocationLine, actionLines, npxNoteLines, isNpx, isWin32 }; +} + module.exports = { hookCommandNeedsPowerShellCallOperator, formatHookCommandForRuntime, formatManagedHookScriptToken, + projectLocalHookPrefix, + projectPortableHookBaseDir, + projectShellCommandText, projectManagedHookCommand, + isManagedHookBasename, + projectLegacySettingsHookCommand, escapeTomlDoubleQuotedString, projectCodexHookTomlCommand, + buildWindowsShimTriple, + formatSdkPathDiagnostic, }; diff --git a/tests/bug-1906-hook-relative-paths.test.cjs b/tests/bug-1906-hook-relative-paths.test.cjs index 6216c97fe..0749c3eaa 100644 --- a/tests/bug-1906-hook-relative-paths.test.cjs +++ b/tests/bug-1906-hook-relative-paths.test.cjs @@ -17,68 +17,33 @@ const { describe, test, before } = require('node:test'); const assert = require('node:assert/strict'); -const fs = require('fs'); const path = require('path'); -const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); - -// All hooks that the installer registers for local installs -const HOOKS = [ - 'gsd-statusline.js', - 'gsd-check-update.js', - 'gsd-context-monitor.js', - 'gsd-prompt-guard.js', - 'gsd-read-guard.js', - 'gsd-workflow-guard.js', - 'gsd-validate-commit.sh', - 'gsd-session-state.sh', - 'gsd-phase-boundary.sh', -]; +const projection = require(path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'shell-command-projection.cjs')); +const { projectLocalHookPrefix, projectShellCommandText } = projection; describe('bug #1906: local hook commands use $CLAUDE_PROJECT_DIR', () => { - let src; - before(() => { - src = fs.readFileSync(INSTALL_SRC, 'utf-8'); + assert.equal(typeof projectLocalHookPrefix, 'function'); + assert.equal(typeof projectShellCommandText, 'function'); }); - test('localPrefix definition includes $CLAUDE_PROJECT_DIR for non-Gemini runtimes', () => { - // localPrefix is now a ternary — Gemini/Antigravity use bare dirName (#2557), - // all other runtimes use "$CLAUDE_PROJECT_DIR"/ to anchor hook paths. - assert.match(src, /const localPrefix\s*=[\s\S]*?"\$CLAUDE_PROJECT_DIR"/, - 'localPrefix definition must include "$CLAUDE_PROJECT_DIR" branch for non-Gemini runtimes'); + test('non-Gemini runtimes get $CLAUDE_PROJECT_DIR anchored local prefix', () => { + const prefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }); + assert.equal(prefix, '"$CLAUDE_PROJECT_DIR"/.claude'); }); - for (const hook of HOOKS) { - test(`${hook} local command uses localPrefix (not bare dirName)`, () => { - // Find all local command strings for this hook - // The pattern is: ` ' + localPrefix + '/hooks/'` - // or the old broken pattern: ` ' + dirName + '/hooks/'` - const hookEscaped = hook.replace(/\./g, '\\.'); - const brokenPattern = new RegExp( - `['"](?:node|bash)\\s['"]\\s*\\+\\s*dirName\\s*\\+\\s*['"]/hooks/${hookEscaped}['"]` - ); - assert.ok( - !brokenPattern.test(src), - `${hook} must not use bare dirName — should use localPrefix for cwd-independent resolution` - ); + test('local command projection for non-Gemini keeps $CLAUDE_PROJECT_DIR anchor', () => { + const prefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }); + const command = projectShellCommandText({ + runnerToken: '"/usr/local/bin/node"', + argTokens: [`${prefix}/hooks/gsd-context-monitor.js`], + runtime: 'claude', + platform: 'linux', }); - } - - test('no local hook command uses bare dirName + /hooks/', () => { - // Broader check: no local (non-global) hook path should use dirName directly - // The pattern `': ' ' + dirName + '/hooks/'` is the broken form - const lines = src.split('\n'); - const offenders = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - // Match lines that build local hook commands with bare dirName - if (/['"](?:node|bash)\s['"][^;]*\+\s*dirName\s*\+\s*['"]\/hooks\//.test(line)) { - offenders.push(`line ${i + 1}: ${line.trim()}`); - } - } - assert.equal(offenders.length, 0, - 'Found local hook commands using bare dirName instead of localPrefix:\n' + - offenders.join('\n')); + assert.equal( + command, + '"/usr/local/bin/node" "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js', + ); }); }); diff --git a/tests/bug-2557-gemini-local-hook-paths.test.cjs b/tests/bug-2557-gemini-local-hook-paths.test.cjs index e1a20c70a..b6351ebf3 100644 --- a/tests/bug-2557-gemini-local-hook-paths.test.cjs +++ b/tests/bug-2557-gemini-local-hook-paths.test.cjs @@ -13,62 +13,40 @@ * dir as cwd. Claude Code and others still use "$CLAUDE_PROJECT_DIR"/ (#1906). */ -const { describe, test, before } = require('node:test'); +const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); -const fs = require('node:fs'); const path = require('node:path'); -const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); +const projection = require(path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'shell-command-projection.cjs')); +const { projectLocalHookPrefix, projectShellCommandText } = projection; describe('bug #2557: Gemini/Antigravity local hooks use relative paths (not $CLAUDE_PROJECT_DIR)', () => { - let src; - - before(() => { - src = fs.readFileSync(INSTALL_SRC, 'utf-8'); + test('Gemini local prefix is bare dirName', () => { + assert.equal(projectLocalHookPrefix({ runtime: 'gemini', dirName: '.gemini' }), '.gemini'); }); - test('localPrefix is a ternary that branches on Gemini/Antigravity', () => { - assert.match( - src, - /const localPrefix\s*=\s*\(runtime\s*===\s*['"]gemini['"]/, - 'localPrefix must branch on runtime === "gemini"', + test('Antigravity local prefix is bare dirName', () => { + assert.equal(projectLocalHookPrefix({ runtime: 'antigravity', dirName: '.agent' }), '.agent'); + }); + + test('non-Gemini local prefix remains $CLAUDE_PROJECT_DIR anchored', () => { + assert.equal( + projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }), + '"$CLAUDE_PROJECT_DIR"/.claude', ); }); - test('Gemini/Antigravity branch of localPrefix uses bare dirName (relative path)', () => { - // The ternary must assign `dirName` (not `"$CLAUDE_PROJECT_DIR"/` + dirName) - // for the Gemini branch so hooks use a relative path on all platforms. - assert.match( - src, - /const localPrefix\s*=\s*\(runtime\s*===\s*['"]gemini['"]\s*\|\|\s*runtime\s*===\s*['"]antigravity['"]\)\s*\n?\s*\?\s*dirName/, - 'Gemini/Antigravity branch must resolve to bare dirName', - ); - }); - - test('non-Gemini branch of localPrefix uses "$CLAUDE_PROJECT_DIR"/', () => { - // The else branch must still use "$CLAUDE_PROJECT_DIR"/ to fix #1906 for - // Claude Code and other runtimes that do set the variable. - assert.match( - src, - /:\s*['"]\"\$CLAUDE_PROJECT_DIR\"\//, - 'non-Gemini branch must use "$CLAUDE_PROJECT_DIR"/ prefix', - ); - }); - - test('Gemini/Antigravity hook commands do not contain "$CLAUDE_PROJECT_DIR" literal', () => { - // Since localPrefix is now dirName for Gemini/Antigravity, no command - // string built via `localPrefix` should contain the variable literal. - // We verify by checking that the only occurrence of $CLAUDE_PROJECT_DIR - // in the localPrefix definition is in the non-Gemini (else) branch. - const lines = src.split('\n'); - const prefixDefIdx = lines.findIndex(l => /const localPrefix\s*=/.test(l)); - assert.ok(prefixDefIdx >= 0, 'localPrefix definition not found'); - // The Gemini (truthy) branch is the line right after the ternary condition. - // It must NOT contain $CLAUDE_PROJECT_DIR. - const geminiLine = lines[prefixDefIdx + 1] || ''; + test('Gemini local command projection does not contain "$CLAUDE_PROJECT_DIR"', () => { + const prefix = projectLocalHookPrefix({ runtime: 'gemini', dirName: '.gemini' }); + const command = projectShellCommandText({ + runnerToken: '"/usr/local/bin/node"', + argTokens: [`${prefix}/hooks/gsd-check-update.js`], + runtime: 'gemini', + platform: 'linux', + }); assert.ok( - !geminiLine.includes('$CLAUDE_PROJECT_DIR'), - 'Gemini branch of localPrefix must not reference $CLAUDE_PROJECT_DIR', + !command.includes('$CLAUDE_PROJECT_DIR'), + 'Gemini local command must not include $CLAUDE_PROJECT_DIR', ); }); }); diff --git a/tests/bug-3413-shell-command-projection.test.cjs b/tests/bug-3413-shell-command-projection.test.cjs index ed2e68c36..c0eb1d554 100644 --- a/tests/bug-3413-shell-command-projection.test.cjs +++ b/tests/bug-3413-shell-command-projection.test.cjs @@ -9,7 +9,14 @@ const path = require('node:path'); const projection = require(path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'shell-command-projection.cjs')); const install = require(path.join(__dirname, '..', 'bin', 'install.js')); -const { hookCommandNeedsPowerShellCallOperator, formatHookCommandForRuntime } = projection; +const { + hookCommandNeedsPowerShellCallOperator, + formatHookCommandForRuntime, + isManagedHookBasename, + projectLocalHookPrefix, + projectLegacySettingsHookCommand, + projectPortableHookBaseDir, +} = projection; const { buildHookCommand, rewriteLegacyManagedNodeHookCommands } = install; describe('bug #3413: Shell Command Projection Module uses runtime-aware hook policy', () => { @@ -71,3 +78,67 @@ describe('bug #3413: installer hook surfaces consume runtime-aware projection', ); }); }); + +describe('bug #3439: shell projection module owns managed-hook policy and legacy rewrite projection', () => { + test('isManagedHookBasename is surface-aware', () => { + assert.equal(isManagedHookBasename('/x/hooks/gsd-check-update.js', { surface: 'settings-json' }), true); + assert.equal(isManagedHookBasename('/x/hooks/gsd-statusline.js', { surface: 'settings-json' }), true); + assert.equal(isManagedHookBasename('/x/hooks/gsd-statusline.js', { surface: 'codex-toml' }), false); + assert.equal(isManagedHookBasename('/x/hooks/custom-hook.js', { surface: 'settings-json' }), false); + }); + + test('projectLegacySettingsHookCommand preserves non-Windows script token shape', () => { + const command = projectLegacySettingsHookCommand({ + absoluteRunner: '"/usr/local/bin/node"', + scriptPath: '/x/hooks/gsd-statusline.js', + scriptToken: "'/x/hooks/gsd-statusline.js'", + platform: 'linux', + runtime: 'claude', + }); + assert.equal(command, `"/usr/local/bin/node" '/x/hooks/gsd-statusline.js'`); + }); + + test('projectLegacySettingsHookCommand normalizes Windows managed paths and runtime wrapper policy', () => { + const command = projectLegacySettingsHookCommand({ + absoluteRunner: '"C:/nvm4w/nodejs/node.exe"', + scriptPath: 'C:\\Users\\me\\.gemini\\hooks\\gsd-prompt-guard.js', + scriptToken: "'C:\\Users\\me\\.gemini\\hooks\\gsd-prompt-guard.js'", + platform: 'win32', + runtime: 'gemini', + }); + assert.equal(command, '& "C:/nvm4w/nodejs/node.exe" "C:/Users/me/.gemini/hooks/gsd-prompt-guard.js"'); + }); + + test('projectLocalHookPrefix centralizes runtime-specific project-dir interpolation policy', () => { + assert.equal(projectLocalHookPrefix({ runtime: 'gemini', dirName: '.gemini' }), '.gemini'); + assert.equal(projectLocalHookPrefix({ runtime: 'antigravity', dirName: '.agent' }), '.agent'); + assert.equal( + projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }), + '"$CLAUDE_PROJECT_DIR"/.claude', + ); + }); + + test('projectPortableHookBaseDir centralizes $HOME interpolation policy', () => { + assert.equal( + projectPortableHookBaseDir({ + configDir: '/Users/me/.claude', + homeDir: '/Users/me', + }), + '$HOME/.claude', + ); + assert.equal( + projectPortableHookBaseDir({ + configDir: 'C:\\Users\\me\\.claude', + homeDir: 'C:\\Users\\me', + }), + '$HOME/.claude', + ); + assert.equal( + projectPortableHookBaseDir({ + configDir: '/opt/custom/.claude', + homeDir: '/Users/me', + }), + '/opt/custom/.claude', + ); + }); +});