From 89248001e48207004a677bc64a1be85e7e076a28 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Fri, 9 Jan 2026 09:39:46 -0500 Subject: [PATCH] feat: implement OpenCode authentication and provider setup - Added OpenCode authentication status check to the OpencodeProvider class. - Introduced OpenCodeAuthStatus interface to manage authentication states. - Updated detectInstallation method to include authentication status in the response. - Created ProvidersSetupStep component to consolidate provider setup UI, including Claude, Cursor, Codex, and OpenCode. - Refactored setup view to streamline navigation and improve user experience. - Enhanced OpenCode CLI integration with updated installation paths and authentication checks. This commit enhances the setup process by allowing users to configure and authenticate multiple AI providers, improving overall functionality and user experience. --- .../server/src/providers/opencode-provider.ts | 60 +- .../routes/setup/routes/opencode-status.ts | 6 +- apps/ui/src/components/views/setup-view.tsx | 126 +- .../setup-view/steps/claude-setup-step.tsx | 5 + .../views/setup-view/steps/index.ts | 5 +- .../setup-view/steps/opencode-setup-step.tsx | 6 +- .../setup-view/steps/providers-setup-step.tsx | 1318 +++++++++++++++++ apps/ui/src/store/setup-store.ts | 1 + libs/platform/src/system-paths.ts | 63 +- 9 files changed, 1485 insertions(+), 105 deletions(-) create mode 100644 apps/ui/src/components/views/setup-view/steps/providers-setup-step.tsx 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