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 <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-12 10:02:00 -06:00 committed by GitHub
parent 56f079009b
commit e93a44d967
5 changed files with 534 additions and 226 deletions

View file

@ -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 <a,b,c> 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)

View file

@ -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

27
src/logo.ts Normal file
View file

@ -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'
}

490
src/onboarding.ts Normal file
View file

@ -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<string, string[]> = {
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<ClackModule> {
try {
return await import('@clack/prompts')
} catch {
throw new Error('[gsd] @clack/prompts not found — onboarding wizard requires this dependency')
}
}
async function loadPico(): Promise<PicoModule> {
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<void> {
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<boolean> {
// 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<string, { id: string; name?: string; usesCallbackServer?: boolean }>,
): Promise<boolean> {
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<boolean> {
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<boolean> {
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<number> {
// 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
}
}
}
}

View file

@ -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<string> {
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<void> {
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`
)
}
}