diff --git a/CHANGELOG.md b/CHANGELOG.md index d23e5b6..a89f3f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +## 0.3.4 — 2026-08-12 + +- Codex and Claude tooltips now show the currently active account for easier multi-account identification +- Claude automatically reads the active email from the local profile and supports a custom account label override +- Account switches now isolate cached Claude usage data, with fallback coverage for Codex account identifiers + ## 0.3.3 — 2026-08-10 - Codex usage now recovers after account switches that create consecutive empty rate-limit logs diff --git a/l10n/bundle.l10n.ja.json b/l10n/bundle.l10n.ja.json index c815ddd..887d3d5 100644 --- a/l10n/bundle.l10n.ja.json +++ b/l10n/bundle.l10n.ja.json @@ -16,6 +16,7 @@ "5-hour": "5時間", "Weekly": "週間", "Plan": "プラン", + "Current account": "現在のアカウント", "resets in {0} ({1})": "リセットまで {0}({1})", "As of {0}": "{0} 時点", "resetting soon": "まもなくリセット", diff --git a/l10n/bundle.l10n.ko.json b/l10n/bundle.l10n.ko.json index f5d0573..1da993f 100644 --- a/l10n/bundle.l10n.ko.json +++ b/l10n/bundle.l10n.ko.json @@ -16,6 +16,7 @@ "5-hour": "5시간", "Weekly": "주간", "Plan": "플랜", + "Current account": "현재 계정", "resets in {0} ({1})": "리셋 {0} 후 ({1})", "As of {0}": "기준 시각 {0}", "resetting soon": "리셋 직후", diff --git a/l10n/bundle.l10n.zh-cn.json b/l10n/bundle.l10n.zh-cn.json index cb8ab7b..322ba0d 100644 --- a/l10n/bundle.l10n.zh-cn.json +++ b/l10n/bundle.l10n.zh-cn.json @@ -16,6 +16,7 @@ "5-hour": "5小时", "Weekly": "每周", "Plan": "套餐", + "Current account": "当前账户", "resets in {0} ({1})": "{0}后重置({1})", "As of {0}": "截至 {0}", "resetting soon": "即将重置", diff --git a/l10n/bundle.l10n.zh-tw.json b/l10n/bundle.l10n.zh-tw.json index 3d4363b..a0e6a20 100644 --- a/l10n/bundle.l10n.zh-tw.json +++ b/l10n/bundle.l10n.zh-tw.json @@ -16,6 +16,7 @@ "5-hour": "5小時", "Weekly": "每週", "Plan": "方案", + "Current account": "目前帳戶", "resets in {0} ({1})": "{0}後重設({1})", "As of {0}": "截至 {0}", "resetting soon": "即將重設", diff --git a/package-lock.json b/package-lock.json index e437a61..1119fa9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-codex-usage-monitor", - "version": "0.3.3", + "version": "0.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-codex-usage-monitor", - "version": "0.3.3", + "version": "0.3.4", "license": "MIT", "devDependencies": { "@types/node": "^18.19.0", diff --git a/package.json b/package.json index b2b2cab..1009ed1 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "claude-codex-usage-monitor", "displayName": "Claude Code Usage & Codex Usage Monitor", "description": "%ext.description%", - "version": "0.3.3", + "version": "0.3.4", "publisher": "jjju", "license": "MIT", "pricing": "Free", @@ -154,16 +154,22 @@ "order": 6, "description": "%cfg.claude.credentialsPath%" }, + "claudeCodexUsage.claude.accountLabel": { + "type": "string", + "default": "", + "order": 7, + "description": "%cfg.claude.accountLabel%" + }, "claudeCodexUsage.codex.enabled": { "type": "boolean", "default": true, - "order": 7, + "order": 8, "description": "%cfg.codex.enabled%" }, "claudeCodexUsage.codex.sessionsPath": { "type": "string", "default": "", - "order": 8, + "order": 9, "description": "%cfg.codex.sessionsPath%" } } diff --git a/package.nls.ja.json b/package.nls.ja.json index 52bf012..eddb91e 100644 --- a/package.nls.ja.json +++ b/package.nls.ja.json @@ -11,6 +11,7 @@ "cfg.errorThreshold": "この使用率(%)以上で赤のエラー背景を表示", "cfg.claude.enabled": "Claude Code の使用量を表示", "cfg.claude.credentialsPath": "Claude 認証情報ファイルのパス(空 = ~/.claude/.credentials.json)", + "cfg.claude.accountLabel": "ツールチップに表示する Claude アカウントラベル(自動メール検出より優先)", "cfg.codex.enabled": "Codex CLI の使用量を表示", "cfg.codex.sessionsPath": "Codex セッションログのディレクトリ(空 = ~/.codex/sessions)" } diff --git a/package.nls.json b/package.nls.json index 5c93fef..54cdde0 100644 --- a/package.nls.json +++ b/package.nls.json @@ -11,6 +11,7 @@ "cfg.errorThreshold": "Show a red error background at or above this usage (%)", "cfg.claude.enabled": "Show Claude Code usage", "cfg.claude.credentialsPath": "Claude credentials file path (empty = ~/.claude/.credentials.json)", + "cfg.claude.accountLabel": "Claude account label shown in the tooltip (overrides automatic email detection)", "cfg.codex.enabled": "Show Codex CLI usage", "cfg.codex.sessionsPath": "Codex session log directory (empty = ~/.codex/sessions)" } diff --git a/package.nls.ko.json b/package.nls.ko.json index 96d9b11..ef1a988 100644 --- a/package.nls.ko.json +++ b/package.nls.ko.json @@ -11,6 +11,7 @@ "cfg.errorThreshold": "이 사용률(%) 이상이면 빨간색 위험 배경 표시", "cfg.claude.enabled": "Claude Code 사용량 표시", "cfg.claude.credentialsPath": "Claude 인증 파일 경로 (비워두면 ~/.claude/.credentials.json)", + "cfg.claude.accountLabel": "툴팁에 표시할 Claude 계정 라벨 (자동 이메일 감지보다 우선)", "cfg.codex.enabled": "Codex CLI 사용량 표시", "cfg.codex.sessionsPath": "Codex 세션 로그 디렉터리 (비워두면 ~/.codex/sessions)" } diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json index cb23901..18114dd 100644 --- a/package.nls.zh-cn.json +++ b/package.nls.zh-cn.json @@ -11,6 +11,7 @@ "cfg.errorThreshold": "用量达到该百分比(%)及以上时显示红色警报背景", "cfg.claude.enabled": "显示 Claude Code 用量", "cfg.claude.credentialsPath": "Claude 凭据文件路径(留空 = ~/.claude/.credentials.json)", + "cfg.claude.accountLabel": "工具提示中显示的 Claude 账户标签(优先于自动电子邮件检测)", "cfg.codex.enabled": "显示 Codex CLI 用量", "cfg.codex.sessionsPath": "Codex 会话日志目录(留空 = ~/.codex/sessions)" } diff --git a/package.nls.zh-tw.json b/package.nls.zh-tw.json index d3acd92..f1a1912 100644 --- a/package.nls.zh-tw.json +++ b/package.nls.zh-tw.json @@ -11,6 +11,7 @@ "cfg.errorThreshold": "用量達到該百分比(%)以上時顯示紅色警示背景", "cfg.claude.enabled": "顯示 Claude Code 用量", "cfg.claude.credentialsPath": "Claude 憑證檔案路徑(留空 = ~/.claude/.credentials.json)", + "cfg.claude.accountLabel": "工具提示中顯示的 Claude 帳戶標籤(優先於自動電子郵件偵測)", "cfg.codex.enabled": "顯示 Codex CLI 用量", "cfg.codex.sessionsPath": "Codex 工作階段日誌目錄(留空 = ~/.codex/sessions)" } diff --git a/src/claude.ts b/src/claude.ts index 320c5d5..97eeb32 100644 --- a/src/claude.ts +++ b/src/claude.ts @@ -82,6 +82,12 @@ interface ClaudeCredentials { }; } +interface ClaudeProfile { + oauthAccount?: { + emailAddress?: string; + }; +} + interface ClaudeWindowInfo { utilization?: number | null; resets_at?: string | null; @@ -125,6 +131,19 @@ function resolveCredentialsPath(customPath: string): string { return customPath || path.join(os.homedir(), '.claude', '.credentials.json'); } +// 현재 Claude 계정 이메일 조회 +async function readClaudeAccount(customCredentialsPath: string): Promise { + if (customCredentialsPath) { + return null; + } + try { + const profile: ClaudeProfile = JSON.parse(await fs.readFile(path.join(os.homedir(), '.claude.json'), 'utf8')); + return profile.oauthAccount?.emailAddress ?? null; + } catch { + return null; + } +} + // macOS Keychain 자격증명 조회 function credentialsFromKeychain(): Promise { return new Promise((resolve) => { @@ -219,7 +238,8 @@ function toWindows(usage: ClaudeUsageResponse): UsageWindow[] { // Claude OAuth usage API 조회 export async function fetchClaudeUsage(customCredentialsPath: string): Promise { // 자격증명 경로 변경 시 캐시·유예 초기화 - const cacheKey = resolveCredentialsPath(customCredentialsPath); + const account = await readClaudeAccount(customCredentialsPath); + const cacheKey = `${resolveCredentialsPath(customCredentialsPath)}:${account ?? ''}`; if (cacheKey !== lastCacheKey) { lastCacheKey = cacheKey; lastResult = null; @@ -320,6 +340,7 @@ export async function fetchClaudeUsage(customCredentialsPath: string): Promise { + let auth: CodexAuthData; + try { + auth = JSON.parse(await fs.readFile(path.join(path.dirname(sessionsDir), 'auth.json'), 'utf8')); + } catch { + return null; + } + + const encodedPayload = auth.tokens?.id_token?.split('.')[1]; + if (encodedPayload) { + try { + const payload: CodexIdTokenPayload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8')); + if (payload.email) { + return payload.email; + } + } catch {} + } + return auth.tokens?.account_id ?? null; +} + // 이름 역순 우선 순회로 최신 jsonl 파일 수집 async function collectRecentJsonlFiles(dir: string, out: string[], limit: number): Promise { let entries; @@ -201,6 +233,7 @@ export async function fetchCodexUsage(customSessionsPath: string): Promise('claude.enabled', true), codexEnabled: cfg.get('codex.enabled', true), claudeCredentialsPath: cfg.get('claude.credentialsPath', ''), + claudeAccountLabel: cfg.get('claude.accountLabel', '').trim(), codexSessionsPath: cfg.get('codex.sessionsPath', ''), }; } @@ -112,7 +114,11 @@ async function refreshAll(bindings: ProviderBinding[]): Promise { binding.item.hide(); return; } - render(binding, result, latestConfig); + const displayResult = + binding.id === 'claude' && result.status === 'ok' && latestConfig.claudeAccountLabel + ? { ...result, data: { ...result.data, account: latestConfig.claudeAccountLabel } } + : result; + render(binding, displayResult, latestConfig); }), ); } diff --git a/src/types.ts b/src/types.ts index 2f1de0e..46a2602 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,7 @@ export interface UsageWindow { export interface UsageSnapshot { windows: UsageWindow[]; plan: string | null; + account: string | null; fetchedAt: Date; sourceNote: string | null; } diff --git a/src/view.ts b/src/view.ts index 8a675bc..a75262f 100644 --- a/src/view.ts +++ b/src/view.ts @@ -127,6 +127,9 @@ export function buildTooltip( ): vscode.MarkdownString { const planSuffix = data.plan ? ` · ${vscode.l10n.t('Plan')} ${data.plan}` : ''; const lines: string[] = [`$(${icon}) **${title}**${planSuffix}`]; + if (data.account) { + lines.push(`${vscode.l10n.t('Current account')}: **${data.account}**`); + } for (const window of data.windows) { lines.push( `${severityIcon(window.percent, warningThreshold, errorThreshold)}${windowDisplayName(window)}: ${buildMeter(window.percent)} **${formatPercent(window.percent)}**${formatResetDetail(window.resetsAt)}`, diff --git a/test/claude.test.cjs b/test/claude.test.cjs index 485d543..8382ee2 100644 --- a/test/claude.test.cjs +++ b/test/claude.test.cjs @@ -6,6 +6,7 @@ const { setTimeout: delay } = require('node:timers/promises'); const originalFetch = global.fetch; const originalLoad = Module._load; const claudePath = require.resolve('../out/claude'); +let claudeAccountEmail = 'claude@example.com'; // VS Code 런타임 번역 대체 function translate(message, ...args) { @@ -24,10 +25,12 @@ Module._load = function load(request, parent, isMain) { } if (request === 'fs/promises') { return { - readFile: async () => - JSON.stringify({ - claudeAiOauth: { accessToken: 'test-token', subscriptionType: 'max' }, - }), + readFile: async (file) => + JSON.stringify( + String(file).endsWith('.claude.json') + ? { oauthAccount: { emailAddress: claudeAccountEmail } } + : { claudeAiOauth: { accessToken: 'test-token', subscriptionType: 'max' } }, + ), }; } return originalLoad.call(this, request, parent, isMain); @@ -144,3 +147,20 @@ test('AbortError는 요청 시간 초과 오류로 표시한다', async () => { message: 'Usage API request timed out', }); }); + +// 현재 Claude 계정 이메일 조회 검증 +test('기본 자격증명 사용 시 현재 Claude 계정 이메일을 반환한다', async () => { + global.fetch = async () => ({ + ok: true, + status: 200, + headers: new Map(), + text: async () => JSON.stringify({ five_hour: { utilization: 12 } }), + }); + claudeAccountEmail = 'current-claude@example.com'; + const { fetchClaudeUsage } = loadClaude(); + + const result = await fetchClaudeUsage(''); + + assert.equal(result.status, 'ok'); + assert.equal(result.data.account, 'current-claude@example.com'); +}); diff --git a/test/codex.test.cjs b/test/codex.test.cjs index 0e8f9eb..7d282f2 100644 --- a/test/codex.test.cjs +++ b/test/codex.test.cjs @@ -62,6 +62,16 @@ async function writeTimestampedLogFile(dir, name, rateLimits, recordedAt, mtime) return file; } +// Codex 현재 계정 인증 정보 기록 +async function writeAuthFile(sessionsDir, email, accountId) { + const encodedPayload = Buffer.from(JSON.stringify({ email })).toString('base64url'); + await fsp.writeFile( + path.join(path.dirname(sessionsDir), 'auth.json'), + JSON.stringify({ tokens: { id_token: `header.${encodedPayload}.signature`, account_id: accountId } }), + 'utf8', + ); +} + // 최신 로그 우선 조회 검증 test('가장 최근 세션 로그의 rate_limits를 사용한다', async () => { const root = await createTempDir(); @@ -144,6 +154,35 @@ test('window_minutes가 없으면 primary는 세션 구간으로 분류한다', } }); +// 현재 Codex 계정 식별 정보 조회 검증 +test('Codex 인증 정보에서 이메일을 우선하고 계정 ID를 대체 사용한다', async () => { + const root = await createTempDir(); + const sessionsDir = path.join(root, 'sessions'); + try { + await writeLogFile(path.join(sessionsDir, '2026', '08', '12'), 'rollout-account.jsonl', { + primary: { used_percent: 25, window_minutes: 300, resets_in_seconds: 3600 }, + }); + await writeAuthFile(sessionsDir, 'current@example.com', 'account-id'); + + const emailResult = await fetchCodexUsage(sessionsDir); + + assert.equal(emailResult.status, 'ok'); + assert.equal(emailResult.data.account, 'current@example.com'); + + await fsp.writeFile( + path.join(root, 'auth.json'), + JSON.stringify({ tokens: { id_token: 'invalid-token', account_id: 'account-id' } }), + 'utf8', + ); + const accountIdResult = await fetchCodexUsage(sessionsDir); + + assert.equal(accountIdResult.status, 'ok'); + assert.equal(accountIdResult.data.account, 'account-id'); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } +}); + // 미설치·경로 오류 상태 구분 검증 test('기본 세션 경로가 없으면 absent, 커스텀 경로가 없으면 missing을 반환한다', async () => { const root = await createTempDir(); diff --git a/test/view.test.cjs b/test/view.test.cjs index a390487..8ebcc67 100644 --- a/test/view.test.cjs +++ b/test/view.test.cjs @@ -12,12 +12,20 @@ function translate(message, ...args) { // VS Code 런타임 모듈 대체 Module._load = function load(request, parent, isMain) { if (request === 'vscode') { - return { env: { language: 'en' }, l10n: { t: translate } }; + return { + env: { language: 'en' }, + l10n: { t: translate }, + MarkdownString: class MarkdownString { + constructor(value) { + this.value = value; + } + }, + }; } return originalLoad.call(this, request, parent, isMain); }; -const { buildStatusText, pickSeverity } = require('../out/view'); +const { buildStatusText, buildTooltip, pickSeverity } = require('../out/view'); // 사용량 테스트 스냅샷 생성 function createSnapshot(now) { @@ -37,6 +45,7 @@ function createSnapshot(now) { }, ], plan: 'max', + account: 'user@example.com', fetchedAt: new Date(now), sourceNote: null, }; @@ -71,11 +80,19 @@ test('full 모드는 각 사용량 구간의 초기화 시간을 표시한다', } }); +// 현재 계정 툴팁 표시 검증 +test('툴팁에 현재 계정 이메일을 표시한다', () => { + const tooltip = buildTooltip('codex-usage-logo', 'Codex CLI Usage', createSnapshot(Date.now()), 80, 95); + + assert.match(tooltip.value, /Current account: \*\*user@example\.com\*\*/); +}); + // 표시값·경고 단계 반올림 일치 검증 test('경고 단계는 표시와 같은 반올림 값 기준으로 판정한다', () => { const data = { windows: [{ kind: 'session', label: '5h', percent: 94.6, resetsAt: null }], plan: null, + account: null, fetchedAt: new Date(), sourceNote: null, };