fix(auth): Long-lived OAuth authentication with multi-profile usage display (#1443)

* fix(auth): use CLAUDE_CONFIG_DIR instead of cached OAuth tokens

Stop caching OAuth tokens in profiles and always use CLAUDE_CONFIG_DIR
to let Claude CLI read fresh tokens from Keychain. This fixes 401 errors
that occurred after 8-12 hours when cached tokens expired.

Root cause: AutoClaude was storing OAuth access tokens in profiles and
using CLAUDE_CODE_OAUTH_TOKEN env var. These tokens expire in 8-12 hours
but we assumed 1-year validity. Meanwhile, Claude CLI's auto-refresh
mechanism updates Keychain tokens properly, but we weren't benefiting.

Solution:
- Remove setProfileToken() calls that cached tokens after authentication
- Update getProfileEnv() to always return CLAUDE_CONFIG_DIR for non-default profiles
- Update getActiveProfileEnv() to never fall back to cached oauthToken
- Auto-create configDir for profiles that don't have one
- Add deprecation notice to hasValidToken() for backwards compat

Now Claude CLI reads fresh tokens from Keychain on each invocation,
benefiting from its built-in token refresh mechanism.

See: docs/LONG_LIVED_AUTH_PLAN.md for full investigation details.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(auth): resolve long-lived authentication issues with Claude OAuth

The root cause was that AutoClaude cached OAuth tokens (which expire in
8-12 hours) instead of letting Claude CLI read fresh tokens from Keychain.

Changes:
- UsageMonitor now reads fresh tokens from Keychain via getCredentialsFromKeychain()
- Added anthropic-beta: oauth-2025-04-20 header required for OAuth API calls
- Fixed normalizeAnthropicResponse() to handle actual nested API format:
  { "five_hour": { "utilization": 19 } } instead of { "five_hour_utilization": 0.19 }
- Profile migration removes stale cached oauthToken values on load
- Added debug logging for keychain cache hits with token hashes
- Clear keychain cache on 401 authentication failures for quick recovery

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(usage-indicator): improve UX with click-to-pin popup and email display

- Replace Tooltip with Popover for persistent click-to-pin behavior
  - Clicking the badge opens popup, clicking outside dismisses
  - Standard dropdown UX pattern
- Add email display under profile name in Active Account section
  - Email is fetched from keychain credentials
  - Displayed in smaller text below profile name
- Add click-to-navigate on Active Account section
  - Clicking navigates to Settings > Integrations tab
  - Provides quick access to profile management
- Add profileEmail field to ClaudeUsageSnapshot type
- Pass email through UsageMonitor fetch chain

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(usage-indicator): restore hover behavior while adding click-to-pin

Previous commit accidentally removed hover functionality when adding
click-to-pin. Users wanted BOTH behaviors:
- Hover: Show popup on hover, auto-close on mouse leave
- Click: Pin popup open until clicking outside or clicking badge again

Implemented with isPinned state to distinguish between hover-opened
and click-pinned states, with timeout-based delays for smooth UX.
Also fixed settings navigation with proper event bubbling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(usage-monitor): fix failing tests and settings navigation

- Add keychain-utils mock to prevent tests from reading real Keychain
- Add backward compatibility for legacy Anthropic response format
  (0.72 float → 72 integer conversion)
- Fix UsageIndicator settings navigation event name
  (open-settings → open-app-settings)

All 9 previously failing tests now pass.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(usage-indicator): add multi-profile usage display with quick swap

- Show real usage data for all Claude profiles, not just active one
- Display dual session|weekly percentages in badge with independent colors
- Add "Swap" button for instant profile switching from usage dropdown
- Use optimistic UI updates for fluid swap experience
- Extract color threshold constants for consistency (95/91/71)
- Add empty profile name fallback

Fetches inactive profile usage via their keychain credentials.
Swap immediately updates UI, then syncs with backend.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(IntegrationSettings): remove check interval settings UI

- Removed the check interval settings UI for proactive swap feature in IntegrationSettings component.
- Simplified the component structure by eliminating unnecessary elements related to usage check interval.
- Maintained existing functionality for session threshold settings.

This change streamlines the settings interface, focusing on essential configurations while enhancing user experience.

* fix(profiles): correct keychain lookup and email extraction for OAuth profiles

- Fix keychain service name mismatch for default profiles by always using
  configDir path instead of undefined (fixes wrong usage data display)
- Add ANSI escape code stripping to email extraction to prevent truncated
  emails from terminal color codes breaking regex matching
- Always update profile email on re-authentication instead of only when missing
- Add account priority management UI with drag-and-drop reordering
- Add AccountSettings component for profile management in settings
- Clean up debug logging and optimize IntegrationSettings component

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(profiles): fix email truncation from ANSI codes and remove debug logging

- Enhanced stripAnsi() to handle OSC 8 hyperlink sequences that were
  corrupting email extraction from terminal output
- Added getEmailFromConfigDir() to read email from Claude's config file
  as authoritative source
- Added one-time migration to fix existing corrupted profile emails
- Removed temporary file-based debug-logger, keeping only console.warn
  logging that runs in debug mode (DEBUG=true)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(usage-monitor): implement HTTP error type guard and improve error handling

- Added a type guard function `isHttpError` to check for errors with HTTP status codes.
- Updated error handling in `UsageMonitor` to utilize the new type guard for better clarity and safety.
- Enhanced the `UsageIndicator` component to revert to previous state on profile swap failure.
- Improved error logging in `AccountSettings` to provide more context on loading failures.

These changes enhance error management and improve the robustness of the application.

* feat(credentials): add cross-platform credential retrieval for macOS, Linux, and Windows

Replace macOS-only keychain-utils.ts with cross-platform credential-utils.ts that supports:
- macOS: Keychain via `security` command (existing)
- Linux: .credentials.json file in config directory
- Windows: Windows Credential Manager via PowerShell

Changes:
- Add credential-utils.ts with platform-specific implementations
- Add comprehensive tests (32 test cases) for all platforms
- Fix error cache TTL bug (errors now properly cache for 10 seconds)
- Add timeout constants for better maintainability
- Update all imports from keychain-utils to credential-utils
- Delete deprecated keychain-utils.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(security): resolve CodeQL security alerts for credential handling

- Replace SHA-256 token hashing with safe fingerprint display for debug logs
  (shows first 8 + last 4 chars instead of hash to avoid CodeQL password hash warning)
- Add domain allowlist validation for usage API fetch requests
  (only allows api.anthropic.com, api.z.ai, open.bigmodel.cn)
- Remove unused afterEach import from credential-utils.test.ts

Fixes 6 high severity, 1 medium severity, and 1 note from CodeQL scan.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: resolve PR review findings for code quality and accessibility

- Replace alert() with toast() for consistent UX (3 locations)
- Add accessibility attributes to range inputs (id, htmlFor, aria-describedby)
- Remove dead code: unused profilesFile.activeProfileId assignment
- Consolidate duplicate getProfileEnv by delegating to profile manager
- Refactor getAllProfilesUsage to fetch inactive profiles in parallel
- Replace inline require('os') with top-level import

Addresses 6 of 9 PR review findings (2 medium, 4 low priority).
Remaining: Large component refactoring (separate PR), acceptable patterns.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(tests): update mocks to include getActiveProfileEnv and getProfileEnv

After refactoring getProfileEnv to delegate to profile manager,
the test mocks needed to include the new methods:
- getActiveProfileEnv() for active profile env vars
- getProfileEnv(profileId) for specific profile env vars

Updated mocks in:
- long-lived-auth.test.ts
- subprocess-spawn.test.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: resolve PR review findings for race condition and dead code

- Fix race condition in getAllProfilesUsage() by batching profile updates
  after all parallel fetches complete (single save instead of concurrent saves)
- Add batchUpdateProfileUsageFromAPI() method to profile manager for atomic updates
- Remove dead IntegrationSettings component and its test file (never imported)
- Add defense-in-depth validation for credential target names (PowerShell)
- Add defense-in-depth validation for credentials paths (Linux)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: make credential path validation cross-platform compatible

Remove absolute path requirement from isValidCredentialsPath() as path.join
produces different formats on Unix vs Windows. The path traversal check
(rejecting '..') provides sufficient defense-in-depth protection.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-23 12:56:37 +01:00
committed by GitHub
parent 1a2a1b1fc9
commit 12e788417d
35 changed files with 5432 additions and 1974 deletions
@@ -66,7 +66,10 @@ const mockProfileManager = {
getProfile: (_profileId: string) => mockProfile,
// Token decryption methods - return mock token for tests
getActiveProfileToken: () => 'mock-decrypted-token-for-testing',
getProfileToken: (_profileId: string) => 'mock-decrypted-token-for-testing'
getProfileToken: (_profileId: string) => 'mock-decrypted-token-for-testing',
// Environment methods for rate-limit-detector delegation
getActiveProfileEnv: () => ({}),
getProfileEnv: (_profileId: string) => ({})
};
vi.mock('../../main/claude-profile-manager', () => ({
@@ -0,0 +1,196 @@
/**
* Tests for Long-Lived Auth Fix
*
* Verifies that:
* 1. getProfileEnv() always uses CLAUDE_CONFIG_DIR instead of cached OAuth tokens
* 2. Profile migration removes cached oauthToken values
* 3. UsageMonitor reads fresh tokens from Keychain
*
* See: docs/LONG_LIVED_AUTH_PLAN.md
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the profile manager
const mockGetProfile = vi.fn();
const mockGetActiveProfile = vi.fn();
const mockGetProfileToken = vi.fn();
const mockGetActiveProfileToken = vi.fn();
const mockGetProfileEnv = vi.fn();
const mockGetActiveProfileEnv = vi.fn();
vi.mock('../claude-profile-manager', () => ({
getClaudeProfileManager: () => ({
getProfile: mockGetProfile,
getActiveProfile: mockGetActiveProfile,
getProfileToken: mockGetProfileToken,
getActiveProfileToken: mockGetActiveProfileToken,
getProfileEnv: mockGetProfileEnv,
getActiveProfileEnv: mockGetActiveProfileEnv,
}),
}));
// Import after mocking
import { getProfileEnv } from '../rate-limit-detector';
// Mock for profile storage tests - needs to be imported dynamically
const mockFs = {
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
};
vi.mock('fs', () => ({
existsSync: (...args: unknown[]) => mockFs.existsSync(...args),
readFileSync: (...args: unknown[]) => mockFs.readFileSync(...args),
writeFileSync: (...args: unknown[]) => mockFs.writeFileSync(...args),
readFile: vi.fn(),
}));
describe('Long-Lived Auth Fix', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getProfileEnv', () => {
it('should return empty env for default profile (Claude CLI uses ~/.claude)', () => {
// Since getProfileEnv now delegates to profile manager, mock the manager's method
mockGetActiveProfileEnv.mockReturnValue({});
const env = getProfileEnv();
expect(env).toEqual({});
expect(mockGetActiveProfileEnv).toHaveBeenCalled();
// Should NOT call getProfileToken or getActiveProfileToken
expect(mockGetProfileToken).not.toHaveBeenCalled();
expect(mockGetActiveProfileToken).not.toHaveBeenCalled();
});
it('should return CLAUDE_CONFIG_DIR for non-default profile with configDir', () => {
// Since getProfileEnv now delegates to profile manager, mock the manager's method
mockGetActiveProfileEnv.mockReturnValue({
CLAUDE_CONFIG_DIR: '/Users/test/.claude-profiles/work',
});
const env = getProfileEnv();
expect(env).toEqual({
CLAUDE_CONFIG_DIR: '/Users/test/.claude-profiles/work',
});
expect(mockGetActiveProfileEnv).toHaveBeenCalled();
// Should NOT use the cached token - this is the key fix!
expect(mockGetProfileToken).not.toHaveBeenCalled();
expect(mockGetActiveProfileToken).not.toHaveBeenCalled();
});
it('should NOT return CLAUDE_CODE_OAUTH_TOKEN even when profile has oauthToken', () => {
// Since getProfileEnv now delegates to profile manager, mock the manager's method
// The profile manager's implementation should never include CLAUDE_CODE_OAUTH_TOKEN
mockGetActiveProfileEnv.mockReturnValue({
CLAUDE_CONFIG_DIR: '/Users/test/.claude-profiles/personal',
});
const env = getProfileEnv();
// Key assertion: Should NEVER return CLAUDE_CODE_OAUTH_TOKEN
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
expect(env.CLAUDE_CONFIG_DIR).toBe('/Users/test/.claude-profiles/personal');
});
it('should return empty env for profile without configDir (edge case)', () => {
// Since getProfileEnv now delegates to profile manager, mock the manager's method
// Profile manager returns empty env when no configDir is set
mockGetActiveProfileEnv.mockReturnValue({});
const env = getProfileEnv();
// Without configDir, cannot authenticate via CLAUDE_CONFIG_DIR
// Should NOT fall back to oauthToken (that's the bug we're fixing)
expect(env).toEqual({});
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
});
it('should use specific profile when profileId is provided', () => {
// Since getProfileEnv now delegates to profile manager, mock the manager's method
mockGetProfileEnv.mockReturnValue({
CLAUDE_CONFIG_DIR: '/Users/test/.claude-profiles/specific',
});
const env = getProfileEnv('specific-profile');
expect(mockGetProfileEnv).toHaveBeenCalledWith('specific-profile');
expect(env).toEqual({
CLAUDE_CONFIG_DIR: '/Users/test/.claude-profiles/specific',
});
});
});
describe('Profile Storage Migration', () => {
it('should remove oauthToken during profile migration', async () => {
// Create a profile store with cached oauthToken
const storeWithToken = {
version: 3,
activeProfileId: 'work',
profiles: [
{
id: 'work',
name: 'Work Account',
isDefault: false,
configDir: '/Users/test/.claude-profiles/work',
oauthToken: 'enc:stale-cached-token-that-should-be-removed',
tokenCreatedAt: '2024-01-01T00:00:00.000Z',
createdAt: '2024-01-01T00:00:00.000Z',
},
],
};
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue(JSON.stringify(storeWithToken));
// Import profile storage dynamically to get fresh module with mocks
const { loadProfileStore } = await import('../claude-profile/profile-storage');
const result = loadProfileStore('/test/path');
expect(result).not.toBeNull();
expect(result?.profiles[0]).toBeDefined();
// Key assertion: oauthToken and tokenCreatedAt should be removed
expect(result?.profiles[0]).not.toHaveProperty('oauthToken');
expect(result?.profiles[0]).not.toHaveProperty('tokenCreatedAt');
// Other properties should be preserved
expect(result?.profiles[0].id).toBe('work');
expect(result?.profiles[0].name).toBe('Work Account');
expect(result?.profiles[0].configDir).toBe('/Users/test/.claude-profiles/work');
});
it('should preserve profiles without oauthToken', async () => {
const storeWithoutToken = {
version: 3,
activeProfileId: 'default',
profiles: [
{
id: 'default',
name: 'Default',
isDefault: true,
configDir: '/Users/test/.claude',
createdAt: '2024-01-01T00:00:00.000Z',
// No oauthToken - this profile never had one
},
],
};
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue(JSON.stringify(storeWithoutToken));
const { loadProfileStore } = await import('../claude-profile/profile-storage');
const result = loadProfileStore('/test/path');
expect(result).not.toBeNull();
expect(result?.profiles[0].id).toBe('default');
expect(result?.profiles[0]).not.toHaveProperty('oauthToken');
});
});
});
+193 -23
View File
@@ -89,9 +89,49 @@ export class ClaudeProfileManager {
this.data = loadedData;
}
// Run one-time migration to fix corrupted emails
// This repairs emails that were truncated due to ANSI escape codes in terminal output
this.migrateCorruptedEmails();
this.initialized = true;
}
/**
* One-time migration to fix emails that were corrupted by ANSI escape codes
* during terminal output parsing.
*
* This reads the authoritative email from Claude's config file (.claude.json)
* for each profile and updates any that differ from what we have stored.
*/
private migrateCorruptedEmails(): void {
let needsSave = false;
for (const profile of this.data.profiles) {
if (!profile.configDir) {
continue;
}
// Import dynamically to avoid circular dependency issues
const { getEmailFromConfigDir } = require('./claude-profile/profile-utils');
const configEmail = getEmailFromConfigDir(profile.configDir);
if (configEmail && profile.email !== configEmail) {
console.warn('[ClaudeProfileManager] Migrating corrupted email for profile:', {
profileId: profile.id,
oldEmail: profile.email,
newEmail: configEmail
});
profile.email = configEmail;
needsSave = true;
}
}
if (needsSave) {
this.save();
console.warn('[ClaudeProfileManager] Email migration complete');
}
}
/**
* Check if the profile manager has been initialized
*/
@@ -105,6 +145,18 @@ export class ClaudeProfileManager {
private load(): ProfileStoreData {
const loadedData = loadProfileStore(this.storePath);
if (loadedData) {
if (process.env.DEBUG === 'true') {
console.warn('[ClaudeProfileManager] Loaded profiles:', {
count: loadedData.profiles.length,
activeProfileId: loadedData.activeProfileId,
profiles: loadedData.profiles.map(p => ({
id: p.id,
name: p.name,
email: p.email,
isDefault: p.isDefault
}))
});
}
return loadedData;
}
@@ -176,6 +228,24 @@ export class ClaudeProfileManager {
this.save();
}
/**
* Get unified account priority order
* Returns array of account IDs in priority order (first = highest priority)
* IDs are prefixed: 'oauth-{profileId}' for OAuth, 'api-{profileId}' for API profiles
*/
getAccountPriorityOrder(): string[] {
return this.data.accountPriorityOrder || [];
}
/**
* Set unified account priority order
* @param order Array of account IDs in priority order
*/
setAccountPriorityOrder(order: string[]): void {
this.data.accountPriorityOrder = order;
this.save();
}
/**
* Get a specific profile by ID
*/
@@ -192,11 +262,35 @@ export class ClaudeProfileManager {
// Fallback to default
const defaultProfile = this.data.profiles.find(p => p.isDefault);
if (defaultProfile) {
if (process.env.DEBUG === 'true') {
console.warn('[ClaudeProfileManager] getActiveProfile - using default:', {
id: defaultProfile.id,
name: defaultProfile.name,
email: defaultProfile.email
});
}
return defaultProfile;
}
// If somehow no default exists, return first profile
return this.data.profiles[0];
const fallback = this.data.profiles[0];
if (process.env.DEBUG === 'true') {
console.warn('[ClaudeProfileManager] getActiveProfile - using fallback:', {
id: fallback.id,
name: fallback.name,
email: fallback.email
});
}
return fallback;
}
if (process.env.DEBUG === 'true') {
console.warn('[ClaudeProfileManager] getActiveProfile:', {
id: active.id,
name: active.name,
email: active.email
});
}
return active;
}
@@ -278,11 +372,21 @@ export class ClaudeProfileManager {
* Set the active profile
*/
setActiveProfile(profileId: string): boolean {
const previousProfileId = this.data.activeProfileId;
const profile = this.getProfile(profileId);
if (!profile) {
console.warn('[ClaudeProfileManager] setActiveProfile failed - profile not found:', { profileId });
return false;
}
if (process.env.DEBUG === 'true') {
console.warn('[ClaudeProfileManager] setActiveProfile:', {
from: previousProfileId,
to: profileId,
profileName: profile.name
});
}
this.data.activeProfileId = profileId;
profile.lastUsedAt = new Date();
this.save();
@@ -362,39 +466,40 @@ export class ClaudeProfileManager {
/**
* Get environment variables for spawning processes with the active profile.
* Sets CLAUDE_CONFIG_DIR to point Claude CLI to the profile's config directory.
* Claude CLI handles token storage in the system Keychain.
*
* IMPORTANT: When CLAUDE_CONFIG_DIR is set, we do NOT set CLAUDE_CODE_OAUTH_TOKEN
* because Claude Code prioritizes CLAUDE_CODE_OAUTH_TOKEN over Keychain lookup.
* The OAuth token alone doesn't contain subscription tier info (like "max"),
* causing Claude Code to show "Claude API" instead of "Claude Max".
* By only setting CLAUDE_CONFIG_DIR, Claude Code reads from the Keychain which
* has the full credential object including subscriptionType and rateLimitTier.
* IMPORTANT: Always uses CLAUDE_CONFIG_DIR to let Claude CLI read fresh tokens from Keychain.
* We NEVER use cached OAuth tokens (CLAUDE_CODE_OAUTH_TOKEN) because:
* 1. OAuth tokens expire in 8-12 hours
* 2. Claude CLI's token refresh mechanism works (updates Keychain)
* 3. Cached tokens don't benefit from Claude CLI's automatic refresh
* 4. CLAUDE_CODE_OAUTH_TOKEN doesn't include subscription tier info
*
* By using CLAUDE_CONFIG_DIR, Claude CLI reads fresh tokens from Keychain each time,
* which includes any refreshed tokens and full credential metadata.
*
* See: docs/LONG_LIVED_AUTH_PLAN.md for full context.
*/
getActiveProfileEnv(): Record<string, string> {
const profile = this.getActiveProfile();
const env: Record<string, string> = {};
// For non-default profiles, set CLAUDE_CONFIG_DIR
// Claude CLI will use credentials stored in that directory's Keychain
if (profile?.configDir && !profile.isDefault) {
// Default profile: Claude CLI uses ~/.claude implicitly (no env var needed)
if (profile?.isDefault) {
console.warn('[ClaudeProfileManager] Using default profile (Claude CLI uses ~/.claude)');
return env;
}
// Non-default profiles: set CLAUDE_CONFIG_DIR to point Claude CLI to profile's config
// Claude CLI will read fresh tokens from Keychain, benefiting from auto-refresh
if (profile?.configDir) {
// Expand ~ to home directory for the environment variable
const expandedConfigDir = profile.configDir.startsWith('~')
? profile.configDir.replace(/^~/, require('os').homedir())
: profile.configDir;
env.CLAUDE_CONFIG_DIR = expandedConfigDir;
console.warn('[ClaudeProfileManager] Using configDir for profile:', profile.name, expandedConfigDir);
// DO NOT set CLAUDE_CODE_OAUTH_TOKEN here - let Claude Code use Keychain
// credentials which include subscription tier info. See comment above.
} else if (profile?.oauthToken) {
// Only use stored OAuth token for default profile (no configDir)
// This is a legacy path for backward compatibility
const decryptedToken = decryptToken(profile.oauthToken);
if (decryptedToken) {
env.CLAUDE_CODE_OAUTH_TOKEN = decryptedToken;
console.warn('[ClaudeProfileManager] Using stored OAuth token for profile:', profile.name);
}
console.warn('[ClaudeProfileManager] Using CLAUDE_CONFIG_DIR for profile:', profile.name, expandedConfigDir);
} else {
console.warn('[ClaudeProfileManager] Profile has no configDir configured:', profile?.name);
}
return env;
@@ -415,6 +520,71 @@ export class ClaudeProfileManager {
return usage;
}
/**
* Update usage data for a profile from API response (percentages directly)
* This is called by the usage monitor after fetching usage via the API
*/
updateProfileUsageFromAPI(profileId: string, sessionPercent: number, weeklyPercent: number): ClaudeUsageData | null {
const profile = this.getProfile(profileId);
if (!profile) {
return null;
}
// Preserve existing reset times if available, otherwise use empty string
const existingUsage = profile.usage;
const usage: ClaudeUsageData = {
sessionUsagePercent: sessionPercent,
sessionResetTime: existingUsage?.sessionResetTime ?? '',
weeklyUsagePercent: weeklyPercent,
weeklyResetTime: existingUsage?.weeklyResetTime ?? '',
opusUsagePercent: existingUsage?.opusUsagePercent,
lastUpdated: new Date()
};
profile.usage = usage;
this.save();
return usage;
}
/**
* Batch update usage data for multiple profiles from API responses.
* Updates all profiles in memory first, then saves once to avoid race conditions.
*
* @param updates - Array of { profileId, sessionPercent, weeklyPercent } objects
* @returns Number of profiles successfully updated
*/
batchUpdateProfileUsageFromAPI(
updates: Array<{ profileId: string; sessionPercent: number; weeklyPercent: number }>
): number {
let updatedCount = 0;
for (const { profileId, sessionPercent, weeklyPercent } of updates) {
const profile = this.getProfile(profileId);
if (!profile) {
continue;
}
// Preserve existing reset times if available
const existingUsage = profile.usage;
const usage: ClaudeUsageData = {
sessionUsagePercent: sessionPercent,
sessionResetTime: existingUsage?.sessionResetTime ?? '',
weeklyUsagePercent: weeklyPercent,
weeklyResetTime: existingUsage?.weeklyResetTime ?? '',
opusUsagePercent: existingUsage?.opusUsagePercent,
lastUpdated: new Date()
};
profile.usage = usage;
updatedCount++;
}
// Single save after all updates
if (updatedCount > 0) {
this.save();
}
return updatedCount;
}
/**
* Record a rate limit event for a profile
*/
@@ -0,0 +1,474 @@
/**
* Cross-Platform Credential Utilities Tests
*
* Tests for credential retrieval on macOS, Linux, and Windows platforms.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createHash } from 'crypto';
import { join } from 'path';
// Mock dependencies before importing the module
vi.mock('../platform', () => ({
isMacOS: vi.fn(() => false),
isWindows: vi.fn(() => false),
isLinux: vi.fn(() => false),
}));
vi.mock('fs', () => ({
existsSync: vi.fn(() => false),
readFileSync: vi.fn(() => ''),
}));
vi.mock('child_process', () => ({
execFileSync: vi.fn(() => ''),
}));
vi.mock('os', () => ({
homedir: vi.fn(() => '/home/testuser'),
}));
// Import after mocks are set up
import {
calculateConfigDirHash,
getKeychainServiceName,
getWindowsCredentialTarget,
getCredentialsFromKeychain,
getCredentials,
clearKeychainCache,
clearCredentialCache,
} from './credential-utils';
import { isMacOS, isWindows, isLinux } from '../platform';
import { existsSync, readFileSync } from 'fs';
import { execFileSync } from 'child_process';
import { homedir } from 'os';
describe('credential-utils', () => {
beforeEach(() => {
vi.clearAllMocks();
// Clear the credential cache before each test
clearCredentialCache();
});
describe('calculateConfigDirHash', () => {
it('should return first 8 characters of SHA256 hash', () => {
const configDir = '/home/user/.claude-profiles/work';
const expectedHash = createHash('sha256').update(configDir).digest('hex').slice(0, 8);
expect(calculateConfigDirHash(configDir)).toBe(expectedHash);
});
it('should return different hashes for different paths', () => {
const hash1 = calculateConfigDirHash('/path/one');
const hash2 = calculateConfigDirHash('/path/two');
expect(hash1).not.toBe(hash2);
});
it('should return consistent hash for same path', () => {
const path = '/home/user/.claude';
expect(calculateConfigDirHash(path)).toBe(calculateConfigDirHash(path));
});
});
describe('getKeychainServiceName', () => {
it('should return default service name when no configDir provided', () => {
expect(getKeychainServiceName()).toBe('Claude Code-credentials');
});
it('should return default service name for undefined', () => {
expect(getKeychainServiceName(undefined)).toBe('Claude Code-credentials');
});
it('should return hashed service name for custom configDir', () => {
const configDir = '/home/user/.claude-profiles/work';
const hash = calculateConfigDirHash(configDir);
expect(getKeychainServiceName(configDir)).toBe(`Claude Code-credentials-${hash}`);
});
});
describe('getWindowsCredentialTarget', () => {
it('should use same naming convention as macOS Keychain', () => {
expect(getWindowsCredentialTarget()).toBe('Claude Code-credentials');
const configDir = '/home/user/.claude-profiles/work';
expect(getWindowsCredentialTarget(configDir)).toBe(getKeychainServiceName(configDir));
});
});
describe('getCredentialsFromKeychain (macOS)', () => {
beforeEach(() => {
vi.mocked(isMacOS).mockReturnValue(true);
vi.mocked(isWindows).mockReturnValue(false);
vi.mocked(isLinux).mockReturnValue(false);
});
it('should return credentials from macOS Keychain', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-test-token-123',
email: 'test@example.com',
},
}));
const result = getCredentialsFromKeychain();
expect(result.token).toBe('sk-ant-test-token-123');
expect(result.email).toBe('test@example.com');
expect(result.error).toBeUndefined();
});
it('should return null when security command not found', () => {
vi.mocked(existsSync).mockReturnValue(false);
const result = getCredentialsFromKeychain();
expect(result.token).toBeNull();
expect(result.email).toBeNull();
expect(result.error).toBe('macOS security command not found');
});
it('should return null for invalid JSON', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(execFileSync).mockReturnValue('invalid json');
const result = getCredentialsFromKeychain();
expect(result.token).toBeNull();
expect(result.email).toBeNull();
});
it('should reject invalid token format', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'invalid-token',
email: 'test@example.com',
},
}));
const result = getCredentialsFromKeychain();
expect(result.token).toBeNull();
expect(result.email).toBe('test@example.com');
});
it('should handle exit code 44 (item not found)', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(execFileSync).mockImplementation(() => {
const error = new Error('Item not found') as Error & { status: number };
error.status = 44;
throw error;
});
const result = getCredentialsFromKeychain();
expect(result.token).toBeNull();
expect(result.email).toBeNull();
expect(result.error).toBeUndefined();
});
it('should use cache on subsequent calls', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-test-token-123',
email: 'test@example.com',
},
}));
// First call
getCredentialsFromKeychain();
// Second call should use cache
getCredentialsFromKeychain();
expect(execFileSync).toHaveBeenCalledTimes(1);
});
it('should bypass cache when forceRefresh is true', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-test-token-123',
email: 'test@example.com',
},
}));
// First call
getCredentialsFromKeychain();
// Second call with forceRefresh
getCredentialsFromKeychain(undefined, true);
expect(execFileSync).toHaveBeenCalledTimes(2);
});
});
describe('getCredentialsFromKeychain (Linux)', () => {
beforeEach(() => {
vi.mocked(isMacOS).mockReturnValue(false);
vi.mocked(isWindows).mockReturnValue(false);
vi.mocked(isLinux).mockReturnValue(true);
vi.mocked(homedir).mockReturnValue('/home/testuser');
});
it('should return credentials from .credentials.json', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-linux-token-456',
email: 'linux@example.com',
},
}));
const result = getCredentialsFromKeychain();
expect(result.token).toBe('sk-ant-linux-token-456');
expect(result.email).toBe('linux@example.com');
expect(result.error).toBeUndefined();
});
it('should return null when credentials file not found', () => {
vi.mocked(existsSync).mockReturnValue(false);
const result = getCredentialsFromKeychain();
expect(result.token).toBeNull();
expect(result.email).toBeNull();
});
it('should use custom configDir for credentials path', () => {
const customConfigDir = '/home/user/.claude-profiles/work';
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-custom-token',
email: 'custom@example.com',
},
}));
const result = getCredentialsFromKeychain(customConfigDir);
expect(existsSync).toHaveBeenCalledWith(join(customConfigDir, '.credentials.json'));
expect(result.token).toBe('sk-ant-custom-token');
});
it('should handle emailAddress field (alternative email location)', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-test-token',
emailAddress: 'alternative@example.com',
},
}));
const result = getCredentialsFromKeychain();
expect(result.email).toBe('alternative@example.com');
});
it('should handle top-level email field', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-test-token',
},
email: 'toplevel@example.com',
}));
const result = getCredentialsFromKeychain();
expect(result.email).toBe('toplevel@example.com');
});
it('should handle file read permission errors', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockImplementation(() => {
throw new Error('EACCES: permission denied');
});
const result = getCredentialsFromKeychain();
expect(result.token).toBeNull();
expect(result.email).toBeNull();
});
});
describe('getCredentialsFromKeychain (Windows)', () => {
beforeEach(() => {
vi.mocked(isMacOS).mockReturnValue(false);
vi.mocked(isWindows).mockReturnValue(true);
vi.mocked(isLinux).mockReturnValue(false);
vi.mocked(homedir).mockReturnValue('C:\\Users\\TestUser');
});
it('should return null when PowerShell not found', () => {
vi.mocked(existsSync).mockReturnValue(false);
const result = getCredentialsFromKeychain();
expect(result.token).toBeNull();
expect(result.email).toBeNull();
expect(result.error).toBe('PowerShell not found');
});
it('should return credentials from Windows Credential Manager', () => {
// Mock PowerShell path found
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-windows-token-789',
email: 'windows@example.com',
},
}));
const result = getCredentialsFromKeychain();
expect(result.token).toBe('sk-ant-windows-token-789');
expect(result.email).toBe('windows@example.com');
});
it('should return null when credential not found', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(execFileSync).mockReturnValue('');
const result = getCredentialsFromKeychain();
expect(result.token).toBeNull();
expect(result.email).toBeNull();
});
it('should handle invalid JSON from Credential Manager', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(execFileSync).mockReturnValue('invalid json');
const result = getCredentialsFromKeychain();
expect(result.token).toBeNull();
expect(result.email).toBeNull();
});
});
describe('getCredentialsFromKeychain (unsupported platform)', () => {
beforeEach(() => {
vi.mocked(isMacOS).mockReturnValue(false);
vi.mocked(isWindows).mockReturnValue(false);
vi.mocked(isLinux).mockReturnValue(false);
});
it('should return error for unsupported platform', () => {
const result = getCredentialsFromKeychain();
expect(result.token).toBeNull();
expect(result.email).toBeNull();
expect(result.error).toContain('Unsupported platform');
});
});
describe('getCredentials alias', () => {
it('should be an alias for getCredentialsFromKeychain', () => {
expect(getCredentials).toBe(getCredentialsFromKeychain);
});
});
describe('clearKeychainCache', () => {
beforeEach(() => {
vi.mocked(isMacOS).mockReturnValue(true);
vi.mocked(existsSync).mockReturnValue(true);
});
it('should clear all caches when no configDir provided', () => {
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: { accessToken: 'sk-ant-test', email: 'test@test.com' },
}));
// Prime the cache
getCredentialsFromKeychain();
expect(execFileSync).toHaveBeenCalledTimes(1);
// Clear cache
clearKeychainCache();
// Should fetch again
getCredentialsFromKeychain();
expect(execFileSync).toHaveBeenCalledTimes(2);
});
it('should clear specific profile cache when configDir provided', () => {
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: { accessToken: 'sk-ant-test', email: 'test@test.com' },
}));
const configDir = '/custom/path';
// Prime the cache
getCredentialsFromKeychain(configDir);
expect(execFileSync).toHaveBeenCalledTimes(1);
// Clear specific cache
clearKeychainCache(configDir);
// Should fetch again
getCredentialsFromKeychain(configDir);
expect(execFileSync).toHaveBeenCalledTimes(2);
});
});
describe('clearCredentialCache alias', () => {
it('should be an alias for clearKeychainCache', () => {
expect(clearCredentialCache).toBe(clearKeychainCache);
});
});
describe('token validation', () => {
beforeEach(() => {
vi.mocked(isMacOS).mockReturnValue(true);
vi.mocked(existsSync).mockReturnValue(true);
});
it('should accept tokens starting with sk-ant-', () => {
const validTokens = [
'sk-ant-oat01-test',
'sk-ant-oat02-test',
'sk-ant-api-key',
];
for (const token of validTokens) {
clearCredentialCache();
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: { accessToken: token, email: 'test@test.com' },
}));
const result = getCredentialsFromKeychain();
expect(result.token).toBe(token);
}
});
it('should reject tokens not starting with sk-ant-', () => {
const invalidTokens = [
'invalid-token',
'sk-api-key',
'api-key-123',
];
for (const token of invalidTokens) {
clearCredentialCache();
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: { accessToken: token, email: 'test@test.com' },
}));
const result = getCredentialsFromKeychain();
expect(result.token).toBeNull();
}
});
it('should reject empty token string', () => {
clearCredentialCache();
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: { accessToken: '', email: 'test@test.com' },
}));
const result = getCredentialsFromKeychain();
expect(result.token).toBeNull();
expect(result.email).toBe('test@test.com');
});
});
});
@@ -0,0 +1,690 @@
/**
* Cross-Platform Credential Utilities
*
* Provides functions to retrieve Claude Code OAuth tokens and email from
* platform-specific secure storage:
* - macOS: Keychain (via `security` command)
* - Linux: .credentials.json file in config directory
* - Windows: Windows Credential Manager (via PowerShell)
*
* Supports both:
* - Default profile: "Claude Code-credentials" service / default config dir
* - Custom profiles: "Claude Code-credentials-{sha256-8-hash}" where hash is first 8 chars
* of SHA256 hash of the CLAUDE_CONFIG_DIR path
*
* Mirrors the functionality of apps/backend/core/auth.py get_token_from_keychain()
*/
import { execFileSync } from 'child_process';
import { createHash } from 'crypto';
import { existsSync, readFileSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
import { isMacOS, isWindows, isLinux } from '../platform';
/**
* Create a safe fingerprint of a token for debug logging.
* Shows first 8 and last 4 characters, hiding the sensitive middle portion.
* This is NOT for authentication - only for human-readable debug identification.
*
* @param token - The token to create a fingerprint for
* @returns A safe fingerprint like "sk-ant-oa...xyz9" or "null" if no token
*/
function getTokenFingerprint(token: string | null | undefined): string {
if (!token) return 'null';
if (token.length <= 16) return token.slice(0, 4) + '...' + token.slice(-2);
return token.slice(0, 8) + '...' + token.slice(-4);
}
/**
* Credentials retrieved from platform-specific secure storage
*/
export interface PlatformCredentials {
token: string | null;
email: string | null;
error?: string; // Set when credential access fails (locked, permission denied, etc.)
}
// Legacy alias for backwards compatibility
export type KeychainCredentials = PlatformCredentials;
/**
* Cache for credentials to avoid repeated blocking calls
* Map key is the cache key (e.g., "macos:Claude Code-credentials" or "linux:/home/user/.claude")
*/
interface CredentialCacheEntry {
credentials: PlatformCredentials;
timestamp: number;
}
const credentialCache = new Map<string, CredentialCacheEntry>();
// Cache for 5 minutes (300,000 ms) for successful results
const CACHE_TTL_MS = 5 * 60 * 1000;
// Cache for 10 seconds for error results (allows quick retry after unlock)
const ERROR_CACHE_TTL_MS = 10 * 1000;
// Timeouts for credential retrieval operations
const MACOS_KEYCHAIN_TIMEOUT_MS = 5000;
const WINDOWS_CREDMAN_TIMEOUT_MS = 10000;
// Defense-in-depth: Pattern for valid credential target names
// Matches "Claude Code-credentials" or "Claude Code-credentials-{8 hex chars}"
const VALID_TARGET_NAME_PATTERN = /^Claude Code-credentials(-[a-f0-9]{8})?$/;
/**
* Validate that a credential target name matches the expected format.
* Defense-in-depth check to prevent injection attacks.
*
* @param targetName - The target name to validate
* @returns true if valid, false otherwise
*/
function isValidTargetName(targetName: string): boolean {
return VALID_TARGET_NAME_PATTERN.test(targetName);
}
/**
* Validate that a credentials path is within expected boundaries.
* Defense-in-depth check to prevent path traversal attacks.
*
* @param credentialsPath - The path to validate
* @returns true if valid, false otherwise
*/
function isValidCredentialsPath(credentialsPath: string): boolean {
// Credentials path should:
// 1. Not contain path traversal sequences (works on both Unix and Windows)
// 2. End with the expected file name
// Note: We allow custom config directories since they come from user settings
// The configDir is from profile settings, which is trusted user input
return (
!credentialsPath.includes('..') &&
credentialsPath.endsWith('.credentials.json')
);
}
/**
* Calculate the credential storage identifier suffix for a config directory.
* Claude Code uses SHA256 hash of the config dir path, taking first 8 hex chars.
*
* @param configDir - The CLAUDE_CONFIG_DIR path
* @returns The 8-character hex hash suffix
*/
export function calculateConfigDirHash(configDir: string): string {
return createHash('sha256').update(configDir).digest('hex').slice(0, 8);
}
/**
* Get the Keychain service name for a config directory (macOS).
*
* @param configDir - Optional CLAUDE_CONFIG_DIR path. If not provided, returns default service name.
* @returns The Keychain service name (e.g., "Claude Code-credentials-d74c9506")
*/
export function getKeychainServiceName(configDir?: string): string {
if (!configDir) {
return 'Claude Code-credentials';
}
const hash = calculateConfigDirHash(configDir);
return `Claude Code-credentials-${hash}`;
}
/**
* Get the Windows Credential Manager target name for a config directory.
*
* @param configDir - Optional CLAUDE_CONFIG_DIR path. If not provided, returns default target name.
* @returns The Credential Manager target name (e.g., "Claude Code-credentials-d74c9506")
*/
export function getWindowsCredentialTarget(configDir?: string): string {
// Windows uses the same naming convention as macOS Keychain
return getKeychainServiceName(configDir);
}
/**
* Validate the structure of parsed credential JSON data
* @param data - Parsed JSON data from credential store
* @returns true if data structure is valid, false otherwise
*/
function validateCredentialData(data: unknown): data is { claudeAiOauth?: { accessToken?: string; email?: string; emailAddress?: string }; email?: string } {
if (!data || typeof data !== 'object') {
return false;
}
const obj = data as Record<string, unknown>;
// Check if claudeAiOauth exists and is an object
if (obj.claudeAiOauth !== undefined) {
if (typeof obj.claudeAiOauth !== 'object' || obj.claudeAiOauth === null) {
return false;
}
const oauth = obj.claudeAiOauth as Record<string, unknown>;
// Validate accessToken if present
if (oauth.accessToken !== undefined && typeof oauth.accessToken !== 'string') {
return false;
}
// Validate email if present (can be 'email' or 'emailAddress')
if (oauth.email !== undefined && typeof oauth.email !== 'string') {
return false;
}
if (oauth.emailAddress !== undefined && typeof oauth.emailAddress !== 'string') {
return false;
}
}
// Validate top-level email if present
if (obj.email !== undefined && typeof obj.email !== 'string') {
return false;
}
return true;
}
/**
* Extract token and email from validated credential data
*/
function extractCredentials(data: { claudeAiOauth?: { accessToken?: string; email?: string; emailAddress?: string }; email?: string }): { token: string | null; email: string | null } {
// Extract OAuth token from nested structure
const token = data?.claudeAiOauth?.accessToken || null;
// Extract email (might be in different locations depending on Claude Code version)
const email = data?.claudeAiOauth?.email || data?.claudeAiOauth?.emailAddress || data?.email || null;
return { token, email };
}
/**
* Validate token format
* Use 'sk-ant-' prefix to support future token format versions (oat02, oat03, etc.)
*/
function isValidTokenFormat(token: string): boolean {
return token.startsWith('sk-ant-');
}
// =============================================================================
// macOS Keychain Implementation
// =============================================================================
/**
* Retrieve credentials from macOS Keychain
*/
function getCredentialsFromMacOSKeychain(configDir?: string, forceRefresh = false): PlatformCredentials {
const serviceName = getKeychainServiceName(configDir);
const cacheKey = `macos:${serviceName}`;
const isDebug = process.env.DEBUG === 'true';
const now = Date.now();
// Return cached credentials if available and fresh
const cached = credentialCache.get(cacheKey);
if (!forceRefresh && cached) {
const ttl = cached.credentials.error ? ERROR_CACHE_TTL_MS : CACHE_TTL_MS;
if ((now - cached.timestamp) < ttl) {
if (isDebug) {
const cacheAge = now - cached.timestamp;
console.warn('[CredentialUtils:macOS:CACHE] Returning cached credentials:', {
serviceName,
hasToken: !!cached.credentials.token,
tokenFingerprint: getTokenFingerprint(cached.credentials.token),
cacheAge: Math.round(cacheAge / 1000) + 's'
});
}
return cached.credentials;
}
}
// Locate the security executable
let securityPath: string | null = null;
const candidatePaths = ['/usr/bin/security', '/bin/security'];
for (const candidate of candidatePaths) {
if (existsSync(candidate)) {
securityPath = candidate;
break;
}
}
if (!securityPath) {
const notFoundResult = { token: null, email: null, error: 'macOS security command not found' };
credentialCache.set(cacheKey, { credentials: notFoundResult, timestamp: now });
return notFoundResult;
}
try {
// Query macOS Keychain for Claude Code credentials
const result = execFileSync(
securityPath,
['find-generic-password', '-s', serviceName, '-w'],
{
encoding: 'utf-8',
timeout: MACOS_KEYCHAIN_TIMEOUT_MS,
windowsHide: true,
}
);
const credentialsJson = result.trim();
if (!credentialsJson) {
const emptyResult = { token: null, email: null };
credentialCache.set(cacheKey, { credentials: emptyResult, timestamp: now });
return emptyResult;
}
// Parse JSON response
let data: unknown;
try {
data = JSON.parse(credentialsJson);
} catch {
console.warn('[CredentialUtils:macOS] Failed to parse Keychain JSON for service:', serviceName);
const errorResult = { token: null, email: null };
credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now });
return errorResult;
}
// Validate JSON structure
if (!validateCredentialData(data)) {
console.warn('[CredentialUtils:macOS] Invalid Keychain data structure for service:', serviceName);
const invalidResult = { token: null, email: null };
credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now });
return invalidResult;
}
const { token, email } = extractCredentials(data);
// Validate token format if present
if (token && !isValidTokenFormat(token)) {
console.warn('[CredentialUtils:macOS] Invalid token format for service:', serviceName);
const result = { token: null, email };
credentialCache.set(cacheKey, { credentials: result, timestamp: now });
return result;
}
const credentials = { token, email };
credentialCache.set(cacheKey, { credentials, timestamp: now });
if (isDebug) {
console.warn('[CredentialUtils:macOS] Retrieved credentials from Keychain for service:', serviceName, {
hasToken: !!token,
hasEmail: !!email,
tokenFingerprint: getTokenFingerprint(token),
forceRefresh
});
}
return credentials;
} catch (error) {
// Check for exit code 44 (errSecItemNotFound) which indicates item not found
if (error && typeof error === 'object' && 'status' in error && error.status === 44) {
const notFoundResult = { token: null, email: null };
credentialCache.set(cacheKey, { credentials: notFoundResult, timestamp: now });
return notFoundResult;
}
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn('[CredentialUtils:macOS] Keychain access failed for service:', serviceName, errorMessage);
const errorResult = { token: null, email: null, error: `Keychain access failed: ${errorMessage}` };
// Use shorter TTL for errors
credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now });
return errorResult;
}
}
// =============================================================================
// Linux Credentials File Implementation
// =============================================================================
/**
* Get the credentials file path for Linux
*/
function getLinuxCredentialsPath(configDir?: string): string {
const baseDir = configDir || join(homedir(), '.claude');
return join(baseDir, '.credentials.json');
}
/**
* Retrieve credentials from Linux .credentials.json file
*/
function getCredentialsFromLinuxFile(configDir?: string, forceRefresh = false): PlatformCredentials {
const credentialsPath = getLinuxCredentialsPath(configDir);
const cacheKey = `linux:${credentialsPath}`;
const isDebug = process.env.DEBUG === 'true';
const now = Date.now();
// Return cached credentials if available and fresh
const cached = credentialCache.get(cacheKey);
if (!forceRefresh && cached) {
const ttl = cached.credentials.error ? ERROR_CACHE_TTL_MS : CACHE_TTL_MS;
if ((now - cached.timestamp) < ttl) {
if (isDebug) {
const cacheAge = now - cached.timestamp;
console.warn('[CredentialUtils:Linux:CACHE] Returning cached credentials:', {
credentialsPath,
hasToken: !!cached.credentials.token,
tokenFingerprint: getTokenFingerprint(cached.credentials.token),
cacheAge: Math.round(cacheAge / 1000) + 's'
});
}
return cached.credentials;
}
}
// Defense-in-depth: Validate credentials path is within expected boundaries
if (!isValidCredentialsPath(credentialsPath)) {
if (isDebug) {
console.warn('[CredentialUtils:Linux] Invalid credentials path rejected:', { credentialsPath });
}
const invalidResult = { token: null, email: null, error: 'Invalid credentials path' };
credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now });
return invalidResult;
}
// Check if credentials file exists
if (!existsSync(credentialsPath)) {
if (isDebug) {
console.warn('[CredentialUtils:Linux] Credentials file not found:', credentialsPath);
}
const notFoundResult = { token: null, email: null };
credentialCache.set(cacheKey, { credentials: notFoundResult, timestamp: now });
return notFoundResult;
}
try {
const content = readFileSync(credentialsPath, 'utf-8');
// Parse JSON
let data: unknown;
try {
data = JSON.parse(content);
} catch {
console.warn('[CredentialUtils:Linux] Failed to parse credentials JSON:', credentialsPath);
const errorResult = { token: null, email: null };
credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now });
return errorResult;
}
// Validate JSON structure
if (!validateCredentialData(data)) {
console.warn('[CredentialUtils:Linux] Invalid credentials data structure:', credentialsPath);
const invalidResult = { token: null, email: null };
credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now });
return invalidResult;
}
const { token, email } = extractCredentials(data);
// Validate token format if present
if (token && !isValidTokenFormat(token)) {
console.warn('[CredentialUtils:Linux] Invalid token format in:', credentialsPath);
const result = { token: null, email };
credentialCache.set(cacheKey, { credentials: result, timestamp: now });
return result;
}
const credentials = { token, email };
credentialCache.set(cacheKey, { credentials, timestamp: now });
if (isDebug) {
console.warn('[CredentialUtils:Linux] Retrieved credentials from file:', credentialsPath, {
hasToken: !!token,
hasEmail: !!email,
tokenFingerprint: getTokenFingerprint(token),
forceRefresh
});
}
return credentials;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn('[CredentialUtils:Linux] Failed to read credentials file:', credentialsPath, errorMessage);
const errorResult = { token: null, email: null, error: `Failed to read credentials: ${errorMessage}` };
credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now });
return errorResult;
}
}
// =============================================================================
// Windows Credential Manager Implementation
// =============================================================================
/**
* Retrieve credentials from Windows Credential Manager using PowerShell
*
* Windows Credential Manager stores credentials with:
* - Target Name: "Claude Code-credentials" or "Claude Code-credentials-{hash}"
* - Type: Generic credential
* - Password field contains JSON with { claudeAiOauth: { accessToken, email } }
*/
function getCredentialsFromWindowsCredentialManager(configDir?: string, forceRefresh = false): PlatformCredentials {
const targetName = getWindowsCredentialTarget(configDir);
const cacheKey = `windows:${targetName}`;
const isDebug = process.env.DEBUG === 'true';
const now = Date.now();
// Return cached credentials if available and fresh
const cached = credentialCache.get(cacheKey);
if (!forceRefresh && cached) {
const ttl = cached.credentials.error ? ERROR_CACHE_TTL_MS : CACHE_TTL_MS;
if ((now - cached.timestamp) < ttl) {
if (isDebug) {
const cacheAge = now - cached.timestamp;
console.warn('[CredentialUtils:Windows:CACHE] Returning cached credentials:', {
targetName,
hasToken: !!cached.credentials.token,
tokenFingerprint: getTokenFingerprint(cached.credentials.token),
cacheAge: Math.round(cacheAge / 1000) + 's'
});
}
return cached.credentials;
}
}
// Defense-in-depth: Validate target name format before using in PowerShell
if (!isValidTargetName(targetName)) {
const invalidResult = { token: null, email: null, error: 'Invalid credential target name format' };
credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now });
if (isDebug) {
console.warn('[CredentialUtils:Windows] Invalid target name rejected:', { targetName });
}
return invalidResult;
}
// Find PowerShell executable
const psPath = findPowerShellPath();
if (!psPath) {
const notFoundResult = { token: null, email: null, error: 'PowerShell not found' };
credentialCache.set(cacheKey, { credentials: notFoundResult, timestamp: now });
return notFoundResult;
}
try {
// PowerShell script to read from Credential Manager
// Uses the Windows Credential Manager API via .NET
const psScript = `
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Runtime.WindowsRuntime
# Use CredRead from advapi32.dll to read generic credentials
$sig = @'
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern bool CredRead(string target, int type, int reservedFlag, out IntPtr credentialPtr);
[DllImport("advapi32.dll", SetLastError = true)]
public static extern bool CredFree(IntPtr cred);
'@
Add-Type -MemberDefinition $sig -Namespace Win32 -Name Credential
$credPtr = [IntPtr]::Zero
# CRED_TYPE_GENERIC = 1
$success = [Win32.Credential]::CredRead("${targetName.replace(/"/g, '`"')}", 1, 0, [ref]$credPtr)
if ($success) {
try {
$cred = [Runtime.InteropServices.Marshal]::PtrToStructure($credPtr, [Type][System.Management.Automation.PSCredential].Assembly.GetType('Microsoft.PowerShell.Commands.CREDENTIAL'))
# Read the credential blob (password field)
$blobSize = $cred.CredentialBlobSize
if ($blobSize -gt 0) {
$blob = [byte[]]::new($blobSize)
[Runtime.InteropServices.Marshal]::Copy($cred.CredentialBlob, $blob, 0, $blobSize)
$password = [System.Text.Encoding]::Unicode.GetString($blob)
Write-Output $password
}
} finally {
[Win32.Credential]::CredFree($credPtr) | Out-Null
}
} else {
# Credential not found - this is expected if user hasn't authenticated
Write-Output ""
}
`;
const result = execFileSync(
psPath,
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', psScript],
{
encoding: 'utf-8',
timeout: WINDOWS_CREDMAN_TIMEOUT_MS,
windowsHide: true,
}
);
const credentialsJson = result.trim();
if (!credentialsJson) {
if (isDebug) {
console.warn('[CredentialUtils:Windows] Credential not found for target:', targetName);
}
const notFoundResult = { token: null, email: null };
credentialCache.set(cacheKey, { credentials: notFoundResult, timestamp: now });
return notFoundResult;
}
// Parse JSON response
let data: unknown;
try {
data = JSON.parse(credentialsJson);
} catch {
console.warn('[CredentialUtils:Windows] Failed to parse credential JSON for target:', targetName);
const errorResult = { token: null, email: null };
credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now });
return errorResult;
}
// Validate JSON structure
if (!validateCredentialData(data)) {
console.warn('[CredentialUtils:Windows] Invalid credential data structure for target:', targetName);
const invalidResult = { token: null, email: null };
credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now });
return invalidResult;
}
const { token, email } = extractCredentials(data);
// Validate token format if present
if (token && !isValidTokenFormat(token)) {
console.warn('[CredentialUtils:Windows] Invalid token format for target:', targetName);
const result = { token: null, email };
credentialCache.set(cacheKey, { credentials: result, timestamp: now });
return result;
}
const credentials = { token, email };
credentialCache.set(cacheKey, { credentials, timestamp: now });
if (isDebug) {
console.warn('[CredentialUtils:Windows] Retrieved credentials from Credential Manager for target:', targetName, {
hasToken: !!token,
hasEmail: !!email,
tokenFingerprint: getTokenFingerprint(token),
forceRefresh
});
}
return credentials;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn('[CredentialUtils:Windows] Credential Manager access failed for target:', targetName, errorMessage);
const errorResult = { token: null, email: null, error: `Credential Manager access failed: ${errorMessage}` };
credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now });
return errorResult;
}
}
/**
* Find PowerShell executable path on Windows
*/
function findPowerShellPath(): string | null {
// Prefer PowerShell 7+ (pwsh) over Windows PowerShell
const candidatePaths = [
join(process.env.ProgramFiles || 'C:\\Program Files', 'PowerShell', '7', 'pwsh.exe'),
join(homedir(), 'AppData', 'Local', 'Microsoft', 'WindowsApps', 'pwsh.exe'),
join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
];
for (const candidate of candidatePaths) {
if (existsSync(candidate)) {
return candidate;
}
}
return null;
}
// =============================================================================
// Cross-Platform Public API
// =============================================================================
/**
* Retrieve Claude Code OAuth credentials (token and email) from platform-specific
* secure storage.
*
* - macOS: Reads from Keychain
* - Linux: Reads from .credentials.json file
* - Windows: Reads from Windows Credential Manager
*
* For default profile: reads from "Claude Code-credentials" or default config dir
* For custom profiles: uses SHA256(configDir).slice(0,8) hash suffix
*
* Uses caching (5-minute TTL) to avoid repeated blocking calls.
*
* @param configDir - Optional CLAUDE_CONFIG_DIR path for custom profiles
* @param forceRefresh - Set to true to bypass cache and fetch fresh credentials
* @returns Object with token and email (both may be null if not found or invalid)
*/
export function getCredentialsFromKeychain(configDir?: string, forceRefresh = false): PlatformCredentials {
if (isMacOS()) {
return getCredentialsFromMacOSKeychain(configDir, forceRefresh);
}
if (isLinux()) {
return getCredentialsFromLinuxFile(configDir, forceRefresh);
}
if (isWindows()) {
return getCredentialsFromWindowsCredentialManager(configDir, forceRefresh);
}
// Unknown platform - return empty
return { token: null, email: null, error: `Unsupported platform: ${process.platform}` };
}
/**
* Alias for getCredentialsFromKeychain for semantic clarity on non-macOS platforms
*/
export const getCredentials = getCredentialsFromKeychain;
/**
* Clear the credentials cache for a specific profile or all profiles.
* Useful when you know the credentials have changed (e.g., after running claude /login)
*
* @param configDir - Optional config dir to clear cache for specific profile. If not provided, clears all.
*/
export function clearKeychainCache(configDir?: string): void {
if (configDir) {
// Clear cache for this specific configDir on all platforms
const macOSKey = `macos:${getKeychainServiceName(configDir)}`;
const linuxKey = `linux:${getLinuxCredentialsPath(configDir)}`;
const windowsKey = `windows:${getWindowsCredentialTarget(configDir)}`;
credentialCache.delete(macOSKey);
credentialCache.delete(linuxKey);
credentialCache.delete(windowsKey);
} else {
credentialCache.clear();
}
}
/**
* Alias for clearKeychainCache for semantic clarity
*/
export const clearCredentialCache = clearKeychainCache;
@@ -1,251 +0,0 @@
/**
* macOS Keychain Utilities
*
* Provides functions to retrieve Claude Code OAuth tokens and email from macOS Keychain.
* Supports both:
* - Default profile: "Claude Code-credentials" service
* - Custom profiles: "Claude Code-credentials-{sha256-8-hash}" where hash is first 8 chars
* of SHA256 hash of the CLAUDE_CONFIG_DIR path
*
* Mirrors the functionality of apps/backend/core/auth.py get_token_from_keychain()
*/
import { execFileSync } from 'child_process';
import { createHash } from 'crypto';
import { existsSync } from 'fs';
import { isMacOS } from '../platform';
/**
* Credentials retrieved from macOS Keychain
*/
export interface KeychainCredentials {
token: string | null;
email: string | null;
error?: string; // Set when keychain access fails (locked, permission denied, etc.)
}
/**
* Cache for keychain credentials to avoid repeated blocking calls
* Map key is the service name (e.g., "Claude Code-credentials" or "Claude Code-credentials-d74c9506")
*/
interface KeychainCacheEntry {
credentials: KeychainCredentials;
timestamp: number;
}
const keychainCache = new Map<string, KeychainCacheEntry>();
// Cache for 5 minutes (300,000 ms) for successful results
const CACHE_TTL_MS = 5 * 60 * 1000;
// Cache for 10 seconds for error results (allows quick retry after keychain unlock)
const ERROR_CACHE_TTL_MS = 10 * 1000;
/**
* Calculate the Keychain service name suffix for a config directory.
* Claude Code uses SHA256 hash of the config dir path, taking first 8 hex chars.
*
* @param configDir - The CLAUDE_CONFIG_DIR path
* @returns The 8-character hex hash suffix
*/
export function calculateConfigDirHash(configDir: string): string {
return createHash('sha256').update(configDir).digest('hex').slice(0, 8);
}
/**
* Get the Keychain service name for a config directory.
*
* @param configDir - Optional CLAUDE_CONFIG_DIR path. If not provided, returns default service name.
* @returns The Keychain service name (e.g., "Claude Code-credentials-d74c9506")
*/
export function getKeychainServiceName(configDir?: string): string {
if (!configDir) {
return 'Claude Code-credentials';
}
const hash = calculateConfigDirHash(configDir);
return `Claude Code-credentials-${hash}`;
}
/**
* Validate the structure of parsed Keychain JSON data
* @param data - Parsed JSON data from Keychain
* @returns true if data structure is valid, false otherwise
*/
function validateKeychainData(data: unknown): data is { claudeAiOauth?: { accessToken?: string; email?: string }; email?: string } {
if (!data || typeof data !== 'object') {
return false;
}
const obj = data as Record<string, unknown>;
// Check if claudeAiOauth exists and is an object
if (obj.claudeAiOauth !== undefined) {
if (typeof obj.claudeAiOauth !== 'object' || obj.claudeAiOauth === null) {
return false;
}
const oauth = obj.claudeAiOauth as Record<string, unknown>;
// Validate accessToken if present
if (oauth.accessToken !== undefined && typeof oauth.accessToken !== 'string') {
return false;
}
// Validate email if present
if (oauth.email !== undefined && typeof oauth.email !== 'string') {
return false;
}
}
// Validate top-level email if present
if (obj.email !== undefined && typeof obj.email !== 'string') {
return false;
}
return true;
}
/**
* Retrieve Claude Code OAuth credentials (token and email) from macOS Keychain.
*
* For default profile: reads from "Claude Code-credentials"
* For custom profiles: reads from "Claude Code-credentials-{hash}" where hash is
* SHA256(configDir).slice(0,8)
*
* Uses caching (5-minute TTL) to avoid repeated blocking calls.
* Only works on macOS (Darwin platform).
*
* @param configDir - Optional CLAUDE_CONFIG_DIR path for custom profiles
* @param forceRefresh - Set to true to bypass cache and fetch fresh credentials
* @returns Object with token and email (both may be null if not found or invalid)
*/
export function getCredentialsFromKeychain(configDir?: string, forceRefresh = false): KeychainCredentials {
// Only attempt on macOS
if (!isMacOS()) {
return { token: null, email: null };
}
const serviceName = getKeychainServiceName(configDir);
// Return cached credentials if available and fresh
const now = Date.now();
const cached = keychainCache.get(serviceName);
if (!forceRefresh && cached && (now - cached.timestamp) < CACHE_TTL_MS) {
return cached.credentials;
}
// Locate the security executable using platform abstraction
let securityPath: string | null = null;
try {
// The 'security' command is macOS-specific and typically in /usr/bin
// Try common macOS locations instead of hardcoding
const candidatePaths = ['/usr/bin/security', '/bin/security'];
for (const candidate of candidatePaths) {
if (existsSync(candidate)) {
securityPath = candidate;
break;
}
}
if (!securityPath) {
// Security command not found - this is expected on non-macOS or if security is missing
const notFoundResult = { token: null, email: null, error: 'macOS security command not found' };
keychainCache.set(serviceName, { credentials: notFoundResult, timestamp: now });
return notFoundResult;
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn('[KeychainUtils] Failed to locate security executable:', errorMessage);
const errorResult = { token: null, email: null, error: `Failed to locate security executable: ${errorMessage}` };
keychainCache.set(serviceName, { credentials: errorResult, timestamp: now - (CACHE_TTL_MS - ERROR_CACHE_TTL_MS) });
return errorResult;
}
try {
// Query macOS Keychain for Claude Code credentials
// Use execFileSync with argument array to prevent command injection
const result = execFileSync(
securityPath,
['find-generic-password', '-s', serviceName, '-w'],
{
encoding: 'utf-8',
timeout: 5000,
windowsHide: true,
}
);
const credentialsJson = result.trim();
if (!credentialsJson) {
const emptyResult = { token: null, email: null };
keychainCache.set(serviceName, { credentials: emptyResult, timestamp: now });
return emptyResult;
}
// Parse JSON response
let data: unknown;
try {
data = JSON.parse(credentialsJson);
} catch {
console.warn('[KeychainUtils] Failed to parse Keychain JSON for service:', serviceName);
const errorResult = { token: null, email: null };
keychainCache.set(serviceName, { credentials: errorResult, timestamp: now });
return errorResult;
}
// Validate JSON structure
if (!validateKeychainData(data)) {
console.warn('[KeychainUtils] Invalid Keychain data structure for service:', serviceName);
const invalidResult = { token: null, email: null };
keychainCache.set(serviceName, { credentials: invalidResult, timestamp: now });
return invalidResult;
}
// Extract OAuth token from nested structure
const token = data?.claudeAiOauth?.accessToken;
// Extract email (might be in different locations depending on Claude Code version)
const email = data?.claudeAiOauth?.email || data?.email || null;
// Validate token format if present
// Use 'sk-ant-' prefix instead of 'sk-ant-oat01-' to support future token format versions
// (e.g., oat02, oat03, etc.) without breaking validation
if (token && !token.startsWith('sk-ant-')) {
console.warn('[KeychainUtils] Invalid token format for service:', serviceName);
const result = { token: null, email };
keychainCache.set(serviceName, { credentials: result, timestamp: now });
return result;
}
const credentials = { token: token || null, email };
keychainCache.set(serviceName, { credentials, timestamp: now });
console.debug('[KeychainUtils] Retrieved credentials from Keychain for service:', serviceName, { hasToken: !!token, hasEmail: !!email });
return credentials;
} catch (error) {
// Check for exit code 44 (errSecItemNotFound) which indicates item not found
if (error && typeof error === 'object' && 'status' in error && error.status === 44) {
// Item not found - this is expected if user hasn't authenticated yet
const notFoundResult = { token: null, email: null };
keychainCache.set(serviceName, { credentials: notFoundResult, timestamp: now });
return notFoundResult;
}
// Other errors (keychain locked, access denied, etc.) - return error details
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn('[KeychainUtils] Keychain access failed for service:', serviceName, errorMessage);
const errorResult = { token: null, email: null, error: `Keychain access failed: ${errorMessage}` };
// Use shorter TTL for errors so users who unlock keychain see quick recovery
keychainCache.set(serviceName, { credentials: errorResult, timestamp: now - (CACHE_TTL_MS - ERROR_CACHE_TTL_MS) });
return errorResult;
}
}
/**
* Clear the keychain credentials cache for a specific service or all services.
* Useful when you know the credentials have changed (e.g., after running claude /login)
*
* @param configDir - Optional config dir to clear cache for specific profile. If not provided, clears all.
*/
export function clearKeychainCache(configDir?: string): void {
if (configDir) {
const serviceName = getKeychainServiceName(configDir);
keychainCache.delete(serviceName);
} else {
keychainCache.clear();
}
}
@@ -29,6 +29,8 @@ export interface ProfileStoreData {
profiles: ClaudeProfile[];
activeProfileId: string;
autoSwitch?: ClaudeAutoSwitchSettings;
/** Unified priority order for both OAuth and API profiles */
accountPriorityOrder?: string[];
}
/**
@@ -45,22 +47,35 @@ function parseAndMigrateProfileData(data: Record<string, unknown>): ProfileStore
}
if (data.version === STORE_VERSION) {
// Parse dates
// Parse dates and migrate profile data
const profiles = data.profiles as ClaudeProfile[];
data.profiles = profiles.map((p: ClaudeProfile) => ({
...p,
createdAt: new Date(p.createdAt),
lastUsedAt: p.lastUsedAt ? new Date(p.lastUsedAt) : undefined,
usage: p.usage ? {
...p.usage,
lastUpdated: new Date(p.usage.lastUpdated)
} : undefined,
rateLimitEvents: p.rateLimitEvents?.map(e => ({
...e,
hitAt: new Date(e.hitAt),
resetAt: new Date(e.resetAt)
}))
}));
data.profiles = profiles.map((p: ClaudeProfile) => {
// MIGRATION: Clear cached oauthToken to prevent stale token issues
// OAuth tokens expire in 8-12 hours. We now read fresh tokens from Keychain
// instead of caching them. See: docs/LONG_LIVED_AUTH_PLAN.md
if (p.oauthToken) {
console.warn('[ProfileStorage] Migrating profile - removing cached oauthToken:', p.name);
}
// Destructure to remove oauthToken and tokenCreatedAt from the profile
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { oauthToken: _, tokenCreatedAt: __, ...profileWithoutToken } = p;
return {
...profileWithoutToken,
createdAt: new Date(p.createdAt),
lastUsedAt: p.lastUsedAt ? new Date(p.lastUsedAt) : undefined,
usage: p.usage ? {
...p.usage,
lastUpdated: new Date(p.usage.lastUpdated)
} : undefined,
rateLimitEvents: p.rateLimitEvents?.map(e => ({
...e,
hitAt: new Date(e.hitAt),
resetAt: new Date(e.resetAt)
}))
};
});
return data as unknown as ProfileStoreData;
}
@@ -128,23 +128,30 @@ export function isProfileAuthenticated(profile: ClaudeProfile): boolean {
}
/**
* Check if a profile has a valid OAuth token.
* Token is valid for 1 year from creation.
* Check if a profile has a valid OAuth token stored in the profile.
*
* DEPRECATED: This function checks for CACHED OAuth tokens which we no longer store.
* OAuth tokens expire in 8-12 hours, not 1 year. We now use CLAUDE_CONFIG_DIR
* to let Claude CLI read fresh tokens from Keychain on each invocation.
*
* This function is kept for backwards compatibility with existing profiles that
* have oauthToken stored. For these profiles, we return true (assuming token might
* still be valid) and let the actual API call determine if re-auth is needed.
*
* New profiles will NOT have oauthToken stored (per the auth flow changes).
* Use isProfileAuthenticated() to check for configDir-based credentials instead.
*
* See: docs/LONG_LIVED_AUTH_PLAN.md for full context.
*/
export function hasValidToken(profile: ClaudeProfile): boolean {
if (!profile?.oauthToken) {
return false;
}
// Check if token is expired (1 year validity)
if (profile.tokenCreatedAt) {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
if (new Date(profile.tokenCreatedAt) < oneYearAgo) {
return false;
}
}
// For legacy profiles with stored oauthToken, return true.
// The actual token validity is determined by the Keychain (via CLAUDE_CONFIG_DIR).
// We keep this for backwards compat to avoid breaking existing profiles during migration.
console.warn('[hasValidToken] DEPRECATED: Profile has cached oauthToken. Using CLAUDE_CONFIG_DIR for fresh tokens.');
return true;
}
@@ -158,3 +165,57 @@ export function expandHomePath(path: string): string {
}
return path;
}
/**
* Get the email address from a profile's Claude config file (.claude.json).
*
* This reads the email directly from Claude's config file, which is the authoritative
* source for the user's email. This is more reliable than parsing terminal output
* which may contain ANSI escape codes that corrupt the email.
*
* @param configDir - The profile's config directory (e.g., ~/.claude or ~/.claude-profiles/work)
* @returns The email address if found, null otherwise
*/
export function getEmailFromConfigDir(configDir?: string): string | null {
if (!configDir) {
return null;
}
// Expand ~ to home directory
const expandedConfigDir = expandHomePath(configDir);
// Check .claude.json (primary config file)
const claudeJsonPath = join(expandedConfigDir, '.claude.json');
if (existsSync(claudeJsonPath)) {
try {
const content = readFileSync(claudeJsonPath, 'utf-8');
const data = JSON.parse(content);
// Check for oauthAccount.emailAddress (modern Claude Code CLI format)
if (data?.oauthAccount?.emailAddress && typeof data.oauthAccount.emailAddress === 'string') {
return data.oauthAccount.emailAddress;
}
} catch (error) {
console.warn(`[profile-utils] Failed to read email from ${claudeJsonPath}:`, error);
}
}
// Fallback: check .credentials.json (used on some Linux setups)
const credentialsJsonPath = join(expandedConfigDir, '.credentials.json');
if (existsSync(credentialsJsonPath)) {
try {
const content = readFileSync(credentialsJsonPath, 'utf-8');
const data = JSON.parse(content);
// Check claudeAiOauth.email or emailAddress
const email = data?.claudeAiOauth?.email || data?.claudeAiOauth?.emailAddress || data?.email;
if (email && typeof email === 'string') {
return email;
}
} catch (error) {
console.warn(`[profile-utils] Failed to read email from ${credentialsJsonPath}:`, error);
}
}
return null;
}
@@ -56,6 +56,15 @@ vi.mock('../services/profile/profile-manager', () => ({
loadProfilesFile: () => mockLoadProfilesFile()
}));
// Mock credential-utils to return mock token instead of reading real credentials
vi.mock('./credential-utils', () => ({
getCredentialsFromKeychain: vi.fn(() => ({
token: 'mock-decrypted-token',
email: 'test@example.com'
})),
clearKeychainCache: vi.fn()
}));
// Mock global fetch
global.fetch = vi.fn(() =>
Promise.resolve({
@@ -720,7 +729,7 @@ describe('usage-monitor', () => {
// 401 errors should throw
await expect(
monitor['fetchUsageViaAPI']('invalid-token', 'test-profile-1', 'Test Profile')
monitor['fetchUsageViaAPI']('invalid-token', 'test-profile-1', 'Test Profile', undefined)
).rejects.toThrow('API Auth Failure: 401');
expect(consoleSpy).toHaveBeenCalled();
@@ -751,7 +760,7 @@ describe('usage-monitor', () => {
// 403 errors should throw
await expect(
monitor['fetchUsageViaAPI']('expired-token', 'test-profile-1', 'Test Profile')
monitor['fetchUsageViaAPI']('expired-token', 'test-profile-1', 'Test Profile', undefined)
).rejects.toThrow('API Auth Failure: 403');
expect(consoleSpy).toHaveBeenCalled();
@@ -771,7 +780,7 @@ describe('usage-monitor', () => {
const monitor = getUsageMonitor();
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile');
const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile', undefined);
expect(usage).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
@@ -786,7 +795,7 @@ describe('usage-monitor', () => {
const monitor = getUsageMonitor();
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile');
const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile', undefined);
expect(usage).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
@@ -808,7 +817,7 @@ describe('usage-monitor', () => {
const monitor = getUsageMonitor();
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile');
const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile', undefined);
expect(usage).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
@@ -831,7 +840,7 @@ describe('usage-monitor', () => {
// 401 errors should throw with proper message
await expect(
monitor['fetchUsageViaAPI']('invalid-token', 'test-profile-1', 'Test Profile')
monitor['fetchUsageViaAPI']('invalid-token', 'test-profile-1', 'Test Profile', undefined)
).rejects.toThrow('API Auth Failure: 401');
expect(consoleSpy).toHaveBeenCalled();
@@ -945,7 +954,7 @@ describe('usage-monitor', () => {
const monitor = getUsageMonitor();
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const usage = await monitor['fetchUsageViaAPI']('zai-api-key', 'zai-profile-1', 'z.ai Profile');
const usage = await monitor['fetchUsageViaAPI']('zai-api-key', 'zai-profile-1', 'z.ai Profile', undefined);
expect(usage).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
@@ -977,7 +986,7 @@ describe('usage-monitor', () => {
const monitor = getUsageMonitor();
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const usage = await monitor['fetchUsageViaAPI']('zhipu-api-key', 'zhipu-profile-1', 'ZHIPU Profile');
const usage = await monitor['fetchUsageViaAPI']('zhipu-api-key', 'zhipu-profile-1', 'ZHIPU Profile', undefined);
expect(usage).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
@@ -1013,6 +1022,7 @@ describe('usage-monitor', () => {
'unknown-api-key',
'unknown-profile-1',
'Unknown Profile',
undefined,
unknownProviderProfile
);
@@ -1442,7 +1452,7 @@ describe('usage-monitor', () => {
const profileId = 'test-profile-cooldown';
// Call fetchUsageViaAPI which should fail and record timestamp
await monitor['fetchUsageViaAPI']('valid-token', profileId, 'Test Profile');
await monitor['fetchUsageViaAPI']('valid-token', profileId, 'Test Profile', undefined);
// Verify failure timestamp was recorded
const failureTimestamp = monitor['apiFailureTimestamps'].get(profileId);
@@ -1569,6 +1579,7 @@ describe('usage-monitor', () => {
'sk-ant-api-key',
'api-profile-1',
'API Profile',
undefined,
predeterminedProfile
);
@@ -1615,6 +1626,7 @@ describe('usage-monitor', () => {
'sk-ant-api-key',
'api-profile-1',
'API Profile',
undefined, // No email
undefined // No activeProfile passed
);
@@ -1655,6 +1667,7 @@ describe('usage-monitor', () => {
'oauth-token',
'oauth-profile',
'OAuth Profile',
undefined,
oauthProfile
);
@@ -10,15 +10,42 @@
*/
import { EventEmitter } from 'events';
import { homedir } from 'os';
import { getClaudeProfileManager } from '../claude-profile-manager';
import { ClaudeUsageSnapshot } from '../../shared/types/agent';
import { ClaudeUsageSnapshot, ProfileUsageSummary, AllProfilesUsage } from '../../shared/types/agent';
import { loadProfilesFile } from '../services/profile/profile-manager';
import type { APIProfile } from '../../shared/types/profile';
import { detectProvider as sharedDetectProvider, type ApiProvider } from '../../shared/utils/provider-detection';
import { getCredentialsFromKeychain, clearKeychainCache } from './credential-utils';
import { isProfileRateLimited } from './rate-limit-manager';
// Re-export for backward compatibility
export type { ApiProvider };
/**
* Create a safe fingerprint of a credential for debug logging.
* Shows first 8 and last 4 characters, hiding the sensitive middle portion.
* This is NOT for authentication - only for human-readable debug identification.
*
* @param credential - The credential (token or API key) to create a fingerprint for
* @returns A safe fingerprint like "sk-ant-oa...xyz9" or "null" if no credential
*/
function getCredentialFingerprint(credential: string | null | undefined): string {
if (!credential) return 'null';
if (credential.length <= 16) return credential.slice(0, 4) + '...' + credential.slice(-2);
return credential.slice(0, 8) + '...' + credential.slice(-4);
}
/**
* Allowed domains for usage API requests.
* Only these domains are permitted for outbound usage monitoring requests.
*/
const ALLOWED_USAGE_API_DOMAINS = new Set([
'api.anthropic.com',
'api.z.ai',
'open.bigmodel.cn',
]);
/**
* Provider usage endpoint configuration
* Maps each provider to its usage monitoring endpoint path
@@ -155,11 +182,21 @@ export function detectProvider(baseUrl: string): ApiProvider {
interface ActiveProfileResult {
profileId: string;
profileName: string;
profileEmail?: string;
isAPIProfile: boolean;
baseUrl: string;
credential?: string;
}
/**
* Type guard to check if an error has an HTTP status code
* @param error - The error to check
* @returns true if the error has a statusCode property
*/
function isHttpError(error: unknown): error is Error & { statusCode?: number } {
return error instanceof Error && 'statusCode' in error;
}
export class UsageMonitor extends EventEmitter {
private static instance: UsageMonitor;
private intervalId: NodeJS.Timeout | null = null;
@@ -175,6 +212,11 @@ export class UsageMonitor extends EventEmitter {
private authFailedProfiles: Map<string, number> = new Map(); // profileId -> timestamp
private static AUTH_FAILURE_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes cooldown
// Cache for all profiles' usage data
// Map<profileId, { usage: ProfileUsageSummary, fetchedAt: number }>
private allProfilesUsageCache: Map<string, { usage: ProfileUsageSummary; fetchedAt: number }> = new Map();
private static PROFILE_USAGE_CACHE_TTL_MS = 60 * 1000; // 1 minute cache for inactive profiles
// Debug flag for verbose logging
private readonly isDebug = process.env.DEBUG === 'true';
@@ -237,13 +279,330 @@ export class UsageMonitor extends EventEmitter {
return this.currentUsage;
}
/**
* Clear the usage cache for a specific profile.
* Called after re-authentication to ensure fresh usage data is fetched.
*
* @param profileId - Profile identifier to clear cache for
*/
clearProfileUsageCache(profileId: string): void {
const deleted = this.allProfilesUsageCache.delete(profileId);
if (this.isDebug) {
console.warn('[UsageMonitor] Cleared usage cache for profile:', {
profileId,
wasInCache: deleted
});
}
}
/**
* Get all profiles usage data (for multi-profile display in UI)
* Returns cached data if fresh, otherwise fetches for all profiles
*
* Uses parallel fetching for inactive profiles to minimize blocking delays.
*/
async getAllProfilesUsage(): Promise<AllProfilesUsage | null> {
const profileManager = getClaudeProfileManager();
const settings = profileManager.getSettings();
const activeProfileId = settings.activeProfileId;
if (!this.currentUsage) {
return null;
}
const now = Date.now();
const allProfiles: ProfileUsageSummary[] = [];
// First pass: identify profiles that need fresh data vs cached
type ProfileToFetch = { profile: typeof settings.profiles[0]; index: number };
const profilesToFetch: ProfileToFetch[] = [];
const profileResults: (ProfileUsageSummary | null)[] = new Array(settings.profiles.length).fill(null);
for (let i = 0; i < settings.profiles.length; i++) {
const profile = settings.profiles[i];
const cached = this.allProfilesUsageCache.get(profile.id);
// Use cached data if fresh (within TTL)
if (cached && (now - cached.fetchedAt) < UsageMonitor.PROFILE_USAGE_CACHE_TTL_MS) {
profileResults[i] = {
...cached.usage,
isActive: profile.id === activeProfileId
};
continue;
}
// For active profile, use the current detailed usage
if (profile.id === activeProfileId && this.currentUsage) {
const summary = this.buildProfileUsageSummary(profile, this.currentUsage);
profileResults[i] = summary;
this.allProfilesUsageCache.set(profile.id, { usage: summary, fetchedAt: now });
continue;
}
// Mark for parallel fetch
profilesToFetch.push({ profile, index: i });
}
// Parallel fetch for all inactive profiles that need fresh data
if (profilesToFetch.length > 0) {
// Collect usage updates for batch save (avoids race condition with concurrent saves)
const usageUpdates: Array<{ profileId: string; sessionPercent: number; weeklyPercent: number }> = [];
const fetchPromises = profilesToFetch.map(async ({ profile, index }) => {
const inactiveUsage = await this.fetchUsageForInactiveProfile(profile);
const rateLimitStatus = isProfileRateLimited(profile);
let sessionPercent = 0;
let weeklyPercent = 0;
if (inactiveUsage) {
sessionPercent = inactiveUsage.sessionPercent;
weeklyPercent = inactiveUsage.weeklyPercent;
// Collect update for batch save (don't save here to avoid race condition)
return {
index,
update: { profileId: profile.id, sessionPercent, weeklyPercent },
profile,
inactiveUsage,
rateLimitStatus
};
} else {
// Fallback to cached profile data if API fetch failed
sessionPercent = profile.usage?.sessionUsagePercent ?? 0;
weeklyPercent = profile.usage?.weeklyUsagePercent ?? 0;
return {
index,
update: null, // No update needed for fallback
profile,
inactiveUsage,
rateLimitStatus,
sessionPercent,
weeklyPercent
};
}
});
// Wait for all fetches to complete in parallel
const fetchResults = await Promise.all(fetchPromises);
// Collect all updates and build summaries
for (const result of fetchResults) {
const { index, update, profile, inactiveUsage, rateLimitStatus } = result;
// Get percentages from either the update or the fallback values
const sessionPercent = update?.sessionPercent ?? result.sessionPercent ?? 0;
const weeklyPercent = update?.weeklyPercent ?? result.weeklyPercent ?? 0;
if (update) {
usageUpdates.push(update);
}
const summary: ProfileUsageSummary = {
profileId: profile.id,
profileName: profile.name,
profileEmail: profile.email,
sessionPercent,
weeklyPercent,
isAuthenticated: profile.isAuthenticated ?? false,
isRateLimited: rateLimitStatus.limited,
rateLimitType: rateLimitStatus.type,
availabilityScore: this.calculateAvailabilityScore(
sessionPercent,
weeklyPercent,
rateLimitStatus.limited,
rateLimitStatus.type,
profile.isAuthenticated ?? false
),
isActive: profile.id === activeProfileId,
lastFetchedAt: inactiveUsage?.fetchedAt?.toISOString() ?? profile.usage?.lastUpdated?.toISOString()
};
this.allProfilesUsageCache.set(profile.id, { usage: summary, fetchedAt: now });
profileResults[index] = summary;
}
// Batch save all usage updates at once (single disk write, no race condition)
if (usageUpdates.length > 0) {
profileManager.batchUpdateProfileUsageFromAPI(usageUpdates);
}
}
// Collect non-null results
for (const result of profileResults) {
if (result) {
allProfiles.push(result);
}
}
// Sort by availability score (highest first = most available)
allProfiles.sort((a, b) => b.availabilityScore - a.availabilityScore);
return {
activeProfile: this.currentUsage,
allProfiles,
fetchedAt: new Date()
};
}
/**
* Fetch usage for an inactive profile using its own credentials
* This allows showing real usage data for non-active profiles
*/
private async fetchUsageForInactiveProfile(
profile: { id: string; name: string; email?: string; configDir?: string; isAuthenticated?: boolean }
): Promise<ClaudeUsageSnapshot | null> {
// Only fetch for authenticated profiles with a configDir
if (!profile.isAuthenticated || !profile.configDir) {
if (this.isDebug) {
console.warn('[UsageMonitor] Skipping inactive profile fetch - not authenticated or no configDir:', {
profileId: profile.id,
profileName: profile.name,
isAuthenticated: profile.isAuthenticated,
hasConfigDir: !!profile.configDir
});
}
return null;
}
try {
// Get credentials from keychain for this profile's configDir
const expandedConfigDir = profile.configDir.startsWith('~')
? profile.configDir.replace(/^~/, homedir())
: profile.configDir;
const keychainCreds = getCredentialsFromKeychain(expandedConfigDir);
if (!keychainCreds.token) {
if (this.isDebug) {
console.warn('[UsageMonitor] No keychain credentials for inactive profile:', profile.name);
}
return null;
}
if (this.isDebug) {
console.warn('[UsageMonitor] Fetching usage for inactive profile:', {
profileId: profile.id,
profileName: profile.name,
tokenFingerprint: getCredentialFingerprint(keychainCreds.token)
});
}
// Fetch usage via API - OAuth profiles always use Anthropic
const usage = await this.fetchUsageViaAPI(
keychainCreds.token,
profile.id,
profile.name,
keychainCreds.email ?? profile.email,
{
profileId: profile.id,
profileName: profile.name,
profileEmail: keychainCreds.email ?? profile.email,
isAPIProfile: false,
baseUrl: 'https://api.anthropic.com'
}
);
if (this.isDebug && usage) {
console.warn('[UsageMonitor] Successfully fetched inactive profile usage:', {
profileName: profile.name,
sessionPercent: usage.sessionPercent,
weeklyPercent: usage.weeklyPercent
});
}
return usage;
} catch (error) {
console.warn('[UsageMonitor] Failed to fetch inactive profile usage:', profile.name, error);
return null;
}
}
/**
* Build a ProfileUsageSummary from a ClaudeUsageSnapshot
*/
private buildProfileUsageSummary(
profile: { id: string; name: string; email?: string; isAuthenticated?: boolean },
usage: ClaudeUsageSnapshot
): ProfileUsageSummary {
const profileManager = getClaudeProfileManager();
const fullProfile = profileManager.getProfile(profile.id);
const rateLimitStatus = fullProfile ? isProfileRateLimited(fullProfile) : { limited: false };
return {
profileId: profile.id,
profileName: profile.name,
profileEmail: usage.profileEmail || profile.email,
sessionPercent: usage.sessionPercent,
weeklyPercent: usage.weeklyPercent,
sessionResetTimestamp: usage.sessionResetTimestamp,
weeklyResetTimestamp: usage.weeklyResetTimestamp,
isAuthenticated: profile.isAuthenticated ?? true,
isRateLimited: rateLimitStatus.limited,
rateLimitType: rateLimitStatus.type,
availabilityScore: this.calculateAvailabilityScore(
usage.sessionPercent,
usage.weeklyPercent,
rateLimitStatus.limited,
rateLimitStatus.type,
profile.isAuthenticated ?? true
),
isActive: usage.profileId === profileManager.getActiveProfile()?.id,
lastFetchedAt: usage.fetchedAt?.toISOString()
};
}
/**
* Calculate availability score for a profile (higher = more available)
*
* Scoring algorithm:
* - Base score: 100
* - Rate limited: -500 (session) or -1000 (weekly)
* - Unauthenticated: -500
* - Weekly usage penalty: -(weeklyPercent * 0.5)
* - Session usage penalty: -(sessionPercent * 0.2)
*/
private calculateAvailabilityScore(
sessionPercent: number,
weeklyPercent: number,
isRateLimited: boolean,
rateLimitType?: 'session' | 'weekly',
isAuthenticated: boolean = true
): number {
let score = 100;
// Penalize rate-limited profiles heavily
if (isRateLimited) {
if (rateLimitType === 'weekly') {
score -= 1000; // Weekly limit is worse (takes longer to reset)
} else {
score -= 500; // Session limit resets sooner
}
}
// Penalize unauthenticated profiles
if (!isAuthenticated) {
score -= 500;
}
// Penalize based on current usage (weekly more important)
score -= weeklyPercent * 0.5;
score -= sessionPercent * 0.2;
return Math.round(score * 100) / 100; // Round to 2 decimal places
}
/**
* Get credential for usage monitoring (OAuth token or API key)
* Detects profile type and returns appropriate credential
*
* Priority:
* 1. API Profile (if active) - returns apiKey directly
* 2. OAuth Profile - returns decrypted oauthToken
* 2. OAuth Profile - reads FRESH token from Keychain (not cached oauthToken)
*
* IMPORTANT: For OAuth profiles, we read from Keychain instead of cached profile.oauthToken.
* OAuth tokens expire in 8-12 hours, but Claude CLI auto-refreshes and stores fresh tokens
* in Keychain. Using cached tokens causes 401 errors after a few hours.
* See: docs/LONG_LIVED_AUTH_PLAN.md
*
* @returns The credential string or undefined if none available
*/
@@ -269,15 +628,32 @@ export class UsageMonitor extends EventEmitter {
}
}
// Fall back to OAuth profile
// Fall back to OAuth profile - read FRESH token from Keychain
const profileManager = getClaudeProfileManager();
const activeProfile = profileManager.getActiveProfile();
if (activeProfile?.oauthToken) {
const decryptedToken = profileManager.getProfileToken(activeProfile.id);
if (this.isDebug && decryptedToken) {
console.warn('[UsageMonitor:TRACE] Using OAuth profile credential:', activeProfile.name);
if (activeProfile) {
// Read fresh token from Keychain using configDir (same as CLAUDE_CONFIG_DIR)
// This ensures we get the auto-refreshed token, not a stale cached copy
// IMPORTANT: Always pass configDir, even for default profiles - the keychain
// service name is based on the expanded path (e.g., /Users/xxx/.claude), not undefined
const keychainCreds = getCredentialsFromKeychain(activeProfile.configDir);
if (keychainCreds.token) {
if (this.isDebug) {
console.warn('[UsageMonitor:TRACE] Using OAuth token from Keychain for profile:', activeProfile.name, {
tokenFingerprint: getCredentialFingerprint(keychainCreds.token)
});
}
return keychainCreds.token;
}
// Keychain read failed - log warning (don't fall back to cached token)
if (keychainCreds.error) {
console.warn('[UsageMonitor] Keychain access failed:', keychainCreds.error);
} else if (this.isDebug) {
console.warn('[UsageMonitor:TRACE] No token in Keychain for profile:', activeProfile.name,
'- user may need to re-authenticate with claude /login');
}
return decryptedToken;
}
// No credential available
@@ -324,9 +700,19 @@ export class UsageMonitor extends EventEmitter {
this.currentUsage = usage;
// Step 2.5: Persist usage to profile for caching (so other profiles can display cached usage)
const profileManager = getClaudeProfileManager();
profileManager.updateProfileUsageFromAPI(profileId, usage.sessionPercent, usage.weeklyPercent);
// Step 3: Emit usage update for UI (always emit, regardless of proactive swap settings)
this.emit('usage-updated', usage);
// Step 3.5: Emit all profiles usage for multi-profile display
const allProfilesUsage = await this.getAllProfilesUsage();
if (allProfilesUsage) {
this.emit('all-profiles-usage-updated', allProfilesUsage);
}
// Step 4: Check thresholds and perform proactive swap (OAuth profiles only)
if (!isAPIProfile) {
const profileManager = getClaudeProfileManager();
@@ -378,7 +764,7 @@ export class UsageMonitor extends EventEmitter {
}
} catch (error) {
// Step 5: Handle auth failures
if ((error as any).statusCode === 401 || (error as any).statusCode === 403) {
if (isHttpError(error) && (error.statusCode === 401 || error.statusCode === 403)) {
if (profileId) {
await this.handleAuthFailure(profileId, isAPIProfile);
return; // handleAuthFailure manages its own logging
@@ -467,19 +853,32 @@ export class UsageMonitor extends EventEmitter {
return null;
}
// Get email from profile or try keychain
let profileEmail = activeOAuthProfile.email;
if (!profileEmail) {
// Try to get email from keychain
// IMPORTANT: Always pass configDir - service name is based on expanded path (e.g., /Users/xxx/.claude)
const keychainCreds = getCredentialsFromKeychain(activeOAuthProfile.configDir);
profileEmail = keychainCreds.email ?? undefined;
}
if (this.isDebug) {
console.warn('[UsageMonitor:TRACE] Active auth type: OAuth Profile', {
profileId: activeOAuthProfile.id,
profileName: activeOAuthProfile.name
profileName: activeOAuthProfile.name,
profileEmail
});
}
return {
const result = {
profileId: activeOAuthProfile.id,
profileName: activeOAuthProfile.name,
profileEmail,
isAPIProfile: false,
baseUrl: 'https://api.anthropic.com'
};
return result;
}
/**
@@ -511,6 +910,17 @@ export class UsageMonitor extends EventEmitter {
*/
private async handleAuthFailure(profileId: string, isAPIProfile: boolean): Promise<void> {
const profileManager = getClaudeProfileManager();
// Clear keychain cache for this profile so next attempt gets fresh credentials
// This handles cases where the token was refreshed by Claude CLI but our cache is stale
if (!isAPIProfile) {
const profile = profileManager.getProfile(profileId);
if (profile?.configDir) {
console.warn('[UsageMonitor] Auth failure - clearing keychain cache for profile:', profileId);
clearKeychainCache(profile.configDir);
}
}
const settings = profileManager.getAutoSwitchSettings();
// Proactive swap is only supported for OAuth profiles, not API profiles
@@ -560,27 +970,45 @@ export class UsageMonitor extends EventEmitter {
credential?: string,
activeProfile?: ActiveProfileResult
): Promise<ClaudeUsageSnapshot | null> {
// Get profile name - check both API profiles and OAuth profiles
// Get profile name and email - prefer activeProfile since it's already determined
let profileName: string | undefined;
let profileEmail: string | undefined;
// First, check if it's an API profile
try {
const profilesFile = await loadProfilesFile();
const apiProfile = profilesFile.profiles.find(p => p.id === profileId);
if (apiProfile) {
profileName = apiProfile.name;
if (this.isDebug) {
console.warn('[UsageMonitor:FETCH] Found API profile:', {
profileId,
profileName,
baseUrl: apiProfile.baseUrl
});
}
}
} catch (error) {
// Failed to load API profiles, continue to OAuth check
// Use activeProfile data if available (already fetched and validated)
// This fixes the bug where API profile names were incorrectly shown for OAuth profiles
if (activeProfile?.profileName) {
profileName = activeProfile.profileName;
profileEmail = activeProfile.profileEmail;
if (this.isDebug) {
console.warn('[UsageMonitor:FETCH] Failed to load API profiles:', error);
console.warn('[UsageMonitor:FETCH] Using activeProfile data:', {
profileId,
profileName,
profileEmail,
isAPIProfile: activeProfile.isAPIProfile
});
}
}
// Only search API profiles if not already set from activeProfile
if (!profileName) {
try {
const profilesFile = await loadProfilesFile();
const apiProfile = profilesFile.profiles.find(p => p.id === profileId);
if (apiProfile) {
profileName = apiProfile.name;
if (this.isDebug) {
console.warn('[UsageMonitor:FETCH] Found API profile:', {
profileId,
profileName,
baseUrl: apiProfile.baseUrl
});
}
}
} catch (error) {
// Failed to load API profiles, continue to OAuth check
if (this.isDebug) {
console.warn('[UsageMonitor:FETCH] Failed to load API profiles:', error);
}
}
}
@@ -590,10 +1018,15 @@ export class UsageMonitor extends EventEmitter {
const oauthProfile = profileManager.getProfile(profileId);
if (oauthProfile) {
profileName = oauthProfile.name;
// Get email from OAuth profile if not already set
if (!profileEmail) {
profileEmail = oauthProfile.email;
}
if (this.isDebug) {
console.warn('[UsageMonitor:FETCH] Found OAuth profile:', {
profileId,
profileName
profileName,
profileEmail
});
}
}
@@ -620,7 +1053,7 @@ export class UsageMonitor extends EventEmitter {
if (this.isDebug) {
console.warn('[UsageMonitor:FETCH] Attempting API fetch method');
}
const apiUsage = await this.fetchUsageViaAPI(credential, profileId, profileName, activeProfile);
const apiUsage = await this.fetchUsageViaAPI(credential, profileId, profileName, profileEmail, activeProfile);
if (apiUsage) {
console.warn('[UsageMonitor] Successfully fetched via API');
if (this.isDebug) {
@@ -665,6 +1098,7 @@ export class UsageMonitor extends EventEmitter {
* @param credential - OAuth token or API key
* @param profileId - Profile identifier
* @param profileName - Profile display name
* @param profileEmail - Optional email associated with the profile
* @param activeProfile - Optional pre-determined active profile info to avoid race conditions
* @returns Normalized usage snapshot or null on failure
*/
@@ -672,6 +1106,7 @@ export class UsageMonitor extends EventEmitter {
credential: string,
profileId: string,
profileName: string,
profileEmail?: string,
activeProfile?: ActiveProfileResult
): Promise<ClaudeUsageSnapshot | null> {
if (this.isDebug) {
@@ -736,6 +1171,14 @@ export class UsageMonitor extends EventEmitter {
return null;
}
if (this.isDebug) {
console.warn('[UsageMonitor:API_FETCH] API request:', {
endpoint: usageEndpoint,
profileId,
credentialFingerprint: getCredentialFingerprint(credential)
});
}
if (this.isDebug) {
console.warn('[UsageMonitor:API_FETCH] Fetching from endpoint:', {
provider,
@@ -744,17 +1187,45 @@ export class UsageMonitor extends EventEmitter {
});
}
// Step 4: Fetch usage from provider endpoint
// Step 4: Validate endpoint domain before making request
// Security: Only allow requests to known provider domains
let endpointHostname: string;
try {
const endpointUrl = new URL(usageEndpoint);
endpointHostname = endpointUrl.hostname;
} catch {
console.error('[UsageMonitor] Invalid usage endpoint URL:', usageEndpoint);
return null;
}
if (!ALLOWED_USAGE_API_DOMAINS.has(endpointHostname)) {
console.error('[UsageMonitor] Blocked request to unauthorized domain:', endpointHostname, {
allowedDomains: Array.from(ALLOWED_USAGE_API_DOMAINS)
});
return null;
}
// Step 5: Fetch usage from provider endpoint
// All providers use Bearer token authentication (RFC 6750)
const authHeader = `Bearer ${credential}`;
// Build headers based on provider
// Anthropic OAuth requires the 'anthropic-beta: oauth-2025-04-20' header
// See: https://codelynx.dev/posts/claude-code-usage-limits-statusline
const headers: Record<string, string> = {
'Authorization': authHeader,
'Content-Type': 'application/json',
};
if (provider === 'anthropic') {
// OAuth authentication requires the beta header
headers['anthropic-beta'] = 'oauth-2025-04-20';
headers['anthropic-version'] = '2023-06-01';
}
const response = await fetch(usageEndpoint, {
method: 'GET',
headers: {
'Authorization': authHeader,
'Content-Type': 'application/json',
...(provider === 'anthropic' && { 'anthropic-version': '2023-06-01' })
}
headers
});
if (!response.ok) {
@@ -874,13 +1345,13 @@ export class UsageMonitor extends EventEmitter {
switch (provider) {
case 'anthropic':
normalizedUsage = this.normalizeAnthropicResponse(rawData, profileId, profileName);
normalizedUsage = this.normalizeAnthropicResponse(rawData, profileId, profileName, profileEmail);
break;
case 'zai':
normalizedUsage = this.normalizeZAIResponse(responseData, profileId, profileName);
normalizedUsage = this.normalizeZAIResponse(responseData, profileId, profileName, profileEmail);
break;
case 'zhipu':
normalizedUsage = this.normalizeZhipuResponse(responseData, profileId, profileName);
normalizedUsage = this.normalizeZhipuResponse(responseData, profileId, profileName, profileEmail);
break;
default:
console.warn('[UsageMonitor] Unsupported provider for usage normalization:', provider);
@@ -895,7 +1366,10 @@ export class UsageMonitor extends EventEmitter {
}
if (this.isDebug) {
console.warn('[UsageMonitor:PROVIDER] Normalized usage:', {
console.warn('[UsageMonitor:API_FETCH] Fetch completed - usage:', {
profileId,
profileName,
email: normalizedUsage.profileEmail,
provider,
sessionPercent: normalizedUsage.sessionPercent,
weeklyPercent: normalizedUsage.weeklyPercent,
@@ -922,32 +1396,63 @@ export class UsageMonitor extends EventEmitter {
/**
* Normalize Anthropic API response to ClaudeUsageSnapshot
*
* Expected Anthropic response format:
* Actual Anthropic OAuth usage API response format:
* {
* "five_hour_utilization": 0.72, // 0.0-1.0
* "seven_day_utilization": 0.45, // 0.0-1.0
* "five_hour_reset_at": "2025-01-17T15:00:00Z",
* "seven_day_reset_at": "2025-01-20T12:00:00Z"
* "five_hour": {
* "utilization": 19, // integer 0-100
* "resets_at": "2025-01-17T15:00:00Z"
* },
* "seven_day": {
* "utilization": 45, // integer 0-100
* "resets_at": "2025-01-20T12:00:00Z"
* }
* }
*/
private normalizeAnthropicResponse(
data: any,
profileId: string,
profileName: string
profileName: string,
profileEmail?: string
): ClaudeUsageSnapshot {
const fiveHourUtil = data.five_hour_utilization ?? 0;
const sevenDayUtil = data.seven_day_utilization ?? 0;
// Support both new nested format and legacy flat format for backward compatibility
//
// NEW format (current API): { five_hour: { utilization: 72, resets_at: "..." } }
// OLD format (legacy): { five_hour_utilization: 0.72, five_hour_reset_at: "..." }
let fiveHourUtil: number;
let sevenDayUtil: number;
let sessionResetTimestamp: string | undefined;
let weeklyResetTimestamp: string | undefined;
// Check for new nested format first
if (data.five_hour !== undefined || data.seven_day !== undefined) {
// New nested format - utilization is already 0-100 integer
fiveHourUtil = data.five_hour?.utilization ?? 0;
sevenDayUtil = data.seven_day?.utilization ?? 0;
sessionResetTimestamp = data.five_hour?.resets_at;
weeklyResetTimestamp = data.seven_day?.resets_at;
} else {
// Legacy flat format - utilization is 0-1 float, needs *100
const rawFiveHour = data.five_hour_utilization ?? 0;
const rawSevenDay = data.seven_day_utilization ?? 0;
// Convert 0-1 float to 0-100 integer
fiveHourUtil = Math.round(rawFiveHour * 100);
sevenDayUtil = Math.round(rawSevenDay * 100);
sessionResetTimestamp = data.five_hour_reset_at;
weeklyResetTimestamp = data.seven_day_reset_at;
}
return {
sessionPercent: Math.round(fiveHourUtil * 100),
weeklyPercent: Math.round(sevenDayUtil * 100),
sessionPercent: fiveHourUtil,
weeklyPercent: sevenDayUtil,
// Omit sessionResetTime/weeklyResetTime - renderer uses timestamps with formatTimeRemaining
sessionResetTime: undefined,
weeklyResetTime: undefined,
sessionResetTimestamp: data.five_hour_reset_at,
weeklyResetTimestamp: data.seven_day_reset_at,
sessionResetTimestamp,
weeklyResetTimestamp,
profileId,
profileName,
profileEmail,
fetchedAt: new Date(),
limitType: sevenDayUtil > fiveHourUtil ? 'weekly' : 'session',
usageWindows: {
@@ -966,6 +1471,7 @@ export class UsageMonitor extends EventEmitter {
* @param data - Raw response data with limits array
* @param profileId - Profile identifier
* @param profileName - Profile display name
* @param profileEmail - Optional email associated with the profile
* @param providerName - Provider name for logging ('zai' or 'zhipu')
* @returns Normalized usage snapshot or null on parse failure
*/
@@ -973,6 +1479,7 @@ export class UsageMonitor extends EventEmitter {
data: any,
profileId: string,
profileName: string,
profileEmail: string | undefined,
providerName: 'zai' | 'zhipu'
): ClaudeUsageSnapshot | null {
const logPrefix = providerName.toUpperCase();
@@ -1072,6 +1579,7 @@ export class UsageMonitor extends EventEmitter {
weeklyResetTimestamp,
profileId,
profileName,
profileEmail,
fetchedAt: new Date(),
limitType: weeklyPercent > sessionPercent ? 'weekly' : 'session',
usageWindows: {
@@ -1120,10 +1628,11 @@ export class UsageMonitor extends EventEmitter {
private normalizeZAIResponse(
data: any,
profileId: string,
profileName: string
profileName: string,
profileEmail?: string
): ClaudeUsageSnapshot | null {
// Delegate to shared quota/limit response normalization
return this.normalizeQuotaLimitResponse(data, profileId, profileName, 'zai');
return this.normalizeQuotaLimitResponse(data, profileId, profileName, profileEmail, 'zai');
}
/**
@@ -1137,10 +1646,11 @@ export class UsageMonitor extends EventEmitter {
private normalizeZhipuResponse(
data: any,
profileId: string,
profileName: string
profileName: string,
profileEmail?: string
): ClaudeUsageSnapshot | null {
// Delegate to shared quota/limit response normalization
return this.normalizeQuotaLimitResponse(data, profileId, profileName, 'zhipu');
return this.normalizeQuotaLimitResponse(data, profileId, profileName, profileEmail, 'zhipu');
}
/**
@@ -1172,13 +1682,61 @@ export class UsageMonitor extends EventEmitter {
additionalExclusions: string[] = []
): Promise<void> {
const profileManager = getClaudeProfileManager();
// Get all profiles to swap to, excluding current and any additional exclusions
const allProfiles = profileManager.getProfilesSortedByAvailability();
const excludeIds = new Set([currentProfileId, ...additionalExclusions]);
const eligibleProfiles = allProfiles.filter(p => !excludeIds.has(p.id));
if (eligibleProfiles.length === 0) {
// Get priority order for unified account system
const priorityOrder = profileManager.getAccountPriorityOrder();
// Build unified list of available accounts
type UnifiedSwapTarget = {
id: string;
unifiedId: string; // oauth-{id} or api-{id}
name: string;
type: 'oauth' | 'api';
priorityIndex: number;
};
const unifiedAccounts: UnifiedSwapTarget[] = [];
// Add OAuth profiles (sorted by availability)
const oauthProfiles = profileManager.getProfilesSortedByAvailability();
for (const profile of oauthProfiles) {
if (!excludeIds.has(profile.id)) {
const unifiedId = `oauth-${profile.id}`;
const priorityIndex = priorityOrder.indexOf(unifiedId);
unifiedAccounts.push({
id: profile.id,
unifiedId,
name: profile.name,
type: 'oauth',
priorityIndex: priorityIndex === -1 ? Infinity : priorityIndex
});
}
}
// Add API profiles (always considered available since they have unlimited usage)
try {
const profilesFile = await loadProfilesFile();
for (const apiProfile of profilesFile.profiles) {
if (!excludeIds.has(apiProfile.id) && apiProfile.apiKey) {
const unifiedId = `api-${apiProfile.id}`;
const priorityIndex = priorityOrder.indexOf(unifiedId);
unifiedAccounts.push({
id: apiProfile.id,
unifiedId,
name: apiProfile.name,
type: 'api',
priorityIndex: priorityIndex === -1 ? Infinity : priorityIndex
});
}
}
} catch (error) {
if (this.isDebug) {
console.warn('[UsageMonitor] Failed to load API profiles for swap:', error);
}
}
if (unifiedAccounts.length === 0) {
console.warn('[UsageMonitor] No alternative profile for proactive swap (excluded:', Array.from(excludeIds), ')');
this.emit('proactive-swap-failed', {
reason: additionalExclusions.length > 0 ? 'all_alternatives_failed_auth' : 'no_alternative',
@@ -1188,30 +1746,75 @@ export class UsageMonitor extends EventEmitter {
return;
}
// Use the best available from eligible profiles
const bestProfile = eligibleProfiles[0];
// Sort by priority order (lower index = higher priority)
// If no priority order is set, OAuth profiles come first (they were already sorted by availability)
unifiedAccounts.sort((a, b) => {
// If both have priority indices, use them
if (a.priorityIndex !== Infinity || b.priorityIndex !== Infinity) {
return a.priorityIndex - b.priorityIndex;
}
// Otherwise, prefer OAuth profiles (which are sorted by availability)
if (a.type !== b.type) {
return a.type === 'oauth' ? -1 : 1;
}
return 0;
});
// Use the best available from unified accounts
const bestAccount = unifiedAccounts[0];
console.warn('[UsageMonitor] Proactive swap:', {
from: currentProfileId,
to: bestProfile.id,
to: bestAccount.id,
toType: bestAccount.type,
reason: limitType
});
// Switch profile
profileManager.setActiveProfile(bestProfile.id);
// Switch to the new profile
if (bestAccount.type === 'oauth') {
// Switch OAuth profile via profile manager
profileManager.setActiveProfile(bestAccount.id);
} else {
// Switch API profile via profile-manager service
try {
const { setActiveAPIProfile } = await import('../services/profile/profile-manager');
await setActiveAPIProfile(bestAccount.id);
} catch (error) {
console.error('[UsageMonitor] Failed to set active API profile:', error);
return;
}
}
// Get the "from" profile name
let fromProfileName: string | undefined;
const fromOAuthProfile = profileManager.getProfile(currentProfileId);
if (fromOAuthProfile) {
fromProfileName = fromOAuthProfile.name;
} else {
// It might be an API profile
try {
const profilesFile = await loadProfilesFile();
const fromAPIProfile = profilesFile.profiles.find(p => p.id === currentProfileId);
if (fromAPIProfile) {
fromProfileName = fromAPIProfile.name;
}
} catch {
// Ignore
}
}
// Emit swap event
this.emit('proactive-swap-completed', {
fromProfile: { id: currentProfileId, name: profileManager.getProfile(currentProfileId)?.name },
toProfile: { id: bestProfile.id, name: bestProfile.name },
fromProfile: { id: currentProfileId, name: fromProfileName },
toProfile: { id: bestAccount.id, name: bestAccount.name },
limitType,
timestamp: new Date()
});
// Notify UI
this.emit('show-swap-notification', {
fromProfile: profileManager.getProfile(currentProfileId)?.name,
toProfile: bestProfile.name,
fromProfile: fromProfileName,
toProfile: bestAccount.name,
reason: 'proactive',
limitType
});
@@ -22,6 +22,8 @@ import { readSettingsFile, writeSettingsFile } from '../settings-utils';
import { isSecurePath } from '../utils/windows-paths';
import { getClaudeProfileManager } from '../claude-profile-manager';
import { isValidConfigDir } from '../utils/config-path-validator';
import { clearKeychainCache } from '../claude-profile/credential-utils';
import { getUsageMonitor } from '../claude-profile/usage-monitor';
import semver from 'semver';
const execFileAsync = promisify(execFile);
@@ -1310,7 +1312,13 @@ export function registerClaudeCodeHandlers(): void {
}
}
// If authenticated, update the profile with the email and OAuth token
// If authenticated, update the profile with the email
// 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).
// By only storing metadata, we ensure getProfileEnv() uses CLAUDE_CONFIG_DIR,
// which allows Claude CLI's working token refresh mechanism to be used.
// See: docs/LONG_LIVED_AUTH_PLAN.md for full context.
if (result.authenticated) {
profile.isAuthenticated = true;
@@ -1318,18 +1326,21 @@ export function registerClaudeCodeHandlers(): void {
profile.email = result.email;
}
// Save the OAuth token if available (critical for re-authentication)
if (result.oauthAccount?.accessToken) {
console.warn('[Claude Code] Saving OAuth token for profile:', profileId);
profileManager.setProfileToken(
profileId,
result.oauthAccount.accessToken,
result.email
);
} else {
// No OAuth token, just save the email update
profileManager.saveProfile(profile);
}
// Save profile metadata (email, isAuthenticated) but NOT the OAuth token
profileManager.saveProfile(profile);
// CRITICAL: Clear keychain cache for this profile's configDir
// This ensures the new token is read from keychain instead of using a stale cached token
// Without this, UsageMonitor would use the old cached token and show incorrect usage data
clearKeychainCache(expandedConfigDir);
console.warn('[Claude Code] Cleared keychain cache for profile after re-authentication:', profileId);
// CRITICAL: Also clear the UsageMonitor's usage cache for this profile
// This ensures fresh usage data is fetched from the API instead of using stale cached data
// The keychain cache clear alone is not enough - we also need to clear the usage cache
const usageMonitor = getUsageMonitor();
usageMonitor.clearProfileUsageCache(profileId);
console.warn('[Claude Code] Cleared usage cache for profile after re-authentication:', profileId);
// Clean up backup file after successful authentication
if (existsSync(claudeJsonBakPath)) {
@@ -5,6 +5,7 @@ import path from 'path';
import { minimatch } from 'minimatch';
import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
import { execSync, execFileSync, spawn, spawnSync, exec, execFile } from 'child_process';
import { homedir } from 'os';
import { projectStore } from '../../project-store';
import { getConfiguredPythonPath, PythonEnvManager, pythonEnvManager as pythonEnvManagerSingleton } from '../../python-env-manager';
import { getEffectiveSourcePath } from '../../updater/path-resolver';
@@ -951,7 +952,7 @@ async function detectLinuxApps(): Promise<Set<string>> {
const desktopDirs = [
'/usr/share/applications',
'/usr/local/share/applications',
`${process.env.HOME}/.local/share/applications`,
`${homedir()}/.local/share/applications`,
'/var/lib/flatpak/exports/share/applications',
'/var/lib/snapd/desktop/applications'
];
@@ -1021,7 +1022,7 @@ function isAppInstalled(
for (const checkPath of specificPaths) {
const expandedPath = checkPath
.replace('%USERNAME%', process.env.USERNAME || process.env.USER || '')
.replace('~', process.env.HOME || '');
.replace('~', homedir());
// Validate path doesn't contain traversal attempts after expansion
if (!isPathSafe(expandedPath)) {
@@ -1,7 +1,7 @@
import { ipcMain } from 'electron';
import type { BrowserWindow } from 'electron';
import { IPC_CHANNELS } from '../../shared/constants';
import type { IPCResult, TerminalCreateOptions, ClaudeProfile, ClaudeProfileSettings, ClaudeUsageSnapshot } from '../../shared/types';
import type { IPCResult, TerminalCreateOptions, ClaudeProfile, ClaudeProfileSettings, ClaudeUsageSnapshot, AllProfilesUsage } from '../../shared/types';
import { getClaudeProfileManager } from '../claude-profile-manager';
import { getUsageMonitor } from '../claude-profile/usage-monitor';
import { TerminalManager } from '../terminal-manager';
@@ -10,7 +10,7 @@ import { terminalNameGenerator } from '../terminal-name-generator';
import { readSettingsFileAsync } from '../settings-utils';
import { debugLog, debugError } from '../../shared/utils/debug-logger';
import { migrateSession } from '../claude-profile/session-utils';
import { DEFAULT_CLAUDE_CONFIG_DIR } from '../claude-profile/profile-utils';
import { DEFAULT_CLAUDE_CONFIG_DIR, createProfileDirectory } from '../claude-profile/profile-utils';
import { isValidConfigDir } from '../utils/config-path-validator';
@@ -142,9 +142,17 @@ export function registerTerminalHandlers(
profile.id = profileManager.generateProfileId(profile.name);
}
// Security: Validate configDir path to prevent path traversal attacks
// Only validate non-default profiles with custom configDir
if (!profile.isDefault && profile.configDir) {
// For non-default profiles, ensure configDir is ALWAYS set
// This is critical for the CLAUDE_CONFIG_DIR-based auth flow
// See: docs/LONG_LIVED_AUTH_PLAN.md for context
if (!profile.isDefault) {
if (!profile.configDir) {
// Auto-create a configDir in ~/.claude-profiles/{profile-name}/
console.warn('[CLAUDE_PROFILE_SAVE] Profile missing configDir, creating one:', profile.name);
profile.configDir = await createProfileDirectory(profile.name);
}
// Security: Validate configDir path to prevent path traversal attacks
if (!isValidConfigDir(profile.configDir)) {
return {
success: false,
@@ -152,7 +160,7 @@ export function registerTerminalHandlers(
};
}
// Ensure config directory exists for non-default profiles
// Ensure config directory exists
const { mkdirSync, existsSync } = await import('fs');
if (!existsSync(profile.configDir)) {
mkdirSync(profile.configDir, { recursive: true });
@@ -395,6 +403,40 @@ export function registerTerminalHandlers(
}
);
// Get account priority order
ipcMain.handle(
IPC_CHANNELS.ACCOUNT_PRIORITY_GET,
async (): Promise<IPCResult<string[]>> => {
try {
const profileManager = getClaudeProfileManager();
const order = profileManager.getAccountPriorityOrder();
return { success: true, data: order };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get account priority order'
};
}
}
);
// Set account priority order
ipcMain.handle(
IPC_CHANNELS.ACCOUNT_PRIORITY_SET,
async (_, order: string[]): Promise<IPCResult> => {
try {
const profileManager = getClaudeProfileManager();
profileManager.setAccountPriorityOrder(order);
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to set account priority order'
};
}
}
);
// Fetch usage by sending /usage command to terminal
ipcMain.handle(
IPC_CHANNELS.CLAUDE_PROFILE_FETCH_USAGE,
@@ -498,6 +540,23 @@ export function registerTerminalHandlers(
}
);
// Request all profiles usage immediately (for startup/refresh)
ipcMain.handle(
IPC_CHANNELS.ALL_PROFILES_USAGE_REQUEST,
async (): Promise<IPCResult<AllProfilesUsage | null>> => {
try {
const monitor = getUsageMonitor();
const allProfilesUsage = await monitor.getAllProfilesUsage();
return { success: true, data: allProfilesUsage };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get all profiles usage'
};
}
}
);
// Terminal session management (persistence/restore)
ipcMain.handle(
@@ -676,6 +735,11 @@ export function initializeUsageMonitorForwarding(mainWindow: BrowserWindow): voi
mainWindow.webContents.send(IPC_CHANNELS.USAGE_UPDATED, usage);
});
// Forward all profiles usage updates to renderer (for multi-profile display)
monitor.on('all-profiles-usage-updated', (allProfilesUsage: AllProfilesUsage) => {
mainWindow.webContents.send(IPC_CHANNELS.ALL_PROFILES_USAGE_UPDATED, allProfilesUsage);
});
// Forward proactive swap notifications to renderer
monitor.on('show-swap-notification', (notification: unknown) => {
mainWindow.webContents.send(IPC_CHANNELS.PROACTIVE_SWAP_NOTIFICATION, notification);
+18 -52
View File
@@ -254,63 +254,29 @@ export function isAuthFailureError(output: string): boolean {
/**
* Get environment variables for a specific Claude profile.
* Uses OAuth token (CLAUDE_CODE_OAUTH_TOKEN) if available, otherwise falls back to CLAUDE_CONFIG_DIR.
* OAuth tokens are preferred as they provide instant, reliable profile switching.
* Note: Tokens are decrypted automatically by the profile manager.
*
* IMPORTANT: Always uses CLAUDE_CONFIG_DIR to let Claude CLI read fresh tokens from Keychain.
* We do NOT use cached OAuth tokens (CLAUDE_CODE_OAUTH_TOKEN) because:
* 1. OAuth tokens expire in 8-12 hours
* 2. Claude CLI's token refresh mechanism works (updates Keychain)
* 3. Cached tokens don't benefit from Claude CLI's automatic refresh
*
* By using CLAUDE_CONFIG_DIR, Claude CLI reads fresh tokens from Keychain each time,
* which includes any refreshed tokens. This solves the 401 errors after a few hours.
*
* See: docs/LONG_LIVED_AUTH_PLAN.md for full context.
*
* @param profileId - Optional profile ID. If not provided, uses active profile.
* @returns Environment variables for Claude CLI invocation
*/
export function getProfileEnv(profileId?: string): Record<string, string> {
const profileManager = getClaudeProfileManager();
const profile = profileId
? profileManager.getProfile(profileId)
: profileManager.getActiveProfile();
console.warn('[getProfileEnv] Active profile:', {
profileId: profile?.id,
profileName: profile?.name,
email: profile?.email,
isDefault: profile?.isDefault,
hasOAuthToken: !!profile?.oauthToken,
configDir: profile?.configDir
});
if (!profile) {
console.warn('[getProfileEnv] No profile found, using defaults');
return {};
// Delegate to profile manager's implementation to avoid code duplication
if (profileId) {
return profileManager.getProfileEnv(profileId);
}
// Prefer OAuth token (instant switching, no browser auth needed)
// Use profile manager to get decrypted token
if (profile.oauthToken) {
const decryptedToken = profileId
? profileManager.getProfileToken(profileId)
: profileManager.getActiveProfileToken();
if (decryptedToken) {
console.warn('[getProfileEnv] Using OAuth token for profile:', profile.name);
return {
CLAUDE_CODE_OAUTH_TOKEN: decryptedToken
};
} else {
console.warn('[getProfileEnv] Failed to decrypt token for profile:', profile.name);
}
}
// Fallback: If default profile, no env vars needed
if (profile.isDefault) {
console.warn('[getProfileEnv] Using default profile (no env vars)');
return {};
}
// Fallback: Use configDir for profiles without OAuth token (legacy)
if (profile.configDir) {
console.warn('[getProfileEnv] Using configDir for profile:', profile.name);
return {
CLAUDE_CONFIG_DIR: profile.configDir
};
}
console.warn('[getProfileEnv] Profile has no auth method configured');
return {};
return profileManager.getActiveProfileEnv();
}
/**
@@ -216,6 +216,29 @@ export async function withProfilesLock<T>(fn: () => Promise<T>): Promise<T> {
}
}
/**
* Set the active API profile by ID
* This atomically updates the activeProfileId in profiles.json
*
* @param profileId - The profile ID to set as active, or null to clear
* @returns The updated ProfilesFile
*/
export async function setActiveAPIProfile(profileId: string | null): Promise<ProfilesFile> {
return await atomicModifyProfiles((file) => {
// Validate that the profile exists if setting an ID
if (profileId !== null) {
const profile = file.profiles.find(p => p.id === profileId);
if (!profile) {
throw new Error(`API profile not found: ${profileId}`);
}
}
return {
...file,
activeProfileId: profileId
};
});
}
/**
* Atomically modify the profiles file
* Loads, modifies, and saves the file within an exclusive lock
@@ -253,5 +253,58 @@ describe('output-parser', () => {
// The pattern includes space after "as" to match Claude CLI output
expect(extractEmail('Authenticated as user@example.com')).toBe('user@example.com');
});
it('extracts email from "user@example.com\'s Organization" format', () => {
expect(extractEmail("andre@mikalsenai.no's Organization")).toBe('andre@mikalsenai.no');
expect(extractEmail("user@example.com's Organization")).toBe('user@example.com');
});
describe('ANSI escape code handling', () => {
it('strips basic CSI color codes from email', () => {
// Email with bold formatting: \x1b[1m starts bold, \x1b[0m resets
const withBold = "\x1b[1mandre\x1b[0m@mikalsenai.no's Organization";
expect(extractEmail(withBold)).toBe('andre@mikalsenai.no');
});
it('strips OSC 8 hyperlink sequences from email', () => {
// Modern terminals wrap emails in OSC 8 hyperlinks
// Format: \x1b]8;;url\x07visible_text\x1b]8;;\x07
const withHyperlink = "\x1b]8;;mailto:andre@mikalsenai.no\x07andre@mikalsenai.no\x1b]8;;\x07's Organization";
expect(extractEmail(withHyperlink)).toBe('andre@mikalsenai.no');
});
it('strips mixed ANSI codes (CSI + OSC)', () => {
// Combination of color codes and hyperlinks
const mixed = "\x1b[1m\x1b]8;;mailto:andre@mikalsenai.no\x07andre@mikalsenai.no\x1b]8;;\x07\x1b[0m's Organization";
expect(extractEmail(mixed)).toBe('andre@mikalsenai.no');
});
it('strips OSC window title sequences', () => {
// \x1b]0;title\x07 sets window title
const withTitle = "\x1b]0;Claude Code\x07andre@mikalsenai.no's Organization";
expect(extractEmail(withTitle)).toBe('andre@mikalsenai.no');
});
it('strips 256-color and true-color codes', () => {
// 256-color: \x1b[38;5;123m
// True-color: \x1b[38;2;255;128;0m
const with256Color = "\x1b[38;5;123mandre\x1b[0m@mikalsenai.no's Organization";
const withTrueColor = "\x1b[38;2;255;128;0mandre\x1b[0m@mikalsenai.no's Organization";
expect(extractEmail(with256Color)).toBe('andre@mikalsenai.no');
expect(extractEmail(withTrueColor)).toBe('andre@mikalsenai.no');
});
it('strips private mode CSI sequences', () => {
// \x1b[?25h shows cursor, \x1b[?25l hides cursor
const withPrivateMode = "\x1b[?25handre@mikalsenai.no\x1b[?25l's Organization";
expect(extractEmail(withPrivateMode)).toBe('andre@mikalsenai.no');
});
it('handles email with formatting inside the address', () => {
// Edge case: color code appears INSIDE the email address
const colorInsideEmail = "andr\x1b[1me\x1b[0m@mikalsenai.no's Organization";
expect(extractEmail(colorInsideEmail)).toBe('andre@mikalsenai.no');
});
});
});
});
@@ -10,7 +10,8 @@ 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/keychain-utils';
import { getCredentialsFromKeychain, clearKeychainCache } from '../claude-profile/credential-utils';
import { getEmailFromConfigDir } from '../claude-profile/profile-utils';
import * as OutputParser from './output-parser';
import * as SessionHandler from './session-handler';
import * as PtyManager from './pty-manager';
@@ -495,34 +496,54 @@ export function handleOAuthToken(
}
if (keychainCreds.token) {
// Store the token in our profile store
const success = profileManager.setProfileToken(
profileId,
keychainCreds.token,
emailFromOutput || keychainCreds.email || undefined
);
// NOTE: We intentionally do NOT store the OAuth token in the profile.
// Storing causes AutoClaude to use a stale cached token instead of letting
// Claude CLI read fresh tokens from Keychain (which auto-refreshes).
// See: docs/LONG_LIVED_AUTH_PLAN.md for full context.
if (success) {
console.warn('[ClaudeIntegration] OAuth token extracted from Keychain and saved to profile:', profileId);
// Get email from multiple sources, preferring config file as the authoritative source
// Terminal output parsing can be corrupted by ANSI escape codes
let email = emailFromOutput || keychainCreds.email;
// Set flag to watch for Claude's ready state (onboarding complete)
terminal.awaitingOnboardingComplete = true;
const win = getWindow();
if (win) {
// needsOnboarding: true tells the UI to show "complete setup" message
// instead of "success" - user should finish Claude's onboarding before closing
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
terminalId: terminal.id,
profileId,
email: emailFromOutput || keychainCreds.email || profile?.email,
success: true,
needsOnboarding: true,
detectedAt: new Date().toISOString()
} as OAuthTokenEvent);
// Fallback/validation: Read from Claude's config file (authoritative source)
const configEmail = getEmailFromConfigDir(profile.configDir);
if (configEmail) {
if (!email) {
console.warn('[ClaudeIntegration] Email not found in output/keychain, using config file:', maskEmail(configEmail));
email = configEmail;
} else if (configEmail !== email) {
// Config file email is different (terminal extraction might be corrupt)
console.warn('[ClaudeIntegration] Email from output differs from config file, using config file:', {
outputEmail: maskEmail(email),
configEmail: maskEmail(configEmail)
});
email = configEmail;
}
} else {
console.error('[ClaudeIntegration] Failed to save Keychain token to profile:', profileId);
}
if (email) {
profile.email = email;
}
profile.isAuthenticated = true;
profileManager.saveProfile(profile);
console.warn('[ClaudeIntegration] Profile credentials verified via Keychain (not caching token):', profileId);
// Set flag to watch for Claude's ready state (onboarding complete)
terminal.awaitingOnboardingComplete = true;
const win = getWindow();
if (win) {
// needsOnboarding: true tells the UI to show "complete setup" message
// instead of "success" - user should finish Claude's onboarding before closing
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
terminalId: terminal.id,
profileId,
email: emailFromOutput || keychainCreds.email || profile?.email,
success: true,
needsOnboarding: true,
detectedAt: new Date().toISOString()
} as OAuthTokenEvent);
}
} else {
// Token not in Keychain yet, but profile may still be authenticated via configDir
@@ -563,18 +584,38 @@ export function handleOAuthToken(
console.warn('[ClaudeIntegration] OAuth token detected in output');
const email = OutputParser.extractEmail(terminal.outputBuffer);
let email = OutputParser.extractEmail(terminal.outputBuffer);
if (profileId) {
// Save to specific profile (profile login terminal)
// Update profile metadata (but NOT the token - see docs/LONG_LIVED_AUTH_PLAN.md)
const profileManager = getClaudeProfileManager();
const profile = profileManager.getProfile(profileId);
const success = profileManager.setProfileToken(profileId, token, email || undefined);
if (success) {
if (profile) {
// Fallback/validation: Read email from Claude's config file (authoritative source)
const configEmail = getEmailFromConfigDir(profile.configDir);
if (configEmail) {
if (!email) {
console.warn('[ClaudeIntegration] Email not found in output, using config file:', maskEmail(configEmail));
email = configEmail;
} else if (configEmail !== email) {
console.warn('[ClaudeIntegration] Email from output differs from config file, using config file:', {
outputEmail: maskEmail(email),
configEmail: maskEmail(configEmail)
});
email = configEmail;
}
}
if (email) {
profile.email = email;
}
profile.isAuthenticated = true;
profileManager.saveProfile(profile);
// Clear keychain cache so next getCredentialsFromKeychain() fetches fresh token
clearKeychainCache(profile?.configDir);
console.warn('[ClaudeIntegration] OAuth token auto-saved to profile:', profileId);
clearKeychainCache(profile.configDir);
console.warn('[ClaudeIntegration] Profile credentials verified (not caching token):', profileId);
const win = getWindow();
if (win) {
@@ -587,17 +628,18 @@ export function handleOAuthToken(
} as OAuthTokenEvent);
}
} else {
console.error('[ClaudeIntegration] Failed to save OAuth token to profile:', profileId);
console.error('[ClaudeIntegration] Profile not found for OAuth token:', profileId);
}
} else {
// No profile-specific terminal, save to active profile (GitHub OAuth flow, etc.)
console.warn('[ClaudeIntegration] OAuth token detected in non-profile terminal, saving to active profile');
// No profile-specific terminal, update active profile metadata (GitHub OAuth flow, etc.)
// NOTE: We do NOT store the token - see docs/LONG_LIVED_AUTH_PLAN.md
console.warn('[ClaudeIntegration] OAuth token detected in non-profile terminal, updating active profile metadata');
const profileManager = getClaudeProfileManager();
const activeProfile = profileManager.getActiveProfile();
// Defensive null check for active profile
if (!activeProfile) {
console.error('[ClaudeIntegration] Failed to save OAuth token: no active profile found');
console.error('[ClaudeIntegration] Failed to update profile: no active profile found');
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
@@ -612,37 +654,41 @@ export function handleOAuthToken(
return;
}
const success = profileManager.setProfileToken(activeProfile.id, token, email || undefined);
if (success) {
// Clear keychain cache so next getCredentialsFromKeychain() fetches fresh token
clearKeychainCache(activeProfile.configDir);
console.warn('[ClaudeIntegration] OAuth token auto-saved to active profile:', activeProfile.name);
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
terminalId: terminal.id,
profileId: activeProfile.id,
email,
success: true,
detectedAt: new Date().toISOString()
} as OAuthTokenEvent);
}
} else {
console.error('[ClaudeIntegration] Failed to save OAuth token to active profile:', activeProfile.name);
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
terminalId: terminal.id,
profileId: activeProfile?.id,
email,
success: false,
message: 'Failed to save token to active profile',
detectedAt: new Date().toISOString()
} as OAuthTokenEvent);
// Fallback/validation: Read email from Claude's config file (authoritative source)
const configEmail = getEmailFromConfigDir(activeProfile.configDir);
if (configEmail) {
if (!email) {
console.warn('[ClaudeIntegration] Email not found in output, using config file:', maskEmail(configEmail));
email = configEmail;
} else if (configEmail !== email) {
console.warn('[ClaudeIntegration] Email from output differs from config file, using config file:', {
outputEmail: maskEmail(email),
configEmail: maskEmail(configEmail)
});
email = configEmail;
}
}
if (email) {
activeProfile.email = email;
}
activeProfile.isAuthenticated = true;
profileManager.saveProfile(activeProfile);
// Clear keychain cache so next getCredentialsFromKeychain() fetches fresh token
clearKeychainCache(activeProfile.configDir);
console.warn('[ClaudeIntegration] Active profile credentials verified (not caching token):', activeProfile.name);
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
terminalId: terminal.id,
profileId: activeProfile.id,
email,
success: true,
detectedAt: new Date().toISOString()
} as OAuthTokenEvent);
}
}
}
@@ -679,57 +725,54 @@ export function handleOnboardingComplete(
const profileId = extractProfileIdFromAuthTerminalId(terminal.id) || undefined;
// Try to extract email from the welcome screen (e.g., "user@example.com's Organization")
// Strip ANSI escape codes first since terminal output contains formatting
// eslint-disable-next-line no-control-regex
const stripAnsi = (str: string) => str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
const cleanData = stripAnsi(data);
const cleanBuffer = stripAnsi(terminal.outputBuffer);
let email = OutputParser.extractEmail(cleanData);
// Note: extractEmail automatically strips ANSI escape codes internally
let email = OutputParser.extractEmail(data);
if (!email) {
email = OutputParser.extractEmail(cleanBuffer);
email = OutputParser.extractEmail(terminal.outputBuffer);
}
// Debug: Test each pattern individually to see what matches
const emailPatternTests = [
{ name: 'Authenticated/Logged in', pattern: /(?:Authenticated as |Logged in as |email[:\s]+)([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i },
{ name: "email's Organization", pattern: /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})['\u2019]s\s*Organization/i },
{ name: 'Claude Max · email', pattern: /Claude\s+(?:Max|Pro|Team|Enterprise)\s*[·•]\s*([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i },
{ name: "email's (broad)", pattern: /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})['\u2019]s/i },
{ name: 'any email', pattern: /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i },
];
// Fallback: If terminal extraction failed or might be corrupt, read directly from Claude's config file
// This is the authoritative source and doesn't suffer from ANSI escape code issues
const profileManager = getClaudeProfileManager();
const profile = profileId ? profileManager.getProfile(profileId) : null;
const patternResults = emailPatternTests.map(({ name, pattern }) => {
const match = cleanBuffer.match(pattern);
return { name, matched: !!match, email: match?.[1] || null };
});
if (!email && profile?.configDir) {
const configEmail = getEmailFromConfigDir(profile.configDir);
if (configEmail) {
console.warn('[ClaudeIntegration] Email not found in terminal output, using config file:', maskEmail(configEmail));
email = configEmail;
}
}
// Redact PII from pattern results for logging
const redactedPatternResults = patternResults.map(({ name, matched, email: foundEmail }) => ({
name,
matched,
email: maskEmail(foundEmail)
}));
// Validate email looks correct (basic sanity check)
// If terminal extraction gave us a truncated email but config file has the correct one, prefer config
if (email && profile?.configDir) {
const configEmail = getEmailFromConfigDir(profile.configDir);
if (configEmail && configEmail !== email) {
// Config file email is different - it's more authoritative
console.warn('[ClaudeIntegration] Terminal email differs from config file, using config file:', {
terminalEmail: maskEmail(email),
configEmail: maskEmail(configEmail)
});
email = configEmail;
}
}
console.warn('[ClaudeIntegration] Email extraction attempt:', {
profileId,
foundEmail: maskEmail(email),
dataLength: data.length,
bufferLength: terminal.outputBuffer.length,
cleanBufferLength: cleanBuffer.length,
patternResults: redactedPatternResults
bufferLength: terminal.outputBuffer.length
});
// Update profile with email if found and profile exists
if (profileId && email) {
const profileManager = getClaudeProfileManager();
const profile = profileManager.getProfile(profileId);
if (profile && !profile.email) {
// Update the profile with the email
profile.email = email;
profileManager.saveProfile(profile);
console.warn('[ClaudeIntegration] Updated profile email from welcome screen:', profileId, maskEmail(email));
// 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;
profileManager.saveProfile(profile);
if (previousEmail !== email) {
console.warn('[ClaudeIntegration] Updated profile email from welcome screen:', profileId, maskEmail(email), '(was:', maskEmail(previousEmail), ')');
}
}
@@ -99,13 +99,66 @@ export function hasOAuthUrl(data: string): boolean {
return OAUTH_URL_PATTERN.test(data);
}
/**
* Strip ANSI escape codes from a string
*
* Handles comprehensive escape sequences including:
* - CSI sequences: \x1b[...X (colors, cursor movement, SGR, etc.)
* - OSC sequences: \x1b]...BEL or \x1b]...ST (hyperlinks, window title, etc.)
* - OSC 8 hyperlinks wrap text like: \x1b]8;;url\x07text\x1b]8;;\x07
* - These are used by modern terminals to make emails/URLs clickable
* - DCS sequences: \x1bP...ST (device control)
* - Other single-character escape sequences
*
* This comprehensive stripping is critical for email extraction because terminals
* often wrap emails in OSC 8 hyperlink sequences, which would otherwise corrupt
* the email address during regex matching.
*/
// eslint-disable-next-line no-control-regex
const ANSI_ESCAPE_PATTERNS = [
// CSI sequences: \x1b[ followed by optional private mode indicator (?, >, !),
// then parameters (numbers and semicolons), then a command letter
// Examples: \x1b[0m (reset), \x1b[1;32m (bold green), \x1b[?25h (show cursor)
/\x1b\[[?!>]?[0-9;]*[a-zA-Z]/g,
// OSC sequences: \x1b] followed by content, terminated by BEL (\x07) or ST (\x1b\\)
// Examples: \x1b]0;title\x07 (set window title), \x1b]8;;url\x07 (hyperlink)
// The [^\x07]* matches any chars except BEL, allowing nested content
/\x1b\][^\x07]*(?:\x07|\x1b\\)/g,
// DCS sequences: \x1bP followed by content, terminated by ST (\x1b\\)
// Used for device control strings (less common but should be handled)
/\x1bP[^\x1b]*\x1b\\/g,
// Single-character escapes: \x1b followed by specific characters
// Examples: \x1b= (keypad mode), \x1b> (normal keypad), \x1bM (reverse index)
/\x1b[=>ABCDEFGHIJKLMNOPQRSTUVWXYZ\\^_`abcdefghijklmnopqrstuvwxyz{|}~]/g,
// APC, PM, SOS sequences (Application Program Command, Privacy Message, Start of String)
// Format: \x1b_ or \x1b^ or \x1bX followed by content, terminated by ST
/\x1b[_X^][^\x1b]*\x1b\\/g,
];
function stripAnsi(str: string): string {
let result = str;
for (const pattern of ANSI_ESCAPE_PATTERNS) {
result = result.replace(pattern, '');
}
return result;
}
/**
* Extract email from output
* Tries multiple patterns to handle different output formats
* Automatically strips ANSI escape codes before matching
*/
export function extractEmail(data: string): string | null {
// Strip ANSI escape codes - terminal output often contains formatting
// that can break regex matching (e.g., color codes within the email text)
const cleanData = stripAnsi(data);
for (const pattern of EMAIL_PATTERNS) {
const match = data.match(pattern);
const match = cleanData.match(pattern);
if (match && match[1]) {
return match[1];
}
@@ -110,6 +110,8 @@ export interface TerminalAPI {
verifyClaudeProfileAuth: (profileId: string) => Promise<IPCResult<{ authenticated: boolean; email?: string }>>;
getAutoSwitchSettings: () => Promise<IPCResult<import('../../shared/types').ClaudeAutoSwitchSettings>>;
updateAutoSwitchSettings: (settings: Partial<import('../../shared/types').ClaudeAutoSwitchSettings>) => Promise<IPCResult>;
getAccountPriorityOrder: () => Promise<IPCResult<string[]>>;
setAccountPriorityOrder: (order: string[]) => Promise<IPCResult>;
fetchClaudeUsage: (terminalId: string) => Promise<IPCResult>;
getBestAvailableProfile: (excludeProfileId?: string) => Promise<IPCResult<import('../../shared/types').ClaudeProfile | null>>;
onSDKRateLimit: (callback: (info: import('../../shared/types').SDKRateLimitInfo) => void) => () => void;
@@ -118,7 +120,9 @@ export interface TerminalAPI {
// Usage Monitoring (Proactive Account Switching)
requestUsageUpdate: () => Promise<IPCResult<import('../../shared/types').ClaudeUsageSnapshot | null>>;
requestAllProfilesUsage: () => Promise<IPCResult<import('../../shared/types').AllProfilesUsage | null>>;
onUsageUpdated: (callback: (usage: import('../../shared/types').ClaudeUsageSnapshot) => void) => () => void;
onAllProfilesUsageUpdated: (callback: (allProfilesUsage: import('../../shared/types').AllProfilesUsage) => void) => () => void;
onProactiveSwapNotification: (callback: (notification: ProactiveSwapNotification) => void) => () => void;
}
@@ -465,6 +469,12 @@ export const createTerminalAPI = (): TerminalAPI => ({
updateAutoSwitchSettings: (settings: Partial<import('../../shared/types').ClaudeAutoSwitchSettings>): Promise<IPCResult> =>
ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_UPDATE_AUTO_SWITCH, settings),
getAccountPriorityOrder: (): Promise<IPCResult<string[]>> =>
ipcRenderer.invoke(IPC_CHANNELS.ACCOUNT_PRIORITY_GET),
setAccountPriorityOrder: (order: string[]): Promise<IPCResult> =>
ipcRenderer.invoke(IPC_CHANNELS.ACCOUNT_PRIORITY_SET, order),
fetchClaudeUsage: (terminalId: string): Promise<IPCResult> =>
ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_FETCH_USAGE, terminalId),
@@ -508,6 +518,9 @@ export const createTerminalAPI = (): TerminalAPI => ({
requestUsageUpdate: (): Promise<IPCResult<import('../../shared/types').ClaudeUsageSnapshot | null>> =>
ipcRenderer.invoke(IPC_CHANNELS.USAGE_REQUEST),
requestAllProfilesUsage: (): Promise<IPCResult<import('../../shared/types').AllProfilesUsage | null>> =>
ipcRenderer.invoke(IPC_CHANNELS.ALL_PROFILES_USAGE_REQUEST),
onUsageUpdated: (
callback: (usage: import('../../shared/types').ClaudeUsageSnapshot) => void
): (() => void) => {
@@ -523,6 +536,21 @@ export const createTerminalAPI = (): TerminalAPI => ({
};
},
onAllProfilesUsageUpdated: (
callback: (allProfilesUsage: import('../../shared/types').AllProfilesUsage) => void
): (() => void) => {
const handler = (
_event: Electron.IpcRendererEvent,
allProfilesUsage: import('../../shared/types').AllProfilesUsage
): void => {
callback(allProfilesUsage);
};
ipcRenderer.on(IPC_CHANNELS.ALL_PROFILES_USAGE_UPDATED, handler);
return () => {
ipcRenderer.removeListener(IPC_CHANNELS.ALL_PROFILES_USAGE_UPDATED, handler);
};
},
onProactiveSwapNotification: (
callback: (notification: ProactiveSwapNotification) => void
): (() => void) => {
+2 -2
View File
@@ -1118,7 +1118,7 @@ export function App() {
{/* Auth Failure Modal - shows when Claude CLI encounters 401/auth errors */}
<AuthFailureModal onOpenSettings={() => {
setSettingsInitialSection('integrations');
setSettingsInitialSection('accounts');
setIsSettingsDialogOpen(true);
}} />
@@ -1128,7 +1128,7 @@ export function App() {
onClose={handleVersionWarningClose}
onOpenSettings={() => {
handleVersionWarningClose();
setSettingsInitialSection('integrations');
setSettingsInitialSection('accounts');
setIsSettingsDialogOpen(true);
}}
/>
@@ -2,11 +2,17 @@
* Usage Indicator - Real-time Claude usage display in header
*
* Displays current session/weekly usage as a badge with color-coded status.
* Shows detailed breakdown on hover.
* - Hover to show breakdown popup (auto-closes on mouse leave)
* - Click to pin popup open (stays until clicking outside)
*/
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Activity, TrendingUp, AlertCircle, Clock, User, ChevronRight, Info } from 'lucide-react';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from './ui/popover';
import {
Tooltip,
TooltipContent,
@@ -15,33 +21,87 @@ import {
} from './ui/tooltip';
import { useTranslation } from 'react-i18next';
import { formatTimeRemaining, localizeUsageWindowLabel, hasHardcodedText } from '../../shared/utils/format-time';
import type { ClaudeUsageSnapshot } from '../../shared/types/agent';
import type { ClaudeUsageSnapshot, ProfileUsageSummary } from '../../shared/types/agent';
import type { AppSection } from './settings/AppSettings';
/**
* Usage threshold constants for color coding
*/
const THRESHOLD_CRITICAL = 95; // Red: At or near limit
const THRESHOLD_WARNING = 91; // Orange: Very high usage
const THRESHOLD_ELEVATED = 71; // Yellow: Moderate usage
// Below 71 is considered normal (green)
/**
* Get color class based on usage percentage
*/
const getColorClass = (percent: number): string => {
if (percent >= THRESHOLD_CRITICAL) return 'text-red-500';
if (percent >= THRESHOLD_WARNING) return 'text-orange-500';
if (percent >= THRESHOLD_ELEVATED) return 'text-yellow-500';
return 'text-green-500';
};
/**
* Get background/border color classes for badges based on usage percentage
*/
const getBadgeColorClasses = (percent: number): string => {
if (percent >= THRESHOLD_CRITICAL) return 'text-red-500 bg-red-500/10 border-red-500/20';
if (percent >= THRESHOLD_WARNING) return 'text-orange-500 bg-orange-500/10 border-orange-500/20';
if (percent >= THRESHOLD_ELEVATED) return 'text-yellow-500 bg-yellow-500/10 border-yellow-500/20';
return 'text-green-500 bg-green-500/10 border-green-500/20';
};
/**
* Get gradient background class based on usage percentage
*/
const getGradientClass = (percent: number): string => {
if (percent >= THRESHOLD_CRITICAL) return 'bg-gradient-to-r from-red-600 to-red-500';
if (percent >= THRESHOLD_WARNING) return 'bg-gradient-to-r from-orange-600 to-orange-500';
if (percent >= THRESHOLD_ELEVATED) return 'bg-gradient-to-r from-yellow-600 to-yellow-500';
return 'bg-gradient-to-r from-green-600 to-green-500';
};
/**
* Get background class for small usage bars based on usage percentage
*/
const getBarColorClass = (percent: number): string => {
if (percent >= THRESHOLD_CRITICAL) return 'bg-red-500';
if (percent >= THRESHOLD_WARNING) return 'bg-orange-500';
if (percent >= THRESHOLD_ELEVATED) return 'bg-yellow-500';
return 'bg-green-500';
};
export function UsageIndicator() {
const { t, i18n } = useTranslation(['common']);
const [usage, setUsage] = useState<ClaudeUsageSnapshot | null>(null);
const [otherProfiles, setOtherProfiles] = useState<ProfileUsageSummary[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isAvailable, setIsAvailable] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [isPinned, setIsPinned] = useState(false);
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
/**
* Helper function to get initials from a profile name
*/
const getInitials = (name: string): string => {
if (!name || name.trim().length === 0) {
return 'UN'; // Unknown
}
const words = name.trim().split(/\s+/);
if (words.length >= 2) {
return (words[0][0] + words[1][0]).toUpperCase();
}
return name.substring(0, 2).toUpperCase();
};
/**
* Helper function to format large numbers with locale-aware compact notation
*
* Returns undefined for null/undefined values. The caller (JSX conditional guards)
* is responsible for checking values before calling this function.
*
* @param value - The number to format (undefined, null, or number)
* @returns Formatted compact number string (e.g., "1.2K", "3.4M"), or undefined if input is null/undefined
*
* @example
* formatUsageValue(1234) // "1.2K" (en-US)
* formatUsageValue(null) // undefined
* formatUsageValue(undefined) // undefined
*/
const formatUsageValue = (value?: number | null): string | undefined => {
if (value == null) return undefined;
// Use Intl.NumberFormat for locale-aware compact number formatting
// Fallback to toString() if Intl is not available
if (typeof Intl !== 'undefined' && Intl.NumberFormat) {
try {
return new Intl.NumberFormat(i18n.language, {
@@ -56,8 +116,172 @@ export function UsageIndicator() {
return value.toString();
};
/**
* Navigate to settings accounts tab
*/
const handleOpenAccounts = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// Close the popover first
setIsOpen(false);
setIsPinned(false);
// Dispatch custom event to open settings with accounts section
// Small delay to allow popover to close first
setTimeout(() => {
const event = new CustomEvent<AppSection>('open-app-settings', {
detail: 'accounts'
});
window.dispatchEvent(event);
}, 100);
}, []);
/**
* Handle swapping to a different profile
* Uses optimistic UI update for immediate feedback, then fetches fresh data
*/
const handleSwapProfile = useCallback(async (e: React.MouseEvent, profileId: string) => {
e.preventDefault();
e.stopPropagation();
// Capture previous state for revert (before any changes)
const previousUsage = usage;
const previousOtherProfiles = otherProfiles;
// Find the profile we're swapping to
const targetProfile = otherProfiles.find(p => p.profileId === profileId);
if (!targetProfile) {
console.error('[UsageIndicator] Target profile not found:', profileId);
return;
}
// Optimistic update: immediately swap profiles in the UI
// 1. Convert current active profile to a ProfileUsageSummary for the "other" list
const currentActiveAsSummary: ProfileUsageSummary = {
profileId: usage?.profileId || '',
profileName: usage?.profileName || '',
profileEmail: usage?.profileEmail,
sessionPercent: usage?.sessionPercent || 0,
weeklyPercent: usage?.weeklyPercent || 0,
sessionResetTimestamp: usage?.sessionResetTimestamp,
weeklyResetTimestamp: usage?.weeklyResetTimestamp,
isAuthenticated: true,
isRateLimited: false,
availabilityScore: 100 - Math.max(usage?.sessionPercent || 0, usage?.weeklyPercent || 0),
isActive: false, // It's no longer active
};
// 2. Convert target profile to a ClaudeUsageSnapshot for the active display
const newActiveUsage: ClaudeUsageSnapshot = {
profileId: targetProfile.profileId,
profileName: targetProfile.profileName,
profileEmail: targetProfile.profileEmail,
sessionPercent: targetProfile.sessionPercent,
weeklyPercent: targetProfile.weeklyPercent,
sessionResetTimestamp: targetProfile.sessionResetTimestamp,
weeklyResetTimestamp: targetProfile.weeklyResetTimestamp,
fetchedAt: new Date(),
};
// 3. Update the other profiles list: remove target, add current active
const newOtherProfiles = otherProfiles
.filter(p => p.profileId !== profileId)
.concat(usage ? [currentActiveAsSummary] : [])
.sort((a, b) => b.availabilityScore - a.availabilityScore);
// Apply optimistic update immediately
setUsage(newActiveUsage);
setOtherProfiles(newOtherProfiles);
try {
// Actually switch the profile on the backend
const result = await window.electronAPI.setActiveClaudeProfile(profileId);
if (result.success) {
// Fetch fresh data in the background (will update via event listeners)
window.electronAPI.requestUsageUpdate();
window.electronAPI.requestAllProfilesUsage?.();
} else {
// Revert to captured previous state
console.error('[UsageIndicator] Failed to swap profile, reverting');
if (previousUsage) setUsage(previousUsage);
setOtherProfiles(previousOtherProfiles);
}
} catch (error) {
console.error('[UsageIndicator] Failed to swap profile:', error);
// Revert to captured previous state
if (previousUsage) setUsage(previousUsage);
setOtherProfiles(previousOtherProfiles);
}
}, [usage, otherProfiles]);
/**
* Handle mouse enter - show popup after short delay (unless pinned)
*/
const handleMouseEnter = useCallback(() => {
if (isPinned) return;
// Clear any pending close timeout
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
// Open after short delay for smoother UX
hoverTimeoutRef.current = setTimeout(() => {
setIsOpen(true);
}, 150);
}, [isPinned]);
/**
* Handle mouse leave - close popup after delay (unless pinned)
*/
const handleMouseLeave = useCallback(() => {
if (isPinned) return;
// Clear any pending open timeout
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
// Close after delay to allow moving to popup content
hoverTimeoutRef.current = setTimeout(() => {
setIsOpen(false);
}, 300);
}, [isPinned]);
/**
* Handle click on trigger - toggle pinned state
*/
const handleTriggerClick = useCallback((e: React.MouseEvent) => {
e.preventDefault();
if (isPinned) {
// Clicking when pinned unpins and closes
setIsPinned(false);
setIsOpen(false);
} else {
// Clicking when not pinned pins it open
setIsPinned(true);
setIsOpen(true);
}
}, [isPinned]);
/**
* Handle popover open change (e.g., clicking outside)
*/
const handleOpenChange = useCallback((open: boolean) => {
if (!open) {
// Closing from outside click
setIsOpen(false);
setIsPinned(false);
}
}, []);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
};
}, []);
// Get formatted reset times (calculated dynamically from timestamps)
// Only fall back to sessionResetTime/weeklyResetTime if they don't contain placeholder/hardcoded text
const sessionResetTime = usage?.sessionResetTimestamp
? (formatTimeRemaining(usage.sessionResetTimestamp, t) ??
(hasHardcodedText(usage?.sessionResetTime) ? undefined : usage?.sessionResetTime))
@@ -75,6 +299,13 @@ export function UsageIndicator() {
setIsLoading(false);
});
// Listen for all profiles usage updates (for multi-profile display)
const unsubscribeAllProfiles = window.electronAPI.onAllProfilesUsageUpdated?.((allProfilesUsage) => {
// Filter out the active profile - we only want to show "other" profiles
const nonActiveProfiles = allProfilesUsage.allProfiles.filter(p => !p.isActive);
setOtherProfiles(nonActiveProfiles);
});
// Request initial usage on mount
window.electronAPI.requestUsageUpdate().then((result) => {
setIsLoading(false);
@@ -82,23 +313,31 @@ export function UsageIndicator() {
setUsage(result.data);
setIsAvailable(true);
} else {
// No usage data available (endpoint not supported or error)
setIsAvailable(false);
}
}).catch((error) => {
// Handle errors (IPC failure, network issues, etc.)
console.warn('[UsageIndicator] Failed to fetch initial usage:', error);
setIsLoading(false);
setIsAvailable(false);
});
// Request all profiles usage immediately on mount (so other accounts show right away)
window.electronAPI.requestAllProfilesUsage?.().then((result) => {
if (result.success && result.data) {
const nonActiveProfiles = result.data.allProfiles.filter(p => !p.isActive);
setOtherProfiles(nonActiveProfiles);
}
}).catch((error) => {
console.warn('[UsageIndicator] Failed to fetch all profiles usage:', error);
});
return () => {
unsubscribe();
unsubscribeAllProfiles?.();
};
}, []);
// Always show the badge, but display different states
// Show loading state initially
// Show loading state
if (isLoading) {
return (
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md border bg-muted/50 text-muted-foreground">
@@ -108,7 +347,7 @@ export function UsageIndicator() {
);
}
// Show unavailable state when endpoint doesn't return data
// Show unavailable state
if (!isAvailable || !usage) {
return (
<TooltipProvider delayDuration={200}>
@@ -132,17 +371,18 @@ export function UsageIndicator() {
);
}
// Determine color based on session usage (5-hour window)
// This is what should be shown on the badge per QA feedback
const badgeUsage = usage.sessionPercent;
const badgeColorClasses =
badgeUsage >= 95 ? 'text-red-500 bg-red-500/10 border-red-500/20' :
badgeUsage >= 91 ? 'text-orange-500 bg-orange-500/10 border-orange-500/20' :
badgeUsage >= 71 ? 'text-yellow-500 bg-yellow-500/10 border-yellow-500/20' :
'text-green-500 bg-green-500/10 border-green-500/20';
// Determine colors and labels based on the LIMITING factor (higher of session/weekly)
const sessionPercent = usage.sessionPercent;
const weeklyPercent = usage.weeklyPercent;
const limitingPercent = Math.max(sessionPercent, weeklyPercent);
// Badge color based on the limiting (higher) percentage
const badgeColorClasses = getBadgeColorClasses(limitingPercent);
// Individual colors for session and weekly in the badge
const sessionColorClass = getColorClass(sessionPercent);
const weeklyColorClass = getColorClass(weeklyPercent);
// Get window labels for display
// Map backend-provided labels to localized versions with appropriate defaults
const sessionLabel = localizeUsageWindowLabel(
usage?.usageWindows?.sessionWindowLabel,
t,
@@ -154,145 +394,253 @@ export function UsageIndicator() {
'common:usage.weeklyDefault'
);
// For icon, use the highest of the two windows
const maxUsage = Math.max(usage.sessionPercent, usage.weeklyPercent);
const Icon =
maxUsage >= 91 ? AlertCircle :
maxUsage >= 71 ? TrendingUp :
maxUsage >= THRESHOLD_WARNING ? AlertCircle :
maxUsage >= THRESHOLD_ELEVATED ? TrendingUp :
Activity;
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<button
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md border transition-all hover:opacity-80 ${badgeColorClasses}`}
aria-label={t('common:usage.usageStatusAriaLabel')}
>
<Icon className="h-3.5 w-3.5" />
<span className="text-xs font-semibold font-mono">
{Math.round(badgeUsage)}%
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<button
className={`flex items-center gap-1 px-2 py-1.5 rounded-md border transition-all hover:opacity-80 ${badgeColorClasses}`}
aria-label={t('common:usage.usageStatusAriaLabel')}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleTriggerClick}
>
<Icon className="h-3.5 w-3.5 flex-shrink-0" />
{/* Dual usage display: Session | Weekly */}
<div className="flex items-center gap-0.5 text-xs font-semibold font-mono">
<span className={sessionColorClass} title={t('common:usage.sessionShort')}>
{Math.round(sessionPercent)}
</span>
<span className="text-muted-foreground/50"></span>
<span className={weeklyColorClass} title={t('common:usage.weeklyShort')}>
{Math.round(weeklyPercent)}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs w-72 p-0">
<div className="p-3 space-y-3">
{/* Header with overall status */}
<div className="flex items-center pb-2 border-b">
<Icon className="h-3.5 w-3.5" />
<span className="font-semibold text-xs">{t('common:usage.usageBreakdown')}</span>
</div>
{/* Session/5-hour usage */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-muted-foreground font-medium text-[11px] flex items-center gap-1">
<Clock className="h-3 w-3" />
{sessionLabel}
</span>
<span className={`font-semibold tabular-nums text-xs ${
usage.sessionPercent >= 95 ? 'text-red-500' :
usage.sessionPercent >= 91 ? 'text-orange-500' :
usage.sessionPercent >= 71 ? 'text-yellow-600' :
'text-green-600'
}`}>
{Math.round(usage.sessionPercent)}%
</span>
</div>
{sessionResetTime && (
<div className="text-[10px] text-muted-foreground pl-4 flex items-center gap-1">
<Info className="h-2.5 w-2.5" />
{sessionResetTime}
</div>
)}
{/* Enhanced progress bar with gradient */}
<div className="h-2 bg-muted rounded-full overflow-hidden shadow-inner">
<div
className={`h-full rounded-full transition-all duration-500 ease-out relative overflow-hidden ${
usage.sessionPercent >= 95 ? 'bg-gradient-to-r from-red-600 to-red-500' :
usage.sessionPercent >= 91 ? 'bg-gradient-to-r from-orange-600 to-orange-500' :
usage.sessionPercent >= 71 ? 'bg-gradient-to-r from-yellow-600 to-yellow-500' :
'bg-gradient-to-r from-green-600 to-green-500'
}`}
style={{ width: `${Math.min(usage.sessionPercent, 100)}%` }}
>
{/* Subtle shine effect */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent motion-safe:animate-pulse" />
</div>
</div>
{/* Raw usage value with better styling */}
{usage.sessionUsageValue != null && usage.sessionUsageLimit != null && (
<div className="flex items-center justify-between text-[10px]">
<span className="text-muted-foreground">{t('common:usage.used')}</span>
<span className="font-medium tabular-nums">
{formatUsageValue(usage.sessionUsageValue)} <span className="text-muted-foreground mx-1">/</span> {formatUsageValue(usage.sessionUsageLimit)}
</span>
</div>
)}
</div>
{/* Weekly/Monthly usage */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-muted-foreground font-medium text-[11px] flex items-center gap-1">
<TrendingUp className="h-3 w-3" />
{weeklyLabel}
</span>
<span className={`font-semibold tabular-nums text-xs ${
usage.weeklyPercent >= 99 ? 'text-red-500' :
usage.weeklyPercent >= 91 ? 'text-orange-500' :
usage.weeklyPercent >= 71 ? 'text-yellow-600' :
'text-green-600'
}`}>
{Math.round(usage.weeklyPercent)}%
</span>
</div>
{weeklyResetTime && (
<div className="text-[10px] text-muted-foreground pl-4 flex items-center gap-1">
<Info className="h-2.5 w-2.5" />
{weeklyResetTime}
</div>
)}
{/* Enhanced progress bar with gradient */}
<div className="h-2 bg-muted rounded-full overflow-hidden shadow-inner">
<div
className={`h-full rounded-full transition-all duration-500 ease-out relative overflow-hidden ${
usage.weeklyPercent >= 99 ? 'bg-gradient-to-r from-red-600 to-red-500' :
usage.weeklyPercent >= 91 ? 'bg-gradient-to-r from-orange-600 to-orange-500' :
usage.weeklyPercent >= 71 ? 'bg-gradient-to-r from-yellow-600 to-yellow-500' :
'bg-gradient-to-r from-green-600 to-green-500'
}`}
style={{ width: `${Math.min(usage.weeklyPercent, 100)}%` }}
>
{/* Subtle shine effect */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent motion-safe:animate-pulse" />
</div>
</div>
{/* Raw usage value with better styling */}
{usage.weeklyUsageValue != null && usage.weeklyUsageLimit != null && (
<div className="flex items-center justify-between text-[10px]">
<span className="text-muted-foreground">{t('common:usage.used')}</span>
<span className="font-medium tabular-nums">
{formatUsageValue(usage.weeklyUsageValue)} <span className="text-muted-foreground mx-1">/</span> {formatUsageValue(usage.weeklyUsageLimit)}
</span>
</div>
)}
</div>
{/* Active account footer */}
<div className="pt-2 border-t flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<User className="h-3 w-3" />
<span>{t('common:usage.activeAccount')}</span>
</div>
<div className="flex items-center gap-1 text-xs font-medium text-primary">
<span>{usage.profileName}</span>
<ChevronRight className="h-3 w-3" />
</div>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="end"
className="text-xs w-72 p-0"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="p-3 space-y-3">
{/* Header with overall status */}
<div className="flex items-center gap-1.5 pb-2 border-b">
<Icon className="h-3.5 w-3.5" />
<span className="font-semibold text-xs">{t('common:usage.usageBreakdown')}</span>
</div>
{/* Session/5-hour usage */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-muted-foreground font-medium text-[11px] flex items-center gap-1">
<Clock className="h-3 w-3" />
{sessionLabel}
</span>
<span className={`font-semibold tabular-nums text-xs ${getColorClass(usage.sessionPercent).replace('500', '600')}`}>
{Math.round(usage.sessionPercent)}%
</span>
</div>
{sessionResetTime && (
<div className="text-[10px] text-muted-foreground pl-4 flex items-center gap-1">
<Info className="h-2.5 w-2.5" />
{sessionResetTime}
</div>
)}
<div className="h-2 bg-muted rounded-full overflow-hidden shadow-inner">
<div
className={`h-full rounded-full transition-all duration-500 ease-out relative overflow-hidden ${getGradientClass(usage.sessionPercent)}`}
style={{ width: `${Math.min(usage.sessionPercent, 100)}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent motion-safe:animate-pulse" />
</div>
</div>
{usage.sessionUsageValue != null && usage.sessionUsageLimit != null && (
<div className="flex items-center justify-between text-[10px]">
<span className="text-muted-foreground">{t('common:usage.used')}</span>
<span className="font-medium tabular-nums">
{formatUsageValue(usage.sessionUsageValue)} <span className="text-muted-foreground mx-1">/</span> {formatUsageValue(usage.sessionUsageLimit)}
</span>
</div>
)}
</div>
{/* Weekly/Monthly usage */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-muted-foreground font-medium text-[11px] flex items-center gap-1">
<TrendingUp className="h-3 w-3" />
{weeklyLabel}
</span>
<span className={`font-semibold tabular-nums text-xs ${getColorClass(usage.weeklyPercent).replace('500', '600')}`}>
{Math.round(usage.weeklyPercent)}%
</span>
</div>
{weeklyResetTime && (
<div className="text-[10px] text-muted-foreground pl-4 flex items-center gap-1">
<Info className="h-2.5 w-2.5" />
{weeklyResetTime}
</div>
)}
<div className="h-2 bg-muted rounded-full overflow-hidden shadow-inner">
<div
className={`h-full rounded-full transition-all duration-500 ease-out relative overflow-hidden ${getGradientClass(usage.weeklyPercent)}`}
style={{ width: `${Math.min(usage.weeklyPercent, 100)}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent motion-safe:animate-pulse" />
</div>
</div>
{usage.weeklyUsageValue != null && usage.weeklyUsageLimit != null && (
<div className="flex items-center justify-between text-[10px]">
<span className="text-muted-foreground">{t('common:usage.used')}</span>
<span className="font-medium tabular-nums">
{formatUsageValue(usage.weeklyUsageValue)} <span className="text-muted-foreground mx-1">/</span> {formatUsageValue(usage.weeklyUsageLimit)}
</span>
</div>
)}
</div>
{/* Active account footer - clickable to go to settings */}
<button
type="button"
onClick={handleOpenAccounts}
className={`w-full pt-3 border-t flex items-center gap-2.5 hover:bg-muted/50 -mx-3 px-3 ${otherProfiles.length === 0 ? '-mb-3 pb-3 rounded-b-md' : 'pb-2'} transition-colors cursor-pointer group`}
>
{/* Initials Avatar */}
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<span className="text-xs font-semibold text-primary">
{getInitials(usage.profileName)}
</span>
</div>
{/* Account Info */}
<div className="flex-1 min-w-0 text-left">
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground font-medium">
{t('common:usage.activeAccount')}
</span>
</div>
<div className="font-medium text-xs text-primary truncate">
{usage.profileEmail || usage.profileName}
</div>
</div>
{/* Chevron */}
<ChevronRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors flex-shrink-0" />
</button>
{/* Other profiles section - sorted by availability */}
{otherProfiles.length > 0 && (
<div className="pt-2 -mx-3 px-3 -mb-3 pb-3 space-y-1">
<div className="text-[10px] text-muted-foreground font-medium mb-1.5">
{t('common:usage.otherAccounts')}
</div>
{otherProfiles.map((profile, index) => (
<div
key={profile.profileId}
className="flex items-center gap-2 py-1.5 px-1 rounded hover:bg-muted/30 transition-colors"
>
{/* Initials Avatar with status indicator */}
<div className="relative">
<div className={`w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 ${
profile.isRateLimited
? 'bg-red-500/10'
: !profile.isAuthenticated
? 'bg-muted'
: 'bg-muted/80'
}`}>
<span className={`text-[10px] font-semibold ${
profile.isRateLimited
? 'text-red-500'
: !profile.isAuthenticated
? 'text-muted-foreground'
: 'text-foreground/70'
}`}>
{getInitials(profile.profileName)}
</span>
</div>
{/* Status dot */}
{profile.isRateLimited && (
<div className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-red-500 rounded-full border-2 border-background" />
)}
</div>
{/* Profile Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-medium truncate">
{profile.profileEmail || profile.profileName}
</span>
{index === 0 && !profile.isRateLimited && profile.isAuthenticated && (
<span className="text-[9px] px-1.5 py-0.5 bg-primary/10 text-primary rounded font-semibold">
{t('common:usage.next')}
</span>
)}
{/* Swap button - only show for authenticated profiles */}
{profile.isAuthenticated && (
<button
onClick={(e) => handleSwapProfile(e, profile.profileId)}
className="text-[9px] px-1.5 py-0.5 bg-muted hover:bg-muted/80 text-muted-foreground hover:text-foreground rounded transition-colors ml-auto"
>
{t('common:usage.swap')}
</button>
)}
</div>
{/* Usage bars or status - show both session and weekly */}
{profile.isRateLimited ? (
<span className="text-[9px] text-red-500">
{profile.rateLimitType === 'weekly'
? t('common:usage.weeklyLimitReached')
: t('common:usage.sessionLimitReached')}
</span>
) : !profile.isAuthenticated ? (
<span className="text-[9px] text-muted-foreground">
{t('common:usage.notAuthenticated')}
</span>
) : (
<div className="flex items-center gap-2 mt-0.5">
{/* Session usage (short-term) */}
<div className="flex items-center gap-1">
<Clock className="h-2.5 w-2.5 text-muted-foreground/70" />
<div className="w-10 h-1 bg-muted rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${getBarColorClass(profile.sessionPercent)}`}
style={{ width: `${Math.min(profile.sessionPercent, 100)}%` }}
/>
</div>
<span className={`text-[9px] tabular-nums w-6 ${getColorClass(profile.sessionPercent).replace('text-green-500', 'text-muted-foreground').replace('500', '600')}`}>
{Math.round(profile.sessionPercent)}%
</span>
</div>
{/* Weekly usage (long-term) */}
<div className="flex items-center gap-1">
<TrendingUp className="h-2.5 w-2.5 text-muted-foreground/70" />
<div className="w-10 h-1 bg-muted rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${getBarColorClass(profile.weeklyPercent)}`}
style={{ width: `${Math.min(profile.weeklyPercent, 100)}%` }}
/>
</div>
<span className={`text-[9px] tabular-nums w-6 ${getColorClass(profile.weeklyPercent).replace('text-green-500', 'text-muted-foreground').replace('500', '600')}`}>
{Math.round(profile.weeklyPercent)}%
</span>
</div>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,457 @@
/**
* AccountPriorityList - Unified drag-and-drop priority list with usage visualization
*
* Displays ALL accounts in a single, unified priority list. Position determines
* fallback order - the system uses accounts from top to bottom.
*
* Supports all user scenarios:
* - OAuth accounts as primary with API fallback
* - API endpoints as primary with OAuth fallback
* - Any mix of providers in any order
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
GripVertical,
Star,
Tag,
Infinity,
AlertCircle,
Users,
Server,
Clock,
TrendingUp,
Info
} from 'lucide-react';
import { cn } from '../../lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
/**
* Usage threshold constants for color coding (matching UsageIndicator)
*/
const THRESHOLD_CRITICAL = 95; // Red: At or near limit
const THRESHOLD_WARNING = 91; // Orange: Very high usage
const THRESHOLD_ELEVATED = 71; // Yellow: Moderate usage
/**
* Get color class based on usage percentage
*/
const getColorClass = (percent: number): string => {
if (percent >= THRESHOLD_CRITICAL) return 'text-red-500';
if (percent >= THRESHOLD_WARNING) return 'text-orange-500';
if (percent >= THRESHOLD_ELEVATED) return 'text-yellow-500';
return 'text-green-500';
};
/**
* Get background class for progress bars
*/
const getBarColorClass = (percent: number): string => {
if (percent >= THRESHOLD_CRITICAL) return 'bg-red-500';
if (percent >= THRESHOLD_WARNING) return 'bg-orange-500';
if (percent >= THRESHOLD_ELEVATED) return 'bg-yellow-500';
return 'bg-green-500';
};
/**
* Get status label key based on usage
*/
const getStatusKey = (sessionPercent?: number, weeklyPercent?: number, isRateLimited?: boolean): string => {
if (isRateLimited) return 'rateLimited';
const maxPercent = Math.max(sessionPercent ?? 0, weeklyPercent ?? 0);
if (maxPercent >= THRESHOLD_CRITICAL) return 'nearLimit';
if (maxPercent >= THRESHOLD_WARNING) return 'highUsage';
if (maxPercent >= THRESHOLD_ELEVATED) return 'moderate';
return 'healthy';
};
/**
* Unified account representation for the priority list
*/
export interface UnifiedAccount {
id: string;
name: string;
type: 'oauth' | 'api';
displayName: string;
identifier: string; // email for OAuth, baseUrl for API
isActive: boolean; // TRUE only for the ONE account currently in use
isNext: boolean;
isAvailable: boolean;
hasUnlimitedUsage: boolean;
sessionPercent?: number;
weeklyPercent?: number;
isRateLimited?: boolean;
rateLimitType?: 'session' | 'weekly';
isAuthenticated?: boolean;
/** Set when this account has identical usage to another - may indicate same underlying account */
isDuplicateUsage?: boolean;
}
interface SortableAccountItemProps {
account: UnifiedAccount;
index: number;
}
function SortableAccountItem({ account, index }: SortableAccountItemProps) {
const { t } = useTranslation('settings');
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({ id: account.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 50 : undefined
};
const statusKey = getStatusKey(account.sessionPercent, account.weeklyPercent, account.isRateLimited);
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'flex items-center gap-3 p-3 rounded-lg border transition-all',
isDragging && 'opacity-60 shadow-lg scale-[1.02]',
account.isActive
? 'border-primary bg-primary/5'
: account.isAvailable
? 'border-border bg-background hover:bg-muted/50'
: 'border-border/50 bg-muted/20 opacity-60'
)}
>
{/* Drag handle */}
<div
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground p-1 -ml-1"
>
<GripVertical className="h-4 w-4" />
</div>
{/* Priority number */}
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center text-xs font-medium text-muted-foreground shrink-0">
{index + 1}
</div>
{/* Account icon - visual distinction between OAuth and API */}
<div className={cn(
"h-8 w-8 rounded-full flex items-center justify-center shrink-0",
account.type === 'oauth' ? "bg-primary/10 text-primary" : "bg-secondary text-secondary-foreground"
)}>
{account.type === 'oauth' ? (
<Users className="h-4 w-4" />
) : (
<Server className="h-4 w-4" />
)}
</div>
{/* Account info and usage */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-foreground truncate">
{account.displayName}
</span>
{/* Account type indicator */}
<span className="text-[10px] text-muted-foreground px-1.5 py-0.5 bg-muted rounded">
{account.type === 'oauth' ? t('accounts.priority.typeOAuth') : t('accounts.priority.typeAPI')}
</span>
{/* Status badges - only ONE account should have "In Use" */}
{account.isActive && (
<span className="text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded flex items-center gap-1">
<Star className="h-2.5 w-2.5" />
{t('accounts.priority.inUse')}
</span>
)}
{account.isNext && !account.isActive && (
<span className="text-[10px] bg-warning/20 text-warning px-1.5 py-0.5 rounded flex items-center gap-1">
<Tag className="h-2.5 w-2.5" />
{t('accounts.priority.next')}
</span>
)}
</div>
<span className="text-xs text-muted-foreground truncate block">
{account.identifier}
</span>
{/* Usage bars for OAuth accounts */}
{account.type === 'oauth' && account.isAvailable && account.sessionPercent !== undefined && (
<div className="flex items-center gap-3 mt-2">
{/* Session usage */}
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 flex-1 max-w-[120px]">
<Clock className="h-3 w-3 text-muted-foreground/70 shrink-0" />
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn("h-full rounded-full transition-all", getBarColorClass(account.sessionPercent))}
style={{ width: `${Math.min(account.sessionPercent, 100)}%` }}
/>
</div>
<span className={cn("text-[10px] tabular-nums font-medium w-8", getColorClass(account.sessionPercent))}>
{Math.round(account.sessionPercent)}%
</span>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{t('accounts.priority.sessionUsage')}
</TooltipContent>
</Tooltip>
{/* Weekly usage */}
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 flex-1 max-w-[120px]">
<TrendingUp className="h-3 w-3 text-muted-foreground/70 shrink-0" />
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn("h-full rounded-full transition-all", getBarColorClass(account.weeklyPercent ?? 0))}
style={{ width: `${Math.min(account.weeklyPercent ?? 0, 100)}%` }}
/>
</div>
<span className={cn("text-[10px] tabular-nums font-medium w-8", getColorClass(account.weeklyPercent ?? 0))}>
{Math.round(account.weeklyPercent ?? 0)}%
</span>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{t('accounts.priority.weeklyUsage')}
</TooltipContent>
</Tooltip>
{/* Status indicator */}
<span className={cn(
"text-[10px] px-1.5 py-0.5 rounded shrink-0",
statusKey === 'healthy' && 'bg-green-500/10 text-green-600',
statusKey === 'moderate' && 'bg-yellow-500/10 text-yellow-600',
statusKey === 'highUsage' && 'bg-orange-500/10 text-orange-600',
statusKey === 'nearLimit' && 'bg-red-500/10 text-red-600',
statusKey === 'rateLimited' && 'bg-red-500/20 text-red-600 font-medium'
)}>
{t(`accounts.priority.status.${statusKey}`)}
</span>
</div>
)}
{/* OAuth account not authenticated */}
{account.type === 'oauth' && !account.isAvailable && (
<div className="flex items-center gap-1.5 mt-1.5">
<AlertCircle className="h-3 w-3 text-destructive" />
<span className="text-[10px] text-destructive">
{t('accounts.priority.needsAuth')}
</span>
</div>
)}
{/* Duplicate usage warning - may indicate same underlying Anthropic account */}
{account.type === 'oauth' && account.isDuplicateUsage && account.isAvailable && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 mt-1.5 cursor-help">
<AlertCircle className="h-3 w-3 text-warning" />
<span className="text-[10px] text-warning">
{t('accounts.priority.duplicateUsage')}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs max-w-[250px]">
{t('accounts.priority.duplicateUsageHint')}
</TooltipContent>
</Tooltip>
)}
</div>
{/* Right side badge for API profiles */}
{account.type === 'api' && (
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-[10px] bg-muted text-muted-foreground px-2 py-1 rounded flex items-center gap-1">
<Infinity className="h-3 w-3" />
{t('accounts.priority.payPerUse')}
</span>
</div>
)}
</div>
);
}
interface AccountPriorityListProps {
accounts: UnifiedAccount[];
onReorder: (newOrder: string[]) => void;
isLoading?: boolean;
}
export function AccountPriorityList({ accounts, onReorder, isLoading }: AccountPriorityListProps) {
const { t } = useTranslation('settings');
const [items, setItems] = useState<UnifiedAccount[]>(accounts);
// Sync with external accounts prop
useEffect(() => {
setItems(accounts);
}, [accounts]);
// Determine "next" account - first available account after the active one
const nextAccountId = useMemo(() => {
const activeIndex = items.findIndex(a => a.isActive);
if (activeIndex === -1) {
// No active account - first available is "next"
return items.find(a => a.isAvailable)?.id ?? null;
}
for (let i = activeIndex + 1; i < items.length; i++) {
if (items[i].isAvailable && !items[i].isActive) {
return items[i].id;
}
}
// Wrap around to beginning if needed
for (let i = 0; i < activeIndex; i++) {
if (items[i].isAvailable && !items[i].isActive) {
return items[i].id;
}
}
return null;
}, [items]);
// Detect duplicate usage - OAuth accounts with identical non-zero usage may be the same underlying account
const duplicateUsageIds = useMemo(() => {
const duplicates = new Set<string>();
const oauthAccounts = items.filter(a => a.type === 'oauth' && a.isAvailable);
// Only check if we have 2+ OAuth accounts with usage data
if (oauthAccounts.length < 2) return duplicates;
// Build usage signature map
const usageSignatures = new Map<string, string[]>();
for (const account of oauthAccounts) {
// Create a signature from usage percentages
// Only consider it a duplicate if both session and weekly are defined and non-zero
if (account.sessionPercent !== undefined && account.weeklyPercent !== undefined) {
// Skip if both are 0 (could be new accounts or accounts with reset usage)
if (account.sessionPercent === 0 && account.weeklyPercent === 0) continue;
const signature = `${account.sessionPercent}-${account.weeklyPercent}`;
const existing = usageSignatures.get(signature) ?? [];
existing.push(account.id);
usageSignatures.set(signature, existing);
}
}
// Mark accounts with duplicate signatures
for (const [, ids] of usageSignatures) {
if (ids.length > 1) {
ids.forEach(id => duplicates.add(id));
}
}
return duplicates;
}, [items]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setItems((currentItems) => {
const oldIndex = currentItems.findIndex((item) => item.id === active.id);
const newIndex = currentItems.findIndex((item) => item.id === over.id);
const newItems = arrayMove(currentItems, oldIndex, newIndex);
// Notify parent of new order
onReorder(newItems.map(item => item.id));
return newItems;
});
}
}, [onReorder]);
if (items.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
<p className="text-sm">{t('accounts.priority.noAccounts')}</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Header */}
<div>
<h4 className="text-sm font-semibold text-foreground mb-1">
{t('accounts.priority.title')}
</h4>
<p className="text-xs text-muted-foreground">
{t('accounts.priority.description')}
</p>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={items.map(item => item.id)}
strategy={verticalListSortingStrategy}
>
<div className={cn(
"space-y-2",
isLoading && "opacity-50 pointer-events-none"
)}>
{items.map((account, index) => (
<SortableAccountItem
key={account.id}
account={{
...account,
isNext: account.id === nextAccountId,
isDuplicateUsage: duplicateUsageIds.has(account.id)
}}
index={index}
/>
))}
</div>
</SortableContext>
</DndContext>
{/* Explanatory tip - provider agnostic */}
<div className="rounded-lg bg-info/10 border border-info/30 p-3 mt-4">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 text-info shrink-0 mt-0.5" />
<div className="text-xs text-muted-foreground space-y-1">
<p className="font-medium text-foreground">{t('accounts.priority.tipTitle')}</p>
<p>{t('accounts.priority.tipDescription')}</p>
</div>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -7,7 +7,6 @@ import {
Palette,
Bot,
FolderOpen,
Key,
Package,
Bell,
Settings2,
@@ -19,7 +18,7 @@ import {
Globe,
Code,
Bug,
Server
Users
} from 'lucide-react';
// GitLab icon component (lucide-react doesn't have one)
@@ -48,11 +47,10 @@ import { ThemeSettings } from './ThemeSettings';
import { DisplaySettings } from './DisplaySettings';
import { LanguageSettings } from './LanguageSettings';
import { GeneralSettings } from './GeneralSettings';
import { IntegrationSettings } from './IntegrationSettings';
import { AdvancedSettings } from './AdvancedSettings';
import { DevToolsSettings } from './DevToolsSettings';
import { DebugSettings } from './DebugSettings';
import { ProfileList } from './ProfileList';
import { AccountSettings } from './AccountSettings';
import { ProjectSelector } from './ProjectSelector';
import { ProjectSettingsContent, ProjectSettingsSection } from './ProjectSettingsContent';
import { useProjectStore } from '../../stores/project-store';
@@ -67,7 +65,7 @@ interface AppSettingsDialogProps {
}
// App-level settings sections
export type AppSection = 'appearance' | 'display' | 'language' | 'devtools' | 'agent' | 'paths' | 'integrations' | 'api-profiles' | 'updates' | 'notifications' | 'debug';
export type AppSection = 'appearance' | 'display' | 'language' | 'devtools' | 'agent' | 'paths' | 'accounts' | 'updates' | 'notifications' | 'debug';
interface NavItemConfig<T extends string> {
id: T;
@@ -81,8 +79,7 @@ const appNavItemsConfig: NavItemConfig<AppSection>[] = [
{ id: 'devtools', icon: Code },
{ id: 'agent', icon: Bot },
{ id: 'paths', icon: FolderOpen },
{ id: 'integrations', icon: Key },
{ id: 'api-profiles', icon: Server },
{ id: 'accounts', icon: Users },
{ id: 'updates', icon: Package },
{ id: 'notifications', icon: Bell },
{ id: 'debug', icon: Bug }
@@ -192,10 +189,8 @@ export function AppSettingsDialog({ open, onOpenChange, initialSection, initialP
return <GeneralSettings settings={settings} onSettingsChange={setSettings} section="agent" />;
case 'paths':
return <GeneralSettings settings={settings} onSettingsChange={setSettings} section="paths" />;
case 'integrations':
return <IntegrationSettings settings={settings} onSettingsChange={setSettings} isOpen={open} />;
case 'api-profiles':
return <ProfileList />;
case 'accounts':
return <AccountSettings settings={settings} onSettingsChange={setSettings} isOpen={open} />;
case 'updates':
return <AdvancedSettings settings={settings} onSettingsChange={setSettings} section="updates" version={version} />;
case 'notifications':
@@ -1,936 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
Key,
Eye,
EyeOff,
Info,
Users,
Plus,
Trash2,
Star,
Check,
Pencil,
X,
Loader2,
LogIn,
ChevronDown,
ChevronRight,
RefreshCw,
Activity,
AlertCircle
} from 'lucide-react';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Switch } from '../ui/switch';
import { cn } from '../../lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { SettingsSection } from './SettingsSection';
import { AuthTerminal } from './AuthTerminal';
import { loadClaudeProfiles as loadGlobalClaudeProfiles } from '../../stores/claude-profile-store';
import { useToast } from '../../hooks/use-toast';
import type { AppSettings, ClaudeProfile, ClaudeAutoSwitchSettings } from '../../../shared/types';
interface IntegrationSettingsProps {
settings: AppSettings;
onSettingsChange: (settings: AppSettings) => void;
isOpen: boolean;
}
/**
* Integration settings for Claude accounts and API keys
*/
export function IntegrationSettings({ settings, onSettingsChange, isOpen }: IntegrationSettingsProps) {
const { t } = useTranslation('settings');
const { t: tCommon } = useTranslation('common');
const { toast } = useToast();
// Password visibility toggle for global API keys
const [showGlobalOpenAIKey, setShowGlobalOpenAIKey] = useState(false);
// Claude Accounts state
const [claudeProfiles, setClaudeProfiles] = useState<ClaudeProfile[]>([]);
const [activeProfileId, setActiveProfileId] = useState<string | null>(null);
const [isLoadingProfiles, setIsLoadingProfiles] = useState(false);
const [newProfileName, setNewProfileName] = useState('');
const [isAddingProfile, setIsAddingProfile] = useState(false);
const [deletingProfileId, setDeletingProfileId] = useState<string | null>(null);
const [editingProfileId, setEditingProfileId] = useState<string | null>(null);
const [editingProfileName, setEditingProfileName] = useState('');
const [authenticatingProfileId, setAuthenticatingProfileId] = useState<string | null>(null);
const [expandedTokenProfileId, setExpandedTokenProfileId] = useState<string | null>(null);
const [manualToken, setManualToken] = useState('');
const [manualTokenEmail, setManualTokenEmail] = useState('');
const [showManualToken, setShowManualToken] = useState(false);
const [savingTokenProfileId, setSavingTokenProfileId] = useState<string | null>(null);
// Auto-swap settings state
const [autoSwitchSettings, setAutoSwitchSettings] = useState<ClaudeAutoSwitchSettings | null>(null);
const [isLoadingAutoSwitch, setIsLoadingAutoSwitch] = useState(false);
// Auth terminal state - for embedded authentication
const [authTerminal, setAuthTerminal] = useState<{
terminalId: string;
configDir: string;
profileId: string;
profileName: string;
} | null>(null);
// Load Claude profiles and auto-swap settings when section is shown
useEffect(() => {
if (isOpen) {
loadClaudeProfiles();
loadAutoSwitchSettings();
}
}, [isOpen]);
const loadClaudeProfiles = async () => {
setIsLoadingProfiles(true);
try {
const result = await window.electronAPI.getClaudeProfiles();
if (result.success && result.data) {
setClaudeProfiles(result.data.profiles);
setActiveProfileId(result.data.activeProfileId);
// Also update the global store
await loadGlobalClaudeProfiles();
} else if (!result.success) {
toast({
variant: 'destructive',
title: t('integrations.toast.loadProfilesFailed'),
description: result.error || t('integrations.toast.tryAgain'),
});
}
} catch (err) {
console.warn('[IntegrationSettings] Failed to load Claude profiles:', err);
toast({
variant: 'destructive',
title: t('integrations.toast.loadProfilesFailed'),
description: t('integrations.toast.tryAgain'),
});
} finally {
setIsLoadingProfiles(false);
}
};
const handleAddProfile = async () => {
if (!newProfileName.trim()) {
return;
}
setIsAddingProfile(true);
try {
const profileName = newProfileName.trim();
const profileSlug = profileName.toLowerCase().replace(/\s+/g, '-');
// Create the profile first
const result = await window.electronAPI.saveClaudeProfile({
id: `profile-${Date.now()}`,
name: profileName,
configDir: `~/.claude-profiles/${profileSlug}`,
isDefault: false,
createdAt: new Date()
});
if (result.success && result.data) {
await loadClaudeProfiles();
setNewProfileName('');
// Get terminal config for authentication
const authResult = await window.electronAPI.authenticateClaudeProfile(result.data.id);
if (authResult.success && authResult.data) {
setAuthenticatingProfileId(result.data.id);
// Set up embedded auth terminal
setAuthTerminal({
terminalId: authResult.data.terminalId,
configDir: authResult.data.configDir,
profileId: result.data.id,
profileName,
});
console.warn('[IntegrationSettings] New profile auth terminal ready:', authResult.data);
} else {
alert(t('integrations.alerts.profileCreatedAuthFailed', { error: authResult.error || t('integrations.toast.tryAgain') }));
}
}
} catch (err) {
toast({
variant: 'destructive',
title: t('integrations.toast.addProfileFailed'),
description: t('integrations.toast.tryAgain'),
});
} finally {
setIsAddingProfile(false);
}
};
const handleDeleteProfile = async (profileId: string) => {
setDeletingProfileId(profileId);
try {
const result = await window.electronAPI.deleteClaudeProfile(profileId);
if (result.success) {
await loadClaudeProfiles();
} else {
toast({
variant: 'destructive',
title: t('integrations.toast.deleteProfileFailed'),
description: result.error || t('integrations.toast.tryAgain'),
});
}
} catch (err) {
console.warn('[IntegrationSettings] Failed to delete profile:', err);
toast({
variant: 'destructive',
title: t('integrations.toast.deleteProfileFailed'),
description: t('integrations.toast.tryAgain'),
});
} finally {
setDeletingProfileId(null);
}
};
const startEditingProfile = (profile: ClaudeProfile) => {
setEditingProfileId(profile.id);
setEditingProfileName(profile.name);
};
const cancelEditingProfile = () => {
setEditingProfileId(null);
setEditingProfileName('');
};
const handleRenameProfile = async () => {
if (!editingProfileId || !editingProfileName.trim()) return;
try {
const result = await window.electronAPI.renameClaudeProfile(editingProfileId, editingProfileName.trim());
if (result.success) {
await loadClaudeProfiles();
} else {
toast({
variant: 'destructive',
title: t('integrations.toast.renameProfileFailed'),
description: result.error || t('integrations.toast.tryAgain'),
});
}
} catch (err) {
console.warn('[IntegrationSettings] Failed to rename profile:', err);
toast({
variant: 'destructive',
title: t('integrations.toast.renameProfileFailed'),
description: t('integrations.toast.tryAgain'),
});
} finally {
setEditingProfileId(null);
setEditingProfileName('');
}
};
const handleSetActiveProfile = async (profileId: string) => {
try {
const result = await window.electronAPI.setActiveClaudeProfile(profileId);
if (result.success) {
setActiveProfileId(profileId);
await loadGlobalClaudeProfiles();
} else {
toast({
variant: 'destructive',
title: t('integrations.toast.setActiveProfileFailed'),
description: result.error || t('integrations.toast.tryAgain'),
});
}
} catch (err) {
console.warn('[IntegrationSettings] Failed to set active profile:', err);
toast({
variant: 'destructive',
title: t('integrations.toast.setActiveProfileFailed'),
description: t('integrations.toast.tryAgain'),
});
}
};
const handleAuthenticateProfile = async (profileId: string) => {
// Find the profile name for display
const profile = claudeProfiles.find(p => p.id === profileId);
const profileName = profile?.name || 'Profile';
setAuthenticatingProfileId(profileId);
try {
// Get terminal config from backend (terminalId and configDir)
const result = await window.electronAPI.authenticateClaudeProfile(profileId);
if (!result.success || !result.data) {
alert(t('integrations.alerts.authPrepareFailed', { error: result.error || t('integrations.toast.tryAgain') }));
setAuthenticatingProfileId(null);
return;
}
// Set up embedded auth terminal
setAuthTerminal({
terminalId: result.data.terminalId,
configDir: result.data.configDir,
profileId,
profileName,
});
console.warn('[IntegrationSettings] Auth terminal ready:', result.data);
} catch (err) {
console.error('Failed to authenticate profile:', err);
alert(t('integrations.alerts.authStartFailedMessage'));
setAuthenticatingProfileId(null);
}
};
// Handle auth terminal close
const handleAuthTerminalClose = useCallback(() => {
setAuthTerminal(null);
setAuthenticatingProfileId(null);
}, []);
// Handle auth terminal success
const handleAuthTerminalSuccess = useCallback(async (email?: string) => {
console.warn('[IntegrationSettings] Auth success:', email);
// Close terminal immediately
setAuthTerminal(null);
setAuthenticatingProfileId(null);
// Reload profiles to get updated auth state
await loadClaudeProfiles();
}, []);
// Handle auth terminal error
const handleAuthTerminalError = useCallback((error: string) => {
console.error('[IntegrationSettings] Auth error:', error);
// Don't auto-close on error - let user see the error and close manually
}, []);
const toggleTokenEntry = (profileId: string) => {
if (expandedTokenProfileId === profileId) {
setExpandedTokenProfileId(null);
setManualToken('');
setManualTokenEmail('');
setShowManualToken(false);
} else {
setExpandedTokenProfileId(profileId);
setManualToken('');
setManualTokenEmail('');
setShowManualToken(false);
}
};
const handleSaveManualToken = async (profileId: string) => {
if (!manualToken.trim()) return;
setSavingTokenProfileId(profileId);
try {
const result = await window.electronAPI.setClaudeProfileToken(
profileId,
manualToken.trim(),
manualTokenEmail.trim() || undefined
);
if (result.success) {
await loadClaudeProfiles();
setExpandedTokenProfileId(null);
setManualToken('');
setManualTokenEmail('');
setShowManualToken(false);
toast({
title: t('integrations.toast.tokenSaved'),
description: t('integrations.toast.tokenSavedDescription'),
});
} else {
toast({
variant: 'destructive',
title: t('integrations.toast.tokenSaveFailed'),
description: result.error || t('integrations.toast.tryAgain'),
});
}
} catch (err) {
toast({
variant: 'destructive',
title: t('integrations.toast.tokenSaveFailed'),
description: t('integrations.toast.tryAgain'),
});
} finally {
setSavingTokenProfileId(null);
}
};
// Load auto-swap settings
const loadAutoSwitchSettings = async () => {
setIsLoadingAutoSwitch(true);
try {
const result = await window.electronAPI.getAutoSwitchSettings();
if (result.success && result.data) {
setAutoSwitchSettings(result.data);
}
} catch (err) {
// Silently handle errors
} finally {
setIsLoadingAutoSwitch(false);
}
};
// Update auto-swap settings
const handleUpdateAutoSwitch = async (updates: Partial<ClaudeAutoSwitchSettings>) => {
setIsLoadingAutoSwitch(true);
try {
const result = await window.electronAPI.updateAutoSwitchSettings(updates);
if (result.success) {
await loadAutoSwitchSettings();
} else {
toast({
variant: 'destructive',
title: t('integrations.toast.settingsUpdateFailed'),
description: result.error || t('integrations.toast.tryAgain'),
});
}
} catch (err) {
toast({
variant: 'destructive',
title: t('integrations.toast.settingsUpdateFailed'),
description: t('integrations.toast.tryAgain'),
});
} finally {
setIsLoadingAutoSwitch(false);
}
};
return (
<SettingsSection
title={t('integrations.title')}
description={t('integrations.description')}
>
<div className="space-y-6">
{/* Claude Accounts Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<h4 className="text-sm font-semibold text-foreground">{t('integrations.claudeAccounts')}</h4>
</div>
<div className="rounded-lg bg-muted/30 border border-border p-4">
<p className="text-sm text-muted-foreground mb-4">
{t('integrations.claudeAccountsDescription')}
</p>
{/* Accounts list */}
{isLoadingProfiles ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : claudeProfiles.length === 0 ? (
<div className="rounded-lg border border-dashed border-border p-4 text-center mb-4">
<p className="text-sm text-muted-foreground">{t('integrations.noAccountsYet')}</p>
</div>
) : (
<div className="space-y-2 mb-4">
{claudeProfiles.map((profile) => (
<div
key={profile.id}
className={cn(
"rounded-lg border transition-colors",
profile.id === activeProfileId
? "border-primary bg-primary/5"
: "border-border bg-background"
)}
>
<div className={cn(
"flex items-center justify-between p-3",
expandedTokenProfileId !== profile.id && "hover:bg-muted/50"
)}>
<div className="flex items-center gap-3">
<div className={cn(
"h-7 w-7 rounded-full flex items-center justify-center text-xs font-medium shrink-0",
profile.id === activeProfileId
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
)}>
{(editingProfileId === profile.id ? editingProfileName : profile.name).charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
{editingProfileId === profile.id ? (
<div className="flex items-center gap-2">
<Input
value={editingProfileName}
onChange={(e) => setEditingProfileName(e.target.value)}
className="h-7 text-sm w-40"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameProfile();
if (e.key === 'Escape') cancelEditingProfile();
}}
/>
<Button
variant="ghost"
size="icon"
onClick={handleRenameProfile}
className="h-7 w-7 text-success hover:text-success hover:bg-success/10"
aria-label={t('common:accessibility.saveEditAriaLabel')}
>
<Check className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={cancelEditingProfile}
className="h-7 w-7 text-muted-foreground hover:text-foreground"
aria-label={t('common:accessibility.cancelEditAriaLabel')}
>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-foreground">{profile.name}</span>
{profile.isDefault && (
<span className="text-xs bg-muted px-1.5 py-0.5 rounded">{t('integrations.default')}</span>
)}
{profile.id === activeProfileId && (
<span className="text-xs bg-primary/20 text-primary px-1.5 py-0.5 rounded flex items-center gap-1">
<Star className="h-3 w-3" />
{t('integrations.active')}
</span>
)}
{profile.isAuthenticated ? (
<span className="text-xs bg-success/20 text-success px-1.5 py-0.5 rounded flex items-center gap-1">
<Check className="h-3 w-3" />
{t('integrations.authenticated')}
</span>
) : (
<span className="text-xs bg-warning/20 text-warning px-1.5 py-0.5 rounded">
{t('integrations.needsAuth')}
</span>
)}
</div>
{profile.email && (
<span className="text-xs text-muted-foreground">{profile.email}</span>
)}
</>
)}
</div>
</div>
{editingProfileId !== profile.id && (
<div className="flex items-center gap-1">
{/* Authenticate button - show only if NOT authenticated */}
{!profile.isAuthenticated ? (
<Button
variant="outline"
size="sm"
onClick={() => handleAuthenticateProfile(profile.id)}
disabled={authenticatingProfileId === profile.id}
className="gap-1 h-7 text-xs"
>
{authenticatingProfileId === profile.id ? (
<>
<Loader2 className="h-3 w-3 animate-spin" />
{t('integrations.authenticating')}
</>
) : (
<>
<LogIn className="h-3 w-3" />
{t('integrations.authenticate')}
</>
)}
</Button>
) : (
/* Re-authenticate button for already authenticated profiles */
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => handleAuthenticateProfile(profile.id)}
disabled={authenticatingProfileId === profile.id}
className="h-7 w-7 text-muted-foreground hover:text-foreground"
aria-label={t('common:accessibility.refreshAriaLabel')}
>
{authenticatingProfileId === profile.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{t('common:accessibility.reAuthenticateProfileAriaLabel')}</TooltipContent>
</Tooltip>
)}
{profile.id !== activeProfileId && (
<Button
variant="outline"
size="sm"
onClick={() => handleSetActiveProfile(profile.id)}
className="gap-1 h-7 text-xs"
>
<Check className="h-3 w-3" />
{t('integrations.setActive')}
</Button>
)}
{/* Toggle token entry button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => toggleTokenEntry(profile.id)}
className="h-7 w-7 text-muted-foreground hover:text-foreground"
aria-label={expandedTokenProfileId === profile.id ? t('common:accessibility.collapseAriaLabel') : t('common:accessibility.expandAriaLabel')}
>
{expandedTokenProfileId === profile.id ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{expandedTokenProfileId === profile.id ? t('common:accessibility.hideTokenEntryAriaLabel') : t('common:accessibility.enterTokenManuallyAriaLabel')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => startEditingProfile(profile)}
className="h-7 w-7 text-muted-foreground hover:text-foreground"
aria-label={t('common:accessibility.renameAriaLabel')}
>
<Pencil className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common:accessibility.renameProfileAriaLabel')}</TooltipContent>
</Tooltip>
{!profile.isDefault && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteProfile(profile.id)}
disabled={deletingProfileId === profile.id}
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
aria-label={t('common:accessibility.deleteAriaLabel')}
>
{deletingProfileId === profile.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Trash2 className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{t('common:accessibility.deleteProfileAriaLabel')}</TooltipContent>
</Tooltip>
)}
</div>
)}
</div>
{/* Expanded token entry section */}
{expandedTokenProfileId === profile.id && (
<div className="px-3 pb-3 pt-0 border-t border-border/50 mt-0">
<div className="bg-muted/30 rounded-lg p-3 mt-3 space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium text-muted-foreground">
{t('integrations.manualTokenEntry')}
</Label>
<span className="text-xs text-muted-foreground">
{t('integrations.runSetupToken')}
</span>
</div>
<div className="space-y-2">
<div className="relative">
<Input
type={showManualToken ? 'text' : 'password'}
placeholder={t('integrations.tokenPlaceholder')}
value={manualToken}
onChange={(e) => setManualToken(e.target.value)}
className="pr-10 font-mono text-xs h-8"
/>
<button
type="button"
onClick={() => setShowManualToken(!showManualToken)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showManualToken ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</button>
</div>
<Input
type="email"
placeholder={t('integrations.emailPlaceholder')}
value={manualTokenEmail}
onChange={(e) => setManualTokenEmail(e.target.value)}
className="text-xs h-8"
/>
</div>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => toggleTokenEntry(profile.id)}
className="h-7 text-xs"
>
{tCommon('buttons.cancel')}
</Button>
<Button
size="sm"
onClick={() => handleSaveManualToken(profile.id)}
disabled={!manualToken.trim() || savingTokenProfileId === profile.id}
className="h-7 text-xs gap-1"
>
{savingTokenProfileId === profile.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Check className="h-3 w-3" />
)}
{t('integrations.saveToken')}
</Button>
</div>
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Embedded Auth Terminal */}
{authTerminal && (
<div className="mb-4">
<div className="rounded-lg border border-primary/30 overflow-hidden" style={{ height: '320px' }}>
<AuthTerminal
terminalId={authTerminal.terminalId}
configDir={authTerminal.configDir}
profileName={authTerminal.profileName}
onClose={handleAuthTerminalClose}
onAuthSuccess={handleAuthTerminalSuccess}
onAuthError={handleAuthTerminalError}
/>
</div>
</div>
)}
{/* Add new account */}
<div className="flex items-center gap-2">
<Input
placeholder={t('integrations.accountNamePlaceholder')}
value={newProfileName}
onChange={(e) => setNewProfileName(e.target.value)}
className="flex-1 h-8 text-sm"
disabled={!!authTerminal}
onKeyDown={(e) => {
if (e.key === 'Enter' && newProfileName.trim()) {
handleAddProfile();
}
}}
/>
<Button
onClick={handleAddProfile}
disabled={!newProfileName.trim() || isAddingProfile || !!authTerminal}
size="sm"
className="gap-1 shrink-0"
>
{isAddingProfile ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Plus className="h-3 w-3" />
)}
{tCommon('buttons.add')}
</Button>
</div>
</div>
</div>
{/* Auto-Switch Settings Section */}
{claudeProfiles.length > 1 && (
<div className="space-y-4 pt-6 border-t border-border">
<div className="flex items-center gap-2">
<RefreshCw className="h-4 w-4 text-muted-foreground" />
<h4 className="text-sm font-semibold text-foreground">{t('integrations.autoSwitching')}</h4>
</div>
<div className="rounded-lg bg-muted/30 border border-border p-4 space-y-4">
<p className="text-sm text-muted-foreground">
{t('integrations.autoSwitchingDescription')}
</p>
{/* Master toggle */}
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium">{t('integrations.enableAutoSwitching')}</Label>
<p className="text-xs text-muted-foreground mt-1">
{t('integrations.masterSwitch')}
</p>
</div>
<Switch
checked={autoSwitchSettings?.enabled ?? false}
onCheckedChange={(enabled) => handleUpdateAutoSwitch({ enabled })}
disabled={isLoadingAutoSwitch}
/>
</div>
{autoSwitchSettings?.enabled && (
<>
{/* Proactive Monitoring Section */}
<div className="pl-6 space-y-4 pt-2 border-l-2 border-primary/20">
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium flex items-center gap-2">
<Activity className="h-3.5 w-3.5" />
{t('integrations.proactiveMonitoring')}
</Label>
<p className="text-xs text-muted-foreground mt-1">
{t('integrations.proactiveDescription')}
</p>
</div>
<Switch
checked={autoSwitchSettings?.proactiveSwapEnabled ?? true}
onCheckedChange={(value) => handleUpdateAutoSwitch({ proactiveSwapEnabled: value })}
disabled={isLoadingAutoSwitch}
/>
</div>
{autoSwitchSettings?.proactiveSwapEnabled && (
<>
{/* Check interval */}
<div className="space-y-2">
<Label className="text-sm">{t('integrations.checkUsageEvery')}</Label>
<select
className="w-full px-3 py-2 bg-background border border-input rounded-md text-sm"
value={autoSwitchSettings?.usageCheckInterval ?? 30000}
onChange={(e) => handleUpdateAutoSwitch({ usageCheckInterval: parseInt(e.target.value) })}
disabled={isLoadingAutoSwitch}
>
<option value={15000}>{t('integrations.seconds15')}</option>
<option value={30000}>{t('integrations.seconds30')}</option>
<option value={60000}>{t('integrations.minute1')}</option>
<option value={0}>{t('integrations.disabled')}</option>
</select>
</div>
{/* Session threshold */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm">{t('integrations.sessionThreshold')}</Label>
<span className="text-sm font-mono">{autoSwitchSettings?.sessionThreshold ?? 95}%</span>
</div>
<input
type="range"
min="70"
max="99"
step="1"
value={autoSwitchSettings?.sessionThreshold ?? 95}
onChange={(e) => handleUpdateAutoSwitch({ sessionThreshold: parseInt(e.target.value) })}
disabled={isLoadingAutoSwitch}
className="w-full"
/>
<p className="text-xs text-muted-foreground">
{t('integrations.sessionThresholdDescription')}
</p>
</div>
{/* Weekly threshold */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm">{t('integrations.weeklyThreshold')}</Label>
<span className="text-sm font-mono">{autoSwitchSettings?.weeklyThreshold ?? 99}%</span>
</div>
<input
type="range"
min="70"
max="99"
step="1"
value={autoSwitchSettings?.weeklyThreshold ?? 99}
onChange={(e) => handleUpdateAutoSwitch({ weeklyThreshold: parseInt(e.target.value) })}
disabled={isLoadingAutoSwitch}
className="w-full"
/>
<p className="text-xs text-muted-foreground">
{t('integrations.weeklyThresholdDescription')}
</p>
</div>
</>
)}
</div>
{/* Reactive Recovery Section */}
<div className="pl-6 space-y-4 pt-2 border-l-2 border-orange-500/20">
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium flex items-center gap-2">
<AlertCircle className="h-3.5 w-3.5" />
{t('integrations.reactiveRecovery')}
</Label>
<p className="text-xs text-muted-foreground mt-1">
{t('integrations.reactiveDescription')}
</p>
</div>
<Switch
checked={autoSwitchSettings?.autoSwitchOnRateLimit ?? false}
onCheckedChange={(value) => handleUpdateAutoSwitch({ autoSwitchOnRateLimit: value })}
disabled={isLoadingAutoSwitch}
/>
</div>
</div>
</>
)}
</div>
</div>
)}
{/* API Keys Section */}
<div className="space-y-4 pt-4 border-t border-border">
<div className="flex items-center gap-2">
<Key className="h-4 w-4 text-muted-foreground" />
<h4 className="text-sm font-semibold text-foreground">{t('integrations.apiKeys')}</h4>
</div>
<div className="rounded-lg bg-info/10 border border-info/30 p-3">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 text-info shrink-0 mt-0.5" />
<p className="text-xs text-muted-foreground">
{t('integrations.apiKeysInfo')}
</p>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="globalOpenAIKey" className="text-sm font-medium text-foreground">
{t('integrations.openaiKey')}
</Label>
<p className="text-xs text-muted-foreground">
{t('integrations.openaiKeyDescription')}
</p>
<div className="relative max-w-lg">
<Input
id="globalOpenAIKey"
type={showGlobalOpenAIKey ? 'text' : 'password'}
placeholder="sk-..."
value={settings.globalOpenAIApiKey || ''}
onChange={(e) =>
onSettingsChange({ ...settings, globalOpenAIApiKey: e.target.value || undefined })
}
className="pr-10 font-mono text-sm"
/>
<button
type="button"
onClick={() => setShowGlobalOpenAIKey(!showGlobalOpenAIKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showGlobalOpenAIKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
</div>
</div>
</div>
</SettingsSection>
);
}
@@ -1,283 +0,0 @@
/**
* IntegrationSettings handleAddProfile function tests
*
* Tests for the handleAddProfile function logic in IntegrationSettings component.
* Verifies that IPC calls (saveClaudeProfile and initializeClaudeProfile) are made correctly.
*
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Import browser mock to get full ElectronAPI structure
import '../../../lib/browser-mock';
// Mock functions for IPC calls
const mockSaveClaudeProfile = vi.fn();
const mockInitializeClaudeProfile = vi.fn();
const mockGetClaudeProfiles = vi.fn();
describe('IntegrationSettings - handleAddProfile IPC Logic', () => {
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks();
// Setup window.electronAPI mocks
if (window.electronAPI) {
window.electronAPI.saveClaudeProfile = mockSaveClaudeProfile;
window.electronAPI.initializeClaudeProfile = mockInitializeClaudeProfile;
window.electronAPI.getClaudeProfiles = mockGetClaudeProfiles;
}
// Default mock implementations
mockSaveClaudeProfile.mockResolvedValue({
success: true,
data: {
id: 'profile-123',
name: 'Test Profile',
configDir: '~/.claude-profiles/test-profile',
isDefault: false,
createdAt: new Date()
}
});
mockInitializeClaudeProfile.mockResolvedValue({
success: true
});
mockGetClaudeProfiles.mockResolvedValue({
success: true,
data: { profiles: [], activeProfileId: null }
});
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Add Profile Flow', () => {
it('should call saveClaudeProfile with correct parameters when adding a profile', async () => {
const newProfile = {
id: 'profile-new',
name: 'Work Account',
configDir: '~/.claude-profiles/work-account',
isDefault: false,
createdAt: new Date()
};
mockSaveClaudeProfile.mockResolvedValue({
success: true,
data: newProfile
});
const result = await window.electronAPI.saveClaudeProfile(newProfile);
expect(mockSaveClaudeProfile).toHaveBeenCalledWith(newProfile);
expect(result.success).toBe(true);
expect(result.data?.name).toBe('Work Account');
});
it('should call initializeClaudeProfile after saveClaudeProfile succeeds', async () => {
const newProfile = {
id: 'profile-456',
name: 'Personal Account',
configDir: '~/.claude-profiles/personal-account',
isDefault: false,
createdAt: new Date()
};
mockSaveClaudeProfile.mockResolvedValue({
success: true,
data: newProfile
});
mockInitializeClaudeProfile.mockResolvedValue({ success: true });
// Simulate the handleAddProfile flow
const saveResult = await window.electronAPI.saveClaudeProfile(newProfile);
if (saveResult.success && saveResult.data) {
await window.electronAPI.initializeClaudeProfile(saveResult.data.id);
}
expect(mockSaveClaudeProfile).toHaveBeenCalled();
expect(mockInitializeClaudeProfile).toHaveBeenCalledWith('profile-456');
});
it('should generate profile slug from name (lowercase with dashes)', () => {
const profileName = 'Work Account';
const profileSlug = profileName.toLowerCase().replace(/\s+/g, '-');
expect(profileSlug).toBe('work-account');
});
it('should handle profile names with multiple spaces', () => {
const profileName = 'My Personal Account';
const profileSlug = profileName.toLowerCase().replace(/\s+/g, '-');
expect(profileSlug).toBe('my-personal-account');
});
it('should handle saveClaudeProfile failure', async () => {
mockSaveClaudeProfile.mockResolvedValue({
success: false,
error: 'Failed to save profile'
});
const result = await window.electronAPI.saveClaudeProfile({
id: 'profile-fail',
name: 'Failing Profile',
isDefault: false,
createdAt: new Date()
});
expect(result.success).toBe(false);
expect(result.error).toBe('Failed to save profile');
});
it('should not call initializeClaudeProfile if saveClaudeProfile fails', async () => {
mockSaveClaudeProfile.mockResolvedValue({
success: false,
error: 'Failed to save profile'
});
// Simulate the handleAddProfile flow
const saveResult = await window.electronAPI.saveClaudeProfile({
id: 'profile-fail',
name: 'Failing Profile',
isDefault: false,
createdAt: new Date()
});
if (saveResult.success && saveResult.data) {
await window.electronAPI.initializeClaudeProfile(saveResult.data.id);
}
expect(mockSaveClaudeProfile).toHaveBeenCalled();
expect(mockInitializeClaudeProfile).not.toHaveBeenCalled();
});
});
describe('Initialize Profile Flow', () => {
it('should call initializeClaudeProfile to trigger OAuth flow', async () => {
mockInitializeClaudeProfile.mockResolvedValue({ success: true });
const profileId = 'profile-1';
const result = await window.electronAPI.initializeClaudeProfile(profileId);
expect(mockInitializeClaudeProfile).toHaveBeenCalledWith(profileId);
expect(result.success).toBe(true);
});
it('should handle initializeClaudeProfile failure (terminal creation error)', async () => {
mockInitializeClaudeProfile.mockResolvedValue({
success: false,
error: 'Terminal creation failed'
});
const result = await window.electronAPI.initializeClaudeProfile('profile-1');
expect(result.success).toBe(false);
expect(result.error).toBe('Terminal creation failed');
});
it('should handle initializeClaudeProfile failure (max terminals reached)', async () => {
mockInitializeClaudeProfile.mockResolvedValue({
success: false,
error: 'Max terminals reached'
});
const result = await window.electronAPI.initializeClaudeProfile('profile-2');
expect(result.success).toBe(false);
expect(result.error).toContain('Max terminals');
});
});
describe('Profile Name Validation', () => {
it('should require non-empty profile name', () => {
const newProfileName = '';
const isValid = newProfileName.trim().length > 0;
expect(isValid).toBe(false);
});
it('should trim whitespace from profile name', () => {
const newProfileName = ' Work Account ';
const isValid = newProfileName.trim().length > 0;
expect(isValid).toBe(true);
expect(newProfileName.trim()).toBe('Work Account');
});
it('should reject whitespace-only profile name', () => {
const newProfileName = ' ';
const isValid = newProfileName.trim().length > 0;
expect(isValid).toBe(false);
});
it('should accept single character profile name', () => {
const newProfileName = 'A';
const isValid = newProfileName.trim().length > 0;
expect(isValid).toBe(true);
});
it('should handle profile names with special characters', () => {
const profileName = "John's Work Account!";
const profileSlug = profileName.toLowerCase().replace(/\s+/g, '-');
expect(profileSlug).toBe("john's-work-account!");
// Note: The actual implementation may further sanitize special characters
});
});
describe('Error Handling', () => {
it('should handle network errors during save', async () => {
mockSaveClaudeProfile.mockRejectedValue(new Error('Network error'));
await expect(
window.electronAPI.saveClaudeProfile({
id: 'profile-test',
name: 'Test',
isDefault: false,
createdAt: new Date()
})
).rejects.toThrow('Network error');
});
it('should handle network errors during initialization', async () => {
mockInitializeClaudeProfile.mockRejectedValue(new Error('Network error'));
await expect(
window.electronAPI.initializeClaudeProfile('profile-1')
).rejects.toThrow('Network error');
});
});
describe('Profile Data Structure', () => {
it('should include all required profile fields when saving', async () => {
const newProfile = {
id: expect.stringContaining('profile-'),
name: 'Test Profile',
configDir: '~/.claude-profiles/test-profile',
isDefault: false,
createdAt: expect.any(Date)
};
mockSaveClaudeProfile.mockResolvedValue({
success: true,
data: newProfile
});
await window.electronAPI.saveClaudeProfile(newProfile);
expect(mockSaveClaudeProfile).toHaveBeenCalledWith(
expect.objectContaining({
name: expect.any(String),
configDir: expect.any(String),
isDefault: expect.any(Boolean),
createdAt: expect.any(Date)
})
);
});
it('should generate unique profile IDs based on timestamp', () => {
const id1 = `profile-${Date.now()}`;
const id2 = `profile-${Date.now()}`;
// IDs should be similar (may be identical if generated in same millisecond)
expect(id1).toContain('profile-');
expect(id2).toContain('profile-');
});
});
});
@@ -7,7 +7,6 @@ export { AppSettingsDialog, type AppSection } from './AppSettings';
export { ThemeSettings } from './ThemeSettings';
export { ThemeSelector } from './ThemeSelector';
export { GeneralSettings } from './GeneralSettings';
export { IntegrationSettings } from './IntegrationSettings';
export { AdvancedSettings } from './AdvancedSettings';
export { SettingsSection } from './SettingsSection';
export { useSettings } from './hooks/useSettings';
@@ -49,6 +49,13 @@ export const claudeProfileMock = {
updateAutoSwitchSettings: async () => ({ success: true }),
getAccountPriorityOrder: async () => ({
success: true,
data: [] as string[]
}),
setAccountPriorityOrder: async () => ({ success: true }),
fetchClaudeUsage: async () => ({ success: true }),
getBestAvailableProfile: async () => ({
@@ -68,8 +75,15 @@ export const claudeProfileMock = {
data: null
}),
requestAllProfilesUsage: async () => ({
success: true,
data: null
}),
onUsageUpdated: () => () => {},
onAllProfilesUsageUpdated: () => () => {},
onProactiveSwapNotification: () => () => {},
// Returns terminal config for embedded authentication
@@ -119,6 +119,10 @@ export const IPC_CHANNELS = {
CLAUDE_PROFILE_FETCH_USAGE: 'claude:fetchUsage',
CLAUDE_PROFILE_GET_BEST_PROFILE: 'claude:getBestProfile',
// Account priority order (unified OAuth + API profile ordering)
ACCOUNT_PRIORITY_GET: 'account:priorityGet',
ACCOUNT_PRIORITY_SET: 'account:prioritySet',
// SDK/CLI rate limit event (for non-terminal Claude invocations)
CLAUDE_SDK_RATE_LIMIT: 'claude:sdkRateLimit',
// Auth failure event (401 errors requiring re-authentication)
@@ -129,6 +133,8 @@ export const IPC_CHANNELS = {
// Usage monitoring (proactive account switching)
USAGE_UPDATED: 'claude:usageUpdated', // Event: usage data updated (main -> renderer)
USAGE_REQUEST: 'claude:usageRequest', // Request current usage snapshot
ALL_PROFILES_USAGE_REQUEST: 'claude:allProfilesUsageRequest', // Request all profiles usage immediately
ALL_PROFILES_USAGE_UPDATED: 'claude:allProfilesUsageUpdated', // Event: all profiles usage data (main -> renderer)
PROACTIVE_SWAP_NOTIFICATION: 'claude:proactiveSwapNotification', // Event: proactive swap occurred
// Settings
@@ -91,7 +91,8 @@
"noData": "No data",
"optional": "Optional",
"required": "Required",
"dismiss": "Dismiss"
"dismiss": "Dismiss",
"important": "Important"
},
"selection": {
"select": "Select",
@@ -441,7 +442,15 @@
"window5Hour": "5-hour window",
"window7Day": "7-day window",
"window5HoursQuota": "5 Hours Quota",
"windowMonthlyToolsQuota": "Monthly Tools Quota"
"windowMonthlyToolsQuota": "Monthly Tools Quota",
"otherAccounts": "Other Accounts",
"next": "Next",
"weeklyLimitReached": "Weekly limit reached",
"sessionLimitReached": "Session limit reached",
"notAuthenticated": "Not authenticated",
"sessionShort": "5-hour session usage",
"weeklyShort": "7-day weekly usage",
"swap": "Swap"
},
"oauth": {
"enterCode": "Manual Code Entry (Fallback)",
@@ -29,13 +29,9 @@
"title": "Paths",
"description": "CLI tools and framework paths"
},
"integrations": {
"title": "Integrations",
"description": "API keys & Claude accounts"
},
"api-profiles": {
"title": "API Profiles",
"description": "Custom API endpoint profiles"
"accounts": {
"title": "Accounts",
"description": "Claude accounts & API endpoints"
},
"updates": {
"title": "Updates",
@@ -412,6 +408,7 @@
"description": "Manage Claude accounts and API keys",
"claudeAccounts": "Claude Accounts",
"claudeAccountsDescription": "Add multiple Claude subscriptions to automatically switch between them when you hit rate limits.",
"claudeAccountsWarning": "When authenticating, ensure you're logged into the correct Claude account in your browser. Each profile should use a different subscription.",
"noAccountsYet": "No accounts configured yet",
"default": "Default",
"active": "Active",
@@ -479,6 +476,123 @@
"authStartFailedMessage": "Failed to start authentication. Please try again."
}
},
"accounts": {
"title": "Accounts",
"description": "Manage Claude accounts and API endpoints",
"tabs": {
"claudeCode": "Claude Code",
"customEndpoints": "Custom Endpoints"
},
"claudeCode": {
"description": "Add multiple Claude subscriptions to automatically switch between them when you hit rate limits.",
"noAccountsYet": "No accounts configured yet",
"default": "Default",
"active": "Active",
"authenticated": "Authenticated",
"needsAuth": "Needs Auth",
"authenticate": "Authenticate",
"authenticating": "Authenticating...",
"setActive": "Set Active",
"manualTokenEntry": "Manual Token Entry",
"runSetupToken": "Run claude and type /login to authenticate",
"tokenPlaceholder": "sk-ant-oat01-...",
"emailPlaceholder": "Email (optional, for display)",
"saveToken": "Save Token",
"accountNamePlaceholder": "Account name (e.g., Work, Personal)"
},
"customEndpoints": {
"description": "Configure custom Anthropic-compatible API endpoints",
"addButton": "Add Profile",
"activeBadge": "Active",
"customModels": "Custom models: {{models}}",
"setActive": {
"label": "Set Active",
"loading": "Setting..."
},
"switchToOauth": {
"label": "Use Claude Code",
"loading": "Switching..."
},
"tooltips": {
"edit": "Edit profile",
"deleteActive": "Cannot delete active profile",
"deleteInactive": "Delete profile"
},
"empty": {
"title": "No Custom Endpoints",
"description": "Configure custom Anthropic-compatible API endpoints to use alternative providers.",
"action": "Add Profile"
},
"dialog": {
"deleteTitle": "Delete Profile",
"deleteDescription": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"cancel": "Cancel",
"delete": "Delete",
"deleting": "Deleting..."
}
},
"autoSwitching": {
"title": "Automatic Account Switching",
"description": "Automatically switch between accounts to avoid interruptions. Configure proactive monitoring to switch before hitting limits.",
"enableAutoSwitching": "Enable automatic switching",
"masterSwitch": "Master switch for all auto-swap features",
"proactiveMonitoring": "Proactive Monitoring",
"proactiveDescription": "Check usage regularly and swap before hitting limits",
"sessionThreshold": "Session usage threshold",
"sessionThresholdDescription": "Switch when session usage reaches this level (recommended: 95%)",
"weeklyThreshold": "Weekly usage threshold",
"weeklyThresholdDescription": "Switch when weekly usage reaches this level (recommended: 99%)",
"reactiveRecovery": "Reactive Recovery",
"reactiveDescription": "Auto-swap when unexpected rate limit is hit"
},
"priority": {
"title": "Account Priority Order",
"description": "Drag to reorder. System will switch to the next available account in order.",
"noAccounts": "No accounts configured. Add accounts above to set priority.",
"noEmail": "No email",
"active": "Active",
"inUse": "In Use",
"next": "Next",
"unlimited": "Unlimited",
"unavailable": "Unavailable",
"typeOAuth": "OAuth",
"typeAPI": "API",
"payPerUse": "Pay-per-use",
"needsAuth": "Not authenticated",
"duplicateUsage": "Duplicate usage detected",
"duplicateUsageHint": "This profile has identical usage to another profile, suggesting they may be authenticated to the same Anthropic account. Re-authenticate with a different account to fix.",
"sessionUsage": "Session usage (5-hour window)",
"weeklyUsage": "Weekly usage (7-day window)",
"oauthSection": "Claude Accounts (cycle through first)",
"apiSection": "Fallback Endpoints (when all accounts exhausted)",
"tipTitle": "How priority works",
"tipDescription": "Claude accounts are included in your subscription and will be cycled through first. API endpoints charge per request and are used as fallbacks when all Claude accounts hit their limits.",
"status": {
"healthy": "Healthy",
"moderate": "Moderate",
"highUsage": "High usage",
"nearLimit": "Near limit",
"rateLimited": "Rate limited"
}
},
"toast": {
"loadProfilesFailed": "Failed to Load Profiles",
"addProfileFailed": "Failed to Add Profile",
"deleteProfileFailed": "Failed to Delete Profile",
"renameProfileFailed": "Failed to Rename Profile",
"setActiveProfileFailed": "Failed to Set Active Profile",
"tokenSaved": "Token Saved",
"tokenSavedDescription": "Your token has been saved successfully.",
"tokenSaveFailed": "Failed to Save Token",
"settingsUpdateFailed": "Failed to Update Settings",
"tryAgain": "Please try again."
},
"alerts": {
"profileCreatedAuthFailed": "Profile created but failed to prepare authentication: {{error}}",
"authPrepareFailed": "Failed to prepare authentication: {{error}}",
"authStartFailedMessage": "Failed to start authentication. Please try again."
}
},
"debug": {
"title": "Debug & Logs",
"description": "Access logs and debug information for troubleshooting",
@@ -91,7 +91,8 @@
"noData": "Aucune donnée",
"optional": "Optionnel",
"required": "Requis",
"dismiss": "Ignorer"
"dismiss": "Ignorer",
"important": "Important"
},
"selection": {
"select": "Sélectionner",
@@ -441,7 +442,15 @@
"window5Hour": "Fenêtre de 5 heures",
"window7Day": "Fenêtre de 7 jours",
"window5HoursQuota": "Quota de 5 heures",
"windowMonthlyToolsQuota": "Quota mensuel d'outils"
"windowMonthlyToolsQuota": "Quota mensuel d'outils",
"otherAccounts": "Autres comptes",
"next": "Suivant",
"weeklyLimitReached": "Limite hebdomadaire atteinte",
"sessionLimitReached": "Limite de session atteinte",
"notAuthenticated": "Non authentifié",
"sessionShort": "Utilisation session 5 heures",
"weeklyShort": "Utilisation hebdomadaire 7 jours",
"swap": "Changer"
},
"oauth": {
"enterCode": "Saisie manuelle du code (secours)",
@@ -29,13 +29,9 @@
"title": "Chemins",
"description": "Outils CLI et chemins framework"
},
"integrations": {
"title": "Intégrations",
"description": "Clés API & comptes Claude"
},
"api-profiles": {
"title": "Profils API",
"description": "Profils d'endpoint API personnalisés"
"accounts": {
"title": "Comptes",
"description": "Comptes Claude & endpoints API"
},
"updates": {
"title": "Mises à jour",
@@ -412,6 +408,7 @@
"description": "Gérer les comptes Claude et les clés API",
"claudeAccounts": "Comptes Claude",
"claudeAccountsDescription": "Ajoutez plusieurs abonnements Claude pour basculer automatiquement entre eux quand vous atteignez les limites.",
"claudeAccountsWarning": "Lors de l'authentification, assurez-vous d'être connecté au bon compte Claude dans votre navigateur. Chaque profil doit utiliser un abonnement différent.",
"noAccountsYet": "Aucun compte configuré",
"default": "Par défaut",
"active": "Actif",
@@ -479,6 +476,123 @@
"authStartFailedMessage": "Échec du démarrage de l'authentification. Veuillez réessayer."
}
},
"accounts": {
"title": "Comptes",
"description": "Gérer les comptes Claude et les endpoints API",
"tabs": {
"claudeCode": "Claude Code",
"customEndpoints": "Endpoints personnalisés"
},
"claudeCode": {
"description": "Ajoutez plusieurs abonnements Claude pour basculer automatiquement entre eux lorsque vous atteignez les limites de taux.",
"noAccountsYet": "Aucun compte configuré",
"default": "Par défaut",
"active": "Actif",
"authenticated": "Authentifié",
"needsAuth": "Auth requise",
"authenticate": "Authentifier",
"authenticating": "Authentification...",
"setActive": "Définir actif",
"manualTokenEntry": "Saisie manuelle du token",
"runSetupToken": "Exécutez claude et tapez /login pour vous authentifier",
"tokenPlaceholder": "sk-ant-oat01-...",
"emailPlaceholder": "Email (optionnel, pour l'affichage)",
"saveToken": "Enregistrer le token",
"accountNamePlaceholder": "Nom du compte (ex: Travail, Personnel)"
},
"customEndpoints": {
"description": "Configurez des endpoints API compatibles Anthropic personnalisés",
"addButton": "Ajouter un profil",
"activeBadge": "Actif",
"customModels": "Modèles personnalisés: {{models}}",
"setActive": {
"label": "Définir actif",
"loading": "Configuration..."
},
"switchToOauth": {
"label": "Utiliser Claude Code",
"loading": "Basculement..."
},
"tooltips": {
"edit": "Modifier le profil",
"deleteActive": "Impossible de supprimer le profil actif",
"deleteInactive": "Supprimer le profil"
},
"empty": {
"title": "Aucun endpoint personnalisé",
"description": "Configurez des endpoints API compatibles Anthropic personnalisés pour utiliser des fournisseurs alternatifs.",
"action": "Ajouter un profil"
},
"dialog": {
"deleteTitle": "Supprimer le profil",
"deleteDescription": "Êtes-vous sûr de vouloir supprimer \"{{name}}\" ? Cette action est irréversible.",
"cancel": "Annuler",
"delete": "Supprimer",
"deleting": "Suppression..."
}
},
"autoSwitching": {
"title": "Basculement automatique de compte",
"description": "Basculez automatiquement entre les comptes pour éviter les interruptions. Configurez la surveillance proactive pour basculer avant d'atteindre les limites.",
"enableAutoSwitching": "Activer le basculement automatique",
"masterSwitch": "Interrupteur principal pour toutes les fonctionnalités d'auto-basculement",
"proactiveMonitoring": "Surveillance proactive",
"proactiveDescription": "Vérifier l'utilisation régulièrement et basculer avant d'atteindre les limites",
"sessionThreshold": "Seuil d'utilisation de session",
"sessionThresholdDescription": "Basculer lorsque l'utilisation de session atteint ce niveau (recommandé: 95%)",
"weeklyThreshold": "Seuil d'utilisation hebdomadaire",
"weeklyThresholdDescription": "Basculer lorsque l'utilisation hebdomadaire atteint ce niveau (recommandé: 99%)",
"reactiveRecovery": "Récupération réactive",
"reactiveDescription": "Auto-basculement en cas de limite de taux inattendue"
},
"priority": {
"title": "Ordre de priorité des comptes",
"description": "Glissez pour réorganiser. Le système basculera vers le prochain compte disponible dans l'ordre.",
"noAccounts": "Aucun compte configuré. Ajoutez des comptes ci-dessus pour définir la priorité.",
"noEmail": "Pas d'email",
"active": "Actif",
"inUse": "En cours",
"next": "Suivant",
"unlimited": "Illimité",
"unavailable": "Indisponible",
"typeOAuth": "OAuth",
"typeAPI": "API",
"payPerUse": "Paiement à l'usage",
"needsAuth": "Non authentifié",
"duplicateUsage": "Doublon détecté",
"duplicateUsageHint": "Ce profil a une utilisation identique à un autre profil, suggérant qu'ils sont peut-être authentifiés sur le même compte Anthropic. Réauthentifiez-vous avec un autre compte pour corriger.",
"sessionUsage": "Utilisation de session (fenêtre de 5 heures)",
"weeklyUsage": "Utilisation hebdomadaire (fenêtre de 7 jours)",
"oauthSection": "Comptes Claude (utilisés en premier)",
"apiSection": "Points de terminaison de secours (quand tous les comptes sont épuisés)",
"tipTitle": "Comment fonctionne la priorité",
"tipDescription": "Les comptes Claude sont inclus dans votre abonnement et seront utilisés en premier. Les endpoints API facturent par requête et sont utilisés comme solutions de secours lorsque tous les comptes Claude atteignent leurs limites.",
"status": {
"healthy": "Sain",
"moderate": "Modéré",
"highUsage": "Utilisation élevée",
"nearLimit": "Proche de la limite",
"rateLimited": "Limité"
}
},
"toast": {
"loadProfilesFailed": "Échec du chargement des profils",
"addProfileFailed": "Échec de l'ajout du profil",
"deleteProfileFailed": "Échec de la suppression du profil",
"renameProfileFailed": "Échec du renommage du profil",
"setActiveProfileFailed": "Échec de la définition du profil actif",
"tokenSaved": "Token enregistré",
"tokenSavedDescription": "Votre token a été enregistré avec succès.",
"tokenSaveFailed": "Échec de l'enregistrement du token",
"settingsUpdateFailed": "Échec de la mise à jour des paramètres",
"tryAgain": "Veuillez réessayer."
},
"alerts": {
"profileCreatedAuthFailed": "Profil créé mais échec de la préparation de l'authentification: {{error}}",
"authPrepareFailed": "Échec de la préparation de l'authentification: {{error}}",
"authStartFailedMessage": "Échec du démarrage de l'authentification. Veuillez réessayer."
}
},
"debug": {
"title": "Debug & Logs",
"description": "Accédez aux logs et informations de débogage pour le dépannage",
+50
View File
@@ -56,6 +56,8 @@ export interface ClaudeUsageSnapshot {
profileId: string;
/** Profile name for display */
profileName: string;
/** Email address associated with the profile (from Keychain or profile data) */
profileEmail?: string;
/** When this snapshot was captured */
fetchedAt: Date;
/** Which limit is closest to threshold ('session' or 'weekly') */
@@ -77,6 +79,54 @@ export interface ClaudeUsageSnapshot {
weeklyUsageLimit?: number;
}
/**
* Profile usage summary for multi-profile display
* Contains the essential data needed to rank and display profiles in the usage indicator
*/
export interface ProfileUsageSummary {
/** Profile ID */
profileId: string;
/** Profile name for display */
profileName: string;
/** Email address (from Keychain or profile) */
profileEmail?: string;
/** Session usage percentage (0-100) */
sessionPercent: number;
/** Weekly usage percentage (0-100) */
weeklyPercent: number;
/** ISO timestamp of when the session limit resets */
sessionResetTimestamp?: string;
/** ISO timestamp of when the weekly limit resets */
weeklyResetTimestamp?: string;
/** Whether this profile is authenticated */
isAuthenticated: boolean;
/** Whether this profile is currently rate limited */
isRateLimited: boolean;
/** Type of rate limit if limited */
rateLimitType?: 'session' | 'weekly';
/** Availability score (higher = more available, used for sorting) */
availabilityScore: number;
/** Whether this is the currently active profile */
isActive: boolean;
/** When this data was last fetched (ISO timestamp) */
lastFetchedAt?: string;
/** Error message if usage fetch failed */
fetchError?: string;
}
/**
* All profiles usage data for the usage indicator
* Emitted alongside the active profile's detailed snapshot
*/
export interface AllProfilesUsage {
/** Detailed snapshot for the active profile */
activeProfile: ClaudeUsageSnapshot;
/** Summary usage data for all profiles (sorted by availability, best first) */
allProfiles: ProfileUsageSummary[];
/** When this data was collected */
fetchedAt: Date;
}
/**
* Rate limit event recorded for a profile
*/
+9
View File
@@ -66,6 +66,7 @@ import type {
ClaudeAutoSwitchSettings,
ClaudeAuthResult,
ClaudeUsageSnapshot,
AllProfilesUsage,
TerminalProfileChangedEvent
} from './agent';
import type { AppSettings, SourceEnvConfig, SourceEnvCheckResult } from './settings';
@@ -292,6 +293,10 @@ export interface ElectronAPI {
getAutoSwitchSettings: () => Promise<IPCResult<ClaudeAutoSwitchSettings>>;
/** Update auto-switch settings */
updateAutoSwitchSettings: (settings: Partial<ClaudeAutoSwitchSettings>) => Promise<IPCResult>;
/** Get unified account priority order (both OAuth and API profiles) */
getAccountPriorityOrder: () => Promise<IPCResult<string[]>>;
/** Set unified account priority order */
setAccountPriorityOrder: (order: string[]) => Promise<IPCResult>;
/** Request usage fetch from a terminal (sends /usage command) */
fetchClaudeUsage: (terminalId: string) => Promise<IPCResult>;
/** Get the best available profile (for manual switching) */
@@ -306,6 +311,8 @@ export interface ElectronAPI {
// Usage Monitoring (Proactive Account Switching)
/** Request current usage snapshot */
requestUsageUpdate: () => Promise<IPCResult<ClaudeUsageSnapshot | null>>;
/** Request all profiles usage immediately (for startup/refresh) */
requestAllProfilesUsage: () => Promise<IPCResult<AllProfilesUsage | null>>;
/** Listen for usage data updates */
onUsageUpdated: (callback: (usage: ClaudeUsageSnapshot) => void) => () => void;
/** Listen for proactive swap notifications */
@@ -315,6 +322,8 @@ export interface ElectronAPI {
reason: string;
usageSnapshot: ClaudeUsageSnapshot;
}) => void) => () => void;
/** Listen for all profiles usage updates (for multi-profile display) */
onAllProfilesUsageUpdated?: (callback: (allProfilesUsage: AllProfilesUsage) => void) => () => void;
// App settings
getSettings: () => Promise<IPCResult<AppSettings>>;