diff --git a/apps/frontend/src/main/claude-profile-manager.ts b/apps/frontend/src/main/claude-profile-manager.ts index 151c7768..642180db 100644 --- a/apps/frontend/src/main/claude-profile-manager.ts +++ b/apps/frontend/src/main/claude-profile-manager.ts @@ -43,7 +43,7 @@ import { shouldProactivelySwitch as shouldProactivelySwitchImpl, getProfilesSortedByAvailability as getProfilesSortedByAvailabilityImpl } from './claude-profile/profile-scorer'; -import { getCredentialsFromKeychain, normalizeWindowsPath } from './claude-profile/credential-utils'; +import { getCredentialsFromKeychain, normalizeWindowsPath, updateProfileSubscriptionMetadata } from './claude-profile/credential-utils'; import { CLAUDE_PROFILES_DIR, generateProfileId as generateProfileIdImpl, @@ -96,6 +96,10 @@ export class ClaudeProfileManager { // This repairs emails that were truncated due to ANSI escape codes in terminal output this.migrateCorruptedEmails(); + // Populate missing subscription metadata for existing profiles + // This reads subscriptionType and rateLimitTier from Keychain credentials + this.populateSubscriptionMetadata(); + this.initialized = true; } @@ -133,6 +137,58 @@ export class ClaudeProfileManager { } } + /** + * Populate missing subscription metadata (subscriptionType, rateLimitTier) for existing profiles. + * + * This reads from Keychain credentials and updates profiles that don't have this metadata. + * Runs on initialization to ensure existing profiles get the subscription info for UI display. + */ + private populateSubscriptionMetadata(): void { + let needsSave = false; + + for (const profile of this.data.profiles) { + if (!profile.configDir) { + continue; + } + + // Skip if profile already has subscription metadata + if (profile.subscriptionType && profile.rateLimitTier) { + continue; + } + + // Expand ~ to home directory + const expandedConfigDir = normalizeWindowsPath( + profile.configDir.startsWith('~') + ? profile.configDir.replace(/^~/, homedir()) + : profile.configDir + ); + + // Use helper with onlyIfMissing option to preserve existing values + const result = updateProfileSubscriptionMetadata(profile, expandedConfigDir, { onlyIfMissing: true }); + + if (result.subscriptionTypeUpdated) { + needsSave = true; + console.warn('[ClaudeProfileManager] Populated subscriptionType for profile:', { + profileId: profile.id, + subscriptionType: result.subscriptionType + }); + } + + if (result.rateLimitTierUpdated) { + needsSave = true; + console.warn('[ClaudeProfileManager] Populated rateLimitTier for profile:', { + profileId: profile.id, + rateLimitTier: result.rateLimitTier + }); + } + } + + if (needsSave) { + this.save(); + console.warn('[ClaudeProfileManager] Subscription metadata population complete'); + } + } + /** * Check if the profile manager has been initialized */ diff --git a/apps/frontend/src/main/claude-profile/credential-utils.ts b/apps/frontend/src/main/claude-profile/credential-utils.ts index 67cffd48..029988f4 100644 --- a/apps/frontend/src/main/claude-profile/credential-utils.ts +++ b/apps/frontend/src/main/claude-profile/credential-utils.ts @@ -2205,3 +2205,101 @@ export function updateKeychainCredentials( return { success: false, error: `Unsupported platform: ${process.platform}` }; } + +// ============================================================================= +// Profile Subscription Metadata Helper +// ============================================================================= + +/** + * Result of updating profile subscription metadata + */ +export interface UpdateSubscriptionMetadataResult { + /** Whether subscriptionType was updated */ + subscriptionTypeUpdated: boolean; + /** Whether rateLimitTier was updated */ + rateLimitTierUpdated: boolean; + /** The subscriptionType value (if found) */ + subscriptionType?: string | null; + /** The rateLimitTier value (if found) */ + rateLimitTier?: string | null; +} + +/** + * Options for updateProfileSubscriptionMetadata + */ +export interface UpdateSubscriptionMetadataOptions { + /** + * If true, only update fields that are currently missing (undefined/null/empty). + * This is useful for migration/initialization code that should not overwrite existing values. + * Default: false (always update if credentials have values) + */ + onlyIfMissing?: boolean; +} + +/** + * Update a profile's subscription metadata (subscriptionType, rateLimitTier) from Keychain credentials. + * + * This helper centralizes the common pattern of reading subscription info from Keychain + * and updating a profile object. It's used after OAuth login, onboarding completion, + * and profile authentication verification. + * + * NOTE: This function mutates the profile object directly. The caller is responsible + * for saving the profile after calling this function. + * + * @param profile - The profile object to update (must have subscriptionType and rateLimitTier properties) + * @param configDirOrCredentials - Either a config directory path to read credentials from, + * or pre-fetched FullOAuthCredentials to avoid redundant reads + * @param options - Optional settings like onlyIfMissing + * @returns Information about what was updated + * + * @example + * ```typescript + * // Option 1: Pass configDir - helper fetches credentials + * const result = updateProfileSubscriptionMetadata(profile, profile.configDir); + * + * // Option 2: Pass pre-fetched credentials (more efficient when already fetched) + * const fullCreds = getFullCredentialsFromKeychain(profile.configDir); + * const result = updateProfileSubscriptionMetadata(profile, fullCreds); + * + * // Option 3: Only populate if missing (for migration/initialization) + * const result = updateProfileSubscriptionMetadata(profile, profile.configDir, { onlyIfMissing: true }); + * + * if (result.subscriptionTypeUpdated || result.rateLimitTierUpdated) { + * profileManager.saveProfile(profile); + * } + * ``` + */ +export function updateProfileSubscriptionMetadata( + profile: { subscriptionType?: string | null; rateLimitTier?: string | null }, + configDirOrCredentials: string | undefined | FullOAuthCredentials, + options?: UpdateSubscriptionMetadataOptions +): UpdateSubscriptionMetadataResult { + const result: UpdateSubscriptionMetadataResult = { + subscriptionTypeUpdated: false, + rateLimitTierUpdated: false, + }; + + const onlyIfMissing = options?.onlyIfMissing ?? false; + + // Determine if we received pre-fetched credentials or a configDir + const fullCreds: FullOAuthCredentials = + typeof configDirOrCredentials === 'object' && configDirOrCredentials !== null + ? configDirOrCredentials + : getFullCredentialsFromKeychain(configDirOrCredentials); + + // Update subscriptionType if credentials have it and (not onlyIfMissing OR profile doesn't have it) + if (fullCreds.subscriptionType && (!onlyIfMissing || !profile.subscriptionType)) { + profile.subscriptionType = fullCreds.subscriptionType; + result.subscriptionTypeUpdated = true; + result.subscriptionType = fullCreds.subscriptionType; + } + + // Update rateLimitTier if credentials have it and (not onlyIfMissing OR profile doesn't have it) + if (fullCreds.rateLimitTier && (!onlyIfMissing || !profile.rateLimitTier)) { + profile.rateLimitTier = fullCreds.rateLimitTier; + result.rateLimitTierUpdated = true; + result.rateLimitTier = fullCreds.rateLimitTier; + } + + return result; +} diff --git a/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts b/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts index 0fde3dfd..14bf8909 100644 --- a/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts @@ -23,7 +23,7 @@ import { isSecurePath } from '../utils/windows-paths'; import { isWindows, isMacOS, isLinux } from '../platform'; import { getClaudeProfileManager } from '../claude-profile-manager'; import { isValidConfigDir } from '../utils/config-path-validator'; -import { clearKeychainCache, getCredentialsFromKeychain } from '../claude-profile/credential-utils'; +import { clearKeychainCache, getCredentialsFromKeychain, updateProfileSubscriptionMetadata } from '../claude-profile/credential-utils'; import { getUsageMonitor } from '../claude-profile/usage-monitor'; import semver from 'semver'; @@ -1370,7 +1370,7 @@ export function registerClaudeCodeHandlers(): void { } } - // If authenticated, update the profile with the email + // If authenticated, update the profile with metadata from credentials // NOTE: We intentionally do NOT store the OAuth token in the profile. // Storing the token causes AutoClaude to use a stale cached token instead of // letting Claude CLI read fresh tokens from Keychain (which auto-refreshes). @@ -1384,7 +1384,11 @@ export function registerClaudeCodeHandlers(): void { profile.email = result.email; } - // Save profile metadata (email, isAuthenticated) but NOT the OAuth token + // Update subscription metadata from Keychain credentials + // These are needed to display "Max" vs "Pro" in the UI + updateProfileSubscriptionMetadata(profile, expandedConfigDir); + + // Save profile metadata (email, isAuthenticated, subscriptionType, rateLimitTier) but NOT the OAuth token profileManager.saveProfile(profile); // CRITICAL: Clear keychain cache for this profile's configDir diff --git a/apps/frontend/src/main/terminal/claude-integration-handler.ts b/apps/frontend/src/main/terminal/claude-integration-handler.ts index cc5b3668..9478254d 100644 --- a/apps/frontend/src/main/terminal/claude-integration-handler.ts +++ b/apps/frontend/src/main/terminal/claude-integration-handler.ts @@ -10,7 +10,7 @@ import * as path from 'path'; import * as crypto from 'crypto'; import { IPC_CHANNELS } from '../../shared/constants'; import { getClaudeProfileManager, initializeClaudeProfileManager } from '../claude-profile-manager'; -import { getCredentialsFromKeychain, clearKeychainCache } from '../claude-profile/credential-utils'; +import { getFullCredentialsFromKeychain, clearKeychainCache, updateProfileSubscriptionMetadata } from '../claude-profile/credential-utils'; import { getUsageMonitor } from '../claude-profile/usage-monitor'; import { getEmailFromConfigDir } from '../claude-profile/profile-utils'; import * as OutputParser from './output-parser'; @@ -486,8 +486,8 @@ export function handleOAuthToken( // Clear Keychain cache to get fresh credentials clearKeychainCache(profile.configDir); - // Extract token from Keychain using the profile's configDir - const keychainCreds = getCredentialsFromKeychain(profile.configDir, true); + // Extract full credentials from Keychain including subscriptionType and rateLimitTier + const keychainCreds = getFullCredentialsFromKeychain(profile.configDir); // Check if there was a keychain access error (not just "not found") if (keychainCreds.error) { @@ -525,6 +525,8 @@ export function handleOAuthToken( if (email) { profile.email = email; } + // Update subscription metadata from Keychain credentials + updateProfileSubscriptionMetadata(profile, keychainCreds); profile.isAuthenticated = true; profileManager.saveProfile(profile); @@ -611,6 +613,8 @@ export function handleOAuthToken( if (email) { profile.email = email; } + // Update subscription metadata from Keychain credentials + updateProfileSubscriptionMetadata(profile, profile.configDir); profile.isAuthenticated = true; profileManager.saveProfile(profile); @@ -673,6 +677,8 @@ export function handleOAuthToken( if (email) { activeProfile.email = email; } + // Update subscription metadata from Keychain credentials + updateProfileSubscriptionMetadata(activeProfile, activeProfile.configDir); activeProfile.isAuthenticated = true; profileManager.saveProfile(activeProfile); @@ -766,11 +772,13 @@ export function handleOnboardingComplete( bufferLength: terminal.outputBuffer.length }); - // Update profile with email if found and profile exists + // Update profile with email and subscription metadata if found and profile exists // Always update - the newly extracted email from re-authentication should overwrite any stale/truncated email if (profileId && email && profile) { const previousEmail = profile.email; profile.email = email; + // Also update subscription metadata from Keychain credentials + updateProfileSubscriptionMetadata(profile, profile.configDir); profileManager.saveProfile(profile); if (previousEmail !== email) { console.warn('[ClaudeIntegration] Updated profile email from welcome screen:', profileId, maskEmail(email), '(was:', maskEmail(previousEmail), ')'); diff --git a/apps/frontend/src/shared/types/agent.ts b/apps/frontend/src/shared/types/agent.ts index f21bb980..4df6ab82 100644 --- a/apps/frontend/src/shared/types/agent.ts +++ b/apps/frontend/src/shared/types/agent.ts @@ -185,6 +185,16 @@ export interface ClaudeProfile { * This is NOT persisted, it's computed dynamically on each getSettings() call. */ isAuthenticated?: boolean; + /** + * Subscription type from OAuth credentials (e.g., "max" for Claude Max subscription). + * Used to display "Max" vs "Pro" in the UI. Populated from Keychain credentials. + */ + subscriptionType?: string; + /** + * Rate limit tier from OAuth credentials (e.g., "default_claude_max_20x"). + * Indicates the user's rate limit tier level. Populated from Keychain credentials. + */ + rateLimitTier?: string; } /** diff --git a/tests/test_integration_phase4.py b/tests/test_integration_phase4.py index ba6d15dd..d34b8896 100644 --- a/tests/test_integration_phase4.py +++ b/tests/test_integration_phase4.py @@ -90,7 +90,8 @@ orchestrator_spec = importlib.util.spec_from_file_location( / "parallel_orchestrator_reviewer.py", ) orchestrator_module = importlib.util.module_from_spec(orchestrator_spec) -# Register module in sys.modules BEFORE exec_module - required for @dataclass decorator +# Register module in sys.modules BEFORE exec_module to allow @dataclass decorator to work +# Without this, dataclass fails on Windows with: AttributeError: 'NoneType' object has no attribute '__dict__' sys.modules["parallel_orchestrator_reviewer"] = orchestrator_module # Mock dependencies that aren't needed for unit testing # IMPORTANT: Save and restore ALL mocked modules to avoid polluting sys.modules for other tests