From 46a083fc5e057e82d2fd74624fa66925b808b1da Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Fri, 13 Mar 2026 10:50:43 -0600 Subject: [PATCH 1/2] feat: add interactive wizard mode for /gsd prefs (#130) Co-Authored-By: Claude Opus 4.6 --- src/resources/extensions/gsd/commands.ts | 194 ++++++++++++++++++++++- 1 file changed, 192 insertions(+), 2 deletions(-) diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index ddb7b09f9..961107334 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -74,7 +74,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void { if (parts[0] === "prefs" && parts.length <= 2) { const subPrefix = parts[1] ?? ""; - return ["global", "project", "status"] + return ["global", "project", "status", "wizard", "setup"] .filter((cmd) => cmd.startsWith(subPrefix)) .map((cmd) => ({ value: `prefs ${cmd}`, label: cmd })); } @@ -219,6 +219,13 @@ async function handlePrefs(args: string, ctx: ExtensionCommandContext): Promise< return; } + if (trimmed === "wizard" || trimmed === "setup" || trimmed === "wizard global" || trimmed === "setup global" + || trimmed === "wizard project" || trimmed === "setup project") { + const scope = trimmed.includes("project") ? "project" : "global"; + await handlePrefsWizard(ctx, scope); + return; + } + if (trimmed === "status") { const globalPrefs = loadGlobalGSDPreferences(); const projectPrefs = loadProjectGSDPreferences(); @@ -249,7 +256,7 @@ async function handlePrefs(args: string, ctx: ExtensionCommandContext): Promise< return; } - ctx.ui.notify("Usage: /gsd prefs [global|project|status]", "info"); + ctx.ui.notify("Usage: /gsd prefs [global|project|status|wizard|setup]", "info"); } async function handleDoctor(args: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise { @@ -290,6 +297,189 @@ async function handleDoctor(args: string, ctx: ExtensionCommandContext, pi: Exte } } +// ─── Preferences Wizard ─────────────────────────────────────────────────────── + +async function handlePrefsWizard( + ctx: ExtensionCommandContext, + scope: "global" | "project", +): Promise { + const path = scope === "project" ? getProjectGSDPreferencesPath() : getGlobalGSDPreferencesPath(); + const existing = scope === "project" ? loadProjectGSDPreferences() : loadGlobalGSDPreferences(); + const prefs: Record = existing?.preferences ? { ...existing.preferences } : {}; + + ctx.ui.notify(`GSD preferences wizard (${scope}) — press Escape at any prompt to skip it.`, "info"); + + // ─── Models ────────────────────────────────────────────────────────────── + const modelPhases = ["research", "planning", "execution", "completion"] as const; + const models: Record = (prefs.models as Record) ?? {}; + + for (const phase of modelPhases) { + const current = models[phase] ?? ""; + const input = await ctx.ui.input( + `Model for ${phase} phase${current ? ` (current: ${current})` : ""}:`, + current || "e.g. claude-sonnet-4-20250514", + ); + if (input !== null && input !== undefined) { + const val = input.trim(); + if (val) { + models[phase] = val; + } else if (current) { + // User cleared it — remove + delete models[phase]; + } + } + // null/undefined = Escape/skip — keep existing value + } + if (Object.keys(models).length > 0) { + prefs.models = models; + } + + // ─── Auto-supervisor timeouts ──────────────────────────────────────────── + const autoSup: Record = (prefs.auto_supervisor as Record) ?? {}; + const timeoutFields = [ + { key: "soft_timeout_minutes", label: "Soft timeout (minutes)", defaultVal: "20" }, + { key: "idle_timeout_minutes", label: "Idle timeout (minutes)", defaultVal: "10" }, + { key: "hard_timeout_minutes", label: "Hard timeout (minutes)", defaultVal: "30" }, + ] as const; + + for (const field of timeoutFields) { + const current = autoSup[field.key]; + const currentStr = current !== undefined && current !== null ? String(current) : ""; + const input = await ctx.ui.input( + `${field.label}${currentStr ? ` (current: ${currentStr})` : ` (default: ${field.defaultVal})`}:`, + currentStr || field.defaultVal, + ); + if (input !== null && input !== undefined) { + const val = input.trim(); + if (val && /^\d+$/.test(val)) { + autoSup[field.key] = Number(val); + } else if (!val && currentStr) { + delete autoSup[field.key]; + } + } + } + if (Object.keys(autoSup).length > 0) { + prefs.auto_supervisor = autoSup; + } + + // ─── Git main branch ──────────────────────────────────────────────────── + const git: Record = (prefs.git as Record) ?? {}; + const currentBranch = git.main_branch ? String(git.main_branch) : ""; + const branchInput = await ctx.ui.input( + `Git main branch${currentBranch ? ` (current: ${currentBranch})` : ""}:`, + currentBranch || "main", + ); + if (branchInput !== null && branchInput !== undefined) { + const val = branchInput.trim(); + if (val) { + git.main_branch = val; + } else if (currentBranch) { + delete git.main_branch; + } + } + if (Object.keys(git).length > 0) { + prefs.git = git; + } + + // ─── Skill discovery mode ─────────────────────────────────────────────── + const currentDiscovery = (prefs.skill_discovery as string) ?? ""; + const discoveryChoice = await ctx.ui.select( + `Skill discovery mode${currentDiscovery ? ` (current: ${currentDiscovery})` : ""}:`, + ["auto", "suggest", "off", "(keep current)"], + ); + if (discoveryChoice && discoveryChoice !== "(keep current)") { + prefs.skill_discovery = discoveryChoice; + } + + // ─── Serialize to frontmatter ─────────────────────────────────────────── + prefs.version = 1; + const frontmatter = serializePreferencesToFrontmatter(prefs); + const content = `---\n${frontmatter}---\n\n# GSD Skill Preferences\n\nSee \`~/.gsd/agent/extensions/gsd/docs/preferences-reference.md\` for full field documentation and examples.\n`; + + await saveFile(path, content); + await ctx.waitForIdle(); + await ctx.reload(); + ctx.ui.notify(`Saved ${scope} preferences to ${path}`, "success"); +} + +function serializePreferencesToFrontmatter(prefs: Record): string { + const lines: string[] = []; + + function serializeValue(key: string, value: unknown, indent: number): void { + const prefix = " ".repeat(indent); + if (value === null || value === undefined) return; + + if (Array.isArray(value)) { + if (value.length === 0) { + lines.push(`${prefix}${key}: []`); + return; + } + lines.push(`${prefix}${key}:`); + for (const item of value) { + if (typeof item === "object" && item !== null) { + const entries = Object.entries(item as Record); + if (entries.length > 0) { + const [firstKey, firstVal] = entries[0]; + lines.push(`${prefix} - ${firstKey}: ${firstVal}`); + for (let i = 1; i < entries.length; i++) { + const [k, v] = entries[i]; + if (Array.isArray(v)) { + lines.push(`${prefix} ${k}:`); + for (const arrItem of v) { + lines.push(`${prefix} - ${arrItem}`); + } + } else { + lines.push(`${prefix} ${k}: ${v}`); + } + } + } + } else { + lines.push(`${prefix} - ${item}`); + } + } + return; + } + + if (typeof value === "object") { + const entries = Object.entries(value as Record); + if (entries.length === 0) { + lines.push(`${prefix}${key}: {}`); + return; + } + lines.push(`${prefix}${key}:`); + for (const [k, v] of entries) { + serializeValue(k, v, indent + 1); + } + return; + } + + lines.push(`${prefix}${key}: ${value}`); + } + + // Ordered keys for consistent output + const orderedKeys = [ + "version", "always_use_skills", "prefer_skills", "avoid_skills", + "skill_rules", "custom_instructions", "models", "skill_discovery", + "auto_supervisor", "uat_dispatch", "budget_ceiling", "remote_questions", "git", + ]; + + const seen = new Set(); + for (const key of orderedKeys) { + if (key in prefs) { + serializeValue(key, prefs[key], 0); + seen.add(key); + } + } + // Any remaining keys not in the ordered list + for (const [key, value] of Object.entries(prefs)) { + if (!seen.has(key)) { + serializeValue(key, value, 0); + } + } + + return lines.join("\n") + "\n"; +} + async function ensurePreferencesFile( path: string, ctx: ExtensionCommandContext, From c622e1d3a6654c1d74380640f50d98e5ab5ed358 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Fri, 13 Mar 2026 10:59:27 -0600 Subject: [PATCH 2/2] fix: address audit findings for prefs wizard (#130) - YAML-safe string quoting for values with special characters - Preserve existing body content below frontmatter on wizard save - Preserve existing version instead of hardcoding version: 1 - Update unknown-command fallback to include wizard|setup subcommands - Show warning when timeout input is not a valid whole number Co-Authored-By: Claude Opus 4.6 --- src/resources/extensions/gsd/commands.ts | 41 +++++++++++++++++++----- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 961107334..5e122f729 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -168,7 +168,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void { } ctx.ui.notify( - `Unknown: /gsd ${trimmed}. Use /gsd, /gsd next, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status], /gsd doctor [audit|fix|heal] [M###/S##], /gsd migrate , or /gsd remote [slack|discord|status|disconnect].`, + `Unknown: /gsd ${trimmed}. Use /gsd, /gsd next, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status|wizard|setup], /gsd doctor [audit|fix|heal] [M###/S##], /gsd migrate , or /gsd remote [slack|discord|status|disconnect].`, "warning", ); }, @@ -353,6 +353,8 @@ async function handlePrefsWizard( const val = input.trim(); if (val && /^\d+$/.test(val)) { autoSup[field.key] = Number(val); + } else if (val && !/^\d+$/.test(val)) { + ctx.ui.notify(`Invalid value "${val}" for ${field.label} — must be a whole number. Keeping previous value.`, "warning"); } else if (!val && currentStr) { delete autoSup[field.key]; } @@ -392,9 +394,23 @@ async function handlePrefsWizard( } // ─── Serialize to frontmatter ─────────────────────────────────────────── - prefs.version = 1; + prefs.version = prefs.version || 1; const frontmatter = serializePreferencesToFrontmatter(prefs); - const content = `---\n${frontmatter}---\n\n# GSD Skill Preferences\n\nSee \`~/.gsd/agent/extensions/gsd/docs/preferences-reference.md\` for full field documentation and examples.\n`; + + // Preserve existing body content (everything after closing ---) + let body = "\n# GSD Skill Preferences\n\nSee `~/.gsd/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation and examples.\n"; + if (existsSync(path)) { + const existingContent = readFileSync(path, "utf-8"); + const closingIdx = existingContent.indexOf("\n---", existingContent.indexOf("---")); + if (closingIdx !== -1) { + const afterFrontmatter = existingContent.slice(closingIdx + 4); // skip past "\n---" + if (afterFrontmatter.trim()) { + body = afterFrontmatter; + } + } + } + + const content = `---\n${frontmatter}---${body}`; await saveFile(path, content); await ctx.waitForIdle(); @@ -402,6 +418,15 @@ async function handlePrefsWizard( ctx.ui.notify(`Saved ${scope} preferences to ${path}`, "success"); } +/** Wrap a YAML value in double quotes if it contains special characters. */ +function yamlSafeString(val: unknown): string { + if (typeof val !== "string") return String(val); + if (/[:#{\[\]'"`,|>&*!?@%]/.test(val) || val.trim() !== val || val === "") { + return `"${val.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; + } + return val; +} + function serializePreferencesToFrontmatter(prefs: Record): string { const lines: string[] = []; @@ -420,21 +445,21 @@ function serializePreferencesToFrontmatter(prefs: Record): stri const entries = Object.entries(item as Record); if (entries.length > 0) { const [firstKey, firstVal] = entries[0]; - lines.push(`${prefix} - ${firstKey}: ${firstVal}`); + lines.push(`${prefix} - ${firstKey}: ${yamlSafeString(firstVal)}`); for (let i = 1; i < entries.length; i++) { const [k, v] = entries[i]; if (Array.isArray(v)) { lines.push(`${prefix} ${k}:`); for (const arrItem of v) { - lines.push(`${prefix} - ${arrItem}`); + lines.push(`${prefix} - ${yamlSafeString(arrItem)}`); } } else { - lines.push(`${prefix} ${k}: ${v}`); + lines.push(`${prefix} ${k}: ${yamlSafeString(v)}`); } } } } else { - lines.push(`${prefix} - ${item}`); + lines.push(`${prefix} - ${yamlSafeString(item)}`); } } return; @@ -453,7 +478,7 @@ function serializePreferencesToFrontmatter(prefs: Record): stri return; } - lines.push(`${prefix}${key}: ${value}`); + lines.push(`${prefix}${key}: ${yamlSafeString(value)}`); } // Ordered keys for consistent output