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:
6
.changeset/steady-shell-projection.md
Normal file
6
.changeset/steady-shell-projection.md
Normal 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)
|
||||
18
CLAUDE.md
18
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
|
||||
|
||||
|
||||
15
CONTEXT.md
15
CONTEXT.md
@@ -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`
|
||||
|
||||
174
bin/install.js
174
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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user