diff --git a/apps/server/src/providers/opencode-provider.ts b/apps/server/src/providers/opencode-provider.ts index 42a7045f..b54592c3 100644 --- a/apps/server/src/providers/opencode-provider.ts +++ b/apps/server/src/providers/opencode-provider.ts @@ -22,7 +22,18 @@ import type { ContentBlock, } from '@automaker/types'; import { stripProviderPrefix } from '@automaker/types'; -import { type SubprocessOptions } from '@automaker/platform'; +import { type SubprocessOptions, getOpenCodeAuthIndicators } from '@automaker/platform'; + +// ============================================================================= +// OpenCode Auth Types +// ============================================================================= + +export interface OpenCodeAuthStatus { + authenticated: boolean; + method: 'api_key' | 'oauth' | 'none'; + hasOAuthToken?: boolean; + hasApiKey?: boolean; +} // ============================================================================= // OpenCode Stream Event Types @@ -583,6 +594,48 @@ export class OpencodeProvider extends CliProvider { return supportedFeatures.includes(feature); } + // ========================================================================== + // Authentication + // ========================================================================== + + /** + * Check authentication status for OpenCode CLI + * + * Checks for authentication via: + * - OAuth token in auth file + * - API key in auth file + */ + async checkAuth(): Promise { + const authIndicators = await getOpenCodeAuthIndicators(); + + // Check for OAuth token + if (authIndicators.hasOAuthToken) { + return { + authenticated: true, + method: 'oauth', + hasOAuthToken: true, + hasApiKey: authIndicators.hasApiKey, + }; + } + + // Check for API key + if (authIndicators.hasApiKey) { + return { + authenticated: true, + method: 'api_key', + hasOAuthToken: false, + hasApiKey: true, + }; + } + + return { + authenticated: false, + method: 'none', + hasOAuthToken: false, + hasApiKey: false, + }; + } + // ========================================================================== // Installation Detection // ========================================================================== @@ -593,16 +646,21 @@ export class OpencodeProvider extends CliProvider { * Checks if the opencode CLI is available either through: * - Direct installation (npm global) * - NPX (fallback on Windows) + * Also checks authentication status. */ async detectInstallation(): Promise { this.ensureCliDetected(); const installed = await this.isInstalled(); + const auth = await this.checkAuth(); return { installed, path: this.cliPath || undefined, method: this.detectedStrategy === 'npx' ? 'npm' : 'cli', + authenticated: auth.authenticated, + hasApiKey: auth.hasApiKey, + hasOAuthToken: auth.hasOAuthToken, }; } } diff --git a/apps/server/src/routes/setup/routes/opencode-status.ts b/apps/server/src/routes/setup/routes/opencode-status.ts index 7e8edd5e..f474cfb1 100644 --- a/apps/server/src/routes/setup/routes/opencode-status.ts +++ b/apps/server/src/routes/setup/routes/opencode-status.ts @@ -12,7 +12,7 @@ import { getErrorMessage, logError } from '../common.js'; */ export function createOpencodeStatusHandler() { const installCommand = 'curl -fsSL https://opencode.ai/install | bash'; - const loginCommand = 'opencode auth'; + const loginCommand = 'opencode auth login'; return async (_req: Request, res: Response): Promise => { try { @@ -35,11 +35,13 @@ export function createOpencodeStatusHandler() { method: authMethod, hasApiKey: status.hasApiKey || false, hasEnvApiKey: !!process.env.ANTHROPIC_API_KEY || !!process.env.OPENAI_API_KEY, - hasOAuthToken: false, // OpenCode doesn't use OAuth + hasOAuthToken: status.hasOAuthToken || false, }, recommendation: status.installed ? undefined : 'Install OpenCode CLI to use multi-provider AI models.', + installCommand, + loginCommand, installCommands: { macos: installCommand, linux: installCommand, diff --git a/apps/ui/src/components/views/setup-view.tsx b/apps/ui/src/components/views/setup-view.tsx index 82e399ea..f3e9d1dd 100644 --- a/apps/ui/src/components/views/setup-view.tsx +++ b/apps/ui/src/components/views/setup-view.tsx @@ -5,10 +5,7 @@ import { WelcomeStep, ThemeStep, CompleteStep, - ClaudeSetupStep, - CursorSetupStep, - CodexSetupStep, - OpencodeSetupStep, + ProvidersSetupStep, GitHubSetupStep, } from './setup-view/steps'; import { useNavigate } from '@tanstack/react-router'; @@ -17,30 +14,31 @@ const logger = createLogger('SetupView'); // Main Setup View export function SetupView() { - const { currentStep, setCurrentStep, completeSetup, setSkipClaudeSetup } = useSetupStore(); + const { currentStep, setCurrentStep, completeSetup } = useSetupStore(); const navigate = useNavigate(); - const steps = [ - 'welcome', - 'theme', - 'claude', - 'cursor', - 'codex', - 'opencode', - 'github', - 'complete', - ] as const; + // Simplified steps: welcome, theme, providers (combined), github, complete + const steps = ['welcome', 'theme', 'providers', 'github', 'complete'] as const; type StepName = (typeof steps)[number]; + const getStepName = (): StepName => { - if (currentStep === 'claude_detect' || currentStep === 'claude_auth') return 'claude'; + // Map old step names to new consolidated steps if (currentStep === 'welcome') return 'welcome'; if (currentStep === 'theme') return 'theme'; - if (currentStep === 'cursor') return 'cursor'; - if (currentStep === 'codex') return 'codex'; - if (currentStep === 'opencode') return 'opencode'; + if ( + currentStep === 'claude_detect' || + currentStep === 'claude_auth' || + currentStep === 'cursor' || + currentStep === 'codex' || + currentStep === 'opencode' || + currentStep === 'providers' + ) { + return 'providers'; + } if (currentStep === 'github') return 'github'; return 'complete'; }; + const currentIndex = steps.indexOf(getStepName()); const handleNext = (from: string) => { @@ -51,22 +49,10 @@ export function SetupView() { setCurrentStep('theme'); break; case 'theme': - logger.debug('[Setup Flow] Moving to claude_detect step'); - setCurrentStep('claude_detect'); + logger.debug('[Setup Flow] Moving to providers step'); + setCurrentStep('providers'); break; - case 'claude': - logger.debug('[Setup Flow] Moving to cursor step'); - setCurrentStep('cursor'); - break; - case 'cursor': - logger.debug('[Setup Flow] Moving to codex step'); - setCurrentStep('codex'); - break; - case 'codex': - logger.debug('[Setup Flow] Moving to opencode step'); - setCurrentStep('opencode'); - break; - case 'opencode': + case 'providers': logger.debug('[Setup Flow] Moving to github step'); setCurrentStep('github'); break; @@ -83,45 +69,15 @@ export function SetupView() { case 'theme': setCurrentStep('welcome'); break; - case 'claude': + case 'providers': setCurrentStep('theme'); break; - case 'cursor': - setCurrentStep('claude_detect'); - break; - case 'codex': - setCurrentStep('cursor'); - break; - case 'opencode': - setCurrentStep('codex'); - break; case 'github': - setCurrentStep('opencode'); + setCurrentStep('providers'); break; } }; - const handleSkipClaude = () => { - logger.debug('[Setup Flow] Skipping Claude setup'); - setSkipClaudeSetup(true); - setCurrentStep('cursor'); - }; - - const handleSkipCursor = () => { - logger.debug('[Setup Flow] Skipping Cursor setup'); - setCurrentStep('codex'); - }; - - const handleSkipCodex = () => { - logger.debug('[Setup Flow] Skipping Codex setup'); - setCurrentStep('opencode'); - }; - - const handleSkipOpencode = () => { - logger.debug('[Setup Flow] Skipping OpenCode setup'); - setCurrentStep('github'); - }; - const handleSkipGithub = () => { logger.debug('[Setup Flow] Skipping GitHub setup'); setCurrentStep('complete'); @@ -160,35 +116,15 @@ export function SetupView() { handleNext('theme')} onBack={() => handleBack('theme')} /> )} - {(currentStep === 'claude_detect' || currentStep === 'claude_auth') && ( - handleNext('claude')} - onBack={() => handleBack('claude')} - onSkip={handleSkipClaude} - /> - )} - - {currentStep === 'cursor' && ( - handleNext('cursor')} - onBack={() => handleBack('cursor')} - onSkip={handleSkipCursor} - /> - )} - - {currentStep === 'codex' && ( - handleNext('codex')} - onBack={() => handleBack('codex')} - onSkip={handleSkipCodex} - /> - )} - - {currentStep === 'opencode' && ( - handleNext('opencode')} - onBack={() => handleBack('opencode')} - onSkip={handleSkipOpencode} + {(currentStep === 'providers' || + currentStep === 'claude_detect' || + currentStep === 'claude_auth' || + currentStep === 'cursor' || + currentStep === 'codex' || + currentStep === 'opencode') && ( + handleNext('providers')} + onBack={() => handleBack('providers')} /> )} diff --git a/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx index 9637a081..8b56f49c 100644 --- a/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx @@ -38,6 +38,11 @@ interface ClaudeSetupStepProps { onSkip: () => void; } +interface ClaudeSetupContentProps { + /** Hide header and navigation for embedded use */ + embedded?: boolean; +} + type VerificationStatus = 'idle' | 'verifying' | 'verified' | 'error'; // Claude Setup Step diff --git a/apps/ui/src/components/views/setup-view/steps/index.ts b/apps/ui/src/components/views/setup-view/steps/index.ts index 0c25aaed..f6497647 100644 --- a/apps/ui/src/components/views/setup-view/steps/index.ts +++ b/apps/ui/src/components/views/setup-view/steps/index.ts @@ -2,8 +2,11 @@ export { WelcomeStep } from './welcome-step'; export { ThemeStep } from './theme-step'; export { CompleteStep } from './complete-step'; +export { ProvidersSetupStep } from './providers-setup-step'; +export { GitHubSetupStep } from './github-setup-step'; + +// Legacy individual step exports (kept for backwards compatibility) export { ClaudeSetupStep } from './claude-setup-step'; export { CursorSetupStep } from './cursor-setup-step'; export { CodexSetupStep } from './codex-setup-step'; export { OpencodeSetupStep } from './opencode-setup-step'; -export { GitHubSetupStep } from './github-setup-step'; diff --git a/apps/ui/src/components/views/setup-view/steps/opencode-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/opencode-setup-step.tsx index a185d888..afb40b6d 100644 --- a/apps/ui/src/components/views/setup-view/steps/opencode-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/opencode-setup-step.tsx @@ -96,7 +96,7 @@ export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepP try { // Copy login command to clipboard and show instructions - const loginCommand = opencodeCliStatus?.loginCommand || 'opencode login'; + const loginCommand = opencodeCliStatus?.loginCommand || 'opencode auth login'; await navigator.clipboard.writeText(loginCommand); toast.info('Login command copied! Paste in terminal to authenticate.'); @@ -297,13 +297,13 @@ export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepP

