* feat(hooks): opt-in SessionStart update banner for non-statusline users (#2795) When a user declines (or keeps a non-GSD) statusline at install time, the installer now offers an opt-in SessionStart banner that surfaces GSD update availability. The banner reads the existing ~/.cache/gsd/gsd-update-check.json cache (written by gsd-check-update-worker.js) and emits a single systemMessage line only when update_available is true: GSD update available: <installed> → <latest>. Run /gsd-update. It is silent when up-to-date and rate-limits "check failed" diagnostics to once per 24h via a sentinel file so a corrupt cache doesn't nag every session. Removed cleanly by `npx get-shit-done-cc --uninstall` which strips both the script and the SessionStart entry. The banner is never offered when GSD's statusline is being installed (statusline already surfaces update info, so re-prompting would be noise). Implementation: - hooks/gsd-update-banner.js — pure functions buildBannerOutput, shouldSuppressFailureWarning, readCache; thin main() wires them. - bin/install.js — handleUpdateBanner() prompt, parseUpdateBannerInput(), buildUpdateBannerHookEntry(), buildUpdateBannerPromptText(); chained into installAllRuntimes() so finalize() receives both flags. updateBannerCommand computed alongside the other JS-hook commands; finishInstall() registers the SessionStart entry only when shouldInstallBanner === true and the hook file is present at the target. - Hook ships in scripts/build-hooks.js HOOKS_TO_COPY, listed in MANAGED_HOOKS for stale-detection in gsd-check-update-worker.js, in the uninstall hook-removal lists in install.js, and in the rewriteLegacyManagedNodeHookCommands allowlist. Tests: - tests/feat-2795-update-banner.test.cjs — 22 tests, structural-IR assertions on parsed JSON envelopes (no raw-text matching). Covers pure-function branches (cache present/absent, parseError, rate-limit suppression, missing version fields), end-to-end hook invocation against fixture cache states, and install.js wiring (prompt text, input parsing, hook entry shape). - tests/trae-install.test.cjs — updated install() return-shape assertion to include updateBannerCommand: null for the no-settings runtime. - 6881/6881 tests pass. Docs (bundled in same commit per the bundle-docs-with-code skill): - docs/USER-GUIDE.md — new "Surface GSD Update Notifications Without GSD's Statusline" task section with opt-in/opt-out instructions. - docs/FEATURES.md — REQ-HOOK-08 added; "Update Banner" subsection under the Hook System feature with cache flow + removal path. - docs/INVENTORY.md — hook count 11 → 12, new row for gsd-update-banner.js. - docs/INVENTORY-MANIFEST.json — regenerated. Closes #2795 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(install): gate banner prompt on actual installability (CR #3035) CodeRabbit findings on PR #3035: - bin/install.js (Major): continueAfterStatusline gated banner prompt on the raw `shouldInstallStatusline` flag from handleStatusline. But finishInstall later silently skips the statusline write on local installs unless --force-statusline is set (#2248). Two consequences: 1. Interactive local Claude/Gemini installs got neither a statusline nor a banner offer. 2. Codex/Cursor/Copilot/Windsurf/Trae/Cline-only installs (where every result.updateBannerCommand is null) still got prompted even though the choice was silently ignored. Fix: derive willInstallStatusline = shouldInstallStatusline && (isGlobal || forceStatusline), and gate the banner prompt on a canInstallBanner precondition computed from results[].updateBannerCommand. Pass the raw shouldInstallStatusline through to finalize unchanged so per-runtime statusline gating in finishInstall is unaffected. - tests/feat-2795-update-banner.test.cjs (Minor): rate-limit suppression test parsed r1.stdout without first asserting r1.status === 0. Other e2e tests in this file (lines 210, 241) do this. A non-zero exit would surface as a cryptic SyntaxError instead of a status assertion failure. Fix applied verbatim. 6881/6881 tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
202
bin/install.js
202
bin/install.js
@@ -569,6 +569,7 @@ function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner) {
|
||||
'gsd-prompt-guard.js',
|
||||
'gsd-read-guard.js',
|
||||
'gsd-read-injection-scanner.js',
|
||||
'gsd-update-banner.js',
|
||||
'gsd-workflow-guard.js',
|
||||
]);
|
||||
let changed = false;
|
||||
@@ -6474,7 +6475,7 @@ function uninstall(isGlobal, runtime = 'claude') {
|
||||
// 4. Remove GSD hooks
|
||||
const hooksDir = path.join(targetDir, 'hooks');
|
||||
if (fs.existsSync(hooksDir)) {
|
||||
const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-context-monitor.js', 'gsd-prompt-guard.js', 'gsd-read-guard.js', 'gsd-read-injection-scanner.js', 'gsd-workflow-guard.js', 'gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh'];
|
||||
const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.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', 'gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh'];
|
||||
let hookCount = 0;
|
||||
for (const hook of gsdHooks) {
|
||||
const hookPath = path.join(hooksDir, hook);
|
||||
@@ -6530,6 +6531,7 @@ function uninstall(isGlobal, runtime = 'claude') {
|
||||
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']) {
|
||||
@@ -8116,7 +8118,7 @@ function install(isGlobal, runtime = 'claude') {
|
||||
throw wrapped;
|
||||
}
|
||||
|
||||
return { settingsPath: null, settings: null, statuslineCommand: null, runtime, configDir: targetDir };
|
||||
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
||||
}
|
||||
|
||||
if (isCopilot) {
|
||||
@@ -8129,22 +8131,22 @@ function install(isGlobal, runtime = 'claude') {
|
||||
console.log(` ${green}✓${reset} Generated copilot-instructions.md`);
|
||||
}
|
||||
// Copilot: no settings.json, no hooks, no statusline (like Codex)
|
||||
return { settingsPath: null, settings: null, statuslineCommand: null, runtime, configDir: targetDir };
|
||||
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
||||
}
|
||||
|
||||
if (isCursor) {
|
||||
// Cursor uses skills — no config.toml, no settings.json hooks needed
|
||||
return { settingsPath: null, settings: null, statuslineCommand: null, runtime, configDir: targetDir };
|
||||
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
||||
}
|
||||
|
||||
if (isWindsurf) {
|
||||
// Windsurf uses skills — no config.toml, no settings.json hooks needed
|
||||
return { settingsPath: null, settings: null, statuslineCommand: null, runtime, configDir: targetDir };
|
||||
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
||||
}
|
||||
|
||||
if (isTrae) {
|
||||
// Trae uses skills — no settings.json hooks needed
|
||||
return { settingsPath: null, settings: null, statuslineCommand: null, runtime, configDir: targetDir };
|
||||
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
||||
}
|
||||
|
||||
if (isCline) {
|
||||
@@ -8164,7 +8166,7 @@ function install(isGlobal, runtime = 'claude') {
|
||||
].join('\n') + '\n';
|
||||
fs.writeFileSync(clinerulesDest, clinerules);
|
||||
console.log(` ${green}✓${reset} Wrote .clinerules`);
|
||||
return { settingsPath: null, settings: null, statuslineCommand: null, runtime, configDir: targetDir };
|
||||
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
||||
}
|
||||
|
||||
// Configure statusline and hooks in settings.json
|
||||
@@ -8509,13 +8511,30 @@ function install(isGlobal, runtime = 'claude') {
|
||||
}
|
||||
}
|
||||
|
||||
return { settingsPath, settings, statuslineCommand, runtime, configDir: targetDir };
|
||||
// Compute the update-banner hook command alongside the others so
|
||||
// installAllRuntimes can register it at finalize time when the user opts
|
||||
// in (#2795). Computed here (not in finishInstall) so the same buildHookCommand
|
||||
// / localCmd resolution logic is shared with the other JS hooks.
|
||||
const updateBannerCommand = isOpencode || isKilo
|
||||
? null
|
||||
: (isGlobal
|
||||
? buildHookCommand(targetDir, 'gsd-update-banner.js', hookOpts)
|
||||
: localCmd('gsd-update-banner.js'));
|
||||
|
||||
return {
|
||||
settingsPath,
|
||||
settings,
|
||||
statuslineCommand,
|
||||
updateBannerCommand,
|
||||
runtime,
|
||||
configDir: targetDir,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply statusline config, then print completion message
|
||||
*/
|
||||
function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude', isGlobal = true, configDir = null) {
|
||||
function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude', isGlobal = true, configDir = null, bannerOpts = {}) {
|
||||
const isOpencode = runtime === 'opencode';
|
||||
const isKilo = runtime === 'kilo';
|
||||
const isCodex = runtime === 'codex';
|
||||
@@ -8546,6 +8565,36 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
||||
}
|
||||
}
|
||||
|
||||
// Register the opt-in update banner (#2795) when the user accepted the
|
||||
// banner offer at install time. Only applies to runtimes that own a
|
||||
// settings.json hooks block — opencode/kilo/codex/cursor/windsurf/trae/
|
||||
// cline either lack the surface or use a different config schema.
|
||||
const { shouldInstallBanner, bannerCommand } = bannerOpts;
|
||||
if (shouldInstallBanner && settings && !isOpencode && !isKilo && !isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline) {
|
||||
if (!bannerCommand) {
|
||||
console.warn(` ${yellow}⚠${reset} Skipped update banner registration — Node executable path unavailable. See #2979 / #3002.`);
|
||||
} else {
|
||||
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'))
|
||||
);
|
||||
const bannerHookFile = configDir ? path.join(configDir, 'hooks', 'gsd-update-banner.js') : null;
|
||||
const bannerInstalled = bannerHookFile ? fs.existsSync(bannerHookFile) : false;
|
||||
if (alreadyRegistered) {
|
||||
// Idempotent re-install: don't double-register.
|
||||
} else if (!bannerInstalled) {
|
||||
console.warn(` ${yellow}⚠${reset} Skipped update banner — gsd-update-banner.js not found at target`);
|
||||
} else {
|
||||
const entry = buildUpdateBannerHookEntry(bannerCommand);
|
||||
if (entry) {
|
||||
settings.hooks.SessionStart.push(entry);
|
||||
console.log(` ${green}✓${reset} Configured update banner hook (opt-in)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write settings when runtime supports settings.json.
|
||||
// #3002 CR: defense-in-depth — re-run validateHookFields right before
|
||||
// serialization. The push-site guards above already skip null-command
|
||||
@@ -8797,6 +8846,90 @@ function promptRuntime(callback) {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Update banner (#2795) ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the prompt text shown when offering the opt-in update banner.
|
||||
* Pure function — no I/O. Exported for tests so they can assert against the
|
||||
* rendered prompt structurally instead of grepping bin/install.js source.
|
||||
*/
|
||||
function buildUpdateBannerPromptText() {
|
||||
return `
|
||||
${yellow}Optional: GSD update banner${reset}
|
||||
Without GSD's statusline, update notifications won't be visible. You can
|
||||
install a SessionStart banner that surfaces a one-line message when a new
|
||||
GSD release is available. The banner appears only at session start and
|
||||
only when an update exists.
|
||||
|
||||
${cyan}1${reset}) ${dim}No banner (default)${reset}
|
||||
${cyan}2${reset}) Install update banner
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse user input from the banner prompt. Returns true when the user opted
|
||||
* in. Pure function — exported for direct unit testing.
|
||||
*
|
||||
* - Empty input or "1" → false (default: no banner).
|
||||
* - "2" → true.
|
||||
* - "y" / "yes" (case-insensitive) → true. Affirmative shortcuts.
|
||||
*/
|
||||
function parseUpdateBannerInput(answer) {
|
||||
const input = (answer == null ? '' : String(answer)).trim().toLowerCase();
|
||||
if (input === '2' || input === 'y' || input === 'yes') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a SessionStart hook entry (settings.json shape) that runs the
|
||||
* update-banner script. Returns null when the input command is empty so
|
||||
* callers can warn-and-skip rather than writing { command: null } and
|
||||
* tripping the runtime's hook schema (#3002).
|
||||
*
|
||||
* @param {string|null} bannerCommand - Result of buildHookCommand() / localCmd().
|
||||
* @returns {{hooks: Array<{type: 'command', command: string}>}|null}
|
||||
*/
|
||||
function buildUpdateBannerHookEntry(bannerCommand) {
|
||||
if (!bannerCommand) return null;
|
||||
return {
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: bannerCommand,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive prompt that asks the user whether to install the opt-in
|
||||
* update banner. Used by `installAllRuntimes` only when GSD's statusline
|
||||
* was declined or skipped.
|
||||
*
|
||||
* @param {boolean} isInteractive
|
||||
* @param {(shouldInstallBanner: boolean) => void} callback
|
||||
*/
|
||||
function handleUpdateBanner(isInteractive, callback) {
|
||||
if (!isInteractive) {
|
||||
// Never auto-install in non-interactive mode — user can re-run install
|
||||
// interactively or hand-edit settings.json to opt in later.
|
||||
callback(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
console.log(buildUpdateBannerPromptText());
|
||||
|
||||
rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
|
||||
rl.close();
|
||||
callback(parseUpdateBannerInput(answer));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt for install location
|
||||
*/
|
||||
@@ -9629,7 +9762,7 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
|
||||
const statuslineRuntimes = ['claude', 'gemini'];
|
||||
const primaryStatuslineResult = results.find(r => statuslineRuntimes.includes(r.runtime));
|
||||
|
||||
const finalize = (shouldInstallStatusline) => {
|
||||
const finalize = (shouldInstallStatusline, shouldInstallBanner) => {
|
||||
// Verify sdk/dist/cli.js is present and executable. The dist is shipped
|
||||
// prebuilt in the tarball (fix/2441-sdk-decouple); gsd-sdk reaches users via
|
||||
// the parent package's bin/gsd-sdk.js shim, so no sub-install is needed.
|
||||
@@ -9646,7 +9779,8 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
|
||||
useStatusline,
|
||||
result.runtime,
|
||||
isGlobal,
|
||||
result.configDir
|
||||
result.configDir,
|
||||
{ shouldInstallBanner: !!shouldInstallBanner, bannerCommand: result.updateBannerCommand }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -9654,10 +9788,49 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
|
||||
printSummaries();
|
||||
};
|
||||
|
||||
// Statusline first; if it won't actually be installed (declined, or local
|
||||
// install without --force-statusline silently skips it per #2248), offer
|
||||
// the opt-in update banner (#2795) as the secondary surface for update
|
||||
// notifications. Skip the banner prompt entirely when no runtime in this
|
||||
// install set can host the banner (e.g. Codex/Copilot/Cursor/Windsurf/
|
||||
// Trae/Cline-only installs whose updateBannerCommand is null).
|
||||
//
|
||||
// CR #3035: gate on actual installability — `shouldInstallStatusline`
|
||||
// returned by handleStatusline is the raw user choice, but
|
||||
// `finishInstall` later skips the statusline write on local installs
|
||||
// unless --force-statusline is set. Passing the raw flag to
|
||||
// continueAfterStatusline previously caused two bugs: (1) interactive
|
||||
// local installs got neither a statusline nor a banner offer, and (2)
|
||||
// banner-incapable runtimes got prompted even though every
|
||||
// updateBannerCommand was null.
|
||||
const canInstallBanner = results.some((r) => r && r.updateBannerCommand);
|
||||
const continueAfterStatusline = (shouldInstallStatusline) => {
|
||||
const willInstallStatusline =
|
||||
shouldInstallStatusline && (isGlobal || forceStatusline);
|
||||
if (willInstallStatusline) {
|
||||
finalize(true, false);
|
||||
return;
|
||||
}
|
||||
if (!canInstallBanner) {
|
||||
finalize(shouldInstallStatusline, false);
|
||||
return;
|
||||
}
|
||||
handleUpdateBanner(isInteractive, (shouldInstallBanner) => {
|
||||
finalize(shouldInstallStatusline, shouldInstallBanner);
|
||||
});
|
||||
};
|
||||
|
||||
if (primaryStatuslineResult) {
|
||||
handleStatusline(primaryStatuslineResult.settings, isInteractive, finalize);
|
||||
handleStatusline(primaryStatuslineResult.settings, isInteractive, continueAfterStatusline);
|
||||
} else if (canInstallBanner) {
|
||||
// No statusline-capable runtime, but at least one runtime can host the
|
||||
// banner — still offer it.
|
||||
handleUpdateBanner(isInteractive, (shouldInstallBanner) => {
|
||||
finalize(false, shouldInstallBanner);
|
||||
});
|
||||
} else {
|
||||
finalize(false);
|
||||
// Nothing to prompt about — no statusline, no banner-capable runtime.
|
||||
finalize(false, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9760,6 +9933,9 @@ if (process.env.GSD_TEST_MODE) {
|
||||
allRuntimes,
|
||||
parseRuntimeInput,
|
||||
buildRuntimePromptText,
|
||||
buildUpdateBannerPromptText,
|
||||
parseUpdateBannerInput,
|
||||
buildUpdateBannerHookEntry,
|
||||
buildHookCommand,
|
||||
resolveNodeRunner,
|
||||
rewriteLegacyManagedNodeHookCommands,
|
||||
|
||||
Reference in New Issue
Block a user