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:
parent
56f079009b
commit
e93a44d967
5 changed files with 534 additions and 226 deletions
18
src/cli.ts
18
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 <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)
|
||||
|
|
|
|||
|
|
@ -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
27
src/logo.ts
Normal 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
490
src/onboarding.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
195
src/wizard.ts
195
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<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`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue