diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 4bd496bc..c10702bb 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -121,21 +121,89 @@ const BOX_CONTENT_WIDTH = 67; // The Claude Agent SDK can use either ANTHROPIC_API_KEY or Claude Code CLI authentication (async () => { const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY; + const hasEnvOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN; + + logger.debug('[CREDENTIAL_CHECK] Starting credential detection...'); + logger.debug('[CREDENTIAL_CHECK] Environment variables:', { + hasAnthropicKey, + hasEnvOAuthToken, + }); if (hasAnthropicKey) { logger.info('✓ ANTHROPIC_API_KEY detected'); return; } + if (hasEnvOAuthToken) { + logger.info('✓ CLAUDE_CODE_OAUTH_TOKEN detected'); + return; + } + // Check for Claude Code CLI authentication + // Store indicators outside the try block so we can use them in the warning message + let cliAuthIndicators: Awaited> | null = null; + try { - const indicators = await getClaudeAuthIndicators(); + cliAuthIndicators = await getClaudeAuthIndicators(); + const indicators = cliAuthIndicators; + + // Log detailed credential detection results + logger.debug('[CREDENTIAL_CHECK] Claude CLI auth indicators:', { + hasCredentialsFile: indicators.hasCredentialsFile, + hasSettingsFile: indicators.hasSettingsFile, + hasStatsCacheWithActivity: indicators.hasStatsCacheWithActivity, + hasProjectsSessions: indicators.hasProjectsSessions, + credentials: indicators.credentials, + }); + + logger.debug('[CREDENTIAL_CHECK] File check details:', { + settingsFile: { + path: indicators.checks.settingsFile.path, + exists: indicators.checks.settingsFile.exists, + readable: indicators.checks.settingsFile.readable, + error: indicators.checks.settingsFile.error, + }, + statsCache: { + path: indicators.checks.statsCache.path, + exists: indicators.checks.statsCache.exists, + readable: indicators.checks.statsCache.readable, + hasDailyActivity: indicators.checks.statsCache.hasDailyActivity, + error: indicators.checks.statsCache.error, + }, + projectsDir: { + path: indicators.checks.projectsDir.path, + exists: indicators.checks.projectsDir.exists, + readable: indicators.checks.projectsDir.readable, + entryCount: indicators.checks.projectsDir.entryCount, + error: indicators.checks.projectsDir.error, + }, + credentialFiles: indicators.checks.credentialFiles.map((cf) => ({ + path: cf.path, + exists: cf.exists, + readable: cf.readable, + error: cf.error, + })), + }); + const hasCliAuth = indicators.hasStatsCacheWithActivity || (indicators.hasSettingsFile && indicators.hasProjectsSessions) || (indicators.hasCredentialsFile && (indicators.credentials?.hasOAuthToken || indicators.credentials?.hasApiKey)); + logger.debug('[CREDENTIAL_CHECK] Auth determination:', { + hasCliAuth, + reason: hasCliAuth + ? indicators.hasStatsCacheWithActivity + ? 'stats cache with activity' + : indicators.hasSettingsFile && indicators.hasProjectsSessions + ? 'settings file + project sessions' + : indicators.credentials?.hasOAuthToken + ? 'credentials file with OAuth token' + : 'credentials file with API key' + : 'no valid credentials found', + }); + if (hasCliAuth) { logger.info('✓ Claude Code CLI authentication detected'); return; @@ -145,7 +213,7 @@ const BOX_CONTENT_WIDTH = 67; logger.warn('Error checking for Claude Code CLI authentication:', error); } - // No authentication found - show warning + // No authentication found - show warning with paths that were checked const wHeader = '⚠️ WARNING: No Claude authentication configured'.padEnd(BOX_CONTENT_WIDTH); const w1 = 'The Claude Agent SDK requires authentication to function.'.padEnd(BOX_CONTENT_WIDTH); const w2 = 'Options:'.padEnd(BOX_CONTENT_WIDTH); @@ -158,6 +226,33 @@ const BOX_CONTENT_WIDTH = 67; BOX_CONTENT_WIDTH ); + // Build paths checked summary from the indicators (if available) + let pathsCheckedInfo = ''; + if (cliAuthIndicators) { + const pathsChecked: string[] = []; + + // Collect paths that were checked + if (cliAuthIndicators.checks.settingsFile.path) { + pathsChecked.push(`Settings: ${cliAuthIndicators.checks.settingsFile.path}`); + } + if (cliAuthIndicators.checks.statsCache.path) { + pathsChecked.push(`Stats cache: ${cliAuthIndicators.checks.statsCache.path}`); + } + if (cliAuthIndicators.checks.projectsDir.path) { + pathsChecked.push(`Projects dir: ${cliAuthIndicators.checks.projectsDir.path}`); + } + for (const credFile of cliAuthIndicators.checks.credentialFiles) { + pathsChecked.push(`Credentials: ${credFile.path}`); + } + + if (pathsChecked.length > 0) { + pathsCheckedInfo = ` +║ ║ +║ ${'Paths checked:'.padEnd(BOX_CONTENT_WIDTH)}║ +${pathsChecked.map((p) => `║ ${p.substring(0, BOX_CONTENT_WIDTH - 2).padEnd(BOX_CONTENT_WIDTH - 2)} ║`).join('\n')}`; + } + } + logger.warn(` ╔═════════════════════════════════════════════════════════════════════╗ ║ ${wHeader}║ @@ -169,7 +264,7 @@ const BOX_CONTENT_WIDTH = 67; ║ ${w3}║ ║ ${w4}║ ║ ${w5}║ -║ ${w6}║ +║ ${w6}║${pathsCheckedInfo} ║ ║ ╚═════════════════════════════════════════════════════════════════════╝ `); diff --git a/apps/server/src/routes/setup/routes/verify-claude-auth.ts b/apps/server/src/routes/setup/routes/verify-claude-auth.ts index df04d462..2a8d21b0 100644 --- a/apps/server/src/routes/setup/routes/verify-claude-auth.ts +++ b/apps/server/src/routes/setup/routes/verify-claude-auth.ts @@ -320,9 +320,28 @@ export function createVerifyClaudeAuthHandler() { authMethod, }); + // Determine specific auth type for success messages + let authType: 'oauth' | 'api_key' | 'cli' | undefined; + if (authenticated) { + if (authMethod === 'api_key') { + authType = 'api_key'; + } else if (authMethod === 'cli') { + // Check if CLI auth is via OAuth (Claude Code subscription) or generic CLI + // OAuth tokens are stored in the credentials file by the Claude CLI + const { getClaudeAuthIndicators } = await import('@automaker/platform'); + const indicators = await getClaudeAuthIndicators(); + if (indicators.credentials?.hasOAuthToken) { + authType = 'oauth'; + } else { + authType = 'cli'; + } + } + } + res.json({ success: true, authenticated, + authType, error: errorMessage || undefined, }); } catch (error) { diff --git a/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx b/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx index 3a5f6d35..7b597c8c 100644 --- a/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx +++ b/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx @@ -69,6 +69,29 @@ export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialog For safer operation, consider running Automaker in Docker. See the README for instructions.

