Skip to content
Open
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
12 changes: 11 additions & 1 deletion src/ccstatusline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import {
getWidgetSpeedWindowSeconds,
isWidgetSpeedWindowEnabled
} from './utils/speed-window';
import { prefetchProfileDataIfNeeded } from './utils/profile-prefetch';
import { resolveSessionAccount } from './utils/session-affinity';
import { prefetchUsageDataIfNeeded } from './utils/usage-prefetch';

function hasSessionDurationInStatusJson(data: StatusJSON): boolean {
Expand Down Expand Up @@ -121,7 +123,14 @@ async function renderMultipleLines(data: StatusJSON) {
sessionDuration = await getSessionDuration(data.transcript_path);
}

const usageData = await prefetchUsageDataIfNeeded(lines);
// Resolve session-pinned account — each session keeps its original account's
// data. Pin is cleared by the hook handler when /login is detected.
const session = resolveSessionAccount(data.session_id);

const [usageData, profileData] = await Promise.all([
prefetchUsageDataIfNeeded(lines, session),
prefetchProfileDataIfNeeded(lines, session)
]);

let speedMetrics: SpeedMetrics | null = null;
let windowedSpeedMetrics: Record<string, SpeedMetrics> | null = null;
Expand All @@ -147,6 +156,7 @@ async function renderMultipleLines(data: StatusJSON) {
speedMetrics,
windowedSpeedMetrics,
usageData,
profileData,
sessionDuration,
skillsMetrics,
isPreview: false
Expand Down
2 changes: 2 additions & 0 deletions src/types/RenderContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
SkillsMetrics
} from '../types';

import type { ProfileData } from '../utils/profile-fetch';
import type { SpeedMetrics } from './SpeedMetrics';
import type { StatusJSON } from './StatusJSON';
import type { TokenMetrics } from './TokenMetrics';
Expand All @@ -25,6 +26,7 @@ export interface RenderContext {
speedMetrics?: SpeedMetrics | null;
windowedSpeedMetrics?: Record<string, SpeedMetrics> | null;
usageData?: RenderUsageData | null;
profileData?: ProfileData | null;
sessionDuration?: string | null;
blockMetrics?: BlockMetrics | null;
skillsMetrics?: SkillsMetrics | null;
Expand Down
11 changes: 7 additions & 4 deletions src/utils/__tests__/usage-fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,10 @@ https.request = (...args) => {

const { fetchUsageData } = await import(${JSON.stringify(usageModulePath)});

const lockFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage.lock');
const cacheFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage.json');
import { createHash } from 'crypto';
const tokenHash = createHash('sha256').update('test-token').digest('hex').slice(0, 8);
const lockFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage-' + tokenHash + '.lock');
const cacheFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage-' + tokenHash + '.json');
const nowMs = Number(process.env.TEST_NOW_MS || Date.now());
Date.now = () => nowMs;

Expand Down Expand Up @@ -167,7 +169,7 @@ process.stdout.write(JSON.stringify({
fs.mkdirSync(bin, { recursive: true });
fs.mkdirSync(claudeConfig, { recursive: true });

fs.writeFileSync(securityScript, '#!/bin/sh\necho \'{"claudeAiOauth":{"accessToken":"test-token"}}\'\n');
fs.writeFileSync(securityScript, '#!/bin/sh\nif [ "$1" = "dump-keychain" ]; then\n echo \' "svce"<blob>="Claude Code-credentials"\'\nelse\n echo \'{"claudeAiOauth":{"accessToken":"test-token","expiresAt":9999999999}}\'\nfi\n');
fs.chmodSync(securityScript, 0o755);
fs.writeFileSync(credentialsFile, JSON.stringify({ claudeAiOauth: { accessToken: 'test-token' } }));

Expand Down Expand Up @@ -574,7 +576,8 @@ describe('fetchUsageData error handling', () => {
try {
const home = harness.createTokenHome('legacy-lock');
const lockDir = path.join(home.home, '.cache', 'ccstatusline');
const lockFile = path.join(lockDir, 'usage.lock');
const testTokenHash = require('crypto').createHash('sha256').update('test-token').digest('hex').slice(0, 8);
const lockFile = path.join(lockDir, `usage-${testTokenHash}.lock`);

fs.mkdirSync(lockDir, { recursive: true });
fs.writeFileSync(lockFile, '');
Expand Down
132 changes: 132 additions & 0 deletions src/utils/credentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { createHash } from 'crypto';
import { execFileSync, execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { z } from 'zod';

import { getClaudeConfigDir } from './claude-settings';

const TOKEN_CACHE_MAX_AGE = 30; // seconds — short so /login changes are picked up quickly

const CredentialsSchema = z.object({
claudeAiOauth: z.object({
accessToken: z.string().nullable().optional(),
expiresAt: z.number().nullable().optional()
}).optional()
});

/**
* Compute a short hash of an OAuth token, used as a cache-file suffix
* so that each account's data is stored separately.
*/
export function oauthTokenHash(token: string): string {
return createHash('sha256').update(token).digest('hex').slice(0, 8);
}

// Module-level token cache shared across all consumers
let cachedToken: string | null = null;
let tokenCacheTime = 0;

function parseAccessToken(rawJson: string): string | null {
try {
const parsed = CredentialsSchema.safeParse(JSON.parse(rawJson));
return parsed.success ? (parsed.data?.claudeAiOauth?.accessToken ?? null) : null;
} catch {
return null;
}
}

function parseExpiresAt(rawJson: string): number {
try {
const parsed = CredentialsSchema.safeParse(JSON.parse(rawJson));
return parsed.success ? (parsed.data?.claudeAiOauth?.expiresAt ?? 0) : 0;
} catch {
return 0;
}
}

/**
* Find the freshest Claude Code credential entry in the macOS keychain.
* Claude Code v2.x uses installation-suffixed keychain entries
* (e.g. "Claude Code-credentials-fe5233b0") that differ per install.
* We scan all matching entries and pick the one with the latest expiresAt.
*/
function getMacOSToken(): string | null {
try {
const dumpOutput = execSync(
'security dump-keychain 2>/dev/null',
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 10 * 1024 * 1024 }
);

const entryNames: string[] = [];
for (const line of dumpOutput.split('\n')) {
const match = line.match(/"svce"<blob>="(Claude Code-credentials[^"]*)"/);
if (match?.[1]) {
entryNames.push(match[1]);
}
}

if (entryNames.length === 0) {
return null;
}

let bestToken: string | null = null;
let bestExpires = 0;

for (const entry of entryNames) {
try {
const creds = execFileSync(
'security',
['find-generic-password', '-s', entry, '-w'],
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
).trim();

const expires = parseExpiresAt(creds);
if (expires > bestExpires) {
bestExpires = expires;
bestToken = parseAccessToken(creds);
}
} catch {
continue;
}
}

return bestToken;
} catch {
return null;
}
}

/**
* Get a valid OAuth access token for the Anthropic API.
* On macOS, scans all keychain entries and picks the freshest.
* On other platforms, reads from the credentials file.
* Results are cached in memory for 1 hour.
*/
export function getOAuthToken(): string | null {
const now = Math.floor(Date.now() / 1000);

if (cachedToken && (now - tokenCacheTime) < TOKEN_CACHE_MAX_AGE) {
return cachedToken;
}

try {
const isMac = process.platform === 'darwin';
let token: string | null = null;

if (isMac) {
token = getMacOSToken();
} else {
const credFile = path.join(getClaudeConfigDir(), '.credentials.json');
token = parseAccessToken(fs.readFileSync(credFile, 'utf8'));
}

if (token) {
cachedToken = token;
tokenCacheTime = now;
}
return token;
} catch {
return null;
}
}
Loading