Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions l10n/bundle.l10n.ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"5-hour": "5時間",
"Weekly": "週間",
"Plan": "プラン",
"Current account": "現在のアカウント",
"resets in {0} ({1})": "リセットまで {0}({1})",
"As of {0}": "{0} 時点",
"resetting soon": "まもなくリセット",
Expand Down
1 change: 1 addition & 0 deletions l10n/bundle.l10n.ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"5-hour": "5시간",
"Weekly": "주간",
"Plan": "플랜",
"Current account": "현재 계정",
"resets in {0} ({1})": "리셋 {0} 후 ({1})",
"As of {0}": "기준 시각 {0}",
"resetting soon": "리셋 직후",
Expand Down
1 change: 1 addition & 0 deletions l10n/bundle.l10n.zh-cn.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"5-hour": "5小时",
"Weekly": "每周",
"Plan": "套餐",
"Current account": "当前账户",
"resets in {0} ({1})": "{0}后重置({1})",
"As of {0}": "截至 {0}",
"resetting soon": "即将重置",
Expand Down
1 change: 1 addition & 0 deletions l10n/bundle.l10n.zh-tw.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"5-hour": "5小時",
"Weekly": "每週",
"Plan": "方案",
"Current account": "目前帳戶",
"resets in {0} ({1})": "{0}後重設({1})",
"As of {0}": "截至 {0}",
"resetting soon": "即將重設",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 9 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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%"
}
}
Expand Down
1 change: 1 addition & 0 deletions package.nls.ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
1 change: 1 addition & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
1 change: 1 addition & 0 deletions package.nls.ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
1 change: 1 addition & 0 deletions package.nls.zh-cn.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
1 change: 1 addition & 0 deletions package.nls.zh-tw.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
23 changes: 22 additions & 1 deletion src/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ interface ClaudeCredentials {
};
}

interface ClaudeProfile {
oauthAccount?: {
emailAddress?: string;
};
}

interface ClaudeWindowInfo {
utilization?: number | null;
resets_at?: string | null;
Expand Down Expand Up @@ -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<string | null> {
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<string | null> {
return new Promise((resolve) => {
Expand Down Expand Up @@ -219,7 +238,8 @@ function toWindows(usage: ClaudeUsageResponse): UsageWindow[] {
// Claude OAuth usage API 조회
export async function fetchClaudeUsage(customCredentialsPath: string): Promise<UsageResult> {
// 자격증명 경로 변경 시 캐시·유예 초기화
const cacheKey = resolveCredentialsPath(customCredentialsPath);
const account = await readClaudeAccount(customCredentialsPath);
const cacheKey = `${resolveCredentialsPath(customCredentialsPath)}:${account ?? ''}`;
if (cacheKey !== lastCacheKey) {
lastCacheKey = cacheKey;
lastResult = null;
Expand Down Expand Up @@ -320,6 +340,7 @@ export async function fetchClaudeUsage(customCredentialsPath: string): Promise<U
data: {
windows,
plan: oauth?.subscriptionType ?? null,
account,
fetchedAt: new Date(),
sourceNote: null,
},
Expand Down
33 changes: 33 additions & 0 deletions src/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,43 @@ interface CodexRateLimitRecord {
recordedAt: Date;
}

interface CodexAuthData {
tokens?: {
id_token?: string;
account_id?: string;
};
}

interface CodexIdTokenPayload {
email?: string;
}

// Codex 세션 디렉터리 기본 경로 결정
export function resolveSessionsPath(customPath: string): string {
return customPath || path.join(os.homedir(), '.codex', 'sessions');
}

// 현재 Codex 계정 식별 정보 조회
async function readCodexAccount(sessionsDir: string): Promise<string | null> {
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<void> {
let entries;
Expand Down Expand Up @@ -201,6 +233,7 @@ export async function fetchCodexUsage(customSessionsPath: string): Promise<Usage
data: {
windows: latestUsage.windows,
plan: latestUsage.plan,
account: await readCodexAccount(sessionsDir),
fetchedAt: latestUsage.recordedAt,
sourceNote: l10n.t('Updates from session logs when Codex runs'),
},
Expand Down
8 changes: 7 additions & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface ExtensionConfig {
claudeEnabled: boolean;
codexEnabled: boolean;
claudeCredentialsPath: string;
claudeAccountLabel: string;
codexSessionsPath: string;
}

Expand Down Expand Up @@ -51,6 +52,7 @@ function getConfig(): ExtensionConfig {
claudeEnabled: cfg.get<boolean>('claude.enabled', true),
codexEnabled: cfg.get<boolean>('codex.enabled', true),
claudeCredentialsPath: cfg.get<string>('claude.credentialsPath', ''),
claudeAccountLabel: cfg.get<string>('claude.accountLabel', '').trim(),
codexSessionsPath: cfg.get<string>('codex.sessionsPath', ''),
};
}
Expand Down Expand Up @@ -112,7 +114,11 @@ async function refreshAll(bindings: ProviderBinding[]): Promise<void> {
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);
}),
);
}
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface UsageWindow {
export interface UsageSnapshot {
windows: UsageWindow[];
plan: string | null;
account: string | null;
fetchedAt: Date;
sourceNote: string | null;
}
Expand Down
3 changes: 3 additions & 0 deletions src/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`,
Expand Down
28 changes: 24 additions & 4 deletions test/claude.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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');
});
39 changes: 39 additions & 0 deletions test/codex.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading