feat: add /gsd update slash command for in-session self-update (#964)

The update banner already referenced `/gsd:update` but the command
didn't exist. This adds `/gsd update` as a proper subcommand that
checks the npm registry and runs `npm install -g gsd-pi@latest`
when a newer version is available.

- Register `update` in subcommand completions and help text
- Add `handleUpdate()` that reuses `compareSemver` from update-check
- Fix banner text from `/gsd:update` to `/gsd update` (space, not colon)
- Add tests for completion registration and help description
This commit is contained in:
Jeremy McSpadden 2026-03-17 17:13:02 -05:00 committed by GitHub
parent 99c3375f18
commit add957ed31
3 changed files with 121 additions and 2 deletions

View file

@ -77,7 +77,7 @@ function projectRoot(): string {
export function registerGSDCommand(pi: ExtensionAPI): void {
pi.registerCommand("gsd", {
description: "GSD — Get Shit Done: /gsd help|next|auto|stop|pause|status|visualize|queue|quick|capture|triage|dispatch|history|undo|skip|export|cleanup|mode|prefs|config|hooks|run-hook|skill-health|doctor|forensics|migrate|remote|steer|knowledge|new-milestone|parallel",
description: "GSD — Get Shit Done: /gsd help|next|auto|stop|pause|status|visualize|queue|quick|capture|triage|dispatch|history|undo|skip|export|cleanup|mode|prefs|config|hooks|run-hook|skill-health|doctor|forensics|migrate|remote|steer|knowledge|new-milestone|parallel|update",
getArgumentCompletions: (prefix: string) => {
const subcommands = [
{ cmd: "help", desc: "Categorized command reference with descriptions" },
@ -113,6 +113,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
{ cmd: "knowledge", desc: "Add persistent project knowledge (rule, pattern, or lesson)" },
{ cmd: "new-milestone", desc: "Create a milestone from a specification document (headless)" },
{ cmd: "parallel", desc: "Parallel milestone orchestration (start, status, stop, merge)" },
{ cmd: "update", desc: "Update GSD to the latest version" },
];
const parts = prefix.trim().split(/\s+/);
@ -575,6 +576,11 @@ Examples:
return;
}
if (trimmed === "update") {
await handleUpdate(ctx);
return;
}
if (trimmed === "") {
// Bare /gsd defaults to step mode
await startAuto(ctx, pi, projectRoot(), false, { step: true });
@ -630,6 +636,7 @@ function showHelp(ctx: ExtensionCommandContext): void {
" /gsd migrate Upgrade .gsd/ structures to new format",
" /gsd remote Control remote auto-mode [slack|discord|status|disconnect]",
" /gsd inspect Show SQLite DB diagnostics (schema, row counts, recent entries)",
" /gsd update Update GSD to the latest version via npm",
];
ctx.ui.notify(lines.join("\n"), "info");
}
@ -2091,3 +2098,48 @@ Examples:
ctx.ui.notify("Failed to dispatch hook. Auto-mode may have been cancelled.", "error");
}
}
// ─── Self-update handler ────────────────────────────────────────────────────
async function handleUpdate(ctx: ExtensionCommandContext): Promise<void> {
const { execSync } = await import("node:child_process");
const { compareSemver } = await import("../../../update-check.js");
const NPM_PACKAGE = "gsd-pi";
const current = process.env.GSD_VERSION || "0.0.0";
ctx.ui.notify(`Current version: v${current}\nChecking npm registry...`, "info");
let latest: string;
try {
latest = execSync(`npm view ${NPM_PACKAGE} version`, {
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
ctx.ui.notify("Failed to reach npm registry. Check your network connection.", "error");
return;
}
if (compareSemver(latest, current) <= 0) {
ctx.ui.notify(`Already up to date (v${current}).`, "info");
return;
}
ctx.ui.notify(`Updating: v${current} → v${latest}...`, "info");
try {
execSync(`npm install -g ${NPM_PACKAGE}@latest`, {
stdio: ["ignore", "pipe", "ignore"],
});
ctx.ui.notify(
`Updated to v${latest}. Restart your GSD session to use the new version.`,
"info",
);
} catch {
ctx.ui.notify(
`Update failed. Try manually: npm install -g ${NPM_PACKAGE}@latest`,
"error",
);
}
}

View file

@ -0,0 +1,67 @@
import test from "node:test";
import assert from "node:assert/strict";
import { registerGSDCommand } from "../commands.ts";
function createMockPi() {
const commands = new Map<string, any>();
return {
registerCommand(name: string, options: any) {
commands.set(name, options);
},
registerTool() {},
registerShortcut() {},
on() {},
sendMessage() {},
commands,
};
}
function createMockCtx() {
const notifications: { message: string; level: string }[] = [];
return {
notifications,
ui: {
notify(message: string, level: string) {
notifications.push({ message, level });
},
custom: async () => {},
},
shutdown: async () => {},
};
}
test("/gsd update appears in subcommand completions", () => {
const pi = createMockPi();
registerGSDCommand(pi as any);
const gsd = pi.commands.get("gsd");
assert.ok(gsd, "registerGSDCommand should register /gsd");
const completions = gsd.getArgumentCompletions("update");
const updateEntry = completions.find((c: any) => c.value === "update");
assert.ok(updateEntry, "update should appear in completions");
assert.equal(updateEntry.label, "update");
});
test("/gsd update appears in help description", () => {
const pi = createMockPi();
registerGSDCommand(pi as any);
const gsd = pi.commands.get("gsd");
assert.ok(gsd?.description?.includes("update"), "description should mention update");
});
test("/gsd update is listed in completions with correct description", () => {
const pi = createMockPi();
registerGSDCommand(pi as any);
const gsd = pi.commands.get("gsd");
const completions = gsd.getArgumentCompletions("");
const updateEntry = completions.find((c: any) => c.value === "update");
assert.ok(updateEntry, "update should appear in full completion list");
assert.ok(
updateEntry.description.toLowerCase().includes("update"),
"completion description should mention updating",
);
});

View file

@ -50,7 +50,7 @@ export function writeUpdateCache(cache: UpdateCheckCache, cachePath: string = CA
function printUpdateBanner(current: string, latest: string): void {
process.stderr.write(
` ${chalk.yellow('Update available:')} ${chalk.dim(`v${current}`)}${chalk.bold(`v${latest}`)}\n` +
` ${chalk.dim('Run')} npm update -g gsd-pi ${chalk.dim('or')} /gsd:update ${chalk.dim('to upgrade')}\n\n`,
` ${chalk.dim('Run')} npm update -g gsd-pi ${chalk.dim('or')} /gsd update ${chalk.dim('to upgrade')}\n\n`,
)
}