From e93a44d967cade149258c4d3ff38e9164bd09ebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Thu, 12 Mar 2026 10:02:00 -0600 Subject: [PATCH] feat: add clack-based onboarding wizard and `gsd config` command (#118) Replace the plain-text API-key-only wizard with a branded, clack-based onboarding experience that guides first-launch users through LLM provider authentication (OAuth or API key), optional tool API keys, and a summary. - Create src/logo.ts as single source of truth for ASCII logo - Create src/onboarding.ts with shouldRunOnboarding() and runOnboarding() - Trim src/wizard.ts to env hydration only (loadStoredEnvKeys) - Wire onboarding into src/cli.ts, add `gsd config` subcommand - Remove duplicate first-launch banner from src/loader.ts Co-authored-by: Claude Opus 4.6 --- src/cli.ts | 18 +- src/loader.ts | 30 +-- src/logo.ts | 27 +++ src/onboarding.ts | 490 ++++++++++++++++++++++++++++++++++++++++++++++ src/wizard.ts | 195 ------------------ 5 files changed, 534 insertions(+), 226 deletions(-) create mode 100644 src/logo.ts create mode 100644 src/onboarding.ts diff --git a/src/cli.ts b/src/cli.ts index 813f954ec..ebf76b221 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,7 +14,8 @@ import { join } from 'node:path' import { agentDir, sessionsDir, authFilePath } from './app-paths.js' import { initResources } from './resource-loader.js' import { ensureManagedTools } from './tool-bootstrap.js' -import { loadStoredEnvKeys, runWizardIfNeeded } from './wizard.js' +import { loadStoredEnvKeys } from './wizard.js' +import { shouldRunOnboarding, runOnboarding } from './onboarding.js' // --------------------------------------------------------------------------- // Minimal CLI arg parser — detects print/subagent mode flags @@ -65,6 +66,8 @@ function parseCliArgs(argv: string[]): CliFlags { process.stdout.write(' --tools Restrict available tools\n') process.stdout.write(' --version, -v Print version and exit\n') process.stdout.write(' --help, -h Print this help and exit\n') + process.stdout.write('\nSubcommands:\n') + process.stdout.write(' config Re-run the setup wizard\n') process.exit(0) } else if (!arg.startsWith('--') && !arg.startsWith('-')) { flags.messages.push(arg) @@ -76,6 +79,13 @@ function parseCliArgs(argv: string[]): CliFlags { const cliFlags = parseCliArgs(process.argv) 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) + await runOnboarding(authStorage) + process.exit(0) +} + // Pi's tool bootstrap can mis-detect already-installed fd/rg on some systems // because spawnSync(..., ["--version"]) returns EPERM despite a zero exit code. // Provision local managed binaries first so Pi sees them without probing PATH. @@ -84,9 +94,9 @@ ensureManagedTools(join(agentDir, 'bin')) const authStorage = AuthStorage.create(authFilePath) loadStoredEnvKeys(authStorage) -// Skip the setup wizard in print mode — it requires TTY interaction -if (!isPrintMode) { - await runWizardIfNeeded(authStorage) +// Run onboarding wizard on first launch (no LLM provider configured) +if (!isPrintMode && shouldRunOnboarding(authStorage)) { + await runOnboarding(authStorage) } const modelRegistry = new ModelRegistry(authStorage) diff --git a/src/loader.ts b/src/loader.ts index 387c365e8..368d46565 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -1,8 +1,8 @@ #!/usr/bin/env node import { fileURLToPath } from 'url' import { dirname, resolve, join } from 'path' -import { existsSync, readFileSync } from 'fs' -import { agentDir, appRoot } from './app-paths.js' +import { readFileSync } from 'fs' +import { agentDir } from './app-paths.js' // pkg/ is a shim directory: contains gsd's piConfig (package.json) and pi's // theme assets (dist/modes/interactive/theme/) without a src/ directory. @@ -17,31 +17,7 @@ process.env.PI_PACKAGE_DIR = pkgDir process.env.PI_SKIP_VERSION_CHECK = '1' // GSD ships its own update check — suppress pi's process.title = 'gsd' -// Print branded banner on first launch (before ~/.gsd/ exists) -if (!existsSync(appRoot)) { - const cyan = '\x1b[36m' - const green = '\x1b[32m' - const dim = '\x1b[2m' - const reset = '\x1b[0m' - let version = '' - try { - const pkgJson = JSON.parse(readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8')) - version = pkgJson.version ?? '' - } catch { /* ignore */ } - process.stderr.write( - '\n' + - cyan + - ' ██████╗ ███████╗██████╗ \n' + - ' ██╔════╝ ██╔════╝██╔══██╗\n' + - ' ██║ ███╗███████╗██║ ██║\n' + - ' ██║ ██║╚════██║██║ ██║\n' + - ' ╚██████╔╝███████║██████╔╝\n' + - ' ╚═════╝ ╚══════╝╚═════╝ ' + - reset + '\n\n' + - ` Get Shit Done ${dim}v${version}${reset}\n` + - ` ${green}Welcome.${reset} Setting up your environment...\n\n` - ) -} +// First-launch branding is handled by the onboarding wizard (src/onboarding.ts) // GSD_CODING_AGENT_DIR — tells pi's getAgentDir() to return ~/.gsd/agent/ instead of ~/.gsd/agent/ process.env.GSD_CODING_AGENT_DIR = agentDir diff --git a/src/logo.ts b/src/logo.ts new file mode 100644 index 000000000..206406471 --- /dev/null +++ b/src/logo.ts @@ -0,0 +1,27 @@ +/** + * Shared GSD block-letter ASCII logo. + * + * Single source of truth — imported by: + * - scripts/postinstall.js (via dist/logo.js) + * - src/onboarding.ts (via ./logo.js) + */ + +/** Raw logo lines — no ANSI codes, no leading newline. */ +export const GSD_LOGO: string[] = [ + ' ██████╗ ███████╗██████╗ ', + ' ██╔════╝ ██╔════╝██╔══██╗', + ' ██║ ███╗███████╗██║ ██║', + ' ██║ ██║╚════██║██║ ██║', + ' ╚██████╔╝███████║██████╔╝', + ' ╚═════╝ ╚══════╝╚═════╝ ', +] + +/** + * Render the logo block with a color function applied to each line. + * + * @param color — e.g. picocolors.cyan or `(s) => `\x1b[36m${s}\x1b[0m`` + * @returns Ready-to-write string with leading/trailing newlines. + */ +export function renderLogo(color: (s: string) => string): string { + return '\n' + GSD_LOGO.map(color).join('\n') + '\n' +} diff --git a/src/onboarding.ts b/src/onboarding.ts new file mode 100644 index 000000000..48345ff33 --- /dev/null +++ b/src/onboarding.ts @@ -0,0 +1,490 @@ +/** + * Unified first-run onboarding wizard. + * + * Replaces the raw API-key-only wizard with a branded, clack-based experience + * that guides users through LLM provider authentication before the TUI launches. + * + * Flow: logo -> choose LLM provider -> authenticate (OAuth or API key) -> + * optional tool keys -> summary -> TUI launches. + * + * All steps are skippable. All errors are recoverable. Never crashes boot. + */ + +import { exec } from 'node:child_process' +import type { AuthStorage } from '@mariozechner/pi-coding-agent' +import { renderLogo } from './logo.js' + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface ToolKeyConfig { + provider: string + envVar: string + label: string + hint: string +} + +type ClackModule = typeof import('@clack/prompts') +type PicoModule = { + cyan: (s: string) => string + green: (s: string) => string + yellow: (s: string) => string + dim: (s: string) => string + bold: (s: string) => string + red: (s: string) => string + reset: (s: string) => string +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const TOOL_KEYS: ToolKeyConfig[] = [ + { + provider: 'brave', + envVar: 'BRAVE_API_KEY', + label: 'Brave Search', + hint: 'web search + search_and_read tools', + }, + { + provider: 'brave_answers', + envVar: 'BRAVE_ANSWERS_KEY', + label: 'Brave Answers', + hint: 'AI-summarised search answers', + }, + { + provider: 'context7', + envVar: 'CONTEXT7_API_KEY', + label: 'Context7', + hint: 'up-to-date library docs', + }, + { + provider: 'jina', + envVar: 'JINA_API_KEY', + label: 'Jina AI', + hint: 'clean web page extraction', + }, + { + provider: 'slack_bot', + envVar: 'SLACK_BOT_TOKEN', + label: 'Slack Bot', + hint: 'remote questions in auto-mode', + }, + { + provider: 'discord_bot', + envVar: 'DISCORD_BOT_TOKEN', + label: 'Discord Bot', + hint: 'remote questions in auto-mode', + }, +] + +/** Known LLM provider IDs that, if authed, mean the user doesn't need onboarding */ +const LLM_PROVIDER_IDS = [ + 'anthropic', + 'openai', + 'github-copilot', + 'openai-codex', + 'google-gemini-cli', + 'google-antigravity', + 'google', + 'groq', + 'xai', + 'openrouter', + 'mistral', +] + +/** API key prefix validation — loose checks to catch obvious mistakes */ +const API_KEY_PREFIXES: Record = { + anthropic: ['sk-ant-'], + openai: ['sk-'], +} + +const OTHER_PROVIDERS = [ + { value: 'google', label: 'Google (Gemini)' }, + { value: 'groq', label: 'Groq' }, + { value: 'xai', label: 'xAI (Grok)' }, + { value: 'openrouter', label: 'OpenRouter' }, + { value: 'mistral', label: 'Mistral' }, +] + +// ─── Dynamic imports ────────────────────────────────────────────────────────── + +/** + * Dynamically import @clack/prompts and picocolors. + * Dynamic import with fallback so the module doesn't crash if they're missing. + */ +async function loadClack(): Promise { + try { + return await import('@clack/prompts') + } catch { + throw new Error('[gsd] @clack/prompts not found — onboarding wizard requires this dependency') + } +} + +async function loadPico(): Promise { + try { + const mod = await import('picocolors') + return mod.default ?? mod + } catch { + // Fallback: return identity functions + const identity = (s: string) => s + return { cyan: identity, green: identity, yellow: identity, dim: identity, bold: identity, red: identity, reset: identity } + } +} + +// ─── Utilities ──────────────────────────────────────────────────────────────── + +/** Open a URL in the system browser (best-effort, non-blocking) */ +function openBrowser(url: string): void { + const cmd = process.platform === 'darwin' ? 'open' : + process.platform === 'win32' ? 'start' : + 'xdg-open' + exec(`${cmd} "${url}"`, () => { + // Ignore errors — user can manually open the URL + }) +} + +/** Check if an error is a clack cancel signal */ +function isCancelError(p: ClackModule, err: unknown): boolean { + return p.isCancel(err) +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** + * Determine if the onboarding wizard should run. + * + * Returns true when: + * - No LLM provider has credentials in authStorage + * - We're on a TTY (interactive terminal) + * + * Returns false (skip wizard) when: + * - Any LLM provider is already authed (returning user) + * - Not a TTY (piped input, subagent, CI) + */ +export function shouldRunOnboarding(authStorage: AuthStorage): boolean { + if (!process.stdin.isTTY) return false + // Check if any LLM provider has credentials + const authedProviders = authStorage.list() + const hasLlmAuth = authedProviders.some(id => LLM_PROVIDER_IDS.includes(id)) + return !hasLlmAuth +} + +/** + * Run the unified onboarding wizard. + * + * Walks the user through: + * 1. Choose LLM provider + * 2. Authenticate (OAuth or API key) + * 3. Optional tool API keys + * 4. Summary + * + * All steps are skippable. All errors are recoverable. + * Writes status to stderr during execution. + */ +export async function runOnboarding(authStorage: AuthStorage): Promise { + let p: ClackModule + let pc: PicoModule + try { + ;[p, pc] = await Promise.all([loadClack(), loadPico()]) + } catch (err) { + // If clack isn't available, fall back silently — don't block boot + process.stderr.write(`[gsd] Onboarding wizard unavailable: ${err instanceof Error ? err.message : String(err)}\n`) + return + } + + // ── Intro ───────────────────────────────────────────────────────────────── + process.stderr.write(renderLogo(pc.cyan)) + p.intro(pc.bold('Welcome to GSD — let\'s get you set up')) + + // ── LLM Provider Selection ──────────────────────────────────────────────── + let llmConfigured = false + try { + llmConfigured = await runLlmStep(p, pc, authStorage) + } catch (err) { + // User cancelled (Ctrl+C in clack throws) or unexpected error + if (isCancelError(p, err)) { + p.cancel('Setup cancelled — you can run /login inside GSD later.') + return + } + p.log.warn(`LLM setup failed: ${err instanceof Error ? err.message : String(err)}`) + p.log.info('You can configure your LLM provider later with /login inside GSD.') + } + + // ── Tool API Keys ───────────────────────────────────────────────────────── + let toolKeyCount = 0 + try { + toolKeyCount = await runToolKeysStep(p, pc, authStorage) + } catch (err) { + if (isCancelError(p, err)) { + p.cancel('Setup cancelled.') + return + } + p.log.warn(`Tool key setup failed: ${err instanceof Error ? err.message : String(err)}`) + } + + // ── Summary ─────────────────────────────────────────────────────────────── + const summaryLines: string[] = [] + if (llmConfigured) { + // Re-read what provider was stored + const authed = authStorage.list().filter(id => LLM_PROVIDER_IDS.includes(id)) + if (authed.length > 0) { + const name = authed[0] + summaryLines.push(`${pc.green('✓')} LLM provider: ${name}`) + } else { + summaryLines.push(`${pc.green('✓')} LLM provider configured`) + } + } else { + summaryLines.push(`${pc.yellow('↷')} LLM provider: skipped — use /login inside GSD`) + } + + if (toolKeyCount > 0) { + summaryLines.push(`${pc.green('✓')} ${toolKeyCount} tool key${toolKeyCount > 1 ? 's' : ''} saved`) + } else { + summaryLines.push(`${pc.dim('↷')} Tool keys: none configured`) + } + + p.note(summaryLines.join('\n'), 'Setup complete') + p.outro(pc.dim('Launching GSD...')) +} + +// ─── LLM Authentication Step ────────────────────────────────────────────────── + +async function runLlmStep(p: ClackModule, pc: PicoModule, authStorage: AuthStorage): Promise { + // Build the OAuth provider list dynamically from what's registered + const oauthProviders = authStorage.getOAuthProviders() + const oauthMap = new Map(oauthProviders.map(op => [op.id, op])) + + const choice = await p.select({ + message: 'Choose your LLM provider', + options: [ + { value: 'anthropic-oauth', label: 'Anthropic — Claude (OAuth login)', hint: 'recommended' }, + { value: 'anthropic-api-key', label: 'Anthropic — Claude (API key)' }, + { value: 'openai-api-key', label: 'OpenAI (API key)' }, + { value: 'github-copilot-oauth', label: 'GitHub Copilot (OAuth login)' }, + { value: 'openai-codex-oauth', label: 'ChatGPT Plus/Pro — Codex (OAuth login)' }, + { value: 'google-gemini-cli-oauth', label: 'Google Gemini CLI (OAuth login)' }, + { value: 'google-antigravity-oauth', label: 'Antigravity — Gemini 3, Claude, GPT-OSS (OAuth login)' }, + { value: 'other-api-key', label: 'Other provider (API key)' }, + { value: 'skip', label: 'Skip for now', hint: 'use /login inside GSD later' }, + ], + }) + + if (p.isCancel(choice) || choice === 'skip') return false + + // ── OAuth flows ─────────────────────────────────────────────────────────── + if (choice === 'anthropic-oauth') { + return await runOAuthFlow(p, pc, authStorage, 'anthropic', oauthMap) + } + if (choice === 'github-copilot-oauth') { + return await runOAuthFlow(p, pc, authStorage, 'github-copilot', oauthMap) + } + if (choice === 'openai-codex-oauth') { + return await runOAuthFlow(p, pc, authStorage, 'openai-codex', oauthMap) + } + if (choice === 'google-gemini-cli-oauth') { + return await runOAuthFlow(p, pc, authStorage, 'google-gemini-cli', oauthMap) + } + if (choice === 'google-antigravity-oauth') { + return await runOAuthFlow(p, pc, authStorage, 'google-antigravity', oauthMap) + } + + // ── API key flows ───────────────────────────────────────────────────────── + if (choice === 'anthropic-api-key') { + return await runApiKeyFlow(p, pc, authStorage, 'anthropic', 'Anthropic') + } + if (choice === 'openai-api-key') { + return await runApiKeyFlow(p, pc, authStorage, 'openai', 'OpenAI') + } + if (choice === 'other-api-key') { + return await runOtherProviderFlow(p, pc, authStorage) + } + + return false +} + +// ─── OAuth Flow ─────────────────────────────────────────────────────────────── + +async function runOAuthFlow( + p: ClackModule, + pc: PicoModule, + authStorage: AuthStorage, + providerId: string, + oauthMap: Map, +): Promise { + const providerInfo = oauthMap.get(providerId) + const providerName = providerInfo?.name ?? providerId + const usesCallbackServer = providerInfo?.usesCallbackServer ?? false + + const s = p.spinner() + s.start(`Authenticating with ${providerName}...`) + + try { + await authStorage.login(providerId as any, { + onAuth: (info: { url: string; instructions?: string }) => { + s.stop(`Opening browser for ${providerName}`) + openBrowser(info.url) + p.log.info(`${pc.dim('URL:')} ${pc.cyan(info.url)}`) + if (info.instructions) { + p.log.info(pc.yellow(info.instructions)) + } + }, + onPrompt: async (prompt: { message: string; placeholder?: string }) => { + const result = await p.text({ + message: prompt.message, + placeholder: prompt.placeholder, + }) + if (p.isCancel(result)) return '' + return result as string + }, + onProgress: (message: string) => { + p.log.step(pc.dim(message)) + }, + onManualCodeInput: usesCallbackServer + ? async () => { + const result = await p.text({ + message: 'Paste the redirect URL from your browser:', + placeholder: 'http://localhost:...', + }) + if (p.isCancel(result)) return '' + return result as string + } + : undefined, + } as any) + + p.log.success(`Authenticated with ${pc.green(providerName)}`) + return true + } catch (err) { + s.stop(`${providerName} authentication failed`) + const errorMsg = err instanceof Error ? err.message : String(err) + p.log.warn(`OAuth error: ${errorMsg}`) + + // Offer retry or skip + const retry = await p.select({ + message: 'What would you like to do?', + options: [ + { value: 'retry', label: 'Try again' }, + { value: 'skip', label: 'Skip — configure later with /login' }, + ], + }) + + if (p.isCancel(retry) || retry === 'skip') return false + // Recursive retry + return runOAuthFlow(p, pc, authStorage, providerId, oauthMap) + } +} + +// ─── API Key Flow ───────────────────────────────────────────────────────────── + +async function runApiKeyFlow( + p: ClackModule, + pc: PicoModule, + authStorage: AuthStorage, + providerId: string, + providerLabel: string, +): Promise { + const key = await p.password({ + message: `Paste your ${providerLabel} API key:`, + mask: '●', + }) + + if (p.isCancel(key) || !key) return false + const trimmed = (key as string).trim() + if (!trimmed) return false + + // Basic prefix validation + const expectedPrefixes = API_KEY_PREFIXES[providerId] + if (expectedPrefixes && !expectedPrefixes.some(pfx => trimmed.startsWith(pfx))) { + p.log.warn(`Key doesn't start with expected prefix (${expectedPrefixes.join(' or ')}). Saving anyway.`) + } + + authStorage.set(providerId, { type: 'api_key', key: trimmed }) + p.log.success(`API key saved for ${pc.green(providerLabel)}`) + return true +} + +// ─── "Other Provider" Sub-Flow ──────────────────────────────────────────────── + +async function runOtherProviderFlow( + p: ClackModule, + pc: PicoModule, + authStorage: AuthStorage, +): Promise { + const provider = await p.select({ + message: 'Select provider', + options: OTHER_PROVIDERS.map(op => ({ + value: op.value, + label: op.label, + })), + }) + + if (p.isCancel(provider)) return false + const label = OTHER_PROVIDERS.find(op => op.value === provider)?.label ?? String(provider) + return runApiKeyFlow(p, pc, authStorage, provider as string, label) +} + +// ─── Tool API Keys Step ─────────────────────────────────────────────────────── + +async function runToolKeysStep( + p: ClackModule, + pc: PicoModule, + authStorage: AuthStorage, +): Promise { + // Filter to keys not already configured + const missing = TOOL_KEYS.filter(tk => !authStorage.has(tk.provider) && !process.env[tk.envVar]) + if (missing.length === 0) return 0 + + const wantToolKeys = await p.confirm({ + message: 'Set up optional tool API keys? (web search, docs, etc.)', + initialValue: false, + }) + + if (p.isCancel(wantToolKeys) || !wantToolKeys) return 0 + + let savedCount = 0 + for (const tk of missing) { + const key = await p.password({ + message: `${tk.label} ${pc.dim(`(${tk.hint})`)} — Enter to skip:`, + mask: '●', + }) + + if (p.isCancel(key)) break + + const trimmed = (key as string | undefined)?.trim() + if (trimmed) { + authStorage.set(tk.provider, { type: 'api_key', key: trimmed }) + process.env[tk.envVar] = trimmed + p.log.success(`${tk.label} saved`) + savedCount++ + } else { + // Store empty key so wizard doesn't re-ask on next launch + authStorage.set(tk.provider, { type: 'api_key', key: '' }) + p.log.info(pc.dim(`${tk.label} skipped`)) + } + } + + return savedCount +} + +// ─── Env hydration (migrated from wizard.ts) ───────────────────────────────── + +/** + * Hydrate process.env from stored auth.json credentials for optional tool keys. + * Runs on every launch so extensions see Brave/Context7/Jina keys stored via the + * wizard on prior launches. + */ +export function loadStoredEnvKeys(authStorage: AuthStorage): void { + const providers: Array<[string, string]> = [ + ['brave', 'BRAVE_API_KEY'], + ['brave_answers', 'BRAVE_ANSWERS_KEY'], + ['context7', 'CONTEXT7_API_KEY'], + ['jina', 'JINA_API_KEY'], + ['slack_bot', 'SLACK_BOT_TOKEN'], + ['discord_bot', 'DISCORD_BOT_TOKEN'], + ] + for (const [provider, envVar] of providers) { + if (!process.env[envVar]) { + const cred = authStorage.get(provider) + if (cred?.type === 'api_key' && cred.key) { + process.env[envVar] = cred.key + } + } + } +} diff --git a/src/wizard.ts b/src/wizard.ts index ea6ca999a..a70138fd4 100644 --- a/src/wizard.ts +++ b/src/wizard.ts @@ -1,75 +1,5 @@ -import { createInterface } from 'readline' import type { AuthStorage } from '@mariozechner/pi-coding-agent' -// ─── Colors ────────────────────────────────────────────────────────────────── - -const cyan = '\x1b[36m' -const green = '\x1b[32m' -const yellow = '\x1b[33m' -const dim = '\x1b[2m' -const bold = '\x1b[1m' -const reset = '\x1b[0m' - -// ─── Masked input ───────────────────────────────────────────────────────────── - -/** - * Prompt for masked input using raw mode stdin. - * Handles backspace, Ctrl+C, and Enter. - * Falls back to plain readline if setRawMode is unavailable (e.g. some SSH contexts). - */ -async function promptMasked(label: string, hint: string): Promise { - return new Promise((resolve) => { - const question = ` ${cyan}›${reset} ${label} ${dim}${hint}${reset}\n ` - try { - process.stdout.write(question) - process.stdin.setRawMode(true) - process.stdin.resume() - process.stdin.setEncoding('utf8') - let value = '' - const redraw = () => { - process.stdout.clearLine(0) - process.stdout.cursorTo(0) - if (value.length === 0) { - process.stdout.write(' ') - } else { - const dots = '●'.repeat(Math.min(value.length, 24)) - const counter = value.length > 24 ? ` ${dim}(${value.length})${reset}` : ` ${dim}${value.length}${reset}` - process.stdout.write(` ${dots}${counter}`) - } - } - const handler = (ch: string) => { - if (ch === '\r' || ch === '\n') { - process.stdin.setRawMode(false) - process.stdin.pause() - process.stdin.off('data', handler) - process.stdout.write('\n') - resolve(value) - } else if (ch === '\u0003') { - process.stdin.setRawMode(false) - process.stdout.write('\n') - process.exit(0) - } else if (ch === '\u007f' || ch === '\b') { - if (value.length > 0) { - value = value.slice(0, -1) - } - redraw() - } else { - value += ch - redraw() - } - } - process.stdin.on('data', handler) - } catch (_err) { - process.stdout.write(` ${dim}(input will be visible)${reset}\n `) - const rl = createInterface({ input: process.stdin, output: process.stdout }) - rl.question('', (answer) => { - rl.close() - resolve(answer) - }) - } - }) -} - // ─── Env hydration ──────────────────────────────────────────────────────────── /** @@ -96,128 +26,3 @@ export function loadStoredEnvKeys(authStorage: AuthStorage): void { } } } - -// ─── Wizard ─────────────────────────────────────────────────────────────────── - -interface ApiKeyConfig { - provider: string - envVar: string - label: string - hint: string - description: string -} - -const API_KEYS: ApiKeyConfig[] = [ - { - provider: 'brave', - envVar: 'BRAVE_API_KEY', - label: 'Brave Search', - hint: '(search-the-web + search_and_read tools)', - description: 'Web search and page extraction', - }, - { - provider: 'brave_answers', - envVar: 'BRAVE_ANSWERS_KEY', - label: 'Brave Answers', - hint: '(AI-summarised search answers)', - description: 'AI-generated search summaries', - }, - { - provider: 'context7', - envVar: 'CONTEXT7_API_KEY', - label: 'Context7', - hint: '(up-to-date library docs)', - description: 'Live library and framework documentation', - }, - { - provider: 'jina', - envVar: 'JINA_API_KEY', - label: 'Jina AI', - hint: '(clean page extraction)', - description: 'High-quality web page content extraction', - }, - { - provider: 'tavily', - envVar: 'TAVILY_API_KEY', - label: 'Tavily Search', - hint: '(search-the-web + search_and_read tools, starts with tvly-)', - description: 'Web search and page extraction (alternative to Brave)', - }, - { - provider: 'slack_bot', - envVar: 'SLACK_BOT_TOKEN', - label: 'Slack Bot', - hint: '(remote questions in auto-mode)', - description: 'Bot token for remote questions via Slack', - }, - { - provider: 'discord_bot', - envVar: 'DISCORD_BOT_TOKEN', - label: 'Discord Bot', - hint: '(remote questions in auto-mode)', - description: 'Bot token for remote questions via Discord', - }, -] - -/** - * Check for missing optional tool API keys and prompt for them if on a TTY. - * - * Anthropic auth is handled by pi's own OAuth/API key flow — we don't touch it. - * This wizard only collects Brave Search, Context7, and Jina keys which are needed - * for web search and documentation tools. - */ -export async function runWizardIfNeeded(authStorage: AuthStorage): Promise { - const missing = API_KEYS.filter( - k => !authStorage.has(k.provider) && !process.env[k.envVar] - ) - - if (missing.length === 0) return - - // Non-TTY: warn and continue - if (!process.stdin.isTTY) { - const names = missing.map(k => k.label).join(', ') - process.stderr.write( - `[gsd] Warning: optional tool API keys not configured (${names}). Some tools may not work.\n` - ) - return - } - - // ── Header ────────────────────────────────────────────────────────────────── - process.stdout.write( - `\n ${bold}Optional API keys${reset}\n` + - ` ${dim}─────────────────────────────────────────────${reset}\n` + - ` These unlock additional tools. All optional — press ${cyan}Enter${reset} to skip any.\n\n` - ) - - // ── Prompts ───────────────────────────────────────────────────────────────── - let savedCount = 0 - - for (const key of missing) { - const value = await promptMasked(key.label, key.hint) - if (value.trim()) { - authStorage.set(key.provider, { type: 'api_key', key: value.trim() }) - process.env[key.envVar] = value.trim() - process.stdout.write(` ${green}✓${reset} ${key.label} saved\n\n`) - savedCount++ - } else { - authStorage.set(key.provider, { type: 'api_key', key: '' }) - process.stdout.write(` ${dim}↷ ${key.label} skipped${reset}\n\n`) - } - } - - // ── Footer ─────────────────────────────────────────────────────────────────── - process.stdout.write( - ` ${dim}─────────────────────────────────────────────${reset}\n` - ) - if (savedCount > 0) { - process.stdout.write( - ` ${green}✓${reset} ${savedCount} key${savedCount > 1 ? 's' : ''} saved to ${dim}~/.gsd/agent/auth.json${reset}\n` + - ` ${dim}Run ${reset}${cyan}/login${reset}${dim} inside gsd to connect your LLM provider.${reset}\n\n` - ) - } else { - process.stdout.write( - ` ${yellow}↷${reset} All keys skipped — you can add them later via ${dim}~/.gsd/agent/auth.json${reset}\n` + - ` ${dim}Run ${reset}${cyan}/login${reset}${dim} inside gsd to connect your LLM provider.${reset}\n\n` - ) - } -}