feat(shell-projection): centralize managed hook command policy (#3450)

* feat(shell-projection): route persistent PATH hints through projection seam

* refactor(shell-projection): unify managed hook command policy

* fix(install): guard malformed settings hooks during uninstall

* chore(changeset): add fragment for pr 3450

* fix(install): guard malformed settings hook entries
This commit is contained in:
Tom Boucher
2026-05-12 20:15:43 -04:00
committed by GitHub
parent 16f5009270
commit bf3c736029
10 changed files with 341 additions and 42 deletions

View File

@@ -9,11 +9,13 @@ const {
buildWindowsShimTriple: buildWindowsShimTripleFromProjection,
formatSdkPathDiagnostic: formatSdkPathDiagnosticFromProjection,
isManagedHookBasename,
isManagedHookCommand,
projectLocalHookPrefix,
projectLegacySettingsHookCommand,
projectManagedHookCommand,
projectPathActionProjection,
projectPortableHookBaseDir,
projectPersistentPathExportActions,
projectShellCommandText,
projectCodexHookTomlCommand,
} = require('../get-shit-done/bin/lib/shell-command-projection.cjs');
@@ -3276,14 +3278,6 @@ function stripLeakedGsdCodexSections(content) {
* Pure function, exported for test coverage. Returns the input unchanged
* if no GSD-managed hook section is present.
*/
// Legacy hook command basenames to detect during strip. Template-literal
// form so install-hooks-copy.test.cjs's quoted-literal guard continues to
// catch accidental regressions where someone *registers* the inverted
// `gsd-update-check.js` filename in a Codex hook command.
const STALE_HOOK_BASENAMES = new Set([
`gsd-update-check.js`,
`gsd-check-update.js`,
]);
function stripStaleGsdHookBlocks(configContent) {
const sections = getTomlTableSections(configContent);
const lineRecords = getTomlLineRecords(configContent);
@@ -3319,10 +3313,10 @@ function stripStaleGsdHookBlocks(configContent) {
continue;
}
if (typeof parsed.value !== 'string') continue;
// Match the basename — Codex configs reference these files by absolute
// path under the user's `.codex/hooks/` directory.
const basename = parsed.value.split(/[\\/]/).pop() || '';
if (STALE_HOOK_BASENAMES.has(basename)) {
if (isManagedHookCommand(parsed.value, {
surface: 'codex-toml',
includeLegacyAliases: true,
})) {
return true;
}
}
@@ -6718,22 +6712,19 @@ function uninstall(isGlobal, runtime = 'claude') {
// Remove GSD hooks from settings — per-hook granularity to preserve
// user hooks that share an entry with a GSD hook (#1755 followup)
const isGsdHookCommand = (cmd) =>
cmd && (cmd.includes('gsd-check-update') || cmd.includes('gsd-statusline') ||
cmd.includes('gsd-session-state') || cmd.includes('gsd-context-monitor') ||
cmd.includes('gsd-phase-boundary') || cmd.includes('gsd-prompt-guard') ||
cmd.includes('gsd-read-guard') || cmd.includes('gsd-read-injection-scanner') ||
cmd.includes('gsd-update-banner') ||
cmd.includes('gsd-validate-commit') || cmd.includes('gsd-workflow-guard'));
for (const eventName of ['SessionStart', 'PostToolUse', 'AfterTool', 'PreToolUse', 'BeforeTool']) {
if (settings.hooks && settings.hooks[eventName]) {
const before = JSON.stringify(settings.hooks[eventName]);
settings.hooks[eventName] = settings.hooks[eventName]
.map(entry => {
if (!entry.hooks || !Array.isArray(entry.hooks)) return entry;
if (!entry || typeof entry !== 'object' || !Array.isArray(entry.hooks)) return entry;
// Filter out individual GSD hooks, keep user hooks
entry.hooks = entry.hooks.filter(h => !isGsdHookCommand(h.command));
entry.hooks = entry.hooks.filter((h) => {
if (!h || typeof h.command !== 'string') return true;
return !isManagedHookCommand(h.command, {
surface: 'settings-json',
});
});
return entry.hooks.length > 0 ? entry : null;
})
.filter(Boolean);
@@ -9757,7 +9748,8 @@ function homePathCoveredByRc(globalBin, homeDir, rcFileNames) {
* - a diagnostic "already covered via rc file" note, if an rc file has
* `export PATH="$HOME/…/bin:$PATH"` (or equivalent) and the user just
* needs to reopen their shell
* - the absolute `echo 'export PATH="…:$PATH"' >> ~/.zshrc` suggestion,
* - projected shell actions that append `export PATH="…:$PATH"` to
* `~/.zshrc` / `~/.bashrc` when neither PATH nor rc files cover globalBin
* if neither PATH nor any rc file covers globalBin
*
* Exported for tests; the installer calls this from finishInstall.
@@ -9786,14 +9778,13 @@ function maybeSuggestPathExport(globalBin, homeDir) {
console.log('');
console.log(` ${yellow}⚠${reset} ${bold}${globalBin}${reset} is not on your PATH.`);
console.log(` Add it with one of:`);
const projected = projectPathActionProjection({
mode: 'persist',
const projected = projectPersistentPathExportActions({
targetDir: globalBin,
platform: process.platform,
});
for (const action of projected.shellActions) {
const label = action.label ? `${action.label}: ` : '';
console.log(` ${cyan}${label}${action.command}${reset}`);
const labelPrefix = action.label ? `${action.label}: ` : '';
console.log(` ${cyan}${labelPrefix}${action.command}${reset}`);
}
console.log('');
}