+ +
+

+ Already running in Docker? Try these troubleshooting steps: +

+ +
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 b864bfdb..0b4799d6 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 @@ -59,6 +59,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps // CLI Verification state const [cliVerificationStatus, setCliVerificationStatus] = useState('idle'); const [cliVerificationError, setCliVerificationError] = useState(null); + const [cliAuthType, setCliAuthType] = useState<'oauth' | 'cli' | null>(null); // API Key Verification state const [apiKeyVerificationStatus, setApiKeyVerificationStatus] = @@ -119,6 +120,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps const verifyCliAuth = useCallback(async () => { setCliVerificationStatus('verifying'); setCliVerificationError(null); + setCliAuthType(null); try { const api = getElectronAPI(); @@ -138,12 +140,21 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps if (result.authenticated && !hasLimitReachedError) { setCliVerificationStatus('verified'); + // Store the auth type for displaying specific success message + const authType = result.authType === 'oauth' ? 'oauth' : 'cli'; + setCliAuthType(authType); setClaudeAuthStatus({ authenticated: true, - method: 'cli_authenticated', + method: authType === 'oauth' ? 'oauth_token' : 'cli_authenticated', hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false, + oauthTokenValid: authType === 'oauth', }); - toast.success('Claude CLI authentication verified!'); + // Show specific success message based on auth type + if (authType === 'oauth') { + toast.success('Claude Code subscription detected and verified!'); + } else { + toast.success('Claude CLI authentication verified!'); + } } else { setCliVerificationStatus('error'); setCliVerificationError( @@ -436,9 +447,15 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
-

