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:
+
+
+ -
+ Ensure
IS_CONTAINERIZED=true is
+ set in your docker-compose environment
+
+ -
+ Verify the server container has the environment variable:{' '}
+
+ docker exec automaker-server printenv IS_CONTAINERIZED
+
+
+ - Rebuild and restart containers if you recently changed the configuration
+ -
+ Check the server logs for startup messages:{' '}
+
docker-compose logs server
+
+
+
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);
+ });
+ });
+});