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

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