CLI Authentication verified!

+

+ {cliAuthType === 'oauth' + ? 'Claude Code subscription verified!' + : 'CLI Authentication verified!'} +

- Your Claude CLI is working correctly. + {cliAuthType === 'oauth' + ? 'Your Claude Code subscription is active and ready to use.' + : 'Your Claude CLI is working correctly.'}

diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 89aa07ba..22079822 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1442,6 +1442,7 @@ interface SetupAPI { verifyClaudeAuth: (authMethod?: 'cli' | 'api_key') => Promise<{ success: boolean; authenticated: boolean; + authType?: 'oauth' | 'api_key' | 'cli'; error?: string; }>; getGhStatus?: () => Promise<{ diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 1f79ff07..acd75d22 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1350,6 +1350,7 @@ export class HttpApiClient implements ElectronAPI { ): Promise<{ success: boolean; authenticated: boolean; + authType?: 'oauth' | 'api_key' | 'cli'; error?: string; }> => this.post('/api/setup/verify-claude-auth', { authMethod, apiKey }), diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts index 5952ba2d..5c0b8078 100644 --- a/libs/platform/src/index.ts +++ b/libs/platform/src/index.ts @@ -134,6 +134,8 @@ export { findClaudeCliPath, getClaudeAuthIndicators, type ClaudeAuthIndicators, + type FileCheckResult, + type DirectoryCheckResult, findCodexCliPath, getCodexAuthIndicators, type CodexAuthIndicators, diff --git a/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts index 0d900dfa..fb5e6bd3 100644 --- a/libs/platform/src/system-paths.ts +++ b/libs/platform/src/system-paths.ts @@ -976,6 +976,27 @@ export async function findGitBashPath(): Promise { return findFirstExistingPath(getGitBashPaths()); } +/** + * Details about a file check performed during auth detection + */ +export interface FileCheckResult { + path: string; + exists: boolean; + readable: boolean; + error?: string; +} + +/** + * Details about a directory check performed during auth detection + */ +export interface DirectoryCheckResult { + path: string; + exists: boolean; + readable: boolean; + entryCount: number; + error?: string; +} + /** * Get Claude authentication status by checking various indicators */ @@ -988,67 +1009,144 @@ export interface ClaudeAuthIndicators { hasOAuthToken: boolean; hasApiKey: boolean; } | null; + /** Detailed information about what was checked */ + checks: { + settingsFile: FileCheckResult; + statsCache: FileCheckResult & { hasDailyActivity?: boolean }; + projectsDir: DirectoryCheckResult; + credentialFiles: FileCheckResult[]; + }; } export async function getClaudeAuthIndicators(): Promise { + const settingsPath = getClaudeSettingsPath(); + const statsCachePath = getClaudeStatsCachePath(); + const projectsDir = getClaudeProjectsDir(); + const credentialPaths = getClaudeCredentialPaths(); + + // Initialize checks with paths + const settingsFileCheck: FileCheckResult = { + path: settingsPath, + exists: false, + readable: false, + }; + + const statsCacheCheck: FileCheckResult & { hasDailyActivity?: boolean } = { + path: statsCachePath, + exists: false, + readable: false, + }; + + const projectsDirCheck: DirectoryCheckResult = { + path: projectsDir, + exists: false, + readable: false, + entryCount: 0, + }; + + const credentialFileChecks: FileCheckResult[] = credentialPaths.map((p) => ({ + path: p, + exists: false, + readable: false, + })); + const result: ClaudeAuthIndicators = { hasCredentialsFile: false, hasSettingsFile: false, hasStatsCacheWithActivity: false, hasProjectsSessions: false, credentials: null, + checks: { + settingsFile: settingsFileCheck, + statsCache: statsCacheCheck, + projectsDir: projectsDirCheck, + credentialFiles: credentialFileChecks, + }, }; // Check settings file try { - if (await systemPathAccess(getClaudeSettingsPath())) { + if (await systemPathAccess(settingsPath)) { + settingsFileCheck.exists = true; + settingsFileCheck.readable = true; result.hasSettingsFile = true; } - } catch { - // Ignore errors + } catch (err) { + settingsFileCheck.error = err instanceof Error ? err.message : String(err); } // Check stats cache for recent activity try { - const statsContent = await systemPathReadFile(getClaudeStatsCachePath()); - const stats = JSON.parse(statsContent); - if (stats.dailyActivity && stats.dailyActivity.length > 0) { - result.hasStatsCacheWithActivity = true; + const statsContent = await systemPathReadFile(statsCachePath); + statsCacheCheck.exists = true; + statsCacheCheck.readable = true; + try { + const stats = JSON.parse(statsContent); + if (stats.dailyActivity && stats.dailyActivity.length > 0) { + statsCacheCheck.hasDailyActivity = true; + result.hasStatsCacheWithActivity = true; + } else { + statsCacheCheck.hasDailyActivity = false; + } + } catch (parseErr) { + statsCacheCheck.error = `JSON parse error: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`; + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + statsCacheCheck.exists = false; + } else { + statsCacheCheck.error = err instanceof Error ? err.message : String(err); } - } catch { - // Ignore errors } // Check for sessions in projects directory try { - const sessions = await systemPathReaddir(getClaudeProjectsDir()); + const sessions = await systemPathReaddir(projectsDir); + projectsDirCheck.exists = true; + projectsDirCheck.readable = true; + projectsDirCheck.entryCount = sessions.length; if (sessions.length > 0) { result.hasProjectsSessions = true; } - } catch { - // Ignore errors + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + projectsDirCheck.exists = false; + } else { + projectsDirCheck.error = err instanceof Error ? err.message : String(err); + } } // Check credentials files - const credentialPaths = getClaudeCredentialPaths(); - for (const credPath of credentialPaths) { + for (let i = 0; i < credentialPaths.length; i++) { + const credPath = credentialPaths[i]; + const credCheck = credentialFileChecks[i]; try { const content = await systemPathReadFile(credPath); - const credentials = JSON.parse(content); - result.hasCredentialsFile = true; - // Support multiple credential formats: - // 1. Claude Code CLI format: { claudeAiOauth: { accessToken, refreshToken } } - // 2. Legacy format: { oauth_token } or { access_token } - // 3. API key format: { api_key } - const hasClaudeOauth = !!credentials.claudeAiOauth?.accessToken; - const hasLegacyOauth = !!(credentials.oauth_token || credentials.access_token); - result.credentials = { - hasOAuthToken: hasClaudeOauth || hasLegacyOauth, - hasApiKey: !!credentials.api_key, - }; - break; - } catch { - // Continue to next path + credCheck.exists = true; + credCheck.readable = true; + try { + const credentials = JSON.parse(content); + result.hasCredentialsFile = true; + // Support multiple credential formats: + // 1. Claude Code CLI format: { claudeAiOauth: { accessToken, refreshToken } } + // 2. Legacy format: { oauth_token } or { access_token } + // 3. API key format: { api_key } + const hasClaudeOauth = !!credentials.claudeAiOauth?.accessToken; + const hasLegacyOauth = !!(credentials.oauth_token || credentials.access_token); + result.credentials = { + hasOAuthToken: hasClaudeOauth || hasLegacyOauth, + hasApiKey: !!credentials.api_key, + }; + break; + } catch (parseErr) { + credCheck.error = `JSON parse error: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`; + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + credCheck.exists = false; + } else { + credCheck.error = err instanceof Error ? err.message : String(err); + } } } diff --git a/libs/platform/tests/oauth-credential-detection.test.ts b/libs/platform/tests/oauth-credential-detection.test.ts new file mode 100644 index 00000000..cf5a4705 --- /dev/null +++ b/libs/platform/tests/oauth-credential-detection.test.ts @@ -0,0 +1,736 @@ +/** + * Unit tests for OAuth credential detection scenarios + * + * Tests the various Claude credential detection formats including: + * - Claude Code CLI OAuth format (claudeAiOauth) + * - Legacy OAuth token format (oauth_token, access_token) + * - API key format (api_key) + * - Invalid/malformed credential files + * + * These tests use real temp directories to avoid complex fs mocking issues. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; + +describe('OAuth Credential Detection', () => { + let tempDir: string; + let originalHomedir: () => string; + let mockClaudeDir: string; + let mockCodexDir: string; + let mockOpenCodeDir: string; + + beforeEach(async () => { + // Reset modules to get fresh state + vi.resetModules(); + + // Create a temporary directory + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'oauth-detection-test-')); + + // Create mock home directory structure + mockClaudeDir = path.join(tempDir, '.claude'); + mockCodexDir = path.join(tempDir, '.codex'); + mockOpenCodeDir = path.join(tempDir, '.local', 'share', 'opencode'); + + await fs.mkdir(mockClaudeDir, { recursive: true }); + await fs.mkdir(mockCodexDir, { recursive: true }); + await fs.mkdir(mockOpenCodeDir, { recursive: true }); + + // Mock os.homedir to return our temp directory + originalHomedir = os.homedir; + vi.spyOn(os, 'homedir').mockReturnValue(tempDir); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + // Clean up temp directory + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe('getClaudeAuthIndicators', () => { + it('should detect Claude Code CLI OAuth format (claudeAiOauth)', async () => { + const credentialsContent = JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-access-token-12345', + refreshToken: 'oauth-refresh-token-67890', + expiresAt: Date.now() + 3600000, + }, + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials).not.toBeNull(); + expect(indicators.credentials?.hasOAuthToken).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should detect legacy OAuth token format (oauth_token)', async () => { + const credentialsContent = JSON.stringify({ + oauth_token: 'legacy-oauth-token-abcdef', + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should detect legacy access_token format', async () => { + const credentialsContent = JSON.stringify({ + access_token: 'legacy-access-token-xyz', + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should detect API key format', async () => { + const credentialsContent = JSON.stringify({ + api_key: 'sk-ant-api03-xxxxxxxxxxxx', + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(false); + expect(indicators.credentials?.hasApiKey).toBe(true); + }); + + it('should detect both OAuth and API key when present', async () => { + const credentialsContent = JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + refreshToken: 'refresh-token', + }, + api_key: 'sk-ant-api03-xxxxxxxxxxxx', + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(true); + }); + + it('should handle missing credentials file gracefully', async () => { + // No credentials file created + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); + expect(indicators.checks.credentialFiles).toBeDefined(); + expect(indicators.checks.credentialFiles.length).toBeGreaterThan(0); + expect(indicators.checks.credentialFiles[0].exists).toBe(false); + }); + + it('should handle malformed JSON in credentials file', async () => { + const malformedContent = '{ invalid json }'; + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), malformedContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // File exists but parsing fails + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); + expect(indicators.checks.credentialFiles[0].exists).toBe(true); + expect(indicators.checks.credentialFiles[0].error).toContain('JSON parse error'); + }); + + it('should handle empty credentials file', async () => { + const emptyContent = JSON.stringify({}); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), emptyContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials).not.toBeNull(); + expect(indicators.credentials?.hasOAuthToken).toBe(false); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should handle credentials file with null values', async () => { + const nullContent = JSON.stringify({ + claudeAiOauth: null, + api_key: null, + oauth_token: null, + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), nullContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(false); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should handle credentials with empty string values', async () => { + const emptyStrings = JSON.stringify({ + claudeAiOauth: { + accessToken: '', + refreshToken: '', + }, + api_key: '', + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), emptyStrings); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + // Empty strings should not be treated as valid credentials + expect(indicators.credentials?.hasOAuthToken).toBe(false); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should detect settings file presence', async () => { + await fs.writeFile( + path.join(mockClaudeDir, 'settings.json'), + JSON.stringify({ theme: 'dark' }) + ); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasSettingsFile).toBe(true); + expect(indicators.checks.settingsFile.exists).toBe(true); + expect(indicators.checks.settingsFile.readable).toBe(true); + }); + + it('should detect stats cache with activity', async () => { + const statsContent = JSON.stringify({ + dailyActivity: [ + { date: '2025-01-15', messagesCount: 10 }, + { date: '2025-01-16', messagesCount: 5 }, + ], + }); + + await fs.writeFile(path.join(mockClaudeDir, 'stats-cache.json'), statsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasStatsCacheWithActivity).toBe(true); + expect(indicators.checks.statsCache.exists).toBe(true); + expect(indicators.checks.statsCache.hasDailyActivity).toBe(true); + }); + + it('should detect stats cache without activity', async () => { + const statsContent = JSON.stringify({ + dailyActivity: [], + }); + + await fs.writeFile(path.join(mockClaudeDir, 'stats-cache.json'), statsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasStatsCacheWithActivity).toBe(false); + expect(indicators.checks.statsCache.exists).toBe(true); + expect(indicators.checks.statsCache.hasDailyActivity).toBe(false); + }); + + it('should detect project sessions', async () => { + const projectsDir = path.join(mockClaudeDir, 'projects'); + await fs.mkdir(projectsDir, { recursive: true }); + await fs.mkdir(path.join(projectsDir, 'session-1')); + await fs.mkdir(path.join(projectsDir, 'session-2')); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasProjectsSessions).toBe(true); + expect(indicators.checks.projectsDir.exists).toBe(true); + expect(indicators.checks.projectsDir.entryCount).toBe(2); + }); + + it('should return comprehensive check details', async () => { + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Verify all check detail objects are present + expect(indicators.checks).toBeDefined(); + expect(indicators.checks.settingsFile).toBeDefined(); + expect(indicators.checks.settingsFile.path).toContain('settings.json'); + expect(indicators.checks.statsCache).toBeDefined(); + expect(indicators.checks.statsCache.path).toContain('stats-cache.json'); + expect(indicators.checks.projectsDir).toBeDefined(); + expect(indicators.checks.projectsDir.path).toContain('projects'); + expect(indicators.checks.credentialFiles).toBeDefined(); + expect(Array.isArray(indicators.checks.credentialFiles)).toBe(true); + }); + + it('should try both .credentials.json and credentials.json paths', async () => { + // Write to credentials.json (without leading dot) + const credentialsContent = JSON.stringify({ + api_key: 'sk-test-key', + }); + + await fs.writeFile(path.join(mockClaudeDir, 'credentials.json'), credentialsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Should find credentials in the second path + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(true); + }); + + it('should prefer first credentials file if both exist', async () => { + // Write OAuth to .credentials.json (first path checked) + await fs.writeFile( + path.join(mockClaudeDir, '.credentials.json'), + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + refreshToken: 'refresh-token', + }, + }) + ); + + // Write API key to credentials.json (second path) + await fs.writeFile( + path.join(mockClaudeDir, 'credentials.json'), + JSON.stringify({ + api_key: 'sk-test-key', + }) + ); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Should use first file (.credentials.json) which has OAuth + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + }); + + describe('getCodexAuthIndicators', () => { + it('should detect OAuth token in Codex auth file', async () => { + const authContent = JSON.stringify({ + access_token: 'codex-oauth-token-12345', + }); + + await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent); + + const { getCodexAuthIndicators } = await import('../src/system-paths'); + const indicators = await getCodexAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + expect(indicators.hasApiKey).toBe(false); + }); + + it('should detect API key in Codex auth file', async () => { + const authContent = JSON.stringify({ + OPENAI_API_KEY: 'sk-xxxxxxxxxxxxxxxx', + }); + + await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent); + + const { getCodexAuthIndicators } = await import('../src/system-paths'); + const indicators = await getCodexAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(false); + expect(indicators.hasApiKey).toBe(true); + }); + + it('should detect nested tokens in Codex auth file', async () => { + const authContent = JSON.stringify({ + tokens: { + oauth_token: 'nested-oauth-token', + }, + }); + + await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent); + + const { getCodexAuthIndicators } = await import('../src/system-paths'); + const indicators = await getCodexAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + }); + + it('should handle missing Codex auth file', async () => { + // No auth file created + const { getCodexAuthIndicators } = await import('../src/system-paths'); + const indicators = await getCodexAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(false); + expect(indicators.hasOAuthToken).toBe(false); + expect(indicators.hasApiKey).toBe(false); + }); + + it('should detect api_key field in Codex auth', async () => { + const authContent = JSON.stringify({ + api_key: 'sk-api-key-value', + }); + + await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent); + + const { getCodexAuthIndicators } = await import('../src/system-paths'); + const indicators = await getCodexAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasApiKey).toBe(true); + }); + }); + + describe('getOpenCodeAuthIndicators', () => { + it('should detect provider-specific OAuth credentials', async () => { + const authContent = JSON.stringify({ + anthropic: { + type: 'oauth', + access: 'oauth-access-token', + refresh: 'oauth-refresh-token', + }, + }); + + await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent); + + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + expect(indicators.hasApiKey).toBe(false); + }); + + it('should detect GitHub Copilot refresh token as OAuth', async () => { + const authContent = JSON.stringify({ + 'github-copilot': { + type: 'oauth', + access: '', // Empty access token + refresh: 'gh-refresh-token', // But has refresh token + }, + }); + + await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent); + + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + }); + + it('should detect provider-specific API key credentials', async () => { + const authContent = JSON.stringify({ + openai: { + type: 'api_key', + key: 'sk-xxxxxxxxxxxx', + }, + }); + + await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent); + + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(false); + expect(indicators.hasApiKey).toBe(true); + }); + + it('should detect multiple providers', async () => { + const authContent = JSON.stringify({ + anthropic: { + type: 'oauth', + access: 'anthropic-token', + refresh: 'refresh-token', + }, + openai: { + type: 'api_key', + key: 'sk-xxxxxxxxxxxx', + }, + }); + + await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent); + + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + expect(indicators.hasApiKey).toBe(true); + }); + + it('should handle missing OpenCode auth file', async () => { + // No auth file created + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(false); + expect(indicators.hasOAuthToken).toBe(false); + expect(indicators.hasApiKey).toBe(false); + }); + + it('should handle legacy top-level OAuth keys', async () => { + const authContent = JSON.stringify({ + access_token: 'legacy-access-token', + }); + + await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent); + + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + }); + + it('should detect copilot provider OAuth', async () => { + const authContent = JSON.stringify({ + copilot: { + type: 'oauth', + access: 'copilot-access-token', + refresh: 'copilot-refresh-token', + }, + }); + + await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent); + + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + }); + }); + + describe('Credential path helpers', () => { + it('should return correct Claude credential paths', async () => { + const { getClaudeCredentialPaths, getClaudeConfigDir } = await import('../src/system-paths'); + + const configDir = getClaudeConfigDir(); + expect(configDir).toContain('.claude'); + + const credPaths = getClaudeCredentialPaths(); + expect(credPaths.length).toBeGreaterThan(0); + expect(credPaths.some((p) => p.includes('.credentials.json'))).toBe(true); + expect(credPaths.some((p) => p.includes('credentials.json'))).toBe(true); + }); + + it('should return correct Codex auth path', async () => { + const { getCodexAuthPath, getCodexConfigDir } = await import('../src/system-paths'); + + const configDir = getCodexConfigDir(); + expect(configDir).toContain('.codex'); + + const authPath = getCodexAuthPath(); + expect(authPath).toContain('.codex'); + expect(authPath).toContain('auth.json'); + }); + + it('should return correct OpenCode auth path', async () => { + const { getOpenCodeAuthPath, getOpenCodeConfigDir } = await import('../src/system-paths'); + + const configDir = getOpenCodeConfigDir(); + expect(configDir).toContain('opencode'); + + const authPath = getOpenCodeAuthPath(); + expect(authPath).toContain('opencode'); + expect(authPath).toContain('auth.json'); + }); + }); + + describe('Edge cases for credential detection', () => { + it('should handle credentials file with unexpected structure', async () => { + const unexpectedContent = JSON.stringify({ + someUnexpectedKey: 'value', + nested: { + deeply: { + unexpected: true, + }, + }, + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), unexpectedContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(false); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should handle array instead of object in credentials', async () => { + const arrayContent = JSON.stringify(['token1', 'token2']); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), arrayContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Array is valid JSON but wrong structure - should handle gracefully + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(false); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should handle numeric values in credential fields', async () => { + const numericContent = JSON.stringify({ + api_key: 12345, + oauth_token: 67890, + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), numericContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Note: Current implementation uses JavaScript truthiness which accepts numbers + // This documents the actual behavior - ideally would validate string type + expect(indicators.hasCredentialsFile).toBe(true); + // The implementation checks truthiness, not strict string type + expect(indicators.credentials?.hasOAuthToken).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(true); + }); + + it('should handle boolean values in credential fields', async () => { + const booleanContent = JSON.stringify({ + api_key: true, + oauth_token: false, + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), booleanContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Note: Current implementation uses JavaScript truthiness + // api_key: true is truthy, oauth_token: false is falsy + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(false); // false is falsy + expect(indicators.credentials?.hasApiKey).toBe(true); // true is truthy + }); + + it('should handle malformed stats-cache.json gracefully', async () => { + await fs.writeFile(path.join(mockClaudeDir, 'stats-cache.json'), '{ invalid json }'); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasStatsCacheWithActivity).toBe(false); + expect(indicators.checks.statsCache.exists).toBe(true); + expect(indicators.checks.statsCache.error).toBeDefined(); + }); + + it('should handle empty projects directory', async () => { + const projectsDir = path.join(mockClaudeDir, 'projects'); + await fs.mkdir(projectsDir, { recursive: true }); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasProjectsSessions).toBe(false); + expect(indicators.checks.projectsDir.exists).toBe(true); + expect(indicators.checks.projectsDir.entryCount).toBe(0); + }); + }); + + describe('Combined authentication scenarios', () => { + it('should detect CLI authenticated state with settings + sessions', async () => { + // Create settings file + await fs.writeFile( + path.join(mockClaudeDir, 'settings.json'), + JSON.stringify({ theme: 'dark' }) + ); + + // Create projects directory with sessions + const projectsDir = path.join(mockClaudeDir, 'projects'); + await fs.mkdir(projectsDir, { recursive: true }); + await fs.mkdir(path.join(projectsDir, 'session-1')); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasSettingsFile).toBe(true); + expect(indicators.hasProjectsSessions).toBe(true); + }); + + it('should detect recent activity indicating working auth', async () => { + // Create stats cache with recent activity + await fs.writeFile( + path.join(mockClaudeDir, 'stats-cache.json'), + JSON.stringify({ + dailyActivity: [{ date: new Date().toISOString().split('T')[0], messagesCount: 10 }], + }) + ); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasStatsCacheWithActivity).toBe(true); + }); + + it('should handle complete auth setup', async () => { + // Create all auth indicators + await fs.writeFile( + path.join(mockClaudeDir, '.credentials.json'), + JSON.stringify({ + claudeAiOauth: { + accessToken: 'token', + refreshToken: 'refresh', + }, + }) + ); + await fs.writeFile( + path.join(mockClaudeDir, 'settings.json'), + JSON.stringify({ theme: 'dark' }) + ); + await fs.writeFile( + path.join(mockClaudeDir, 'stats-cache.json'), + JSON.stringify({ dailyActivity: [{ date: '2025-01-15', messagesCount: 5 }] }) + ); + const projectsDir = path.join(mockClaudeDir, 'projects'); + await fs.mkdir(projectsDir, { recursive: true }); + await fs.mkdir(path.join(projectsDir, 'session-1')); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.hasSettingsFile).toBe(true); + expect(indicators.hasStatsCacheWithActivity).toBe(true); + expect(indicators.hasProjectsSessions).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(true); + }); + }); +});