feat: add subscriptionType and rateLimitTier to ClaudeProfile (#1688)
* feat: add subscriptionType and rateLimitTier to ClaudeProfile Add subscription metadata fields to ClaudeProfile type to enable displaying "Max" vs "Pro" subscription status in the UI without hitting the Keychain on every render. - Add subscriptionType and rateLimitTier fields to ClaudeProfile interface - Populate fields from Keychain credentials during OAuth authentication - Add populateSubscriptionMetadata() migration for existing profiles - Update 4 auth code paths to save subscription metadata Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: resolve Windows test failure and ruff formatting - Register orchestrator_module in sys.modules before exec_module to fix dataclass decorator failure on Windows - Apply ruff formatting to parallel_orchestrator_reviewer.py and pydantic_models.py Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: extract updateProfileSubscriptionMetadata helper to reduce code duplication This addresses the PR review finding about duplicated subscription metadata update patterns across 5 locations. The helper: - Reads subscriptionType and rateLimitTier from Keychain credentials - Updates the profile object with these values - Accepts either a configDir path or pre-fetched credentials (efficiency) - Supports optional onlyIfMissing mode for migration/initialization code Updated files: - credential-utils.ts: Added updateProfileSubscriptionMetadata helper - claude-integration-handler.ts: 4 instances replaced with helper calls - claude-code-handlers.ts: 1 instance replaced with helper call - claude-profile-manager.ts: 1 instance replaced with helper call Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * ci: retrigger CI --------- Co-authored-by: Test User <test@example.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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), ')');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user