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>
490 lines
18 KiB
TypeScript
490 lines
18 KiB
TypeScript
/**
|
|
* 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
|
|
}
|
|
}
|
|
}
|
|
}
|