From 6046187bd7bf8094bfcd3432b949a78d6289ac6c Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:10:35 +0300 Subject: [PATCH] feat(claude): discover Personal and Team organizations for quota, sessions and today (#1756) --- src/live-sessions.ts | 6 +- src/parser.ts | 15 +- src/providers/claude.ts | 244 +++++++++++++++++- src/quota/claude.ts | 473 +++++++++++++++++++++++++++++++--- src/quota/index.ts | 34 ++- src/usage-aggregator.ts | 8 +- tests/quota-providers.test.ts | 111 +++++++- windows/src/lib/quota.ts | 3 +- 8 files changed, 841 insertions(+), 53 deletions(-) diff --git a/src/live-sessions.ts b/src/live-sessions.ts index 9be09e900..fae4f932b 100644 --- a/src/live-sessions.ts +++ b/src/live-sessions.ts @@ -4,7 +4,7 @@ /// only what it finds here. import { open, readdir, stat } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' -import { getClaudeConfigDirs, getDesktopSessionsDirs, claudeConfigSourceId, claudeDesktopSourceId } from './providers/claude.js' +import { getClaudeConfigDirs, getDesktopSessionsDirs, claudeConfigSourceId, claudeDesktopSourceId, discoverClaudeSessionOrgSources } from './providers/claude.js' import { kimicodeHomes, projectFromWorkDir, readState as readKimicodeState } from './providers/kimicode.js' import { reportedContextWindow } from './context-tree.js' import { modelRowKey } from './models.js' @@ -252,10 +252,12 @@ export async function collectLiveSessionInputs( type PendingSidechain = { sessionId: string; mtimeMs: number; path: string; sourceId: string } const pendingSidechains: PendingSidechain[] = [] + const sessionOrgSources = await discoverClaudeSessionOrgSources().catch(() => new Map()) + const toInput = (scan: ScannedFile, times: FileTimes, fallbackProject: string): LiveSessionInput => ({ id: scan.sessionId, provider: 'claude', - claudeConfigSourceId: times.sourceId, + claudeConfigSourceId: sessionOrgSources.get(scan.sessionId)?.id ?? times.sourceId, project: scan.cwd ? basename(scan.cwd) : fallbackProject, branch: scan.branch, model: scan.model, diff --git a/src/parser.ts b/src/parser.ts index 7fc0d5dbd..26d24428d 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -12,7 +12,7 @@ import { normalizeContentBlocks, flatSlice, flatString } from './content-utils.j import { discoverAllSessions, discoverAllSessionsWithFailures, getProvider } from './providers/index.js' import { evictCachedCodexResults, flushCodexCache, readCachedCodexResults, withCodexCacheDirectory, writeCachedCodexResults } from './codex-cache.js' import { antigravityCascadeIdFromPath, flushAntigravityCache, preloadAntigravityCache, shouldReparseAntigravitySource } from './providers/antigravity.js' -import { getClaudeConfigDirs, getDesktopSessionsDirs } from './providers/claude.js' +import { discoverClaudeSessionOrgSources, getClaudeConfigDirs, getDesktopSessionsDirs } from './providers/claude.js' import { kimicodeLineageForSource } from './providers/kimicode.js' import { isSqliteBusyError } from './sqlite.js' import { clearProviderIssue, recordProviderIssue } from './provider-issues.js' @@ -2545,6 +2545,7 @@ async function scanProjectDirs( ...unchangedFiles.map(f => ({ filePath: f.filePath, dirName: f.dirName, source: f.source })), ...changedFiles.map(f => ({ filePath: f.filePath, dirName: f.info.dirName, source: f.info.source })), ] + const sessionOrgSources = await discoverClaudeSessionOrgSources().catch(() => new Map()) for (const { filePath, dirName, source } of allFiles) { const cachedFile = section.files[filePath] @@ -2619,6 +2620,14 @@ async function scanProjectDirs( if (classifiedTurns.length === 0 && !isSpawnAnchor) continue const sessionId = basename(filePath, '.jsonl') + const subagentMarker = filePath.lastIndexOf(`${sep}subagents${sep}`) + const folderParentId = subagentMarker > 0 ? basename(filePath.slice(0, subagentMarker)) : undefined + const orgSource = sessionOrgSources.get(sessionId) + ?? (cachedFile.parentSessionId ? sessionOrgSources.get(cachedFile.parentSessionId) : undefined) + ?? (folderParentId ? sessionOrgSources.get(folderParentId) : undefined) + const effectiveSource: SessionSourceMetadata | undefined = orgSource + ? { id: orgSource.id, label: orgSource.label, path: orgSource.path, kind: 'claude-config' } + : source const projectPath = cachedFile.canonicalCwd ?? claudeSlugFallbackPath(dirName) const projectName = cachedFile.canonicalProjectName ?? dirName const projectKey = cachedFile.canonicalCwd @@ -2701,13 +2710,13 @@ async function scanProjectDirs( if (groups.length === 1) { const only = groups[0]! const name = only === fileProject || only.key === projectKey ? projectName : projectMap.get(only.key)?.project ?? only.name - const session = decorate(buildSessionSummary(sessionId, name, classifiedTurns, mcpInv, source)) + const session = decorate(buildSessionSummary(sessionId, name, classifiedTurns, mcpInv, effectiveSource)) if (session.apiCalls > 0 || anchorOnly) addToProject({ ...only, name }, session, anchorOnly) continue } const slices = groups.map(group => { const name = group.key === projectKey ? projectName : projectMap.get(group.key)?.project ?? group.name - return { project: { ...group, name }, session: decorate(buildSessionSummary(sessionId, name, group.turns, mcpInv, source)) } + return { project: { ...group, name }, session: decorate(buildSessionSummary(sessionId, name, group.turns, mcpInv, effectiveSource)) } }) // Primary = the slice holding the most cost, so the session row sits where // the money went and a late `cd` cannot move it. Ties keep the earlier slice. diff --git a/src/providers/claude.ts b/src/providers/claude.ts index 500fd44f1..ba3cb781b 100644 --- a/src/providers/claude.ts +++ b/src/providers/claude.ts @@ -1,12 +1,13 @@ import { readdirSync, statSync } from 'fs' import { readFile, readdir, stat } from 'fs/promises' -import { basename, delimiter as pathDelimiter, join, resolve } from 'path' +import { basename, delimiter as pathDelimiter, dirname, join, resolve } from 'path' import { homedir } from 'os' import { createHash } from 'crypto' import type { Provider, ProbeRoot, SessionSource, SessionParser } from './types.js' import { calculateCost, getShortModelName } from '../models.js' import { readConfig } from '../config.js' +import { getCodeburnCacheDir } from '../cache-dir.js' import { FS_SCAN_CONCURRENCY, mapWithConcurrency, readSessionLines } from '../fs-utils.js' import { wslHomes } from '../wsl.js' @@ -14,8 +15,12 @@ export type ClaudeConfigSource = { id: string label: string path: string + orgUuid?: string + accountUuid?: string } +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + function expandHome(p: string): string { if (p === '~') return homedir() if (p.startsWith('~/') || p.startsWith('~\\')) return join(homedir(), p.slice(2)) @@ -45,6 +50,23 @@ export function claudeDesktopSourceId(base: string): string { return 'claude-desktop:' + createHash('sha256').update(resolve(base)).digest('hex').slice(0, 16) } +/// Stable id for a Claude organization (Personal or Team) discovered from +/// Claude Desktop (`claude-code-sessions//` / `oauth:tokenCacheV2`). +export function claudeOrgSourceId(orgUuid: string): string { + return 'claude-org:' + orgUuid.trim().toLowerCase() +} + +/// macOS Keychain service name Claude Code uses for a config directory. +/// `~/.claude` uses the unsuffixed service; any other `CLAUDE_CONFIG_DIR` +/// appends `-`. +export function claudeKeychainService(configDir?: string): string { + const target = resolve(configDir ? expandHome(configDir) : join(homedir(), '.claude')) + const def = resolve(join(homedir(), '.claude')) + if (target === def) return 'Claude Code-credentials' + const suffix = createHash('sha256').update(target.normalize('NFC')).digest('hex').slice(0, 8) + return `Claude Code-credentials-${suffix}` +} + // `\\wsl$\\home\\.claude` -> distro, and the home's own name // (``, or `root`). Either separator after the prefix: the join that // builds these follows the host platform, which is `/` under test. @@ -133,6 +155,226 @@ export async function discoverClaudeConfigSources(): Promise> { + const map = new Map() + try { + const raw = await readFile(claudeOrgProfilesCachePath(), 'utf-8') + const parsed = JSON.parse(raw) as Record + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return map + for (const [key, val] of Object.entries(parsed)) { + if (!val || typeof val !== 'object' || Array.isArray(val)) continue + const row = val as Record + const orgUuid = (typeof row.orgUuid === 'string' ? row.orgUuid : key).trim().toLowerCase() + if (!UUID_RE.test(orgUuid)) continue + map.set(orgUuid, { + orgUuid, + ...(typeof row.accountUuid === 'string' ? { accountUuid: row.accountUuid.trim().toLowerCase() } : {}), + ...(typeof row.label === 'string' && row.label.trim() ? { label: row.label.trim() } : {}), + ...(typeof row.organizationType === 'string' ? { organizationType: row.organizationType.trim().toLowerCase() } : {}), + ...(row.isDefaultConfigOrg === true ? { isDefaultConfigOrg: true } : {}), + }) + } + } catch { + // Missing or unreadable cache is normal on first run. + } + return map +} + +function formatOrgLabel(name: string | undefined, organizationType: string | undefined): string | undefined { + const trimmed = name?.trim() + const type = organizationType?.toLowerCase() + if (type === 'max' || type === 'pro' || type === 'claude_max' || type === 'claude_pro') { + return 'Personal' + } + if (trimmed) { + if (/^.+['’]s organization$/i.test(trimmed)) return 'Personal' + return trimmed + } + if (type === 'team' || type === 'claude_team') return 'Team' + if (type === 'enterprise' || type === 'claude_enterprise') return 'Enterprise' + return undefined +} + +async function readDefaultClaudeOauthAccount(defaultDir: string): Promise<{ + accountUuid?: string + orgUuid?: string + label?: string +}> { + const candidates = [ + join(homedir(), '.claude.json'), + join(defaultDir, '.claude.json'), + ] + for (const file of candidates) { + try { + const parsed = JSON.parse(await readFile(file, 'utf-8')) as { oauthAccount?: Record } + const acct = parsed?.oauthAccount + if (!acct || typeof acct !== 'object') continue + const orgUuid = typeof acct.organizationUuid === 'string' && UUID_RE.test(acct.organizationUuid.trim()) + ? acct.organizationUuid.trim().toLowerCase() + : undefined + const accountUuid = typeof acct.accountUuid === 'string' && UUID_RE.test(acct.accountUuid.trim()) + ? acct.accountUuid.trim().toLowerCase() + : undefined + const orgName = typeof acct.organizationName === 'string' ? acct.organizationName : undefined + if (orgUuid || accountUuid) { + return { + accountUuid, + orgUuid, + label: formatOrgLabel(orgName, undefined), + } + } + } catch { + continue + } + } + return {} +} + +type DiscoveredDesktopOrg = { + orgUuid: string + accountUuid: string + path: string + sessionIds: string[] +} + +async function scanDesktopCodeSessionOrgs(): Promise> { + const byOrg = new Map() + const appRoots = dedupeResolved(getDesktopSessionsDirs().map(dir => dirname(dir))) + for (const appRoot of appRoots) { + const codeSessionsRoot = join(appRoot, 'claude-code-sessions') + const acctEntries = await readdir(codeSessionsRoot, { withFileTypes: true }).catch(() => []) + for (const acctEntry of acctEntries) { + if (!acctEntry.isDirectory() || !UUID_RE.test(acctEntry.name)) continue + const accountUuid = acctEntry.name.toLowerCase() + const acctPath = join(codeSessionsRoot, acctEntry.name) + const orgEntries = await readdir(acctPath, { withFileTypes: true }).catch(() => []) + for (const orgEntry of orgEntries) { + if (!orgEntry.isDirectory() || !UUID_RE.test(orgEntry.name)) continue + const orgUuid = orgEntry.name.toLowerCase() + const orgPath = join(acctPath, orgEntry.name) + const files = await readdir(orgPath).catch(() => []) + const localFiles = files.filter(f => f.startsWith('local_') && f.endsWith('.json')) + if (localFiles.length === 0) continue + const sessionIds: string[] = [] + for (const file of localFiles) { + try { + const meta = JSON.parse(await readFile(join(orgPath, file), 'utf-8')) as { cliSessionId?: unknown } + if (typeof meta?.cliSessionId === 'string' && meta.cliSessionId.trim()) { + sessionIds.push(meta.cliSessionId.trim()) + } + } catch { + continue + } + } + const existing = byOrg.get(orgUuid) + if (existing) { + existing.sessionIds.push(...sessionIds) + } else { + byOrg.set(orgUuid, { orgUuid, accountUuid, path: orgPath, sessionIds }) + } + } + } + } + return byOrg +} + +/** + * Discovers Claude quota/usage profiles across both configured `claudeConfigDirs` + * and multi-organization Claude Desktop setups (Personal + Team accounts). + * Single-account setups without extra organizations return the exact same list as + * `discoverClaudeConfigSources()`. + */ +export async function discoverClaudeProfileSources(): Promise { + const base = await discoverClaudeConfigSources() + const defaultDir = resolve(join(homedir(), '.claude')) + const defaultIndex = base.findIndex(s => resolve(s.path) === defaultDir) + const [defaultAcct, desktopOrgs, cachedOrgs] = await Promise.all([ + readDefaultClaudeOauthAccount(defaultDir), + scanDesktopCodeSessionOrgs(), + readClaudeOrgProfilesCache(), + ]) + + const cachedDefaultEntry = [...cachedOrgs.values()].find(o => o.isDefaultConfigOrg) + ?? [...cachedOrgs.values()].find(o => o.organizationType === 'max' || o.organizationType === 'pro' || o.label === 'Personal') + const defaultOrgUuid = cachedDefaultEntry?.orgUuid ?? defaultAcct.orgUuid + const allOrgUuids = new Set([ + ...desktopOrgs.keys(), + ...cachedOrgs.keys(), + ]) + if (defaultOrgUuid) allOrgUuids.delete(defaultOrgUuid) + + if (allOrgUuids.size === 0) return base + + const result = base.map((source, idx) => { + if (idx !== defaultIndex) return source + const cachedDefault = defaultOrgUuid ? cachedOrgs.get(defaultOrgUuid) : undefined + const label = source.label === 'Default Claude' + ? (formatOrgLabel(cachedDefault?.label, cachedDefault?.organizationType) ?? defaultAcct.label ?? 'Personal') + : source.label + return { + ...source, + label, + ...(defaultOrgUuid ? { orgUuid: defaultOrgUuid } : {}), + ...(cachedDefault?.accountUuid ?? defaultAcct.accountUuid ? { accountUuid: cachedDefault?.accountUuid ?? defaultAcct.accountUuid } : {}), + } + }) + + const extraSources: ClaudeConfigSource[] = [] + for (const orgUuid of allOrgUuids) { + const desktop = desktopOrgs.get(orgUuid) + const cached = cachedOrgs.get(orgUuid) + const orgPath = desktop?.path ?? defaultDir + const accountUuid = desktop?.accountUuid ?? cached?.accountUuid + const label = formatOrgLabel(cached?.label, cached?.organizationType) ?? 'Team' + extraSources.push({ + id: claudeOrgSourceId(orgUuid), + label, + path: orgPath, + orgUuid, + ...(accountUuid ? { accountUuid } : {}), + }) + } + extraSources.sort((a, b) => a.label.localeCompare(b.label)) + return makeUniqueLabels([...result, ...extraSources]) +} + +/** + * Maps `cliSessionId` (the `.jsonl` basename in `~/.claude/projects/`) + * to its owning organization profile when multiple organizations are present. + */ +export async function discoverClaudeSessionOrgSources(): Promise> { + const map = new Map() + const [profiles, desktopOrgs] = await Promise.all([ + discoverClaudeProfileSources(), + scanDesktopCodeSessionOrgs(), + ]) + if (profiles.length <= 1 || desktopOrgs.size === 0) return map + const profileByOrg = new Map() + for (const profile of profiles) { + if (profile.orgUuid) profileByOrg.set(profile.orgUuid.toLowerCase(), profile) + } + for (const [orgUuid, info] of desktopOrgs) { + const profile = profileByOrg.get(orgUuid.toLowerCase()) + if (!profile) continue + for (const sessionId of info.sessionIds) { + map.set(sessionId, profile) + } + } + return map +} + // Filesystem changes under an unchanged input key intentionally do not invalidate this cache. const desktopSessionsDirsCache = new Map() diff --git a/src/quota/claude.ts b/src/quota/claude.ts index ca973686c..2d4368e18 100644 --- a/src/quota/claude.ts +++ b/src/quota/claude.ts @@ -1,25 +1,58 @@ +import crypto from 'node:crypto' +import { mkdir, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' +import { getCodeburnCacheDir } from '../cache-dir.js' +import { + claudeKeychainService, + claudeOrgProfilesCachePath, + getClaudeConfigDirs, + getDesktopSessionsDirs, + readClaudeOrgProfilesCache, + type CachedClaudeOrgProfile, +} from '../providers/claude.js' import { wslHomes } from '../wsl.js' import { fraction, quotaRequestSignal, readKeychainPassword, readSecureFile, sanitizeError } from './security.js' import type { KeychainOutcome } from './security.js' import type { QuotaProvider, QuotaWindow } from './types.js' const ENDPOINT = 'https://api.anthropic.com/api/oauth/usage' +const PROFILE_ENDPOINT = 'https://api.anthropic.com/api/oauth/profile' +const TOKEN_ENDPOINT = 'https://platform.claude.com/v1/oauth/token' +const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e' +const DEFAULT_REFRESH_SCOPES = [ + 'user:profile', + 'user:inference', + 'user:sessions:claude_code', + 'user:mcp_servers', + 'user:file_upload', +] const KEYCHAIN_SERVICE = 'Claude Code-credentials' +const SAFE_STORAGE_SERVICE = 'Claude Safe Storage' const EXPIRED_FOOTER = ['Claude Code login expired. Run Claude Code once, then refresh.'] -type ClaudeCredential = { accessToken: string; expiresAt?: number; rateLimitTier?: string; subscriptionType?: string } +export type ClaudeCredential = { + accessToken: string + refreshToken?: string + expiresAt?: number + rateLimitTier?: string + subscriptionType?: string + scopes?: string[] + clientId?: string + orgUuid?: string + accountUuid?: string +} /** Where the credential in hand came from, so a rejected token is re-read from that same place. */ -type CredentialSource = 'file' | 'keychain' +type CredentialSource = 'file' | 'keychain' | 'org' export type ClaudeDeps = { fetch: typeof fetch credentialPath: string /** Credential files inside running WSL distros (#1061); empty off Windows. */ wslCredentialPaths: () => string[] readFile: typeof readSecureFile - keychain?: () => Promise + keychain?: (service?: string) => Promise + safeStorageKeychain?: () => Promise now: () => number } @@ -41,9 +74,270 @@ function parseCredential(raw: string): ClaudeCredential | null { if (!oauth || typeof oauth.accessToken !== 'string' || oauth.accessToken.length === 0) return null return { accessToken: oauth.accessToken, + refreshToken: typeof oauth.refreshToken === 'string' && oauth.refreshToken.length > 0 ? oauth.refreshToken : undefined, expiresAt: typeof oauth.expiresAt === 'number' ? oauth.expiresAt : undefined, rateLimitTier: typeof oauth.rateLimitTier === 'string' ? oauth.rateLimitTier : undefined, subscriptionType: typeof oauth.subscriptionType === 'string' ? oauth.subscriptionType : undefined, + scopes: Array.isArray(oauth.scopes) ? oauth.scopes.filter((s): s is string => typeof s === 'string') : undefined, + clientId: typeof oauth.clientId === 'string' && oauth.clientId.length > 0 ? oauth.clientId : undefined, + } +} + +function claudeOrgCredentialsCachePath(): string { + return path.join(getCodeburnCacheDir(), 'claude-org-credentials.json') +} + +type StoredOrgRecord = ClaudeCredential & { + orgUuid: string + accountUuid?: string + label?: string + organizationType?: string + isDefaultConfigOrg?: boolean +} + +async function readStoredOrgCredentials(readFile: typeof readSecureFile): Promise> { + const map = new Map() + try { + const raw = await readFile(claudeOrgCredentialsCachePath(), 256 * 1024) + if (!raw) return map + const parsed = JSON.parse(raw) as Record + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return map + for (const [key, val] of Object.entries(parsed)) { + if (!val || typeof val !== 'object' || Array.isArray(val)) continue + const row = val as Record + if (typeof row.accessToken !== 'string' || !row.accessToken) continue + const orgUuid = (typeof row.orgUuid === 'string' ? row.orgUuid : key).trim().toLowerCase() + if (!orgUuid) continue + map.set(orgUuid, { + orgUuid, + accessToken: row.accessToken, + ...(typeof row.refreshToken === 'string' && row.refreshToken ? { refreshToken: row.refreshToken } : {}), + ...(typeof row.expiresAt === 'number' ? { expiresAt: row.expiresAt } : {}), + ...(typeof row.rateLimitTier === 'string' ? { rateLimitTier: row.rateLimitTier } : {}), + ...(typeof row.subscriptionType === 'string' ? { subscriptionType: row.subscriptionType } : {}), + ...(Array.isArray(row.scopes) ? { scopes: row.scopes.filter((s): s is string => typeof s === 'string') } : {}), + ...(typeof row.clientId === 'string' ? { clientId: row.clientId } : {}), + ...(typeof row.accountUuid === 'string' ? { accountUuid: row.accountUuid.toLowerCase() } : {}), + ...(typeof row.label === 'string' ? { label: row.label } : {}), + ...(typeof row.organizationType === 'string' ? { organizationType: row.organizationType } : {}), + ...(row.isDefaultConfigOrg === true ? { isDefaultConfigOrg: true } : {}), + }) + } + } catch { + // Missing or malformed cache is ignored. + } + return map +} + +async function writeStoredOrgCredentials(records: Map): Promise { + try { + const dir = getCodeburnCacheDir() + await mkdir(dir, { recursive: true }) + const credObj: Record = {} + const profObj: Record = await readClaudeOrgProfilesCache().then(m => Object.fromEntries(m)) + for (const [orgUuid, rec] of records) { + credObj[orgUuid] = rec + const prev = profObj[orgUuid] + const isDefault = rec.isDefaultConfigOrg ?? prev?.isDefaultConfigOrg + profObj[orgUuid] = { + orgUuid, + ...(rec.accountUuid ?? prev?.accountUuid ? { accountUuid: rec.accountUuid ?? prev?.accountUuid } : {}), + ...(rec.label ?? prev?.label ? { label: rec.label ?? prev?.label } : {}), + ...(rec.organizationType ?? prev?.organizationType ? { organizationType: rec.organizationType ?? prev?.organizationType } : {}), + ...(isDefault ? { isDefaultConfigOrg: true } : {}), + } + } + await writeFile(claudeOrgCredentialsCachePath(), JSON.stringify(credObj, null, 2) + '\n', { mode: 0o600 }) + await writeFile(claudeOrgProfilesCachePath(), JSON.stringify(profObj, null, 2) + '\n', { mode: 0o600 }) + } catch { + // Best-effort cache write. + } +} + +export function decryptSafeStorageV10(base64Ciphertext: string, password: string): string | null { + try { + const buf = Buffer.from(base64Ciphertext, 'base64') + if (buf.length <= 3 || buf.subarray(0, 3).toString('utf8') !== 'v10') return null + const key = crypto.pbkdf2Sync(password.trim(), 'saltysalt', 1003, 16, 'sha1') + const iv = Buffer.alloc(16, ' ') + const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv) + return Buffer.concat([decipher.update(buf.subarray(3)), decipher.final()]).toString('utf8') + } catch { + return null + } +} + +export function parseDesktopTokenCacheV2(rawJson: string): Map { + const out = new Map() + try { + const parsed = JSON.parse(rawJson) as Record + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return out + for (const [cacheKey, val] of Object.entries(parsed)) { + if (!val || typeof val !== 'object' || Array.isArray(val)) continue + const entry = val as Record + const token = typeof entry.token === 'string' ? entry.token.trim() : '' + if (!token) continue + // Key shape: `acct:|:::` + const pipeIdx = cacheKey.indexOf('|') + const accountUuid = cacheKey.startsWith('acct:') && pipeIdx > 5 + ? cacheKey.slice(5, pipeIdx).toLowerCase() + : undefined + const rest = pipeIdx >= 0 ? cacheKey.slice(pipeIdx + 1) : cacheKey + const parts = rest.split(':') + if (parts.length < 3) continue + const clientId = parts[0] + const orgUuid = parts[1]?.toLowerCase() + if (!orgUuid || !/^[0-9a-f-]{36}$/i.test(orgUuid)) continue + const scopePart = parts.slice(4).join(':') + const scopes = scopePart ? scopePart.split(/\s+/).filter(Boolean) : undefined + // Prefer tokens carrying `user:sessions:claude_code` or `user:inference` over profile-only tokens. + const hasInference = !scopes || scopes.includes('user:inference') || scopes.includes('user:sessions:claude_code') + const existing = out.get(orgUuid) + const existingHasInference = !existing?.scopes || existing.scopes.includes('user:inference') || existing.scopes.includes('user:sessions:claude_code') + if (existing && existingHasInference && !hasInference) continue + const expiresAt = typeof entry.expiresAt === 'number' ? entry.expiresAt : undefined + if (existing && existingHasInference === hasInference && (existing.expiresAt ?? -Infinity) > (expiresAt ?? -Infinity)) { + continue + } + out.set(orgUuid, { + orgUuid, + ...(accountUuid ? { accountUuid } : {}), + ...(clientId ? { clientId } : {}), + accessToken: token, + ...(typeof entry.refreshToken === 'string' && entry.refreshToken ? { refreshToken: entry.refreshToken } : {}), + ...(expiresAt !== undefined ? { expiresAt } : {}), + ...(typeof entry.subscriptionType === 'string' ? { subscriptionType: entry.subscriptionType } : {}), + ...(typeof entry.rateLimitTier === 'string' ? { rateLimitTier: entry.rateLimitTier } : {}), + ...(scopes?.length ? { scopes } : {}), + }) + } + } catch { + // Ignore malformed cache payload. + } + return out +} + +async function readDesktopTokenCacheOrgs(deps: ClaudeDeps, allowKeychain: boolean): Promise> { + const merged = await readStoredOrgCredentials(deps.readFile) + const appRoots = [...new Set(getDesktopSessionsDirs().map(d => path.dirname(d)))] + for (const appRoot of appRoots) { + const cfgPath = path.join(appRoot, 'config.json') + const rawCfg = await deps.readFile(cfgPath, 2 * 1024 * 1024).catch(() => null) + if (!rawCfg) continue + let tokenCacheRaw: unknown + try { + tokenCacheRaw = (JSON.parse(rawCfg) as Record)['oauth:tokenCacheV2'] + } catch { + continue + } + let decrypted: string | null = null + if (typeof tokenCacheRaw === 'string' && tokenCacheRaw.length > 0) { + if (tokenCacheRaw.trim().startsWith('{')) { + decrypted = tokenCacheRaw + } else if (allowKeychain && process.platform === 'darwin') { + const readSafe = deps.safeStorageKeychain ?? (() => readKeychainPassword(SAFE_STORAGE_SERVICE, ['Claude Key', 'Claude', null])) + const outcome = await readSafe().catch(() => ({ status: 'notFound' as const })) + if (outcome.status === 'found') { + decrypted = decryptSafeStorageV10(tokenCacheRaw, outcome.value) + } + } + } else if (tokenCacheRaw && typeof tokenCacheRaw === 'object') { + decrypted = JSON.stringify(tokenCacheRaw) + } + if (!decrypted) continue + for (const [orgUuid, record] of parseDesktopTokenCacheV2(decrypted)) { + const prev = merged.get(orgUuid) + if (!prev || (record.expiresAt ?? -Infinity) >= (prev.expiresAt ?? -Infinity)) { + merged.set(orgUuid, { + ...prev, + ...record, + label: record.label ?? prev?.label, + organizationType: record.organizationType ?? prev?.organizationType, + }) + } + } + } + return merged +} + +async function fetchOrgProfileMetadata( + token: string, + deps: ClaudeDeps, + parent?: AbortSignal, +): Promise<{ orgUuid?: string; accountUuid?: string; label?: string; organizationType?: string; rateLimitTier?: string } | null> { + try { + const response = await deps.fetch(PROFILE_ENDPOINT, { + method: 'GET', + signal: quotaRequestSignal(parent), + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + }) + if (!response.ok) return null + const body = (await response.json()) as { + account?: { uuid?: unknown } + organization?: { uuid?: unknown; name?: unknown; organization_type?: unknown; rate_limit_tier?: unknown } + } + const org = body?.organization + if (!org || typeof org !== 'object') return null + const rawType = typeof org.organization_type === 'string' ? org.organization_type.toLowerCase() : undefined + const organizationType = + rawType === 'claude_team' ? 'team' + : rawType === 'claude_enterprise' ? 'enterprise' + : rawType === 'claude_max' ? 'max' + : rawType === 'claude_pro' ? 'pro' + : rawType + const rawName = typeof org.name === 'string' ? org.name.trim() : undefined + const label = + organizationType === 'max' || organizationType === 'pro' || (rawName && /^.+['’]s organization$/i.test(rawName)) + ? 'Personal' + : rawName || (organizationType === 'team' ? 'Team' : organizationType === 'enterprise' ? 'Enterprise' : undefined) + return { + ...(typeof org.uuid === 'string' ? { orgUuid: org.uuid.toLowerCase() } : {}), + ...(typeof body.account?.uuid === 'string' ? { accountUuid: body.account.uuid.toLowerCase() } : {}), + ...(label ? { label } : {}), + ...(organizationType ? { organizationType } : {}), + ...(typeof org.rate_limit_tier === 'string' ? { rateLimitTier: org.rate_limit_tier } : {}), + } + } catch { + return null + } +} + +async function refreshOAuthCredential( + credential: ClaudeCredential, + deps: ClaudeDeps, + parent?: AbortSignal, +): Promise { + if (!credential.refreshToken) return null + try { + const scopes = credential.scopes?.length ? credential.scopes : DEFAULT_REFRESH_SCOPES + const response = await deps.fetch(TOKEN_ENDPOINT, { + method: 'POST', + signal: quotaRequestSignal(parent), + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: credential.refreshToken, + client_id: credential.clientId ?? OAUTH_CLIENT_ID, + scope: scopes.join(' '), + }), + }) + if (!response.ok) return null + const body = (await response.json()) as Record + const accessToken = typeof body.access_token === 'string' ? body.access_token.trim() : '' + const expiresIn = typeof body.expires_in === 'number' && Number.isFinite(body.expires_in) ? body.expires_in : 0 + if (!accessToken || expiresIn <= 0) return null + return { + ...credential, + accessToken, + refreshToken: typeof body.refresh_token === 'string' && body.refresh_token ? body.refresh_token : credential.refreshToken, + expiresAt: deps.now() + expiresIn * 1000, + ...(typeof body.scope === 'string' ? { scopes: body.scope.split(/\s+/).filter(Boolean) } : {}), + } + } catch { + return null } } @@ -90,18 +384,19 @@ async function credentialFromFiles(deps: ClaudeDeps): Promise<{ credential: Clau * A denied Keychain on a re-read needs no state of its own: the first read * already decided whether that store is reachable. */ -async function credentialFrom(source: CredentialSource, file: string, deps: ClaudeDeps): Promise { +async function credentialFrom(source: CredentialSource, file: string, keychainService: string, deps: ClaudeDeps): Promise { if (source === 'file') return file === deps.credentialPath ? credentialAt(file, deps) : wslCredentialAt(file, deps) - const outcome = await (deps.keychain ?? readClaudeKeychain)() + if (source === 'org') return null + const outcome = await (deps.keychain ? deps.keychain(keychainService) : readClaudeKeychain(keychainService)) return outcome.status === 'found' ? parseCredential(outcome.value) : null } -export async function readClaudeKeychain(): Promise { +export async function readClaudeKeychain(service: string = KEYCHAIN_SERVICE): Promise { // Claude Code has written the item under both `$USER` (2.1.x) and the older // hardcoded "agentseal" account; a user-scoped miss must fall through to the // service-only lookup rather than reporting disconnected. const user = process.env.USER - return readKeychainPassword(KEYCHAIN_SERVICE, user ? [user, null] : [null]) + return readKeychainPassword(service, user ? [user, null] : [null]) } function windowOf(label: string, value: unknown): QuotaWindow | null { @@ -154,7 +449,7 @@ export function decodeClaudeUsage(body: unknown, credential: ClaudeCredential): } } return { - provider: 'claude', connection: 'connected', primary: weekly, + provider: 'claude', connection: 'connected', primary: weekly ?? five, details: [five, weekly, opus, sonnet].filter((row): row is QuotaWindow => row !== null).concat(scoped), planLabel: planLabel(credential), footerLines: [], } @@ -172,35 +467,111 @@ async function request(token: string, deps: ClaudeDeps, parent?: AbortSignal): P }) } -export type ClaudeResult = { quota: QuotaProvider; retryAfterSeconds?: number } +export type ClaudeResult = { quota: QuotaProvider; retryAfterSeconds?: number; label?: string } + +export async function fetchClaudeQuota( + options: Partial & { + signal?: AbortSignal + allowKeychain?: boolean + configDir?: string + orgUuid?: string + } = {}, +): Promise { + const resolvedConfigDir = options.configDir + ?? (options.credentialPath === undefined ? (await getClaudeConfigDirs().catch(() => []))[0] : undefined) + const keychainService = claudeKeychainService(resolvedConfigDir) + const defaultCredentialPath = resolvedConfigDir + ? path.join(resolvedConfigDir, '.credentials.json') + : defaults.credentialPath + const deps: ClaudeDeps = { + ...defaults, + credentialPath: defaultCredentialPath, + ...options, + } + const targetOrgUuid = options.orgUuid?.trim().toLowerCase() -export async function fetchClaudeQuota(options: Partial & { signal?: AbortSignal; allowKeychain?: boolean } = {}): Promise { - const deps = { ...defaults, ...options } try { - const fromFile = await credentialFromFiles(deps) - let credential = fromFile?.credential ?? null + let credential: ClaudeCredential | null = null let source: CredentialSource = 'file' - if (!credential && options.allowKeychain && process.platform === 'darwin') { - const outcome = await (deps.keychain ?? readClaudeKeychain)() - if (outcome.status === 'accessDenied') return { quota: empty('accessDenied') } - credential = outcome.status === 'found' ? parseCredential(outcome.value) : null - source = 'keychain' + let fromFile: { credential: ClaudeCredential; path: string } | null = null + let orgRecords: Map | null = null + let discoveredLabel: string | undefined + + if (targetOrgUuid) { + orgRecords = await readDesktopTokenCacheOrgs(deps, Boolean(options.allowKeychain)) + const orgRecord = orgRecords.get(targetOrgUuid) + if (orgRecord) { + credential = orgRecord + source = 'org' + discoveredLabel = orgRecord.label + } + } + + if (!credential) { + fromFile = await credentialFromFiles(deps) + credential = fromFile?.credential ?? null + source = 'file' + const fileExpired = credential?.expiresAt !== undefined && credential.expiresAt <= deps.now() + if ((!credential || fileExpired) && options.allowKeychain && process.platform === 'darwin') { + const outcome = await (deps.keychain ? deps.keychain(keychainService) : readClaudeKeychain(keychainService)) + if (outcome.status === 'accessDenied' && !credential) return { quota: empty('accessDenied') } + const kcCred = outcome.status === 'found' ? parseCredential(outcome.value) : null + if (kcCred && (!credential || (kcCred.expiresAt ?? Infinity) > (credential.expiresAt ?? -Infinity))) { + credential = kcCred + source = 'keychain' + } + } + // Sync Claude Desktop's oauth:tokenCacheV2 when reading live quota so multi-org + // Personal + Team profiles stay warm in the cache, and fall back to a live desktop + // token when the CLI file/Keychain credential is absent or expired. + if (options.allowKeychain && !options.keychain) { + orgRecords = await readDesktopTokenCacheOrgs(deps, true) + const credExpired = credential?.expiresAt !== undefined && credential.expiresAt <= deps.now() + if ((!credential || (credExpired && !credential.refreshToken)) && orgRecords.size > 0) { + const bestOrg = [...orgRecords.values()].sort((a, b) => (b.expiresAt ?? 0) - (a.expiresAt ?? 0))[0] + if (bestOrg && (bestOrg.expiresAt ?? Infinity) > (credential?.expiresAt ?? -Infinity)) { + credential = bestOrg + source = 'org' + } + } + } } + if (!credential) return { quota: empty('disconnected') } + // If an org-backed credential is already past its expiry and carries a refresh token, + // renew it before hitting /api/oauth/usage so Anthropic does not 429 the stale token. + if (source === 'org' && credential.expiresAt !== undefined && credential.expiresAt <= deps.now() && credential.refreshToken) { + const refreshed = await refreshOAuthCredential(credential, deps, options.signal) + if (refreshed) { + credential = refreshed + if (orgRecords && targetOrgUuid) { + const prev = orgRecords.get(targetOrgUuid) + orgRecords.set(targetOrgUuid, { ...prev, ...refreshed, orgUuid: targetOrgUuid }) + await writeStoredOrgCredentials(orgRecords) + } + } + } + let response = await request(credential.accessToken, deps, options.signal) - if (response.status === 401) { - const reread = await credentialFrom(source, fromFile?.path ?? deps.credentialPath, deps) + const isExpired = () => credential?.expiresAt !== undefined && credential.expiresAt <= deps.now() + if (response.status === 401 || (response.status === 429 && isExpired())) { + let reread = await credentialFrom(source, fromFile?.path ?? deps.credentialPath, keychainService, deps) + if ((!reread || reread.accessToken === credential.accessToken) && isExpired() && credential.refreshToken) { + reread = await refreshOAuthCredential(credential, deps, options.signal) + } if (!reread || reread.accessToken === credential.accessToken) { - // Nothing but a fresh login can clear a 401 on a credential whose life is - // already over, so it is reported as terminal rather than as the blip its - // consumers are meant to retry and to keep their last numbers through. A - // token that has not expired is a real blip and keeps that backoff. - const expired = credential.expiresAt !== undefined && credential.expiresAt <= deps.now() - return { quota: expired ? empty('terminalFailure', EXPIRED_FOOTER) : empty('transientFailure') } + if (response.status === 401) { + // Nothing but a fresh login can clear a 401 on a credential whose life is + // already over, so it is reported as terminal rather than as the blip its + // consumers are meant to retry and to keep their last numbers through. A + // token that has not expired is a real blip and keeps that backoff. + return { quota: isExpired() ? empty('terminalFailure', EXPIRED_FOOTER) : empty('transientFailure') } + } + } else { + credential = reread + response = await request(credential.accessToken, deps, options.signal) } - credential = reread - response = await request(credential.accessToken, deps, options.signal) } if (response.status === 429) { let hint: unknown @@ -209,7 +580,51 @@ export async function fetchClaudeQuota(options: Partial & { signal?: return { quota: empty('transientFailure'), retryAfterSeconds: Math.max(Number.isFinite(parsed) ? parsed : 300, 60) } } if (!response.ok) return { quota: empty(response.status >= 400 && response.status < 500 ? 'terminalFailure' : 'transientFailure') } - return { quota: decodeClaudeUsage(await response.json(), credential) } + const usageJson = await response.json() + if (orgRecords && (targetOrgUuid || orgRecords.size > 0)) { + if (!targetOrgUuid && source !== 'org') { + const existingDefault = [...orgRecords.values()].find(r => r.isDefaultConfigOrg && r.accessToken === credential.accessToken) + if (existingDefault) { + discoveredLabel = existingDefault.label + } else { + const prof = await fetchOrgProfileMetadata(credential.accessToken, deps, options.signal) + if (prof?.orgUuid) { + for (const r of orgRecords.values()) delete r.isDefaultConfigOrg + const prev = orgRecords.get(prof.orgUuid) + orgRecords.set(prof.orgUuid, { + ...prev, + ...credential, + orgUuid: prof.orgUuid, + accountUuid: prof.accountUuid ?? prev?.accountUuid, + label: prof.label ?? prev?.label ?? 'Personal', + organizationType: prof.organizationType ?? prev?.organizationType, + rateLimitTier: prof.rateLimitTier ?? credential.rateLimitTier, + isDefaultConfigOrg: true, + }) + discoveredLabel = prof.label ?? 'Personal' + } + } + } + for (const [orgKey, rec] of orgRecords) { + if (!rec.label && (orgKey === targetOrgUuid || rec.accessToken)) { + const prof = await fetchOrgProfileMetadata(rec.accessToken, deps, options.signal) + if (prof) { + rec.label = prof.label ?? rec.label + rec.organizationType = prof.organizationType ?? rec.organizationType + rec.rateLimitTier = prof.rateLimitTier ?? rec.rateLimitTier + } + } + } + const orgKey = targetOrgUuid ?? credential.orgUuid + if (orgKey && orgRecords.get(orgKey)?.label) { + discoveredLabel = orgRecords.get(orgKey)?.label + } + await writeStoredOrgCredentials(orgRecords) + } + return { + quota: decodeClaudeUsage(usageJson, credential), + ...(discoveredLabel ? { label: discoveredLabel } : {}), + } } catch (error) { // Deliberately sanitize before the only diagnostic sink. Tokens are never returned. console.warn(`Claude quota unavailable: ${sanitizeError(error)}`) diff --git a/src/quota/index.ts b/src/quota/index.ts index 51f501bcf..bec04fe86 100644 --- a/src/quota/index.ts +++ b/src/quota/index.ts @@ -11,7 +11,7 @@ import { existsSync } from 'node:fs' import { renderTable } from '../text-table.js' import { filterProjectsByClaudeConfigSource, parseAllSessions } from '../parser.js' -import { discoverClaudeConfigSources, type ClaudeConfigSource } from '../providers/claude.js' +import { discoverClaudeProfileSources, type ClaudeConfigSource } from '../providers/claude.js' import { buildPeriodData } from '../usage-aggregator.js' import { fetchAntigravityQuota } from './antigravity.js' import { fetchClaudeQuota } from './claude.js' @@ -154,18 +154,24 @@ export function toCommandProvider(id: ProviderName, name: string, quota: QuotaPr } } -/** Reads one config directory's own quota from its `/.credentials.json`. The macOS - * Keychain fallback stays reserved for the default ~/.claude, so non-default directories - * read as disconnected on macOS (fine today: the dock is Windows/Linux only). WSL - * credential aggregation is off because every WSL home is its own config source. */ +/** Reads one profile's own quota from its `/.credentials.json`, its macOS Keychain + * service (`Claude Code-credentials[-]`), or its Claude Desktop organization token. + * WSL credential aggregation is off because every WSL home is its own config source. */ function readClaudeProfile(source: ClaudeConfigSource, signal: AbortSignal): Promise { - const isDefault = path.resolve(source.path) === path.resolve(path.join(os.homedir(), '.claude')) + const isOrgProfile = source.id.startsWith('claude-org:') return fetchClaudeQuota({ signal, - credentialPath: path.join(source.path, '.credentials.json'), + ...(isOrgProfile + ? { orgUuid: source.orgUuid } + : { configDir: source.path, credentialPath: path.join(source.path, '.credentials.json') }), wslCredentialPaths: () => [], - allowKeychain: isDefault, - }).then(result => result.quota) + allowKeychain: true, + }).then(result => { + if (result.label && (source.label === 'Team' || source.label.startsWith('Team '))) { + source.label = result.label + } + return result.quota + }) } /** Each config directory's own today totals from one Claude-only parse of today's range @@ -210,7 +216,7 @@ function toProfileRow(source: ClaudeConfigSource, quota: QuotaProvider | 'timeou } function isDefaultClaudeDir(source: ClaudeConfigSource): boolean { - return path.resolve(source.path) === path.resolve(path.join(os.homedir(), '.claude')) + return !source.id.startsWith('claude-org:') && path.resolve(source.path) === path.resolve(path.join(os.homedir(), '.claude')) } export async function collectQuota(options: { @@ -228,9 +234,11 @@ export async function collectQuota(options: { const readers = options.readers ?? availableReaders() const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS const detail = options.claudeProfileDetail ?? 'list' - // Profiles exist only when two or more config directories are really on disk; a missing - // directory in CLAUDE_CONFIG_DIRS is not a profile. - const sources = (await discoverClaudeConfigSources().catch(() => [])).filter(source => existsSync(source.path)) + // Profiles exist when two or more config directories or Claude Desktop organizations are on disk; + // a missing directory in CLAUDE_CONFIG_DIRS is not a profile. + const sources = (await discoverClaudeProfileSources().catch(() => [])).filter( + source => source.id.startsWith('claude-org:') || existsSync(source.path), + ) const profileSources = sources.length > 1 ? sources : [] const providersTask = Promise.all(readers.map(async entry => { const controller = new AbortController() diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index 9ac5f7816..5a57be8df 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -10,7 +10,7 @@ import { findUnpricedModels, getFlatRateModelsConfigHash, getLocalModelSavingsCo import { getAllProviders, safeDiscoverSessions } from './providers/index.js' import { loadPlugins, pluginPayloadSections } from './plugins/loader.js' import { collectLiveSessions } from './live-sessions.js' -import { claude, getClaudeConfigDirs, getDesktopSessionsDirs } from './providers/claude.js' +import { claude, discoverClaudeProfileSources, getClaudeConfigDirs, getDesktopSessionsDirs } from './providers/claude.js' import { stat } from 'node:fs/promises' import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKeyInTz } from './day-aggregator.js' import { aggregateModelEfficiency, buildRetryTax } from './model-efficiency.js' @@ -346,6 +346,12 @@ async function claudeConfigSelector(projects: ProjectSummary[], selectedId?: str ) continue if (!byId.has(source.sourceId)) byId.set(source.sourceId, { id: source.sourceId, label: source.sourceLabel, path: source.sourcePath }) } + const profileSources = await discoverClaudeProfileSources().catch(() => []) + if (profileSources.length > 1) { + for (const profile of profileSources) { + byId.set(profile.id, { id: profile.id, label: profile.label, path: profile.path }) + } + } } return buildSelector(byId, selectedId) } diff --git a/tests/quota-providers.test.ts b/tests/quota-providers.test.ts index ae4ff1d5c..c645cf2d2 100644 --- a/tests/quota-providers.test.ts +++ b/tests/quota-providers.test.ts @@ -1,16 +1,21 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' -import { mkdirSync, mkdtempSync } from 'node:fs' +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, join, resolve } from 'node:path' import { fetchAntigravityQuota, decodeAntigravitySummary, parseNetstatPorts } from '../src/quota/antigravity.js' -import { decodeClaudeUsage, fetchClaudeQuota, planLabel } from '../src/quota/claude.js' +import { decodeClaudeUsage, fetchClaudeQuota, parseDesktopTokenCacheV2, planLabel } from '../src/quota/claude.js' import { decodeCodexUsage, fetchCodexQuota } from '../src/quota/codex.js' import { decodeCopilotUsage, fetchCopilotQuota } from '../src/quota/copilot.js' import { decodeGeminiUsage, fetchGeminiQuota } from '../src/quota/gemini.js' import { collectQuota, renderQuotaTable } from '../src/quota/index.js' import { decodeKimiUsage, fetchKimiQuota } from '../src/quota/kimi.js' -import { discoverClaudeConfigSources } from '../src/providers/claude.js' +import { + claudeKeychainService, + discoverClaudeConfigSources, + discoverClaudeProfileSources, + discoverClaudeSessionOrgSources, +} from '../src/providers/claude.js' import type { QuotaProvider } from '../src/quota/types.js' const noFile = vi.fn(async () => null) @@ -486,4 +491,104 @@ describe('collectQuota claudeProfiles (#1523)', () => { else process.env.CLAUDE_CONFIG_DIRS = previous } }) + + it('derives the macOS Keychain service suffix for non-default config directories', () => { + const home = process.env.HOME! + expect(claudeKeychainService(join(home, '.claude'))).toBe('Claude Code-credentials') + const customDir = join(home, '.claude-work') + expect(claudeKeychainService(customDir)).toMatch(/^Claude Code-credentials-[0-9a-f]{8}$/) + }) + + it('falls back primary to five_hour when seven_day is null on a Team account', () => { + const quota = decodeClaudeUsage({ + five_hour: { utilization: 1, resets_at: '2026-10-14T03:30:00Z' }, + seven_day: null, + limits: [ + { + kind: 'weekly_scoped', + percent: 0, + resets_at: '2026-10-14T10:00:00Z', + scope: { model: { display_name: 'Fable' } }, + }, + ], + }, { accessToken: 'hidden', subscriptionType: 'team', rateLimitTier: 'default_claude_max_5x' }) + + expect(quota.planLabel).toBe('Team Premium') + expect(quota.primary).toEqual({ + label: '5-hour', + percent: 0.01, + resetsAt: '2026-10-14T03:30:00.000Z', + }) + expect(quota.details.map(r => r.label)).toEqual(['5-hour', 'Weekly · Fable']) + }) + + it('discovers Personal and Team organizations from Claude Desktop and maps cliSessionId ownership', async () => { + const home = mkdtempSync(join(tmpdir(), 'codeburn-org-profiles-')) + const claudeDir = join(home, '.claude') + const desktopRoot = join(home, 'ClaudeDesktop') + const desktopSessionsDir = join(desktopRoot, 'local-agent-mode-sessions') + const personalOrg = '0f0c63b6-3f5a-4a3e-8086-36e7267b2a93' + const personalAcct = '53944a3a-bc57-472d-98c6-8b19091acc9e' + const teamOrg = '64cceafd-5d4b-4fdd-928c-91c807474898' + const teamAcct = '70f6aa58-1194-4d64-9e3f-8a34850ff123' + + mkdirSync(claudeDir, { recursive: true }) + mkdirSync(desktopSessionsDir, { recursive: true }) + writeFileSync(join(home, '.claude.json'), JSON.stringify({ + oauthAccount: { + accountUuid: personalAcct, + organizationUuid: personalOrg, + organizationName: "user@example.com's Organization", + }, + })) + + const personalCodeDir = join(desktopRoot, 'claude-code-sessions', personalAcct, personalOrg) + const teamCodeDir = join(desktopRoot, 'claude-code-sessions', teamAcct, teamOrg) + mkdirSync(personalCodeDir, { recursive: true }) + mkdirSync(teamCodeDir, { recursive: true }) + writeFileSync(join(personalCodeDir, 'local_1.json'), JSON.stringify({ cliSessionId: 'sess-personal-1' })) + writeFileSync(join(teamCodeDir, 'local_2.json'), JSON.stringify({ cliSessionId: 'sess-team-1' })) + + const tokenCache = { + [`acct:${teamAcct}|9d1c250a-e61b-44d9-88ed-5944d1962f5e:${teamOrg}:https://api.anthropic.com:user:inference user:profile user:sessions:claude_code`]: { + token: 'team-access-token', + refreshToken: 'team-refresh-token', + expiresAt: Date.now() + 3600_000, + subscriptionType: 'team', + rateLimitTier: 'default_claude_max_5x', + }, + } + writeFileSync(join(desktopRoot, 'config.json'), JSON.stringify({ + 'oauth:tokenCacheV2': JSON.stringify(tokenCache), + })) + + const prevHome = process.env.HOME + const prevUser = process.env.USERPROFILE + const prevDesktop = process.env.CODEBURN_DESKTOP_SESSIONS_DIR + process.env.HOME = home + process.env.USERPROFILE = home + process.env.CODEBURN_DESKTOP_SESSIONS_DIR = desktopSessionsDir + try { + const parsedTokens = parseDesktopTokenCacheV2(JSON.stringify(tokenCache)) + expect(parsedTokens.get(teamOrg)?.subscriptionType).toBe('team') + + const profiles = await discoverClaudeProfileSources() + expect(profiles).toHaveLength(2) + expect(profiles[0]?.label).toBe('Personal') + expect(profiles[0]?.orgUuid).toBe(personalOrg) + expect(profiles[1]?.id).toBe(`claude-org:${teamOrg}`) + expect(profiles[1]?.orgUuid).toBe(teamOrg) + + const sessionMap = await discoverClaudeSessionOrgSources() + expect(sessionMap.get('sess-personal-1')?.id).toBe(profiles[0]?.id) + expect(sessionMap.get('sess-team-1')?.id).toBe(`claude-org:${teamOrg}`) + } finally { + if (prevHome === undefined) delete process.env.HOME + else process.env.HOME = prevHome + if (prevUser === undefined) delete process.env.USERPROFILE + else process.env.USERPROFILE = prevUser + if (prevDesktop === undefined) delete process.env.CODEBURN_DESKTOP_SESSIONS_DIR + else process.env.CODEBURN_DESKTOP_SESSIONS_DIR = prevDesktop + } + }) }) diff --git a/windows/src/lib/quota.ts b/windows/src/lib/quota.ts index 7ee24f54a..ec638cfd5 100644 --- a/windows/src/lib/quota.ts +++ b/windows/src/lib/quota.ts @@ -31,7 +31,8 @@ export type QuotaWindow = { label: string; usedPct: number; resetsAt?: string } /// The glance value: every provider on the same billing horizon, weekly if there is one, else /// monthly, else the window nearest exhaustion. Empty stays null rather than posing as 0%. export function headlineWindow(windows: QuotaWindow[]): QuotaWindow | null { - const find = (needle: string) => windows.find((row) => row.label.toLowerCase().includes(needle)) + const find = (needle: string) => + windows.find((row) => !row.label.includes('·') && row.label.toLowerCase().includes(needle)) return ( find('week') ?? find('month') ??