diff --git a/scripts/provider-bootstrap.ts b/scripts/provider-bootstrap.ts index ad3f9bd3..cef21aef 100644 --- a/scripts/provider-bootstrap.ts +++ b/scripts/provider-bootstrap.ts @@ -1,6 +1,4 @@ // @ts-nocheck -import { writeFileSync } from 'node:fs' -import { resolve } from 'node:path' import { resolveCodexApiCredentials, } from '../src/services/api/providerConfig.js' @@ -15,6 +13,7 @@ import { buildOllamaProfileEnv, buildOpenAIProfileEnv, createProfileFile, + saveProfileFile, selectAutoProfile, type ProfileFile, type ProviderProfile, @@ -147,8 +146,7 @@ async function main(): Promise { const profile = createProfileFile(selected, env) - const outputPath = resolve(process.cwd(), '.openclaude-profile.json') - writeFileSync(outputPath, JSON.stringify(profile, null, 2), { encoding: 'utf8', mode: 0o600 }) + const outputPath = saveProfileFile(profile) console.log(`Saved profile: ${selected}`) console.log(`Goal: ${goal}`) diff --git a/scripts/provider-discovery.ts b/scripts/provider-discovery.ts index 9e3aacda..126e0d22 100644 --- a/scripts/provider-discovery.ts +++ b/scripts/provider-discovery.ts @@ -1,129 +1,8 @@ -import type { OllamaModelDescriptor } from '../src/utils/providerRecommendation.ts' - -export const DEFAULT_OLLAMA_BASE_URL = 'http://localhost:11434' - -function withTimeoutSignal(timeoutMs: number): { - signal: AbortSignal - clear: () => void -} { - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), timeoutMs) - return { - signal: controller.signal, - clear: () => clearTimeout(timeout), - } -} - -function trimTrailingSlash(value: string): string { - return value.replace(/\/+$/, '') -} - -export function getOllamaApiBaseUrl(baseUrl?: string): string { - const parsed = new URL( - baseUrl || process.env.OLLAMA_BASE_URL || DEFAULT_OLLAMA_BASE_URL, - ) - const pathname = trimTrailingSlash(parsed.pathname) - parsed.pathname = pathname.endsWith('/v1') - ? pathname.slice(0, -3) || '/' - : pathname || '/' - parsed.search = '' - parsed.hash = '' - return trimTrailingSlash(parsed.toString()) -} - -export function getOllamaChatBaseUrl(baseUrl?: string): string { - return `${getOllamaApiBaseUrl(baseUrl)}/v1` -} - -export async function hasLocalOllama(baseUrl?: string): Promise { - const { signal, clear } = withTimeoutSignal(1200) - try { - const response = await fetch(`${getOllamaApiBaseUrl(baseUrl)}/api/tags`, { - method: 'GET', - signal, - }) - return response.ok - } catch { - return false - } finally { - clear() - } -} - -export async function listOllamaModels( - baseUrl?: string, -): Promise { - const { signal, clear } = withTimeoutSignal(5000) - try { - const response = await fetch(`${getOllamaApiBaseUrl(baseUrl)}/api/tags`, { - method: 'GET', - signal, - }) - if (!response.ok) { - return [] - } - - const data = await response.json() as { - models?: Array<{ - name?: string - size?: number - details?: { - family?: string - families?: string[] - parameter_size?: string - quantization_level?: string - } - }> - } - - return (data.models ?? []) - .filter(model => Boolean(model.name)) - .map(model => ({ - name: model.name!, - sizeBytes: typeof model.size === 'number' ? model.size : null, - family: model.details?.family ?? null, - families: model.details?.families ?? [], - parameterSize: model.details?.parameter_size ?? null, - quantizationLevel: model.details?.quantization_level ?? null, - })) - } catch { - return [] - } finally { - clear() - } -} - -export async function benchmarkOllamaModel( - modelName: string, - baseUrl?: string, -): Promise { - const start = Date.now() - const { signal, clear } = withTimeoutSignal(20000) - try { - const response = await fetch(`${getOllamaApiBaseUrl(baseUrl)}/api/chat`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - signal, - body: JSON.stringify({ - model: modelName, - stream: false, - messages: [{ role: 'user', content: 'Reply with OK.' }], - options: { - temperature: 0, - num_predict: 8, - }, - }), - }) - if (!response.ok) { - return null - } - await response.json() - return Date.now() - start - } catch { - return null - } finally { - clear() - } -} +export { + benchmarkOllamaModel, + DEFAULT_OLLAMA_BASE_URL, + getOllamaApiBaseUrl, + getOllamaChatBaseUrl, + hasLocalOllama, + listOllamaModels, +} from '../src/utils/providerDiscovery.ts' diff --git a/scripts/provider-launch.ts b/scripts/provider-launch.ts index 2859e9e8..1c79d795 100644 --- a/scripts/provider-launch.ts +++ b/scripts/provider-launch.ts @@ -1,7 +1,5 @@ // @ts-nocheck import { spawn } from 'node:child_process' -import { existsSync, readFileSync } from 'node:fs' -import { resolve } from 'node:path' import { resolveCodexApiCredentials, } from '../src/services/api/providerConfig.js' @@ -11,6 +9,7 @@ import { } from '../src/utils/providerRecommendation.ts' import { buildLaunchEnv, + loadProfileFile, selectAutoProfile, type ProfileFile, type ProviderProfile, @@ -75,17 +74,7 @@ function parseLaunchOptions(argv: string[]): LaunchOptions { } function loadPersistedProfile(): ProfileFile | null { - const path = resolve(process.cwd(), '.openclaude-profile.json') - if (!existsSync(path)) return null - try { - const parsed = JSON.parse(readFileSync(path, 'utf8')) as ProfileFile - if (parsed.profile === 'openai' || parsed.profile === 'ollama' || parsed.profile === 'codex' || parsed.profile === 'gemini') { - return parsed - } - return null - } catch { - return null - } + return loadProfileFile() } async function resolveOllamaDefaultModel( diff --git a/scripts/provider-recommend.ts b/scripts/provider-recommend.ts index eca811e6..8dc23835 100644 --- a/scripts/provider-recommend.ts +++ b/scripts/provider-recommend.ts @@ -1,6 +1,4 @@ // @ts-nocheck -import { writeFileSync } from 'node:fs' -import { resolve } from 'node:path' import { applyBenchmarkLatency, @@ -16,6 +14,7 @@ import { buildOllamaProfileEnv, buildOpenAIProfileEnv, createProfileFile, + saveProfileFile, sanitizeApiKey, type ProfileFile, type ProviderProfile, @@ -153,11 +152,7 @@ async function maybeApplyProfile( const profileFile = createProfileFile(profile, env) - writeFileSync( - resolve(process.cwd(), '.openclaude-profile.json'), - JSON.stringify(profileFile, null, 2), - 'utf8', - ) + saveProfileFile(profileFile) return true } diff --git a/src/commands.ts b/src/commands.ts index 10f03b22..3858e62f 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -128,6 +128,7 @@ import plan from './commands/plan/index.js' import fast from './commands/fast/index.js' import passes from './commands/passes/index.js' import privacySettings from './commands/privacy-settings/index.js' +import provider from './commands/provider/index.js' import hooks from './commands/hooks/index.js' import files from './commands/files/index.js' import branch from './commands/branch/index.js' @@ -291,6 +292,7 @@ const COMMANDS = memoize((): Command[] => [ outputStyle, remoteEnv, plugin, + provider, pr_comments, releaseNotes, reloadPlugins, diff --git a/src/commands/provider/index.ts b/src/commands/provider/index.ts new file mode 100644 index 00000000..9cd14daa --- /dev/null +++ b/src/commands/provider/index.ts @@ -0,0 +1,12 @@ +import type { Command } from '../../commands.js' +import { shouldInferenceConfigCommandBeImmediate } from '../../utils/immediateCommand.js' + +export default { + type: 'local-jsx', + name: 'provider', + description: 'Set up and save a third-party provider profile for OpenClaude', + get immediate() { + return shouldInferenceConfigCommandBeImmediate() + }, + load: () => import('./provider.js'), +} satisfies Command diff --git a/src/commands/provider/provider.test.tsx b/src/commands/provider/provider.test.tsx new file mode 100644 index 00000000..7f5560dc --- /dev/null +++ b/src/commands/provider/provider.test.tsx @@ -0,0 +1,228 @@ +import { PassThrough } from 'node:stream' + +import { expect, test } from 'bun:test' +import React from 'react' +import stripAnsi from 'strip-ansi' + +import { createRoot, render, useApp } from '../../ink.js' +import { AppStateProvider } from '../../state/AppState.js' +import { + buildCurrentProviderSummary, + buildProfileSaveMessage, + getProviderWizardDefaults, + TextEntryDialog, +} from './provider.js' + +const SYNC_START = '\x1B[?2026h' +const SYNC_END = '\x1B[?2026l' + +function extractLastFrame(output: string): string { + let lastFrame: string | null = null + let cursor = 0 + + while (cursor < output.length) { + const start = output.indexOf(SYNC_START, cursor) + if (start === -1) { + break + } + + const contentStart = start + SYNC_START.length + const end = output.indexOf(SYNC_END, contentStart) + if (end === -1) { + break + } + + const frame = output.slice(contentStart, end) + if (frame.trim().length > 0) { + lastFrame = frame + } + cursor = end + SYNC_END.length + } + + return lastFrame ?? output +} + +async function renderFinalFrame(node: React.ReactNode): Promise { + let output = '' + const { stdout, stdin, getOutput } = createTestStreams() + + const instance = await render(node, { + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + }) + + await instance.waitUntilExit() + return stripAnsi(extractLastFrame(getOutput())) +} + +function createTestStreams(): { + stdout: PassThrough + stdin: PassThrough & { + isTTY: boolean + setRawMode: (mode: boolean) => void + ref: () => void + unref: () => void + } + getOutput: () => string +} { + let output = '' + const stdout = new PassThrough() + const stdin = new PassThrough() as PassThrough & { + isTTY: boolean + setRawMode: (mode: boolean) => void + ref: () => void + unref: () => void + } + stdin.isTTY = true + stdin.setRawMode = () => {} + stdin.ref = () => {} + stdin.unref = () => {} + ;(stdout as unknown as { columns: number }).columns = 120 + stdout.on('data', chunk => { + output += chunk.toString() + }) + + return { + stdout, + stdin, + getOutput: () => output, + } +} + +function StepChangeHarness(): React.ReactNode { + const { exit } = useApp() + const [step, setStep] = React.useState<'api' | 'model'>('api') + + React.useLayoutEffect(() => { + if (step === 'api') { + setStep('model') + return + } + + const timer = setTimeout(exit, 0) + return () => clearTimeout(timer) + }, [exit, step]) + + return ( + + {}} + onCancel={() => {}} + /> + + ) +} + +test('TextEntryDialog resets its input state when initialValue changes', async () => { + const output = await renderFinalFrame() + + expect(output).toContain('Model step') + expect(output).toContain('fresh-model-name') + expect(output).not.toContain('stale-secret-key') +}) + +test('wizard step remount prevents a typed API key from leaking into the next field', async () => { + const { stdout, stdin, getOutput } = createTestStreams() + const root = await createRoot({ + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + }) + + root.render( + + {}} + onCancel={() => {}} + /> + , + ) + + await Bun.sleep(25) + stdin.write('sk-secret-12345678') + await Bun.sleep(25) + + root.render( + + {}} + onCancel={() => {}} + /> + , + ) + + await Bun.sleep(25) + root.unmount() + stdin.end() + stdout.end() + await Bun.sleep(25) + + const output = stripAnsi(extractLastFrame(getOutput())) + expect(output).toContain('Model step') + expect(output).not.toContain('sk-secret-12345678') +}) + +test('buildProfileSaveMessage maps provider fields without echoing secrets', () => { + const message = buildProfileSaveMessage( + 'openai', + { + OPENAI_API_KEY: 'sk-secret-12345678', + OPENAI_MODEL: 'gpt-4o', + OPENAI_BASE_URL: 'https://api.openai.com/v1', + }, + 'D:/codings/Opensource/openclaude/.openclaude-profile.json', + ) + + expect(message).toContain('Saved OpenAI-compatible profile.') + expect(message).toContain('Model: gpt-4o') + expect(message).toContain('Endpoint: https://api.openai.com/v1') + expect(message).toContain('Credentials: configured') + expect(message).not.toContain('sk-secret-12345678') +}) + +test('buildCurrentProviderSummary redacts poisoned model and endpoint values', () => { + const summary = buildCurrentProviderSummary({ + processEnv: { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_API_KEY: 'sk-secret-12345678', + OPENAI_MODEL: 'sk-secret-12345678', + OPENAI_BASE_URL: 'sk-secret-12345678', + }, + persisted: null, + }) + + expect(summary.providerLabel).toBe('OpenAI-compatible') + expect(summary.modelLabel).toBe('sk-...5678') + expect(summary.endpointLabel).toBe('sk-...5678') +}) + +test('getProviderWizardDefaults ignores poisoned current provider values', () => { + const defaults = getProviderWizardDefaults({ + OPENAI_API_KEY: 'sk-secret-12345678', + OPENAI_MODEL: 'sk-secret-12345678', + OPENAI_BASE_URL: 'sk-secret-12345678', + GEMINI_API_KEY: 'AIzaSecret12345678', + GEMINI_MODEL: 'AIzaSecret12345678', + }) + + expect(defaults.openAIModel).toBe('gpt-4o') + expect(defaults.openAIBaseUrl).toBe('https://api.openai.com/v1') + expect(defaults.geminiModel).toBe('gemini-2.0-flash') +}) diff --git a/src/commands/provider/provider.tsx b/src/commands/provider/provider.tsx new file mode 100644 index 00000000..95109e7d --- /dev/null +++ b/src/commands/provider/provider.tsx @@ -0,0 +1,1148 @@ +import * as React from 'react' + +import type { LocalJSXCommandCall, LocalJSXCommandOnDone } from '../../types/command.js' +import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js' +import TextInput from '../../components/TextInput.js' +import { + Select, + type OptionWithDescription, +} from '../../components/CustomSelect/index.js' +import { Dialog } from '../../components/design-system/Dialog.js' +import { LoadingState } from '../../components/design-system/LoadingState.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { Box, Text } from '../../ink.js' +import { + DEFAULT_CODEX_BASE_URL, + DEFAULT_OPENAI_BASE_URL, + resolveCodexApiCredentials, + resolveProviderRequest, +} from '../../services/api/providerConfig.js' +import { + buildCodexProfileEnv, + buildGeminiProfileEnv, + buildOllamaProfileEnv, + buildOpenAIProfileEnv, + createProfileFile, + DEFAULT_GEMINI_BASE_URL, + DEFAULT_GEMINI_MODEL, + deleteProfileFile, + loadProfileFile, + maskSecretForDisplay, + redactSecretValueForDisplay, + sanitizeApiKey, + sanitizeProviderConfigValue, + saveProfileFile, + type ProfileEnv, + type ProfileFile, + type ProviderProfile, +} from '../../utils/providerProfile.js' +import { + getGoalDefaultOpenAIModel, + normalizeRecommendationGoal, + rankOllamaModels, + recommendOllamaModel, + type RecommendationGoal, +} from '../../utils/providerRecommendation.js' +import { hasLocalOllama, listOllamaModels } from '../../utils/providerDiscovery.js' + +type ProviderChoice = 'auto' | ProviderProfile | 'clear' + +type Step = + | { name: 'choose' } + | { name: 'auto-goal' } + | { name: 'auto-detect'; goal: RecommendationGoal } + | { name: 'ollama-detect' } + | { name: 'openai-key'; defaultModel: string } + | { name: 'openai-base'; apiKey: string; defaultModel: string } + | { + name: 'openai-model' + apiKey: string + baseUrl: string | null + defaultModel: string + } + | { name: 'gemini-key' } + | { name: 'gemini-model'; apiKey: string } + | { name: 'codex-check' } + +type CurrentProviderSummary = { + providerLabel: string + modelLabel: string + endpointLabel: string + savedProfileLabel: string +} + +type SavedProfileSummary = { + providerLabel: string + modelLabel: string + endpointLabel: string + credentialLabel?: string +} + +type TextEntryDialogProps = { + title: string + subtitle?: string + resetStateKey?: string + description: React.ReactNode + initialValue: string + placeholder?: string + mask?: string + allowEmpty?: boolean + validate?: (value: string) => string | null + onSubmit: (value: string) => void + onCancel: () => void +} + +type ProviderWizardDefaults = { + openAIModel: string + openAIBaseUrl: string + geminiModel: string +} + +function isEnvTruthy(value: string | undefined): boolean { + if (!value) return false + const normalized = value.trim().toLowerCase() + return normalized !== '' && normalized !== '0' && normalized !== 'false' && normalized !== 'no' +} + +function getSafeDisplayValue( + value: string | undefined, + processEnv: NodeJS.ProcessEnv, + profileEnv?: ProfileEnv, + fallback = '(not set)', +): string { + return ( + redactSecretValueForDisplay(value, processEnv, profileEnv) ?? fallback + ) +} + +export function getProviderWizardDefaults( + processEnv: NodeJS.ProcessEnv = process.env, +): ProviderWizardDefaults { + const safeOpenAIModel = + sanitizeProviderConfigValue(processEnv.OPENAI_MODEL, processEnv) || + 'gpt-4o' + const safeOpenAIBaseUrl = + sanitizeProviderConfigValue(processEnv.OPENAI_BASE_URL, processEnv) || + DEFAULT_OPENAI_BASE_URL + const safeGeminiModel = + sanitizeProviderConfigValue(processEnv.GEMINI_MODEL, processEnv) || + DEFAULT_GEMINI_MODEL + + return { + openAIModel: safeOpenAIModel, + openAIBaseUrl: safeOpenAIBaseUrl, + geminiModel: safeGeminiModel, + } +} + +export function buildCurrentProviderSummary(options?: { + processEnv?: NodeJS.ProcessEnv + persisted?: ProfileFile | null +}): CurrentProviderSummary { + const processEnv = options?.processEnv ?? process.env + const persisted = options?.persisted ?? loadProfileFile() + const savedProfileLabel = persisted?.profile ?? 'none' + + if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_GEMINI)) { + return { + providerLabel: 'Google Gemini', + modelLabel: getSafeDisplayValue( + processEnv.GEMINI_MODEL ?? DEFAULT_GEMINI_MODEL, + processEnv, + ), + endpointLabel: getSafeDisplayValue( + processEnv.GEMINI_BASE_URL ?? DEFAULT_GEMINI_BASE_URL, + processEnv, + ), + savedProfileLabel, + } + } + + if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_OPENAI)) { + const request = resolveProviderRequest({ + model: processEnv.OPENAI_MODEL, + baseUrl: processEnv.OPENAI_BASE_URL, + }) + + let providerLabel = 'OpenAI-compatible' + if (request.transport === 'codex_responses') { + providerLabel = 'Codex' + } else if (request.baseUrl.includes('localhost:11434')) { + providerLabel = 'Ollama' + } else if (request.baseUrl.includes('localhost:1234')) { + providerLabel = 'LM Studio' + } + + return { + providerLabel, + modelLabel: getSafeDisplayValue(request.requestedModel, processEnv), + endpointLabel: getSafeDisplayValue(request.baseUrl, processEnv), + savedProfileLabel, + } + } + + return { + providerLabel: 'Anthropic', + modelLabel: getSafeDisplayValue( + processEnv.ANTHROPIC_MODEL ?? + processEnv.CLAUDE_MODEL ?? + 'claude-sonnet-4-6', + processEnv, + ), + endpointLabel: getSafeDisplayValue( + processEnv.ANTHROPIC_BASE_URL ?? 'https://api.anthropic.com', + processEnv, + ), + savedProfileLabel, + } +} + +function buildSavedProfileSummary( + profile: ProviderProfile, + env: ProfileEnv, +): SavedProfileSummary { + switch (profile) { + case 'gemini': + return { + providerLabel: 'Google Gemini', + modelLabel: getSafeDisplayValue( + env.GEMINI_MODEL ?? DEFAULT_GEMINI_MODEL, + process.env, + env, + ), + endpointLabel: getSafeDisplayValue( + env.GEMINI_BASE_URL ?? DEFAULT_GEMINI_BASE_URL, + process.env, + env, + ), + credentialLabel: + maskSecretForDisplay(env.GEMINI_API_KEY) !== undefined + ? 'configured' + : undefined, + } + case 'codex': + return { + providerLabel: 'Codex', + modelLabel: getSafeDisplayValue( + env.OPENAI_MODEL ?? 'codexplan', + process.env, + env, + ), + endpointLabel: getSafeDisplayValue( + env.OPENAI_BASE_URL ?? DEFAULT_CODEX_BASE_URL, + process.env, + env, + ), + credentialLabel: + maskSecretForDisplay(env.CODEX_API_KEY) !== undefined + ? 'configured' + : undefined, + } + case 'ollama': + return { + providerLabel: 'Ollama', + modelLabel: getSafeDisplayValue( + env.OPENAI_MODEL, + process.env, + env, + ), + endpointLabel: getSafeDisplayValue( + env.OPENAI_BASE_URL, + process.env, + env, + ), + } + case 'openai': + default: + return { + providerLabel: 'OpenAI-compatible', + modelLabel: getSafeDisplayValue( + env.OPENAI_MODEL ?? 'gpt-4o', + process.env, + env, + ), + endpointLabel: getSafeDisplayValue( + env.OPENAI_BASE_URL ?? DEFAULT_OPENAI_BASE_URL, + process.env, + env, + ), + credentialLabel: + maskSecretForDisplay(env.OPENAI_API_KEY) !== undefined + ? 'configured' + : undefined, + } + } +} + +export function buildProfileSaveMessage( + profile: ProviderProfile, + env: ProfileEnv, + filePath: string, +): string { + const summary = buildSavedProfileSummary(profile, env) + const lines = [ + `Saved ${summary.providerLabel} profile.`, + `Model: ${summary.modelLabel}`, + `Endpoint: ${summary.endpointLabel}`, + ] + + if (summary.credentialLabel) { + lines.push(`Credentials: ${summary.credentialLabel}`) + } + + lines.push(`Profile: ${filePath}`) + lines.push('Restart OpenClaude to use it.') + + return lines.join('\n') +} + +function buildUsageText(): string { + const summary = buildCurrentProviderSummary() + return [ + 'Usage: /provider', + '', + 'Guided setup for saved provider profiles.', + '', + `Current provider: ${summary.providerLabel}`, + `Current model: ${summary.modelLabel}`, + `Current endpoint: ${summary.endpointLabel}`, + `Saved profile: ${summary.savedProfileLabel}`, + '', + 'Choose Auto, Ollama, OpenAI-compatible, Gemini, or Codex, then save a profile for the next OpenClaude restart.', + ].join('\n') +} + +function finishProfileSave( + onDone: LocalJSXCommandOnDone, + profile: ProviderProfile, + env: ProfileEnv, +): void { + try { + const profileFile = createProfileFile(profile, env) + const filePath = saveProfileFile(profileFile) + onDone(buildProfileSaveMessage(profile, env, filePath), { + display: 'system', + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + onDone(`Failed to save provider profile: ${message}`, { + display: 'system', + }) + } +} + +export function TextEntryDialog({ + title, + subtitle, + resetStateKey, + description, + initialValue, + placeholder, + mask, + allowEmpty = false, + validate, + onSubmit, + onCancel, +}: TextEntryDialogProps): React.ReactNode { + const { columns } = useTerminalSize() + const [value, setValue] = React.useState(initialValue) + const [cursorOffset, setCursorOffset] = React.useState(initialValue.length) + const [error, setError] = React.useState(null) + + React.useLayoutEffect(() => { + setValue(initialValue) + setCursorOffset(initialValue.length) + setError(null) + }, [initialValue, resetStateKey]) + + const inputColumns = Math.max(30, columns - 6) + + const handleSubmit = React.useCallback( + (nextValue: string) => { + if (!allowEmpty && nextValue.trim().length === 0) { + setError('A value is required for this step.') + return + } + + const validationError = validate?.(nextValue) + if (validationError) { + setError(validationError) + return + } + + setError(null) + onSubmit(nextValue) + }, + [allowEmpty, onSubmit, validate], + ) + + return ( + + + {description} + + {error ? {error} : null} + + + ) +} + +function ProviderChooser({ + onChoose, + onCancel, +}: { + onChoose: (value: ProviderChoice) => void + onCancel: () => void +}): React.ReactNode { + const summary = buildCurrentProviderSummary() + const options: OptionWithDescription[] = [ + { + label: 'Auto', + value: 'auto', + description: + 'Prefer local Ollama when available, otherwise guide you into OpenAI-compatible setup', + }, + { + label: 'Ollama', + value: 'ollama', + description: 'Use a local Ollama model with no API key', + }, + { + label: 'OpenAI-compatible', + value: 'openai', + description: + 'GPT-4o, DeepSeek, OpenRouter, Groq, LM Studio, and similar APIs', + }, + { + label: 'Gemini', + value: 'gemini', + description: 'Use a Google Gemini API key', + }, + { + label: 'Codex', + value: 'codex', + description: 'Use existing ChatGPT Codex CLI auth or env credentials', + }, + ] + + if (summary.savedProfileLabel !== 'none') { + options.push({ + label: 'Clear saved profile', + value: 'clear', + description: 'Remove .openclaude-profile.json and return to normal startup', + }) + } + + return ( + + + + Save a provider profile for the next OpenClaude restart without + editing environment variables first. + + + Current model: {summary.modelLabel} + Current endpoint: {summary.endpointLabel} + Saved profile: {summary.savedProfileLabel} + + + + + ) +} + +function AutoRecommendationStep({ + goal, + onBack, + onSave, + onNeedOpenAI, + onCancel, +}: { + goal: RecommendationGoal + onBack: () => void + onSave: (profile: ProviderProfile, env: ProfileEnv) => void + onNeedOpenAI: (defaultModel: string) => void + onCancel: () => void +}): React.ReactNode { + const [status, setStatus] = React.useState< + | { + state: 'loading' + } + | { + state: 'ollama' + model: string + summary: string + } + | { + state: 'openai' + defaultModel: string + } + | { + state: 'error' + message: string + } + >({ state: 'loading' }) + + React.useEffect(() => { + let cancelled = false + + void (async () => { + const defaultModel = getGoalDefaultOpenAIModel(goal) + try { + const ollamaAvailable = await hasLocalOllama() + if (!ollamaAvailable) { + if (!cancelled) { + setStatus({ state: 'openai', defaultModel }) + } + return + } + + const models = await listOllamaModels() + const recommended = recommendOllamaModel(models, goal) + if (!recommended) { + if (!cancelled) { + setStatus({ state: 'openai', defaultModel }) + } + return + } + + if (!cancelled) { + setStatus({ + state: 'ollama', + model: recommended.name, + summary: recommended.summary, + }) + } + } catch (error) { + if (!cancelled) { + setStatus({ + state: 'error', + message: error instanceof Error ? error.message : String(error), + }) + } + } + })() + + return () => { + cancelled = true + } + }, [goal]) + + if (status.state === 'loading') { + return + } + + if (status.state === 'error') { + return ( + + + {status.message} + { + if (value === 'continue') { + onNeedOpenAI(status.defaultModel) + } else if (value === 'back') { + onBack() + } else { + onCancel() + } + }} + onCancel={onCancel} + /> + + + ) + } + + return ( + + + + Auto setup recommends a local Ollama profile for {goal} based on the + models currently available on this machine. + + + Recommended model: {status.model} + {status.summary ? ` · ${status.summary}` : ''} + + (value === 'back' ? onBack() : onCancel())} + onCancel={onCancel} + /> + + + ) + } + + return ( + + + + Pick one of the installed Ollama models to save into a local provider + profile. + + (value === 'back' ? onBack() : onCancel())} + onCancel={onCancel} + /> + + + ) + } + + const options: OptionWithDescription[] = [ + { + label: 'codexplan', + value: 'codexplan', + description: 'GPT-5.4 with higher reasoning on the Codex backend', + }, + { + label: 'codexspark', + value: 'codexspark', + description: 'Faster Codex Spark tool loop profile', + }, + ] + + return ( + + + + Reuse your existing Codex credentials from{' '} + {credentials.sourceDescription} and save a model alias profile. + +