feat: deepen shell command projection seam (#3445)

* feat: deepen shell command projection seam

* chore: add changeset for shell projection seam

* feat: centralize local and portable hook path projection

* docs: avoid prompt-scan false positive in installer migrations

* docs(adr): fix path-style token punctuation
This commit is contained in:
Tom Boucher
2026-05-12 19:02:18 -04:00
committed by GitHub
parent f8b917d52a
commit 7e91a861ee
12 changed files with 350 additions and 246 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -243,6 +243,21 @@ After stripping prose @-refs, some command `<process>` 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 #<issue>`
`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`

View File

@@ -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) {

View File

@@ -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

View File

@@ -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/<project>/...`). 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.

View File

@@ -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

View File

@@ -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 |

View File

@@ -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,
};

View File

@@ -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: `<runner> ' + localPrefix + '/hooks/<hook>'`
// or the old broken pattern: `<runner> ' + dirName + '/hooks/<hook>'`
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 `': '<runner> ' + 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',
);
});
});

View File

@@ -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',
);
});
});

View File

@@ -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',
);
});
});