From b10501ea79fc7d9e4b3ebafff724eee51d9e749b Mon Sep 17 00:00:00 2001 From: Noam Loewenstern Date: Fri, 30 Jan 2026 02:44:51 +0200 Subject: [PATCH 01/21] fix(ui): handle null selectedWorktree in max concurrency handler --- apps/ui/src/components/views/board-view.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 2624514a..64c4d287 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -1274,8 +1274,11 @@ export function BoardView() { maxConcurrency={maxConcurrency} runningAgentsCount={runningAutoTasks.length} onConcurrencyChange={(newMaxConcurrency) => { - if (currentProject && selectedWorktree) { - const branchName = selectedWorktree.isMain ? null : selectedWorktree.branch; + if (currentProject) { + // If selectedWorktree is undefined, fallback to null (main/primary worktree) + // Use null for the main worktree, otherwise use the branch name; also null if no worktree selected + const branchName = + selectedWorktree && !selectedWorktree.isMain ? selectedWorktree.branch : null; setMaxConcurrencyForWorktree(currentProject.id, branchName, newMaxConcurrency); // Also update backend if auto mode is running if (autoMode.isRunning) { From 07f777da2241bfc6bda932c5a60594e365c56d3f Mon Sep 17 00:00:00 2001 From: Noam Loewenstern Date: Fri, 30 Jan 2026 02:52:27 +0200 Subject: [PATCH 02/21] Update apps/ui/src/components/views/board-view.tsx Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- apps/ui/src/components/views/board-view.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 64c4d287..48d068a1 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -1275,10 +1275,9 @@ export function BoardView() { runningAgentsCount={runningAutoTasks.length} onConcurrencyChange={(newMaxConcurrency) => { if (currentProject) { - // If selectedWorktree is undefined, fallback to null (main/primary worktree) - // Use null for the main worktree, otherwise use the branch name; also null if no worktree selected - const branchName = - selectedWorktree && !selectedWorktree.isMain ? selectedWorktree.branch : null; + // If selectedWorktree is undefined or it's the main worktree, branchName will be null. + // Otherwise, use the branch name. + const branchName = selectedWorktree?.isMain === false ? selectedWorktree.branch : null; setMaxConcurrencyForWorktree(currentProject.id, branchName, newMaxConcurrency); // Also update backend if auto mode is running if (autoMode.isRunning) { From aad3ff2cdf74b7f872dc57eb8aa8117dae3b6952 Mon Sep 17 00:00:00 2001 From: Kacper Date: Mon, 2 Feb 2026 17:35:03 +0100 Subject: [PATCH 03/21] fix(auth): Improve OAuth credential detection and startup warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enhanced getClaudeAuthIndicators() to return detailed check information including file paths checked and specific error details for debugging - Added debug logging to server startup credential detection for easier troubleshooting in Docker environments - Show paths that were checked in the warning message to help users debug mount issues - Added support for CLAUDE_CODE_OAUTH_TOKEN environment variable - Return authType in verify-claude-auth response to distinguish between OAuth and CLI authentication methods - Updated UI to show specific success messages for Claude Code subscription vs generic CLI auth - Added Docker troubleshooting tips to sandbox risk dialog - Added comprehensive unit tests for OAuth credential detection scenarios Closes #721 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/server/src/index.ts | 101 ++- .../routes/setup/routes/verify-claude-auth.ts | 19 + .../dialogs/sandbox-risk-dialog.tsx | 23 + .../setup-view/steps/claude-setup-step.tsx | 25 +- apps/ui/src/lib/electron.ts | 1 + apps/ui/src/lib/http-api-client.ts | 1 + libs/platform/src/index.ts | 2 + libs/platform/src/system-paths.ts | 156 +++- .../tests/oauth-credential-detection.test.ts | 736 ++++++++++++++++++ 9 files changed, 1028 insertions(+), 36 deletions(-) create mode 100644 libs/platform/tests/oauth-credential-detection.test.ts 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); + }); + }); +}); From 0aef72540e5f9eff7919f5b2157ce7c5f1141204 Mon Sep 17 00:00:00 2001 From: Kacper Date: Mon, 2 Feb 2026 17:54:23 +0100 Subject: [PATCH 04/21] fix(auth): Enhance credential detection logic for OAuth - Updated getClaudeAuthIndicators() to ensure that empty or token-less credential files do not prevent the detection of valid credentials in subsequent paths. - Improved error handling for settings file readability checks, providing clearer feedback on file access issues. - Added unit tests to validate the new behavior, ensuring that the system continues to check all credential paths even when some files are empty or invalid. This change improves the robustness of the credential detection process and enhances user experience by allowing for more flexible credential management. --- libs/platform/src/system-paths.ts | 37 ++++++++--- .../tests/oauth-credential-detection.test.ts | 61 +++++++++++++------ 2 files changed, 72 insertions(+), 26 deletions(-) diff --git a/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts index fb5e6bd3..f1749464 100644 --- a/libs/platform/src/system-paths.ts +++ b/libs/platform/src/system-paths.ts @@ -1065,11 +1065,20 @@ export async function getClaudeAuthIndicators(): Promise { }; // Check settings file + // First check existence, then try to read to confirm it's actually readable try { if (await systemPathAccess(settingsPath)) { settingsFileCheck.exists = true; - settingsFileCheck.readable = true; - result.hasSettingsFile = true; + // Try to actually read the file to confirm read permissions + try { + await systemPathReadFile(settingsPath); + settingsFileCheck.readable = true; + result.hasSettingsFile = true; + } catch (readErr) { + // File exists but cannot be read (permission denied, etc.) + settingsFileCheck.readable = false; + settingsFileCheck.error = `Cannot read: ${readErr instanceof Error ? readErr.message : String(readErr)}`; + } } } catch (err) { settingsFileCheck.error = err instanceof Error ? err.message : String(err); @@ -1117,6 +1126,9 @@ export async function getClaudeAuthIndicators(): Promise { } // Check credentials files + // We iterate through all credential paths and only stop when we find a file + // that contains actual credentials (OAuth tokens or API keys). An empty or + // token-less file should not prevent checking subsequent credential paths. for (let i = 0; i < credentialPaths.length; i++) { const credPath = credentialPaths[i]; const credCheck = credentialFileChecks[i]; @@ -1126,18 +1138,27 @@ export async function getClaudeAuthIndicators(): Promise { 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; + const hasOAuthToken = hasClaudeOauth || hasLegacyOauth; + const hasApiKey = !!credentials.api_key; + + // Only consider this a valid credentials file if it actually contains tokens + // An empty JSON file ({}) or file without tokens should not stop us from + // checking subsequent credential paths + if (hasOAuthToken || hasApiKey) { + result.hasCredentialsFile = true; + result.credentials = { + hasOAuthToken, + hasApiKey, + }; + break; // Found valid credentials, stop searching + } + // File exists and is valid JSON but contains no tokens - continue checking other paths } catch (parseErr) { credCheck.error = `JSON parse error: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`; } diff --git a/libs/platform/tests/oauth-credential-detection.test.ts b/libs/platform/tests/oauth-credential-detection.test.ts index cf5a4705..6e445b22 100644 --- a/libs/platform/tests/oauth-credential-detection.test.ts +++ b/libs/platform/tests/oauth-credential-detection.test.ts @@ -173,10 +173,14 @@ describe('OAuth Credential Detection', () => { 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); + // Empty credentials file ({}) should NOT be treated as having credentials + // because it contains no actual tokens. This allows the system to continue + // checking subsequent credential paths that might have valid tokens. + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); + // But the file should still show as existing and readable in the checks + expect(indicators.checks.credentialFiles[0].exists).toBe(true); + expect(indicators.checks.credentialFiles[0].readable).toBe(true); }); it('should handle credentials file with null values', async () => { @@ -191,9 +195,10 @@ describe('OAuth Credential Detection', () => { 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); + // File with all null values should NOT be treated as having credentials + // because null values are not valid tokens + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); }); it('should handle credentials with empty string values', async () => { @@ -210,10 +215,10 @@ describe('OAuth Credential Detection', () => { 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); + // Empty strings should NOT be treated as having credentials + // This allows checking subsequent credential paths for valid tokens + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); }); it('should detect settings file presence', async () => { @@ -337,6 +342,27 @@ describe('OAuth Credential Detection', () => { expect(indicators.credentials?.hasOAuthToken).toBe(true); expect(indicators.credentials?.hasApiKey).toBe(false); }); + + it('should check second credentials file if first file has no tokens', async () => { + // Write empty/token-less content to .credentials.json (first path checked) + // This tests the bug fix: previously, an empty JSON file would stop the search + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), JSON.stringify({})); + + // Write actual credentials to credentials.json (second path) + await fs.writeFile( + path.join(mockClaudeDir, 'credentials.json'), + JSON.stringify({ + api_key: 'sk-test-key-from-second-file', + }) + ); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Should find credentials in second file since first file has no tokens + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(true); + }); }); describe('getCodexAuthIndicators', () => { @@ -585,9 +611,9 @@ describe('OAuth Credential Detection', () => { 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); + // File with unexpected structure but no valid tokens should NOT be treated as having credentials + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); }); it('should handle array instead of object in credentials', async () => { @@ -598,10 +624,9 @@ describe('OAuth Credential Detection', () => { 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); + // Array is valid JSON but wrong structure - no valid tokens, so not treated as credentials file + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); }); it('should handle numeric values in credential fields', async () => { From 88864ad6bc1f17130d76463ca5f491a4aef3663f Mon Sep 17 00:00:00 2001 From: Dhanush Santosh Date: Tue, 3 Feb 2026 20:34:33 +0530 Subject: [PATCH 05/21] feature/custom terminal configs (#717) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(terminal): Add core infrastructure for custom terminal configurations - Add TerminalConfig types to settings schema (global & project-specific) - Create RC generator with hex-to-xterm-256 color mapping - Create RC file manager for .automaker/terminal/ directory - Add terminal theme color data (40 themes) to platform package - Integrate terminal config injection into TerminalService - Support bash, zsh, and sh with proper env var injection (BASH_ENV, ZDOTDIR, ENV) - Add onThemeChange hook for theme synchronization Part of custom terminal configurations feature implementation. Co-Authored-By: Claude Sonnet 4.5 * feat(terminal): Wire terminal service with settings service - Pass SettingsService to TerminalService constructor - Initialize terminal service with settings service dependency - Enable terminal config injection to work with actual settings This completes Steps 1-4 of the terminal configuration plan: - RC Generator (color mapping, prompt formats) - RC File Manager (file I/O, atomic writes) - Settings Schema (GlobalSettings + ProjectSettings) - Terminal Service Integration (env var injection) Next steps: Settings UI and theme change hooks. Co-Authored-By: Claude Sonnet 4.5 * feat(terminal): Add Settings UI and theme change synchronization Complete Steps 5 & 6 of terminal configuration implementation: Settings UI Components: - Add PromptPreview component with live theme-aware rendering - Add TerminalConfigSection with comprehensive controls: * Enable/disable toggle with confirmation dialog * Custom prompt toggle * Prompt format selector (4 formats) * Git branch/status toggles * Custom aliases textarea * Custom env vars key-value editor with validation * Info box explaining behavior - Integrate into existing TerminalSection Theme Change Hook: - Add theme detection in update-global settings route - Regenerate RC files for all projects when theme changes - Skip projects with terminal config disabled - Error handling with per-project logging - Inject terminal service with settings service dependency This completes the full terminal configuration feature: ✓ RC Generator (color mapping, prompts) ✓ RC File Manager (file I/O, versioning) ✓ Settings Schema (types, defaults) ✓ Terminal Service Integration (env vars, PTY spawn) ✓ Settings UI (comprehensive controls, preview) ✓ Theme Synchronization (automatic RC regeneration) New terminals will use custom prompts matching app theme. Existing terminals unaffected. User RC files preserved. Co-Authored-By: Claude Sonnet 4.5 * fix(terminal): Add error handling and explicit field mapping for terminal config - Add try-catch block to handleToggleEnabled - Explicitly set all required terminalConfig fields - Add console logging for debugging - Show error toast if update fails - Include rcFileVersion: 1 in config object This should fix the issue where the toggle doesn't enable after clicking OK in the confirmation dialog. Co-Authored-By: Claude Sonnet 4.5 * fix(terminal): Use React Query mutation hook for settings updates The issue was that `updateGlobalSettings` doesn't exist in the app store. The correct pattern is to use the `useUpdateGlobalSettings` hook from use-settings-mutations.ts, which is a React Query mutation. Changes: - Import useUpdateGlobalSettings from mutations hook - Use mutation.mutate() instead of direct function call - Add proper onSuccess/onError callbacks - Remove async/await pattern (React Query handles this) This fixes the toggle not enabling after clicking OK in the confirmation dialog. Co-Authored-By: Claude Sonnet 4.5 * fix(terminal): Use React Query hook for globalSettings instead of store The root cause: Component was reading globalSettings from the app store, which doesn't update reactively when the mutation completes. Solution: Use useGlobalSettings() React Query hook which: - Automatically refetches when the mutation invalidates the cache - Triggers re-render with updated data - Makes the toggle reflect the new state Now the flow is: 1. User clicks toggle → confirmation dialog 2. Click OK → mutation.mutate() called 3. Mutation succeeds → invalidates queryKeys.settings.global() 4. Query refetches → component re-renders with new globalSettings 5. Toggle shows enabled state ✓ Co-Authored-By: Claude Sonnet 4.5 * debug(terminal): Add detailed logging for terminal config application Add logging to track: - When terminal config check happens - CWD being used - Global and project enabled states - Effective enabled state This will help diagnose why RC files aren't being generated when opening terminals in Automaker. Co-Authored-By: Claude Sonnet 4.5 * Fix terminal rc updates and bash rcfile loading * feat(terminal): add banner on shell start * feat(terminal): colorize banner per theme * chore(terminal): bump rc version for banner colors * feat(terminal): match banner colors to launcher * feat(terminal): add prompt customization controls * feat: integrate oh-my-posh prompt themes * fix: resolve oh-my-posh theme path * fix: correct oh-my-posh config invocation * docs: add terminal theme screenshot * fix: address review feedback and stabilize e2e test * ui: split terminal config into separate card * fix: enable cross-platform Warp terminal detection - Remove macOS-only platform restriction for Warp - Add Linux CLI alias 'warp-terminal' (primary on Linux) - Add CLI launch handler using --cwd flag - Fixes issue where Warp was not detected on Linux systems Co-Authored-By: Claude Sonnet 4.5 --------- Co-authored-by: Claude Sonnet 4.5 --- apps/server/src/index.ts | 2 +- apps/server/src/lib/terminal-themes-data.ts | 25 + .../routes/settings/routes/update-global.ts | 36 + apps/server/src/services/terminal-service.ts | 311 +++++- .../settings-view/terminal/prompt-preview.tsx | 283 +++++ .../terminal/prompt-theme-presets.ts | 253 +++++ .../terminal/terminal-config-section.tsx | 662 ++++++++++++ .../terminal/terminal-section.tsx | 474 ++++----- apps/ui/tests/features/edit-feature.spec.ts | 3 +- docs/pr/terminal-omp.png | Bin 0 -> 26965 bytes docs/terminal-custom-configs-plan.md | 632 ++++++++++++ libs/platform/src/index.ts | 34 + libs/platform/src/rc-file-manager.ts | 308 ++++++ libs/platform/src/rc-generator.ts | 972 ++++++++++++++++++ libs/platform/src/terminal-theme-colors.ts | 468 +++++++++ libs/platform/src/terminal.ts | 9 +- libs/platform/tests/rc-file-manager.test.ts | 100 ++ libs/platform/tests/rc-generator.test.ts | 55 + libs/types/src/settings.ts | 211 +++- start-automaker.sh | 10 +- 20 files changed, 4571 insertions(+), 277 deletions(-) create mode 100644 apps/server/src/lib/terminal-themes-data.ts create mode 100644 apps/ui/src/components/views/settings-view/terminal/prompt-preview.tsx create mode 100644 apps/ui/src/components/views/settings-view/terminal/prompt-theme-presets.ts create mode 100644 apps/ui/src/components/views/settings-view/terminal/terminal-config-section.tsx create mode 100644 docs/pr/terminal-omp.png create mode 100644 docs/terminal-custom-configs-plan.md create mode 100644 libs/platform/src/rc-file-manager.ts create mode 100644 libs/platform/src/rc-generator.ts create mode 100644 libs/platform/src/terminal-theme-colors.ts create mode 100644 libs/platform/tests/rc-file-manager.test.ts create mode 100644 libs/platform/tests/rc-generator.test.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 4bd496bc..2255fdc1 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -390,7 +390,7 @@ const server = createServer(app); // WebSocket servers using noServer mode for proper multi-path support const wss = new WebSocketServer({ noServer: true }); const terminalWss = new WebSocketServer({ noServer: true }); -const terminalService = getTerminalService(); +const terminalService = getTerminalService(settingsService); /** * Authenticate WebSocket upgrade requests diff --git a/apps/server/src/lib/terminal-themes-data.ts b/apps/server/src/lib/terminal-themes-data.ts new file mode 100644 index 00000000..854bf1a8 --- /dev/null +++ b/apps/server/src/lib/terminal-themes-data.ts @@ -0,0 +1,25 @@ +/** + * Terminal Theme Data - Re-export terminal themes from platform package + * + * This module re-exports terminal theme data for use in the server. + */ + +import { terminalThemeColors, getTerminalThemeColors as getThemeColors } from '@automaker/platform'; +import type { ThemeMode } from '@automaker/types'; +import type { TerminalTheme } from '@automaker/platform'; + +/** + * Get terminal theme colors for a given theme mode + */ +export function getTerminalThemeColors(theme: ThemeMode): TerminalTheme { + return getThemeColors(theme); +} + +/** + * Get all terminal themes + */ +export function getAllTerminalThemes(): Record { + return terminalThemeColors; +} + +export default terminalThemeColors; diff --git a/apps/server/src/routes/settings/routes/update-global.ts b/apps/server/src/routes/settings/routes/update-global.ts index b45e9965..817b5c1d 100644 --- a/apps/server/src/routes/settings/routes/update-global.ts +++ b/apps/server/src/routes/settings/routes/update-global.ts @@ -14,6 +14,7 @@ import type { GlobalSettings } from '../../../types/settings.js'; import { getErrorMessage, logError, logger } from '../common.js'; import { setLogLevel, LogLevel } from '@automaker/utils'; import { setRequestLoggingEnabled } from '../../../index.js'; +import { getTerminalService } from '../../../services/terminal-service.js'; /** * Map server log level string to LogLevel enum @@ -57,6 +58,10 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) { }, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}` ); + // Get old settings to detect theme changes + const oldSettings = await settingsService.getGlobalSettings(); + const oldTheme = oldSettings?.theme; + logger.info('[SERVER_SETTINGS_UPDATE] Calling updateGlobalSettings...'); const settings = await settingsService.updateGlobalSettings(updates); logger.info( @@ -64,6 +69,37 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) { settings.projects?.length ?? 0 ); + // Handle theme change - regenerate terminal RC files for all projects + if ('theme' in updates && updates.theme && updates.theme !== oldTheme) { + const terminalService = getTerminalService(settingsService); + const newTheme = updates.theme; + + logger.info( + `[TERMINAL_CONFIG] Theme changed from ${oldTheme} to ${newTheme}, regenerating RC files` + ); + + // Regenerate RC files for all projects with terminal config enabled + const projects = settings.projects || []; + for (const project of projects) { + try { + const projectSettings = await settingsService.getProjectSettings(project.path); + // Check if terminal config is enabled (global or project-specific) + const terminalConfigEnabled = + projectSettings.terminalConfig?.enabled !== false && + settings.terminalConfig?.enabled === true; + + if (terminalConfigEnabled) { + await terminalService.onThemeChange(project.path, newTheme); + logger.info(`[TERMINAL_CONFIG] Regenerated RC files for project: ${project.name}`); + } + } catch (error) { + logger.warn( + `[TERMINAL_CONFIG] Failed to regenerate RC files for project ${project.name}: ${error}` + ); + } + } + } + // Apply server log level if it was updated if ('serverLogLevel' in updates && updates.serverLogLevel) { const level = LOG_LEVEL_MAP[updates.serverLogLevel]; diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index f83aaede..167ab348 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -13,6 +13,14 @@ import * as path from 'path'; // to enforce ALLOWED_ROOT_DIRECTORY security boundary import * as secureFs from '../lib/secure-fs.js'; import { createLogger } from '@automaker/utils'; +import type { SettingsService } from './settings-service.js'; +import { getTerminalThemeColors, getAllTerminalThemes } from '../lib/terminal-themes-data.js'; +import { + getRcFilePath, + getTerminalDir, + ensureRcFilesUpToDate, + type TerminalConfig, +} from '@automaker/platform'; const logger = createLogger('Terminal'); // System paths module handles shell binary checks and WSL detection @@ -24,6 +32,27 @@ import { getShellPaths, } from '@automaker/platform'; +const BASH_LOGIN_ARG = '--login'; +const BASH_RCFILE_ARG = '--rcfile'; +const SHELL_NAME_BASH = 'bash'; +const SHELL_NAME_ZSH = 'zsh'; +const SHELL_NAME_SH = 'sh'; +const DEFAULT_SHOW_USER_HOST = true; +const DEFAULT_SHOW_PATH = true; +const DEFAULT_SHOW_TIME = false; +const DEFAULT_SHOW_EXIT_STATUS = false; +const DEFAULT_PATH_DEPTH = 0; +const DEFAULT_PATH_STYLE: TerminalConfig['pathStyle'] = 'full'; +const DEFAULT_CUSTOM_PROMPT = true; +const DEFAULT_PROMPT_FORMAT: TerminalConfig['promptFormat'] = 'standard'; +const DEFAULT_SHOW_GIT_BRANCH = true; +const DEFAULT_SHOW_GIT_STATUS = true; +const DEFAULT_CUSTOM_ALIASES = ''; +const DEFAULT_CUSTOM_ENV_VARS: Record = {}; +const PROMPT_THEME_CUSTOM = 'custom'; +const PROMPT_THEME_PREFIX = 'omp-'; +const OMP_THEME_ENV_VAR = 'AUTOMAKER_OMP_THEME'; + // Maximum scrollback buffer size (characters) const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal @@ -42,6 +71,114 @@ let maxSessions = parseInt(process.env.TERMINAL_MAX_SESSIONS || '1000', 10); const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive input const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency +function applyBashRcFileArgs(args: string[], rcFilePath: string): string[] { + const sanitizedArgs: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === BASH_LOGIN_ARG) { + continue; + } + if (arg === BASH_RCFILE_ARG) { + index += 1; + continue; + } + sanitizedArgs.push(arg); + } + + sanitizedArgs.push(BASH_RCFILE_ARG, rcFilePath); + return sanitizedArgs; +} + +function normalizePathStyle( + pathStyle: TerminalConfig['pathStyle'] | undefined +): TerminalConfig['pathStyle'] { + if (pathStyle === 'short' || pathStyle === 'basename') { + return pathStyle; + } + return DEFAULT_PATH_STYLE; +} + +function normalizePathDepth(pathDepth: number | undefined): number { + const depth = + typeof pathDepth === 'number' && Number.isFinite(pathDepth) ? pathDepth : DEFAULT_PATH_DEPTH; + return Math.max(DEFAULT_PATH_DEPTH, Math.floor(depth)); +} + +function getShellBasename(shellPath: string): string { + const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\')); + return lastSep >= 0 ? shellPath.slice(lastSep + 1) : shellPath; +} + +function getShellArgsForPath(shellPath: string): string[] { + const shellName = getShellBasename(shellPath).toLowerCase().replace('.exe', ''); + if (shellName === 'powershell' || shellName === 'pwsh' || shellName === 'cmd') { + return []; + } + if (shellName === SHELL_NAME_SH) { + return []; + } + return [BASH_LOGIN_ARG]; +} + +function resolveOmpThemeName(promptTheme: string | undefined): string | null { + if (!promptTheme || promptTheme === PROMPT_THEME_CUSTOM) { + return null; + } + if (promptTheme.startsWith(PROMPT_THEME_PREFIX)) { + return promptTheme.slice(PROMPT_THEME_PREFIX.length); + } + return null; +} + +function buildEffectiveTerminalConfig( + globalTerminalConfig: TerminalConfig | undefined, + projectTerminalConfig: Partial | undefined +): TerminalConfig { + const mergedEnvVars = { + ...(globalTerminalConfig?.customEnvVars ?? DEFAULT_CUSTOM_ENV_VARS), + ...(projectTerminalConfig?.customEnvVars ?? DEFAULT_CUSTOM_ENV_VARS), + }; + + return { + enabled: projectTerminalConfig?.enabled ?? globalTerminalConfig?.enabled ?? false, + customPrompt: globalTerminalConfig?.customPrompt ?? DEFAULT_CUSTOM_PROMPT, + promptFormat: globalTerminalConfig?.promptFormat ?? DEFAULT_PROMPT_FORMAT, + showGitBranch: + projectTerminalConfig?.showGitBranch ?? + globalTerminalConfig?.showGitBranch ?? + DEFAULT_SHOW_GIT_BRANCH, + showGitStatus: + projectTerminalConfig?.showGitStatus ?? + globalTerminalConfig?.showGitStatus ?? + DEFAULT_SHOW_GIT_STATUS, + showUserHost: + projectTerminalConfig?.showUserHost ?? + globalTerminalConfig?.showUserHost ?? + DEFAULT_SHOW_USER_HOST, + showPath: + projectTerminalConfig?.showPath ?? globalTerminalConfig?.showPath ?? DEFAULT_SHOW_PATH, + pathStyle: normalizePathStyle( + projectTerminalConfig?.pathStyle ?? globalTerminalConfig?.pathStyle + ), + pathDepth: normalizePathDepth( + projectTerminalConfig?.pathDepth ?? globalTerminalConfig?.pathDepth + ), + showTime: + projectTerminalConfig?.showTime ?? globalTerminalConfig?.showTime ?? DEFAULT_SHOW_TIME, + showExitStatus: + projectTerminalConfig?.showExitStatus ?? + globalTerminalConfig?.showExitStatus ?? + DEFAULT_SHOW_EXIT_STATUS, + customAliases: + projectTerminalConfig?.customAliases ?? + globalTerminalConfig?.customAliases ?? + DEFAULT_CUSTOM_ALIASES, + customEnvVars: mergedEnvVars, + rcFileVersion: globalTerminalConfig?.rcFileVersion, + }; +} + export interface TerminalSession { id: string; pty: pty.IPty; @@ -77,6 +214,12 @@ export class TerminalService extends EventEmitter { !!(process.versions && (process.versions as Record).electron) || !!process.env.ELECTRON_RUN_AS_NODE; private useConptyFallback = false; // Track if we need to use winpty fallback on Windows + private settingsService: SettingsService | null = null; + + constructor(settingsService?: SettingsService) { + super(); + this.settingsService = settingsService || null; + } /** * Kill a PTY process with platform-specific handling. @@ -102,37 +245,19 @@ export class TerminalService extends EventEmitter { const platform = os.platform(); const shellPaths = getShellPaths(); - // Helper to get basename handling both path separators - const getBasename = (shellPath: string): string => { - const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\')); - return lastSep >= 0 ? shellPath.slice(lastSep + 1) : shellPath; - }; - - // Helper to get shell args based on shell name - const getShellArgs = (shell: string): string[] => { - const shellName = getBasename(shell).toLowerCase().replace('.exe', ''); - // PowerShell and cmd don't need --login - if (shellName === 'powershell' || shellName === 'pwsh' || shellName === 'cmd') { - return []; - } - // sh doesn't support --login in all implementations - if (shellName === 'sh') { - return []; - } - // bash, zsh, and other POSIX shells support --login - return ['--login']; - }; - // Check if running in WSL - prefer user's shell or bash with --login if (platform === 'linux' && this.isWSL()) { const userShell = process.env.SHELL; if (userShell) { // Try to find userShell in allowed paths for (const allowedShell of shellPaths) { - if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) { + if ( + allowedShell === userShell || + getShellBasename(allowedShell) === getShellBasename(userShell) + ) { try { if (systemPathExists(allowedShell)) { - return { shell: allowedShell, args: getShellArgs(allowedShell) }; + return { shell: allowedShell, args: getShellArgsForPath(allowedShell) }; } } catch { // Path not allowed, continue searching @@ -144,7 +269,7 @@ export class TerminalService extends EventEmitter { for (const shell of shellPaths) { try { if (systemPathExists(shell)) { - return { shell, args: getShellArgs(shell) }; + return { shell, args: getShellArgsForPath(shell) }; } } catch { // Path not allowed, continue @@ -158,10 +283,13 @@ export class TerminalService extends EventEmitter { if (userShell && platform !== 'win32') { // Try to find userShell in allowed paths for (const allowedShell of shellPaths) { - if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) { + if ( + allowedShell === userShell || + getShellBasename(allowedShell) === getShellBasename(userShell) + ) { try { if (systemPathExists(allowedShell)) { - return { shell: allowedShell, args: getShellArgs(allowedShell) }; + return { shell: allowedShell, args: getShellArgsForPath(allowedShell) }; } } catch { // Path not allowed, continue searching @@ -174,7 +302,7 @@ export class TerminalService extends EventEmitter { for (const shell of shellPaths) { try { if (systemPathExists(shell)) { - return { shell, args: getShellArgs(shell) }; + return { shell, args: getShellArgsForPath(shell) }; } } catch { // Path not allowed or doesn't exist, continue to next @@ -313,8 +441,9 @@ export class TerminalService extends EventEmitter { const id = `term-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; - const { shell: detectedShell, args: shellArgs } = this.detectShell(); + const { shell: detectedShell, args: detectedShellArgs } = this.detectShell(); const shell = options.shell || detectedShell; + let shellArgs = options.shell ? getShellArgsForPath(shell) : [...detectedShellArgs]; // Validate and resolve working directory // Uses secureFs internally to enforce ALLOWED_ROOT_DIRECTORY @@ -332,6 +461,89 @@ export class TerminalService extends EventEmitter { } } + // Terminal config injection (custom prompts, themes) + const terminalConfigEnv: Record = {}; + if (this.settingsService) { + try { + logger.info( + `[createSession] Checking terminal config for session ${id}, cwd: ${options.cwd || cwd}` + ); + const globalSettings = await this.settingsService.getGlobalSettings(); + const projectSettings = options.cwd + ? await this.settingsService.getProjectSettings(options.cwd) + : null; + + const globalTerminalConfig = globalSettings?.terminalConfig; + const projectTerminalConfig = projectSettings?.terminalConfig; + const effectiveConfig = buildEffectiveTerminalConfig( + globalTerminalConfig, + projectTerminalConfig + ); + + logger.info( + `[createSession] Terminal config: global.enabled=${globalTerminalConfig?.enabled}, project.enabled=${projectTerminalConfig?.enabled}` + ); + logger.info( + `[createSession] Terminal config effective enabled: ${effectiveConfig.enabled}` + ); + + if (effectiveConfig.enabled && globalTerminalConfig) { + const currentTheme = globalSettings?.theme || 'dark'; + const themeColors = getTerminalThemeColors(currentTheme); + const allThemes = getAllTerminalThemes(); + const promptTheme = + projectTerminalConfig?.promptTheme ?? globalTerminalConfig.promptTheme; + const ompThemeName = resolveOmpThemeName(promptTheme); + + // Ensure RC files are up to date + await ensureRcFilesUpToDate( + options.cwd || cwd, + currentTheme, + effectiveConfig, + themeColors, + allThemes + ); + + // Set shell-specific env vars + const shellName = getShellBasename(shell).toLowerCase(); + if (ompThemeName && effectiveConfig.customPrompt) { + terminalConfigEnv[OMP_THEME_ENV_VAR] = ompThemeName; + } + + if (shellName.includes(SHELL_NAME_BASH)) { + const bashRcFilePath = getRcFilePath(options.cwd || cwd, SHELL_NAME_BASH); + terminalConfigEnv.BASH_ENV = bashRcFilePath; + terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt + ? 'true' + : 'false'; + terminalConfigEnv.AUTOMAKER_THEME = currentTheme; + shellArgs = applyBashRcFileArgs(shellArgs, bashRcFilePath); + } else if (shellName.includes(SHELL_NAME_ZSH)) { + terminalConfigEnv.ZDOTDIR = getTerminalDir(options.cwd || cwd); + terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt + ? 'true' + : 'false'; + terminalConfigEnv.AUTOMAKER_THEME = currentTheme; + } else if (shellName === SHELL_NAME_SH) { + terminalConfigEnv.ENV = getRcFilePath(options.cwd || cwd, SHELL_NAME_SH); + terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt + ? 'true' + : 'false'; + terminalConfigEnv.AUTOMAKER_THEME = currentTheme; + } + + // Add custom env vars from config + Object.assign(terminalConfigEnv, effectiveConfig.customEnvVars); + + logger.info( + `[createSession] Terminal config enabled for session ${id}, shell: ${shellName}` + ); + } + } catch (error) { + logger.warn(`[createSession] Failed to apply terminal config: ${error}`); + } + } + const env: Record = { ...cleanEnv, TERM: 'xterm-256color', @@ -341,6 +553,7 @@ export class TerminalService extends EventEmitter { LANG: process.env.LANG || 'en_US.UTF-8', LC_ALL: process.env.LC_ALL || process.env.LANG || 'en_US.UTF-8', ...options.env, + ...terminalConfigEnv, // Apply terminal config env vars last (highest priority) }; logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`); @@ -652,6 +865,44 @@ export class TerminalService extends EventEmitter { return () => this.exitCallbacks.delete(callback); } + /** + * Handle theme change - regenerate RC files with new theme colors + */ + async onThemeChange(projectPath: string, newTheme: string): Promise { + if (!this.settingsService) { + logger.warn('[onThemeChange] SettingsService not available'); + return; + } + + try { + const globalSettings = await this.settingsService.getGlobalSettings(); + const terminalConfig = globalSettings?.terminalConfig; + const projectSettings = await this.settingsService.getProjectSettings(projectPath); + const projectTerminalConfig = projectSettings?.terminalConfig; + const effectiveConfig = buildEffectiveTerminalConfig(terminalConfig, projectTerminalConfig); + + if (effectiveConfig.enabled && terminalConfig) { + const themeColors = getTerminalThemeColors( + newTheme as import('@automaker/types').ThemeMode + ); + const allThemes = getAllTerminalThemes(); + + // Regenerate RC files with new theme + await ensureRcFilesUpToDate( + projectPath, + newTheme as import('@automaker/types').ThemeMode, + effectiveConfig, + themeColors, + allThemes + ); + + logger.info(`[onThemeChange] Regenerated RC files for theme: ${newTheme}`); + } + } catch (error) { + logger.error(`[onThemeChange] Failed to regenerate RC files: ${error}`); + } + } + /** * Clean up all sessions */ @@ -676,9 +927,9 @@ export class TerminalService extends EventEmitter { // Singleton instance let terminalService: TerminalService | null = null; -export function getTerminalService(): TerminalService { +export function getTerminalService(settingsService?: SettingsService): TerminalService { if (!terminalService) { - terminalService = new TerminalService(); + terminalService = new TerminalService(settingsService); } return terminalService; } diff --git a/apps/ui/src/components/views/settings-view/terminal/prompt-preview.tsx b/apps/ui/src/components/views/settings-view/terminal/prompt-preview.tsx new file mode 100644 index 00000000..4315ce5c --- /dev/null +++ b/apps/ui/src/components/views/settings-view/terminal/prompt-preview.tsx @@ -0,0 +1,283 @@ +/** + * Prompt Preview - Shows a live preview of the custom terminal prompt + */ + +import type { ReactNode } from 'react'; +import { cn } from '@/lib/utils'; +import type { ThemeMode } from '@automaker/types'; +import { getTerminalTheme } from '@/config/terminal-themes'; + +interface PromptPreviewProps { + format: 'standard' | 'minimal' | 'powerline' | 'starship'; + theme: ThemeMode; + showGitBranch: boolean; + showGitStatus: boolean; + showUserHost: boolean; + showPath: boolean; + pathStyle: 'full' | 'short' | 'basename'; + pathDepth: number; + showTime: boolean; + showExitStatus: boolean; + isOmpTheme?: boolean; + promptThemeLabel?: string; + className?: string; +} + +export function PromptPreview({ + format, + theme, + showGitBranch, + showGitStatus, + showUserHost, + showPath, + pathStyle, + pathDepth, + showTime, + showExitStatus, + isOmpTheme = false, + promptThemeLabel, + className, +}: PromptPreviewProps) { + const terminalTheme = getTerminalTheme(theme); + + const formatPath = (inputPath: string) => { + let displayPath = inputPath; + let prefix = ''; + + if (displayPath.startsWith('~/')) { + prefix = '~/'; + displayPath = displayPath.slice(2); + } else if (displayPath.startsWith('/')) { + prefix = '/'; + displayPath = displayPath.slice(1); + } + + const segments = displayPath.split('/').filter((segment) => segment.length > 0); + const depth = Math.max(0, pathDepth); + const trimmedSegments = depth > 0 ? segments.slice(-depth) : segments; + + let formattedSegments = trimmedSegments; + if (pathStyle === 'basename' && trimmedSegments.length > 0) { + formattedSegments = [trimmedSegments[trimmedSegments.length - 1]]; + } else if (pathStyle === 'short') { + formattedSegments = trimmedSegments.map((segment, index) => { + if (index < trimmedSegments.length - 1) { + return segment.slice(0, 1); + } + return segment; + }); + } + + const joined = formattedSegments.join('/'); + if (prefix === '/' && joined.length === 0) { + return '/'; + } + if (prefix === '~/' && joined.length === 0) { + return '~'; + } + return `${prefix}${joined}`; + }; + + // Generate preview text based on format + const renderPrompt = () => { + if (isOmpTheme) { + return ( +
+
+ {promptThemeLabel ?? 'Oh My Posh theme'} +
+
+ Rendered by the oh-my-posh CLI in the terminal. +
+
+ Preview here stays generic to avoid misleading output. +
+
+ ); + } + + const user = 'user'; + const host = 'automaker'; + const path = formatPath('~/projects/automaker'); + const branch = showGitBranch ? 'main' : null; + const dirty = showGitStatus && showGitBranch ? '*' : ''; + const time = showTime ? '[14:32]' : ''; + const status = showExitStatus ? '✗ 1' : ''; + + const gitInfo = branch ? ` (${branch}${dirty})` : ''; + + switch (format) { + case 'minimal': { + return ( +
+ {showTime && {time} } + {showUserHost && ( + + {user} + @ + {host}{' '} + + )} + {showPath && {path}} + {gitInfo && {gitInfo}} + {showExitStatus && {status}} + $ + +
+ ); + } + + case 'powerline': { + const powerlineSegments: ReactNode[] = []; + if (showUserHost) { + powerlineSegments.push( + + [{user} + @ + {host}] + + ); + } + if (showPath) { + powerlineSegments.push( + + [{path}] + + ); + } + const powerlineCore = powerlineSegments.flatMap((segment, index) => + index === 0 + ? [segment] + : [ + + ─ + , + segment, + ] + ); + const powerlineExtras: ReactNode[] = []; + if (gitInfo) { + powerlineExtras.push( + + {gitInfo} + + ); + } + if (showTime) { + powerlineExtras.push( + + {time} + + ); + } + if (showExitStatus) { + powerlineExtras.push( + + {status} + + ); + } + const powerlineLine: ReactNode[] = [...powerlineCore]; + if (powerlineExtras.length > 0) { + if (powerlineLine.length > 0) { + powerlineLine.push(' '); + } + powerlineLine.push(...powerlineExtras); + } + + return ( +
+
+ ┌─ + {powerlineLine} +
+
+ └─ + $ + +
+
+ ); + } + + case 'starship': { + return ( +
+
+ {showTime && {time} } + {showUserHost && ( + <> + {user} + @ + {host} + + )} + {showPath && ( + <> + in + {path} + + )} + {branch && ( + <> + on + + {branch} + {dirty} + + + )} + {showExitStatus && {status}} +
+
+ + +
+
+ ); + } + + case 'standard': + default: { + return ( +
+ {showTime && {time} } + {showUserHost && ( + <> + [{user} + @ + {host} + ] + + )} + {showPath && {path}} + {gitInfo && {gitInfo}} + {showExitStatus && {status}} + $ + +
+ ); + } + } + }; + + return ( +
+
Preview
+ {renderPrompt()} +
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/terminal/prompt-theme-presets.ts b/apps/ui/src/components/views/settings-view/terminal/prompt-theme-presets.ts new file mode 100644 index 00000000..48f0ec47 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/terminal/prompt-theme-presets.ts @@ -0,0 +1,253 @@ +import type { TerminalPromptTheme } from '@automaker/types'; + +export const PROMPT_THEME_CUSTOM_ID: TerminalPromptTheme = 'custom'; + +export const OMP_THEME_NAMES = [ + '1_shell', + 'M365Princess', + 'agnoster', + 'agnoster.minimal', + 'agnosterplus', + 'aliens', + 'amro', + 'atomic', + 'atomicBit', + 'avit', + 'blue-owl', + 'blueish', + 'bubbles', + 'bubblesextra', + 'bubblesline', + 'capr4n', + 'catppuccin', + 'catppuccin_frappe', + 'catppuccin_latte', + 'catppuccin_macchiato', + 'catppuccin_mocha', + 'cert', + 'chips', + 'cinnamon', + 'clean-detailed', + 'cloud-context', + 'cloud-native-azure', + 'cobalt2', + 'craver', + 'darkblood', + 'devious-diamonds', + 'di4am0nd', + 'dracula', + 'easy-term', + 'emodipt', + 'emodipt-extend', + 'fish', + 'free-ukraine', + 'froczh', + 'gmay', + 'glowsticks', + 'grandpa-style', + 'gruvbox', + 'half-life', + 'honukai', + 'hotstick.minimal', + 'hul10', + 'hunk', + 'huvix', + 'if_tea', + 'illusi0n', + 'iterm2', + 'jandedobbeleer', + 'jblab_2021', + 'jonnychipz', + 'json', + 'jtracey93', + 'jv_sitecorian', + 'kali', + 'kushal', + 'lambda', + 'lambdageneration', + 'larserikfinholt', + 'lightgreen', + 'marcduiker', + 'markbull', + 'material', + 'microverse-power', + 'mojada', + 'montys', + 'mt', + 'multiverse-neon', + 'negligible', + 'neko', + 'night-owl', + 'nordtron', + 'nu4a', + 'onehalf.minimal', + 'paradox', + 'pararussel', + 'patriksvensson', + 'peru', + 'pixelrobots', + 'plague', + 'poshmon', + 'powerlevel10k_classic', + 'powerlevel10k_lean', + 'powerlevel10k_modern', + 'powerlevel10k_rainbow', + 'powerline', + 'probua.minimal', + 'pure', + 'quick-term', + 'remk', + 'robbyrussell', + 'rudolfs-dark', + 'rudolfs-light', + 'sim-web', + 'slim', + 'slimfat', + 'smoothie', + 'sonicboom_dark', + 'sonicboom_light', + 'sorin', + 'space', + 'spaceship', + 'star', + 'stelbent-compact.minimal', + 'stelbent.minimal', + 'takuya', + 'the-unnamed', + 'thecyberden', + 'tiwahu', + 'tokyo', + 'tokyonight_storm', + 'tonybaloney', + 'uew', + 'unicorn', + 'velvet', + 'wholespace', + 'wopian', + 'xtoys', + 'ys', + 'zash', +] as const; + +type OmpThemeName = (typeof OMP_THEME_NAMES)[number]; + +type PromptFormat = 'standard' | 'minimal' | 'powerline' | 'starship'; + +type PathStyle = 'full' | 'short' | 'basename'; + +export interface PromptThemeConfig { + promptFormat: PromptFormat; + showGitBranch: boolean; + showGitStatus: boolean; + showUserHost: boolean; + showPath: boolean; + pathStyle: PathStyle; + pathDepth: number; + showTime: boolean; + showExitStatus: boolean; +} + +export interface PromptThemePreset { + id: TerminalPromptTheme; + label: string; + description: string; + config: PromptThemeConfig; +} + +const PATH_DEPTH_FULL = 0; +const PATH_DEPTH_TWO = 2; +const PATH_DEPTH_THREE = 3; + +const POWERLINE_HINTS = ['powerline', 'powerlevel10k', 'agnoster', 'bubbles', 'smoothie']; +const MINIMAL_HINTS = ['minimal', 'pure', 'slim', 'negligible']; +const STARSHIP_HINTS = ['spaceship', 'star']; +const SHORT_PATH_HINTS = ['compact', 'lean', 'slim']; +const TIME_HINTS = ['time', 'clock']; +const EXIT_STATUS_HINTS = ['status', 'exit', 'fail', 'error']; + +function toPromptThemeId(name: OmpThemeName): TerminalPromptTheme { + return `omp-${name}` as TerminalPromptTheme; +} + +function formatLabel(name: string): string { + const cleaned = name.replace(/[._-]+/g, ' ').trim(); + return cleaned + .split(' ') + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +function buildPresetConfig(name: OmpThemeName): PromptThemeConfig { + const lower = name.toLowerCase(); + const isPowerline = POWERLINE_HINTS.some((hint) => lower.includes(hint)); + const isMinimal = MINIMAL_HINTS.some((hint) => lower.includes(hint)); + const isStarship = STARSHIP_HINTS.some((hint) => lower.includes(hint)); + let promptFormat: PromptFormat = 'standard'; + + if (isPowerline) { + promptFormat = 'powerline'; + } else if (isMinimal) { + promptFormat = 'minimal'; + } else if (isStarship) { + promptFormat = 'starship'; + } + + const showUserHost = !isMinimal; + const showPath = true; + const pathStyle: PathStyle = isMinimal ? 'short' : 'full'; + let pathDepth = isMinimal ? PATH_DEPTH_THREE : PATH_DEPTH_FULL; + + if (SHORT_PATH_HINTS.some((hint) => lower.includes(hint))) { + pathDepth = PATH_DEPTH_TWO; + } + + if (lower.includes('powerlevel10k')) { + pathDepth = PATH_DEPTH_THREE; + } + + const showTime = TIME_HINTS.some((hint) => lower.includes(hint)); + const showExitStatus = EXIT_STATUS_HINTS.some((hint) => lower.includes(hint)); + + return { + promptFormat, + showGitBranch: true, + showGitStatus: true, + showUserHost, + showPath, + pathStyle, + pathDepth, + showTime, + showExitStatus, + }; +} + +export const PROMPT_THEME_PRESETS: PromptThemePreset[] = OMP_THEME_NAMES.map((name) => ({ + id: toPromptThemeId(name), + label: `${formatLabel(name)} (OMP)`, + description: 'Oh My Posh theme preset', + config: buildPresetConfig(name), +})); + +export function getPromptThemePreset(presetId: TerminalPromptTheme): PromptThemePreset | null { + return PROMPT_THEME_PRESETS.find((preset) => preset.id === presetId) ?? null; +} + +export function getMatchingPromptThemeId(config: PromptThemeConfig): TerminalPromptTheme { + const match = PROMPT_THEME_PRESETS.find((preset) => { + const presetConfig = preset.config; + return ( + presetConfig.promptFormat === config.promptFormat && + presetConfig.showGitBranch === config.showGitBranch && + presetConfig.showGitStatus === config.showGitStatus && + presetConfig.showUserHost === config.showUserHost && + presetConfig.showPath === config.showPath && + presetConfig.pathStyle === config.pathStyle && + presetConfig.pathDepth === config.pathDepth && + presetConfig.showTime === config.showTime && + presetConfig.showExitStatus === config.showExitStatus + ); + }); + + return match?.id ?? PROMPT_THEME_CUSTOM_ID; +} diff --git a/apps/ui/src/components/views/settings-view/terminal/terminal-config-section.tsx b/apps/ui/src/components/views/settings-view/terminal/terminal-config-section.tsx new file mode 100644 index 00000000..ddfd9201 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/terminal/terminal-config-section.tsx @@ -0,0 +1,662 @@ +/** + * Terminal Config Section - Custom terminal configurations with theme synchronization + * + * This component provides UI for enabling custom terminal prompts that automatically + * sync with Automaker's 40 themes. It's an opt-in feature that generates shell configs + * in .automaker/terminal/ without modifying user's existing RC files. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Wand2, GitBranch, Info, Plus, X } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useAppStore } from '@/store/app-store'; +import { toast } from 'sonner'; +import { PromptPreview } from './prompt-preview'; +import type { TerminalPromptTheme } from '@automaker/types'; +import { + PROMPT_THEME_CUSTOM_ID, + PROMPT_THEME_PRESETS, + getMatchingPromptThemeId, + getPromptThemePreset, + type PromptThemeConfig, +} from './prompt-theme-presets'; +import { useUpdateGlobalSettings } from '@/hooks/mutations/use-settings-mutations'; +import { useGlobalSettings } from '@/hooks/queries/use-settings'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; + +export function TerminalConfigSection() { + const PATH_DEPTH_MIN = 0; + const PATH_DEPTH_MAX = 10; + const ENV_VAR_UPDATE_DEBOUNCE_MS = 400; + const ENV_VAR_ID_PREFIX = 'env'; + const TERMINAL_RC_FILE_VERSION = 11; + const { theme } = useAppStore(); + const { data: globalSettings } = useGlobalSettings(); + const updateGlobalSettings = useUpdateGlobalSettings({ showSuccessToast: false }); + const envVarIdRef = useRef(0); + const envVarUpdateTimeoutRef = useRef | null>(null); + const createEnvVarEntry = useCallback( + (key = '', value = '') => { + envVarIdRef.current += 1; + return { + id: `${ENV_VAR_ID_PREFIX}-${envVarIdRef.current}`, + key, + value, + }; + }, + [ENV_VAR_ID_PREFIX] + ); + const [localEnvVars, setLocalEnvVars] = useState< + Array<{ id: string; key: string; value: string }> + >(() => + Object.entries(globalSettings?.terminalConfig?.customEnvVars || {}).map(([key, value]) => + createEnvVarEntry(key, value) + ) + ); + const [showEnableConfirm, setShowEnableConfirm] = useState(false); + + const clampPathDepth = (value: number) => + Math.min(PATH_DEPTH_MAX, Math.max(PATH_DEPTH_MIN, value)); + + const defaultTerminalConfig = { + enabled: false, + customPrompt: true, + promptFormat: 'standard' as const, + promptTheme: PROMPT_THEME_CUSTOM_ID, + showGitBranch: true, + showGitStatus: true, + showUserHost: true, + showPath: true, + pathStyle: 'full' as const, + pathDepth: PATH_DEPTH_MIN, + showTime: false, + showExitStatus: false, + customAliases: '', + customEnvVars: {}, + }; + + const terminalConfig = { + ...defaultTerminalConfig, + ...globalSettings?.terminalConfig, + customAliases: + globalSettings?.terminalConfig?.customAliases ?? defaultTerminalConfig.customAliases, + customEnvVars: + globalSettings?.terminalConfig?.customEnvVars ?? defaultTerminalConfig.customEnvVars, + }; + + const promptThemeConfig: PromptThemeConfig = { + promptFormat: terminalConfig.promptFormat, + showGitBranch: terminalConfig.showGitBranch, + showGitStatus: terminalConfig.showGitStatus, + showUserHost: terminalConfig.showUserHost, + showPath: terminalConfig.showPath, + pathStyle: terminalConfig.pathStyle, + pathDepth: terminalConfig.pathDepth, + showTime: terminalConfig.showTime, + showExitStatus: terminalConfig.showExitStatus, + }; + + const storedPromptTheme = terminalConfig.promptTheme; + const activePromptThemeId = + storedPromptTheme === PROMPT_THEME_CUSTOM_ID + ? PROMPT_THEME_CUSTOM_ID + : (storedPromptTheme ?? getMatchingPromptThemeId(promptThemeConfig)); + const isOmpTheme = + storedPromptTheme !== undefined && storedPromptTheme !== PROMPT_THEME_CUSTOM_ID; + const promptThemePreset = isOmpTheme + ? getPromptThemePreset(storedPromptTheme as TerminalPromptTheme) + : null; + + const applyEnabledUpdate = (enabled: boolean) => { + // Ensure all required fields are present + const updatedConfig = { + enabled, + customPrompt: terminalConfig.customPrompt, + promptFormat: terminalConfig.promptFormat, + showGitBranch: terminalConfig.showGitBranch, + showGitStatus: terminalConfig.showGitStatus, + showUserHost: terminalConfig.showUserHost, + showPath: terminalConfig.showPath, + pathStyle: terminalConfig.pathStyle, + pathDepth: terminalConfig.pathDepth, + showTime: terminalConfig.showTime, + showExitStatus: terminalConfig.showExitStatus, + promptTheme: terminalConfig.promptTheme ?? PROMPT_THEME_CUSTOM_ID, + customAliases: terminalConfig.customAliases, + customEnvVars: terminalConfig.customEnvVars, + rcFileVersion: TERMINAL_RC_FILE_VERSION, + }; + + updateGlobalSettings.mutate( + { terminalConfig: updatedConfig }, + { + onSuccess: () => { + toast.success( + enabled ? 'Custom terminal configs enabled' : 'Custom terminal configs disabled', + { + description: enabled + ? 'New terminals will use custom prompts' + : '.automaker/terminal/ will be cleaned up', + } + ); + }, + onError: (error) => { + console.error('[TerminalConfig] Failed to update settings:', error); + toast.error('Failed to update terminal config', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + }, + } + ); + }; + + useEffect(() => { + setLocalEnvVars( + Object.entries(globalSettings?.terminalConfig?.customEnvVars || {}).map(([key, value]) => + createEnvVarEntry(key, value) + ) + ); + }, [createEnvVarEntry, globalSettings?.terminalConfig?.customEnvVars]); + + useEffect(() => { + return () => { + if (envVarUpdateTimeoutRef.current) { + clearTimeout(envVarUpdateTimeoutRef.current); + } + }; + }, []); + + const handleToggleEnabled = async (enabled: boolean) => { + if (enabled) { + setShowEnableConfirm(true); + return; + } + + applyEnabledUpdate(false); + }; + + const handleUpdateConfig = (updates: Partial) => { + const nextPromptTheme = updates.promptTheme ?? PROMPT_THEME_CUSTOM_ID; + + updateGlobalSettings.mutate( + { + terminalConfig: { + ...terminalConfig, + ...updates, + promptTheme: nextPromptTheme, + }, + }, + { + onError: (error) => { + console.error('[TerminalConfig] Failed to update settings:', error); + toast.error('Failed to update terminal config', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + }, + } + ); + }; + + const scheduleEnvVarsUpdate = (envVarsObject: Record) => { + if (envVarUpdateTimeoutRef.current) { + clearTimeout(envVarUpdateTimeoutRef.current); + } + envVarUpdateTimeoutRef.current = setTimeout(() => { + handleUpdateConfig({ customEnvVars: envVarsObject }); + }, ENV_VAR_UPDATE_DEBOUNCE_MS); + }; + + const handlePromptThemeChange = (themeId: string) => { + if (themeId === PROMPT_THEME_CUSTOM_ID) { + handleUpdateConfig({ promptTheme: PROMPT_THEME_CUSTOM_ID }); + return; + } + + const preset = getPromptThemePreset(themeId as TerminalPromptTheme); + if (!preset) { + handleUpdateConfig({ promptTheme: PROMPT_THEME_CUSTOM_ID }); + return; + } + + handleUpdateConfig({ + ...preset.config, + promptTheme: preset.id, + }); + }; + + const addEnvVar = () => { + setLocalEnvVars([...localEnvVars, createEnvVarEntry()]); + }; + + const removeEnvVar = (id: string) => { + const newVars = localEnvVars.filter((envVar) => envVar.id !== id); + setLocalEnvVars(newVars); + + // Update settings + const envVarsObject = newVars.reduce( + (acc, { key, value }) => { + if (key) acc[key] = value; + return acc; + }, + {} as Record + ); + + scheduleEnvVarsUpdate(envVarsObject); + }; + + const updateEnvVar = (id: string, field: 'key' | 'value', newValue: string) => { + const newVars = localEnvVars.map((envVar) => + envVar.id === id ? { ...envVar, [field]: newValue } : envVar + ); + setLocalEnvVars(newVars); + + // Validate and update settings (only if key is valid) + const envVarsObject = newVars.reduce( + (acc, { key, value }) => { + // Only include vars with valid keys (alphanumeric + underscore) + if (key && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { + acc[key] = value; + } + return acc; + }, + {} as Record + ); + + scheduleEnvVarsUpdate(envVarsObject); + }; + + return ( +
+
+
+
+ +
+

+ Custom Terminal Configurations +

+
+

+ Generate custom shell prompts that automatically sync with your app theme. Opt-in feature + that creates configs in .automaker/terminal/ without modifying your existing RC files. +

+
+ +
+ {/* Enable Toggle */} +
+
+ +

+ Create theme-synced shell configs in .automaker/terminal/ +

+
+ +
+ + {terminalConfig.enabled && ( + <> + {/* Info Box */} +
+ +
+ How it works: Custom configs are applied to new terminals only. + Your ~/.bashrc and ~/.zshrc are still loaded first. Close and reopen terminals to + see changes. +
+
+ + {/* Custom Prompt Toggle */} +
+
+ +

+ Override default shell prompt with themed version +

+
+ handleUpdateConfig({ customPrompt: checked })} + /> +
+ + {terminalConfig.customPrompt && ( + <> + {/* Prompt Format */} +
+ + +
+ + {isOmpTheme && ( +
+ +
+ {promptThemePreset?.label ?? 'Oh My Posh theme'} uses the + oh-my-posh CLI for rendering. Ensure it's installed for the full theme. + Prompt format and segment toggles are ignored while an OMP theme is selected. +
+
+ )} + +
+ + +
+ + {/* Git Info Toggles */} +
+
+
+ + +
+ handleUpdateConfig({ showGitBranch: checked })} + disabled={isOmpTheme} + /> +
+ +
+
+ * + +
+ handleUpdateConfig({ showGitStatus: checked })} + disabled={!terminalConfig.showGitBranch || isOmpTheme} + /> +
+
+ + {/* Prompt Segments */} +
+
+
+ + +
+ handleUpdateConfig({ showUserHost: checked })} + disabled={isOmpTheme} + /> +
+ +
+
+ ~/ + +
+ handleUpdateConfig({ showPath: checked })} + disabled={isOmpTheme} + /> +
+ +
+
+ + +
+ handleUpdateConfig({ showTime: checked })} + disabled={isOmpTheme} + /> +
+ +
+
+ + +
+ handleUpdateConfig({ showExitStatus: checked })} + disabled={isOmpTheme} + /> +
+ +
+
+ + +
+ +
+ + + handleUpdateConfig({ + pathDepth: clampPathDepth(Number(event.target.value) || 0), + }) + } + disabled={!terminalConfig.showPath || isOmpTheme} + /> +
+
+
+ + {/* Live Preview */} +
+ + +
+ + )} + + {/* Custom Aliases */} +
+
+ +

+ Add shell aliases (one per line, e.g., alias ll='ls -la') +

+
+