- {opencodeCliStatus?.loginCommand || 'opencode login'} + {opencodeCliStatus?.loginCommand || 'opencode auth login'} +
+ + Choose one of the following methods to authenticate with Claude: + + + + + {/* CLI Option */} + + +
+
+ +
+

Claude CLI

+

Use Claude Code subscription

+
+
+ +
+
+ + {!claudeCliStatus?.installed && ( +
+
+ +

Install Claude CLI

+
+
+ +
+ + curl -fsSL https://claude.ai/install.sh | bash + + +
+
+ {isInstalling && } + +
+ )} + + {cliVerificationStatus === 'verified' && ( +
+ +

CLI Authentication verified!

+
+ )} + + {cliVerificationStatus === 'error' && cliVerificationError && ( +
+ +
+

Verification failed

+

{cliVerificationError}

+
+
+ )} + + {cliVerificationStatus !== 'verified' && ( + + )} +
+
+ + {/* API Key Option */} + + +
+
+ +
+

Anthropic API Key

+

+ Pay-per-use with your own API key +

+
+
+ +
+
+ +
+
+ + setApiKey(e.target.value)} + className="bg-input border-border text-foreground" + /> +

+ Don't have an API key?{' '} + + Get one from Anthropic Console + + +

+
+
+ + {hasApiKey && ( + + )} +
+
+ + {apiKeyVerificationStatus === 'verified' && ( +
+ +

API Key verified!

+
+ )} + + {apiKeyVerificationStatus === 'error' && apiKeyVerificationError && ( +
+ +
+

Verification failed

+

{apiKeyVerificationError}

+
+
+ )} + + {apiKeyVerificationStatus !== 'verified' && ( + + )} +
+
+
+
+ + ); +} + +// ============================================================================ +// Cursor Content +// ============================================================================ +function CursorContent() { + const { cursorCliStatus, setCursorCliStatus } = useSetupStore(); + const [isChecking, setIsChecking] = useState(false); + const [isLoggingIn, setIsLoggingIn] = useState(false); + const pollIntervalRef = useRef(null); + + const checkStatus = useCallback(async () => { + setIsChecking(true); + try { + const api = getElectronAPI(); + if (!api.setup?.getCursorStatus) return; + const result = await api.setup.getCursorStatus(); + if (result.success) { + setCursorCliStatus({ + installed: result.installed ?? false, + version: result.version, + path: result.path, + auth: result.auth, + installCommand: result.installCommand, + loginCommand: result.loginCommand, + }); + if (result.auth?.authenticated) { + toast.success('Cursor CLI is ready!'); + } + } + } catch { + // Ignore errors + } finally { + setIsChecking(false); + } + }, [setCursorCliStatus]); + + useEffect(() => { + checkStatus(); + return () => { + if (pollIntervalRef.current) clearInterval(pollIntervalRef.current); + }; + }, [checkStatus]); + + const copyCommand = (command: string) => { + navigator.clipboard.writeText(command); + toast.success('Command copied to clipboard'); + }; + + const handleLogin = async () => { + setIsLoggingIn(true); + try { + const loginCommand = cursorCliStatus?.loginCommand || 'cursor-agent login'; + await navigator.clipboard.writeText(loginCommand); + toast.info('Login command copied! Paste in terminal to authenticate.'); + + let attempts = 0; + pollIntervalRef.current = setInterval(async () => { + attempts++; + try { + const api = getElectronAPI(); + if (!api.setup?.getCursorStatus) return; + const result = await api.setup.getCursorStatus(); + if (result.auth?.authenticated) { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + setCursorCliStatus({ + ...cursorCliStatus, + installed: result.installed ?? true, + version: result.version, + path: result.path, + auth: result.auth, + }); + setIsLoggingIn(false); + toast.success('Successfully logged in to Cursor!'); + } + } catch { + // Ignore + } + if (attempts >= 60) { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + setIsLoggingIn(false); + toast.error('Login timed out. Please try again.'); + } + }, 2000); + } catch { + toast.error('Failed to start login process'); + setIsLoggingIn(false); + } + }; + + const isReady = cursorCliStatus?.installed && cursorCliStatus?.auth?.authenticated; + + return ( + + +
+ + + Cursor CLI Status + + +
+ + {cursorCliStatus?.installed + ? cursorCliStatus.auth?.authenticated + ? `Authenticated${cursorCliStatus.version ? ` (v${cursorCliStatus.version})` : ''}` + : 'Installed but not authenticated' + : 'Not installed on your system'} + +
+ + {isReady && ( +
+ +

Cursor CLI is ready!

+
+ )} + + {!cursorCliStatus?.installed && !isChecking && ( +
+
+ +
+

Cursor CLI not found

+

+ Install Cursor IDE to use Cursor AI agent. +

+
+
+
+

Install Cursor:

+
+ + {cursorCliStatus?.installCommand || 'npm install -g @anthropic/cursor-agent'} + + +
+
+
+ )} + + {cursorCliStatus?.installed && !cursorCliStatus?.auth?.authenticated && !isChecking && ( +
+
+ +
+

Cursor CLI not authenticated

+

+ Run the login command to authenticate. +

+
+
+
+
+ + {cursorCliStatus?.loginCommand || 'cursor-agent login'} + + +
+ +
+
+ )} + + {isChecking && ( +
+ +

Checking Cursor CLI status...

+
+ )} +
+
+ ); +} + +// ============================================================================ +// Codex Content +// ============================================================================ +function CodexContent() { + const { codexCliStatus, codexAuthStatus, setCodexCliStatus, setCodexAuthStatus } = + useSetupStore(); + const { setApiKeys, apiKeys } = useAppStore(); + const [isChecking, setIsChecking] = useState(false); + const [apiKey, setApiKey] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const [isLoggingIn, setIsLoggingIn] = useState(false); + const pollIntervalRef = useRef(null); + + const checkStatus = useCallback(async () => { + setIsChecking(true); + try { + const api = getElectronAPI(); + if (!api.setup?.getCodexStatus) return; + const result = await api.setup.getCodexStatus(); + if (result.success) { + setCodexCliStatus({ + installed: result.installed ?? false, + version: result.version, + path: result.path, + }); + if (result.auth?.authenticated) { + setCodexAuthStatus({ + authenticated: true, + method: result.auth.method || 'cli_authenticated', + }); + toast.success('Codex CLI is ready!'); + } + } + } catch { + // Ignore + } finally { + setIsChecking(false); + } + }, [setCodexCliStatus, setCodexAuthStatus]); + + useEffect(() => { + checkStatus(); + return () => { + if (pollIntervalRef.current) clearInterval(pollIntervalRef.current); + }; + }, [checkStatus]); + + const copyCommand = (command: string) => { + navigator.clipboard.writeText(command); + toast.success('Command copied to clipboard'); + }; + + const handleSaveApiKey = async () => { + if (!apiKey.trim()) return; + setIsSaving(true); + try { + const api = getElectronAPI(); + if (!api.setup?.saveApiKey) { + toast.error('Save API not available'); + return; + } + const result = await api.setup.saveApiKey('openai', apiKey); + if (result.success) { + setApiKeys({ ...apiKeys, openai: apiKey }); + setCodexAuthStatus({ authenticated: true, method: 'api_key' }); + toast.success('API key saved successfully!'); + } + } catch { + toast.error('Failed to save API key'); + } finally { + setIsSaving(false); + } + }; + + const handleLogin = async () => { + setIsLoggingIn(true); + try { + await navigator.clipboard.writeText('codex login'); + toast.info('Login command copied! Paste in terminal to authenticate.'); + + let attempts = 0; + pollIntervalRef.current = setInterval(async () => { + attempts++; + try { + const api = getElectronAPI(); + if (!api.setup?.getCodexStatus) return; + const result = await api.setup.getCodexStatus(); + if (result.auth?.authenticated) { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + setCodexAuthStatus({ authenticated: true, method: 'cli_authenticated' }); + setIsLoggingIn(false); + toast.success('Successfully logged in to Codex!'); + } + } catch { + // Ignore + } + if (attempts >= 60) { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + setIsLoggingIn(false); + toast.error('Login timed out. Please try again.'); + } + }, 2000); + } catch { + toast.error('Failed to start login process'); + setIsLoggingIn(false); + } + }; + + const isReady = codexCliStatus?.installed && codexAuthStatus?.authenticated; + const hasApiKey = !!apiKeys.openai || codexAuthStatus?.method === 'api_key'; + + return ( + + +
+ + + Codex CLI Status + + +
+ + {codexCliStatus?.installed + ? codexAuthStatus?.authenticated + ? `Authenticated${codexCliStatus.version ? ` (v${codexCliStatus.version})` : ''}` + : 'Installed but not authenticated' + : 'Not installed on your system'} + +
+ + {isReady && ( +
+ +

Codex CLI is ready!

+
+ )} + + {!codexCliStatus?.installed && !isChecking && ( +
+
+ +
+

Codex CLI not found

+

+ Install the Codex CLI to use OpenAI models. +

+
+
+
+

Install Codex CLI:

+
+ + npm install -g @openai/codex + + +
+
+
+ )} + + {codexCliStatus?.installed && !codexAuthStatus?.authenticated && !isChecking && ( + + + +
+ + Codex CLI Login +
+
+ +
+ + codex login + + +
+ +
+
+ + + +
+ + OpenAI API Key +
+
+ +
+ setApiKey(e.target.value)} + className="bg-input border-border text-foreground" + /> +

+ + Get an API key from OpenAI + + +

+
+ +
+
+
+ )} + + {isChecking && ( +
+ +

Checking Codex CLI status...

+
+ )} +
+
+ ); +} + +// ============================================================================ +// OpenCode Content +// ============================================================================ +function OpencodeContent() { + const { opencodeCliStatus, setOpencodeCliStatus } = useSetupStore(); + const [isChecking, setIsChecking] = useState(false); + const [isLoggingIn, setIsLoggingIn] = useState(false); + const pollIntervalRef = useRef(null); + + const checkStatus = useCallback(async () => { + setIsChecking(true); + try { + const api = getElectronAPI(); + if (!api.setup?.getOpencodeStatus) return; + const result = await api.setup.getOpencodeStatus(); + if (result.success) { + setOpencodeCliStatus({ + installed: result.installed ?? false, + version: result.version, + path: result.path, + auth: result.auth, + installCommand: result.installCommand, + loginCommand: result.loginCommand, + }); + if (result.auth?.authenticated) { + toast.success('OpenCode CLI is ready!'); + } + } + } catch { + // Ignore + } finally { + setIsChecking(false); + } + }, [setOpencodeCliStatus]); + + useEffect(() => { + checkStatus(); + return () => { + if (pollIntervalRef.current) clearInterval(pollIntervalRef.current); + }; + }, [checkStatus]); + + const copyCommand = (command: string) => { + navigator.clipboard.writeText(command); + toast.success('Command copied to clipboard'); + }; + + const handleLogin = async () => { + setIsLoggingIn(true); + try { + const loginCommand = opencodeCliStatus?.loginCommand || 'opencode auth login'; + await navigator.clipboard.writeText(loginCommand); + toast.info('Login command copied! Paste in terminal to authenticate.'); + + let attempts = 0; + pollIntervalRef.current = setInterval(async () => { + attempts++; + try { + const api = getElectronAPI(); + if (!api.setup?.getOpencodeStatus) return; + const result = await api.setup.getOpencodeStatus(); + if (result.auth?.authenticated) { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + setOpencodeCliStatus({ + ...opencodeCliStatus, + installed: result.installed ?? true, + version: result.version, + path: result.path, + auth: result.auth, + }); + setIsLoggingIn(false); + toast.success('Successfully logged in to OpenCode!'); + } + } catch { + // Ignore + } + if (attempts >= 60) { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + setIsLoggingIn(false); + toast.error('Login timed out. Please try again.'); + } + }, 2000); + } catch { + toast.error('Failed to start login process'); + setIsLoggingIn(false); + } + }; + + const isReady = opencodeCliStatus?.installed && opencodeCliStatus?.auth?.authenticated; + + return ( + + +
+ + + OpenCode CLI Status + + +
+ + {opencodeCliStatus?.installed + ? opencodeCliStatus.auth?.authenticated + ? `Authenticated${opencodeCliStatus.version ? ` (v${opencodeCliStatus.version})` : ''}` + : 'Installed but not authenticated' + : 'Not installed on your system'} + +
+ + {isReady && ( +
+ +

OpenCode CLI is ready!

+
+ )} + + {!opencodeCliStatus?.installed && !isChecking && ( +
+
+ +
+

OpenCode CLI not found

+

+ Install the OpenCode CLI for free tier and AWS Bedrock models. +

+
+
+
+

Install OpenCode CLI:

+
+ + {opencodeCliStatus?.installCommand || + 'curl -fsSL https://opencode.ai/install | bash'} + + +
+
+
+ )} + + {opencodeCliStatus?.installed && !opencodeCliStatus?.auth?.authenticated && !isChecking && ( +
+
+ +
+

OpenCode CLI not authenticated

+

+ Run the login command to authenticate. +

+
+
+
+
+ + {opencodeCliStatus?.loginCommand || 'opencode auth login'} + + +
+ +
+
+ )} + + {isChecking && ( +
+ +

Checking OpenCode CLI status...

+
+ )} +
+
+ ); +} + +// ============================================================================ +// Main Component +// ============================================================================ +export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps) { + const [activeTab, setActiveTab] = useState('claude'); + + const { claudeAuthStatus, cursorCliStatus, codexAuthStatus, opencodeCliStatus } = useSetupStore(); + + const isClaudeConfigured = + claudeAuthStatus?.authenticated === true && + (claudeAuthStatus?.method === 'cli_authenticated' || + claudeAuthStatus?.method === 'api_key' || + claudeAuthStatus?.method === 'api_key_env'); + + const isCursorConfigured = cursorCliStatus?.installed && cursorCliStatus?.auth?.authenticated; + const isCodexConfigured = codexAuthStatus?.authenticated === true; + const isOpencodeConfigured = + opencodeCliStatus?.installed && opencodeCliStatus?.auth?.authenticated; + + const hasAtLeastOneProvider = + isClaudeConfigured || isCursorConfigured || isCodexConfigured || isOpencodeConfigured; + + const providers = [ + { + id: 'claude' as const, + label: 'Claude', + icon: AnthropicIcon, + configured: isClaudeConfigured, + color: 'text-brand-500', + }, + { + id: 'cursor' as const, + label: 'Cursor', + icon: CursorIcon, + configured: isCursorConfigured, + color: 'text-blue-500', + }, + { + id: 'codex' as const, + label: 'Codex', + icon: OpenAIIcon, + configured: isCodexConfigured, + color: 'text-emerald-500', + }, + { + id: 'opencode' as const, + label: 'OpenCode', + icon: OpenCodeIcon, + configured: isOpencodeConfigured, + color: 'text-green-500', + }, + ]; + + return ( +
+
+

