fix(#976): detect command+args (wrapped) hook registrations in installer presence checks (#994)

* fix(#976): detect command+args (wrapped) hook registrations in installer presence checks

Add referencesHook() helper that inspects both h.command (standard form) and
h.args[] (args-form / wrapped-launcher form) when checking whether a managed
hook is already registered.  Rewrite all has*Hook predicates and the
alreadyHas* guards to use it so args-form registrations suppress the duplicate
stock string-command entry that was previously appended on every install/update.

Also add an explicit args-form skip to rewriteLegacyManagedNodeHookCommands so
entries with a non-empty args[] are left untouched (they are intentional user
wrappers, not legacy bare-node commands to migrate).

Extend isManagedHookCommand() in shell-command-projection.cts with an optional
args: unknown[] parameter that checks whether any arg's basename matches the
managed hook surface set — backward compatible; existing callers are unaffected.

Regression test added to tests/install-regressions.test.cjs:
- two-pass install with an args-form SessionStart entry pre-written to
  settings.local.json asserts exactly 1 hook entry remains after reinstall
  (previously 2 — the original args-form + a new stock string-command duplicate)
- rewriteLegacyManagedNodeHookCommands test asserts args-form entries unchanged

Closes #976

* chore(#976): backfill changeset pr number (994)

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Tom Boucher
2026-06-10 10:44:24 -04:00
committed by GitHub
parent 2981983bae
commit 898d55788e
4 changed files with 214 additions and 20 deletions

View File

@@ -725,6 +725,10 @@ function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
if (!entry || !Array.isArray(entry.hooks)) continue;
for (const h of entry.hooks) {
if (!h || typeof h.command !== 'string') continue;
// args-form entries have the script path in h.args[] and h.command is
// the launcher executable (not a managed hook command). These are
// intentional user wrappers — do not rewrite them. (#976)
if (Array.isArray(h.args) && h.args.length > 0) continue;
let trimmed = h.command.trim();
const hadPowerShellCallOperator = platform === 'win32' && /^&\s+/.test(trimmed);
if (hadPowerShellCallOperator) {
@@ -11845,6 +11849,18 @@ function install(isGlobal, runtime = 'claude', options = {}) {
}
}
// Helper: detect whether a hook entry references a managed hook by name.
// Checks both the plain command string (standard form) and the args array
// (command+args / wrapped-launcher form used by windowless launchers on
// Windows and some custom PATH-less environments). Without this check the
// presence guards below only inspect h.command, so an args-form wrapper is
// invisible and a stock string-command entry is appended on every
// install/update, running the hook twice. (#976)
function referencesHook(h, hookName) {
return (typeof h.command === 'string' && h.command.includes(hookName)) ||
(Array.isArray(h.args) && h.args.some(a => typeof a === 'string' && a.includes(hookName)));
}
// Configure SessionStart hook for update checking (skip for opencode)
if (!isOpencode && !isKilo) {
if (!settings.hooks) {
@@ -11855,7 +11871,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
}
const hasGsdUpdateHook = settings.hooks.SessionStart.some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-check-update'))
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-check-update'))
);
// Guard: only register if the hook file was actually installed (#1754).
@@ -11883,7 +11899,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
}
const hasContextMonitorHook = settings.hooks[postToolEvent].some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor'))
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-context-monitor'))
);
const contextMonitorFile = path.join(targetDir, 'hooks', 'gsd-context-monitor.js');
@@ -11904,14 +11920,14 @@ function install(isGlobal, runtime = 'claude', options = {}) {
} else {
// Migrate existing context monitor hooks: add matcher and timeout if missing
for (const entry of settings.hooks[postToolEvent]) {
if (entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor'))) {
if (entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-context-monitor'))) {
let migrated = false;
if (!entry.matcher) {
entry.matcher = 'Bash|Edit|Write|MultiEdit|Agent|Task';
migrated = true;
}
for (const h of entry.hooks) {
if (h.command && h.command.includes('gsd-context-monitor') && !h.timeout) {
if (referencesHook(h, 'gsd-context-monitor') && !h.timeout) {
h.timeout = 10;
migrated = true;
}
@@ -11931,7 +11947,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
}
const hasPromptGuardHook = settings.hooks[preToolEvent].some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-prompt-guard'))
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-prompt-guard'))
);
const promptGuardFile = path.join(targetDir, 'hooks', 'gsd-prompt-guard.js');
@@ -11955,7 +11971,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
// Prevents infinite retry loops when non-Claude models attempt to edit
// files without reading them first. Advisory-only — does not block.
const hasReadGuardHook = settings.hooks[preToolEvent].some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-read-guard'))
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-read-guard'))
);
const readGuardFile = path.join(targetDir, 'hooks', 'gsd-read-guard.js');
@@ -11979,7 +11995,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
// Scans content returned by the Read tool for injection patterns, including
// summarisation-specific patterns that survive context compression.
const hasReadInjectionScannerHook = settings.hooks[postToolEvent].some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-read-injection-scanner'))
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-read-injection-scanner'))
);
const readInjectionScannerFile = path.join(targetDir, 'hooks', 'gsd-read-injection-scanner.js');
@@ -12013,7 +12029,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
: localCmd('gsd-workflow-guard.js');
const workflowGuardMatcher = 'Bash|Edit|Write|MultiEdit';
const workflowGuardHookEntry = settings.hooks[preToolEvent].find(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-workflow-guard'))
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-workflow-guard'))
);
const hasWorkflowGuardHook = Boolean(workflowGuardHookEntry);
@@ -12045,7 +12061,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
? buildHookCommand(targetDir, 'gsd-worktree-path-guard.js', hookOpts)
: localCmd('gsd-worktree-path-guard.js');
const hasWorktreePathGuardHook = settings.hooks[preToolEvent].some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-worktree-path-guard'))
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-worktree-path-guard'))
);
const worktreePathGuardFile = path.join(targetDir, 'hooks', 'gsd-worktree-path-guard.js');
if (!hasWorktreePathGuardHook && fs.existsSync(worktreePathGuardFile) && worktreePathGuardCommand) {
@@ -12069,7 +12085,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
? buildHookCommand(targetDir, 'gsd-validate-commit.sh', hookOpts)
: 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'))
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-validate-commit'))
);
// Guard: only register if the .sh file was actually installed. If the npm package
// omitted the file (as happened in v1.32.0, bug #1817), registering a missing hook
@@ -12101,7 +12117,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
? buildHookCommand(targetDir, 'gsd-graphify-update.sh', hookOpts)
: localShellCmd('gsd-graphify-update.sh');
const hasGraphifyUpdateHook = settings.hooks[postToolEvent].some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-graphify-update'))
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-graphify-update'))
);
const graphifyUpdateFile = path.join(targetDir, 'hooks', 'gsd-graphify-update.sh');
if (!hasGraphifyUpdateHook && fs.existsSync(graphifyUpdateFile) && graphifyUpdateCommand) {
@@ -12127,7 +12143,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
? buildHookCommand(targetDir, 'gsd-session-state.sh', hookOpts)
: 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'))
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-session-state'))
);
const sessionStateFile = path.join(targetDir, 'hooks', 'gsd-session-state.sh');
if (!hasSessionStateHook && fs.existsSync(sessionStateFile) && sessionStateCommand) {
@@ -12151,7 +12167,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
? buildHookCommand(targetDir, 'gsd-phase-boundary.sh', hookOpts)
: 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'))
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-phase-boundary'))
);
const phaseBoundaryFile = path.join(targetDir, 'hooks', 'gsd-phase-boundary.sh');
if (!hasPhaseBoundaryHook && fs.existsSync(phaseBoundaryFile) && phaseBoundaryCommand) {
@@ -12195,7 +12211,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
settings.hooks[event] = [];
}
const alreadyHasContextMonitor = settings.hooks[event].some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor'))
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-context-monitor'))
);
if (!alreadyHasContextMonitor && fs.existsSync(contextMonitorFile) && contextMonitorCommand) {
settings.hooks[event].push({
@@ -12244,7 +12260,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
settings.hooks[geminiEvent] = [];
}
const alreadyHasContextMonitor = settings.hooks[geminiEvent].some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor'))
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-context-monitor'))
);
if (!alreadyHasContextMonitor && fs.existsSync(contextMonitorFile) && contextMonitorCommand) {
settings.hooks[geminiEvent].push({
@@ -12281,7 +12297,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
}
const configReloadFile = path.join(targetDir, 'hooks', 'gsd-config-reload.js');
const alreadyHasConfigReload = settings.hooks.FileChanged.some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-config-reload'))
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-config-reload'))
);
if (!alreadyHasConfigReload && fs.existsSync(configReloadFile) && configReloadCommand) {
settings.hooks.FileChanged.push({
@@ -12452,7 +12468,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
if (!settings.hooks) settings.hooks = {};
if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
const alreadyRegistered = settings.hooks.SessionStart.some(entry =>
entry && entry.hooks && entry.hooks.some(h => h && h.command && h.command.includes('gsd-update-banner'))
entry && entry.hooks && entry.hooks.some(h => h && referencesHook(h, 'gsd-update-banner'))
);
const bannerHookFile = configDir ? path.join(configDir, 'hooks', 'gsd-update-banner.js') : null;
const bannerInstalled = bannerHookFile ? fs.existsSync(bannerHookFile) : false;