feat(config): session-internal /gsd config + fix key hydration

Three fixes for config/setup UX:

1. cli.ts: Add missing loadStoredEnvKeys() call in gsd config flow.
   Previously, gsd config showed keys as "not configured" even when
   they existed in auth.json because env vars weren't hydrated first.

2. commands.ts: New /gsd config slash command that lets users configure
   API keys (Tavily, Brave, Context7, Jina, Groq) from within a running
   session. Keys are saved to auth.json and activated immediately.
   No need to exit the session and run gsd config externally.

3. command-search-provider.ts: Show native Anthropic web search status
   when using Claude models, so users know search works even without
   Brave/Tavily keys.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
deseltrus 2026-03-15 08:24:41 +01:00
parent 3bfa444809
commit d154d992dd
3 changed files with 82 additions and 5 deletions

View file

@ -113,6 +113,7 @@ const isPrintMode = cliFlags.print || cliFlags.mode !== undefined
// `gsd config` — replay the setup wizard and exit
if (cliFlags.messages[0] === 'config') {
const authStorage = AuthStorage.create(authFilePath)
loadStoredEnvKeys(authStorage)
await runOnboarding(authStorage)
process.exit(0)
}

View file

@ -5,7 +5,8 @@
*/
import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent";
import { existsSync, readFileSync } from "node:fs";
import { AuthStorage } from "@gsd/pi-coding-agent";
import { existsSync, readFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { deriveState } from "./state.js";
@ -53,10 +54,10 @@ function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportT
export function registerGSDCommand(pi: ExtensionAPI): void {
pi.registerCommand("gsd", {
description: "GSD — Get Shit Done: /gsd next|auto|stop|status|queue|prefs|hooks|doctor|migrate|remote",
description: "GSD — Get Shit Done: /gsd next|auto|stop|status|queue|prefs|config|hooks|doctor|migrate|remote",
getArgumentCompletions: (prefix: string) => {
const subcommands = ["next", "auto", "stop", "status", "queue", "discuss", "prefs", "hooks", "doctor", "migrate", "remote"];
const subcommands = ["next", "auto", "stop", "status", "queue", "discuss", "prefs", "config", "hooks", "doctor", "migrate", "remote"];
const parts = prefix.trim().split(/\s+/);
if (parts.length <= 1) {
@ -151,6 +152,11 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
return;
}
if (trimmed === "config") {
await handleConfig(ctx);
return;
}
if (trimmed === "hooks") {
const { formatHookStatus } = await import("./post-unit-hooks.js");
ctx.ui.notify(formatHookStatus(), "info");
@ -174,7 +180,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|wizard|setup], /gsd hooks, /gsd doctor [audit|fix|heal] [M###/S##], /gsd migrate <path>, 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, /gsd config, /gsd hooks, /gsd doctor [audit|fix|heal] [M###/S##], /gsd migrate <path>, or /gsd remote [slack|discord|status|disconnect].`,
"warning",
);
},
@ -521,6 +527,74 @@ function serializePreferencesToFrontmatter(prefs: Record<string, unknown>): stri
return lines.join("\n") + "\n";
}
// ─── Tool Config Wizard ───────────────────────────────────────────────────────
const TOOL_KEYS = [
{ id: "tavily", env: "TAVILY_API_KEY", label: "Tavily Search", hint: "tavily.com/app/api-keys" },
{ id: "brave", env: "BRAVE_API_KEY", label: "Brave Search", hint: "brave.com/search/api" },
{ id: "context7", env: "CONTEXT7_API_KEY", label: "Context7 Docs", hint: "context7.com/dashboard" },
{ id: "jina", env: "JINA_API_KEY", label: "Jina Page Extract", hint: "jina.ai/api" },
{ id: "groq", env: "GROQ_API_KEY", label: "Groq Voice", hint: "console.groq.com" },
] as const;
function getConfigAuthStorage(): InstanceType<typeof AuthStorage> {
const authPath = join(process.env.HOME ?? "", ".gsd", "agent", "auth.json");
mkdirSync(dirname(authPath), { recursive: true });
return AuthStorage.create(authPath);
}
async function handleConfig(ctx: ExtensionCommandContext): Promise<void> {
const auth = getConfigAuthStorage();
// Show current status
const statusLines = ["GSD Tool Configuration\n"];
for (const tool of TOOL_KEYS) {
const hasKey = !!process.env[tool.env] || !!(auth.get(tool.id) as { key?: string })?.key;
statusLines.push(` ${hasKey ? "✓" : "✗"} ${tool.label}${hasKey ? "" : ` — get key at ${tool.hint}`}`);
}
ctx.ui.notify(statusLines.join("\n"), "info");
// Ask which tools to configure
const options = TOOL_KEYS.map(t => {
const hasKey = !!process.env[t.env] || !!(auth.get(t.id) as { key?: string })?.key;
return `${t.label} ${hasKey ? "(configured ✓)" : "(not set)"}`;
});
options.push("(done)");
let changed = false;
while (true) {
const choice = await ctx.ui.select("Configure which tool? Press Escape when done.", options);
if (!choice || choice === "(done)") break;
const toolIdx = TOOL_KEYS.findIndex(t => choice.startsWith(t.label));
if (toolIdx === -1) break;
const tool = TOOL_KEYS[toolIdx];
const input = await ctx.ui.input(
`API key for ${tool.label} (${tool.hint}):`,
"paste your key here",
);
if (input !== null && input !== undefined) {
const key = input.trim();
if (key) {
auth.set(tool.id, { type: "api_key", key });
process.env[tool.env] = key;
ctx.ui.notify(`${tool.label} key saved and activated.`, "info");
// Update option label
options[toolIdx] = `${tool.label} (configured ✓)`;
changed = true;
}
}
}
if (changed) {
await ctx.waitForIdle();
await ctx.reload();
ctx.ui.notify("Configuration saved. Extensions reloaded with new keys.", "info");
}
}
async function ensurePreferencesFile(
path: string,
ctx: ExtensionCommandContext,

View file

@ -90,8 +90,10 @@ export function registerSearchProviderCommand(pi: ExtensionAPI): void {
setSearchProviderPreference(chosen)
const effective = resolveSearchProvider()
const isAnthropic = ctx.model?.provider === 'anthropic'
const nativeNote = isAnthropic ? '\nNote: Native Anthropic web search is also active (automatic, no API key needed).' : ''
ctx.ui.notify(
`Search provider set to ${chosen}. Effective provider: ${effective ?? 'none (no API keys)'}`,
`Search provider set to ${chosen}. Effective provider: ${effective ?? 'none (no API keys)'}${nativeNote}`,
'info',
)
},