AI Provider Setup

+

Configure at least one AI provider to continue

+
+ + setActiveTab(v as ProviderTab)}> + + {providers.map((provider) => { + const Icon = provider.icon; + return ( + +
+ + {provider.configured && ( + + )} +
+ {provider.label} +
+ ); + })} +
+ +
+ + + + + + + + + + + + +
+
+ +
+ {providers.map((provider) => ( +
+ {provider.configured ? ( + + ) : ( +
+ )} + {provider.label} +
+ ))} +
+ +
+ + +
+ + {!hasAtLeastOneProvider && ( +

+ You can configure providers later in Settings +

+ )} +
+ ); +} diff --git a/apps/ui/src/store/setup-store.ts b/apps/ui/src/store/setup-store.ts index b8e7f717..386896ee 100644 --- a/apps/ui/src/store/setup-store.ts +++ b/apps/ui/src/store/setup-store.ts @@ -113,6 +113,7 @@ export interface InstallProgress { export type SetupStep = | 'welcome' | 'theme' + | 'providers' | 'claude_detect' | 'claude_auth' | 'cursor' diff --git a/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts index a0cbff27..c1faee26 100644 --- a/libs/platform/src/system-paths.ts +++ b/libs/platform/src/system-paths.ts @@ -1019,7 +1019,7 @@ export async function getCodexAuthIndicators(): Promise { // OpenCode CLI Detection // ============================================================================= -const OPENCODE_CONFIG_DIR_NAME = '.opencode'; +const OPENCODE_DATA_DIR = '.local/share/opencode'; const OPENCODE_AUTH_FILENAME = 'auth.json'; const OPENCODE_TOKENS_KEY = 'tokens'; @@ -1092,10 +1092,12 @@ export function getOpenCodeCliPaths(): string[] { } /** - * Get the OpenCode configuration directory path + * Get the OpenCode data directory path + * macOS/Linux: ~/.local/share/opencode + * Windows: %USERPROFILE%\.local\share\opencode */ export function getOpenCodeConfigDir(): string { - return path.join(os.homedir(), OPENCODE_CONFIG_DIR_NAME); + return path.join(os.homedir(), OPENCODE_DATA_DIR); } /** @@ -1121,6 +1123,9 @@ export interface OpenCodeAuthIndicators { const OPENCODE_OAUTH_KEYS = ['access_token', 'oauth_token'] as const; const OPENCODE_API_KEY_KEYS = ['api_key', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY'] as const; +// Provider names that OpenCode uses for provider-specific auth entries +const OPENCODE_PROVIDERS = ['anthropic', 'openai', 'google', 'bedrock', 'amazon-bedrock'] as const; + function getOpenCodeNestedTokens(record: Record): Record | null { const tokens = record[OPENCODE_TOKENS_KEY]; if (tokens && typeof tokens === 'object' && !Array.isArray(tokens)) { @@ -1129,6 +1134,49 @@ function getOpenCodeNestedTokens(record: Record): Record): boolean { + for (const provider of OPENCODE_PROVIDERS) { + const providerAuth = authJson[provider]; + if (providerAuth && typeof providerAuth === 'object' && !Array.isArray(providerAuth)) { + const auth = providerAuth as Record; + // Check for OAuth type with access token + if (auth.type === 'oauth' && typeof auth.access === 'string' && auth.access) { + return true; + } + // Also check for access_token field directly + if (typeof auth.access_token === 'string' && auth.access_token) { + return true; + } + } + } + return false; +} + +/** + * Check if the auth JSON has provider-specific API key credentials + */ +function hasProviderApiKey(authJson: Record): boolean { + for (const provider of OPENCODE_PROVIDERS) { + const providerAuth = authJson[provider]; + if (providerAuth && typeof providerAuth === 'object' && !Array.isArray(providerAuth)) { + const auth = providerAuth as Record; + // Check for API key type + if (auth.type === 'api_key' && typeof auth.key === 'string' && auth.key) { + return true; + } + // Also check for api_key field directly + if (typeof auth.api_key === 'string' && auth.api_key) { + return true; + } + } + } + return false; +} + /** * Get OpenCode authentication status by checking auth file indicators */ @@ -1145,8 +1193,12 @@ export async function getOpenCodeAuthIndicators(): Promise; + + // Check for legacy top-level keys result.hasOAuthToken = hasNonEmptyStringField(authJson, OPENCODE_OAUTH_KEYS); result.hasApiKey = hasNonEmptyStringField(authJson, OPENCODE_API_KEY_KEYS); + + // Check for nested tokens object (legacy format) const nestedTokens = getOpenCodeNestedTokens(authJson); if (nestedTokens) { result.hasOAuthToken = @@ -1154,6 +1206,11 @@ export async function getOpenCodeAuthIndicators(): Promise