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
42 changes: 39 additions & 3 deletions src/utils/__tests__/usage-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import { createHash } from 'node:crypto';
import * as os from 'os';
import * as path from 'path';
import { fileURLToPath } from 'url';
Expand Down Expand Up @@ -129,8 +130,11 @@ 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 as _createHash } from 'node:crypto';
const _claudeConfigDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), '.claude');
const _configHash = _createHash('sha256').update(path.resolve(_claudeConfigDir)).digest('hex').slice(0, 16);
const lockFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage-' + _configHash + '.lock');
const cacheFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage-' + _configHash + '.json');
const nowMs = Number(process.env.TEST_NOW_MS || Date.now());
Date.now = () => nowMs;

Expand Down Expand Up @@ -574,7 +578,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 configHash = createHash('sha256').update(path.resolve(home.claudeConfig)).digest('hex').slice(0, 16);
const lockFile = path.join(lockDir, `usage-${configHash}.lock`);

fs.mkdirSync(lockDir, { recursive: true });
fs.writeFileSync(lockFile, '');
Expand All @@ -595,4 +600,35 @@ describe('fetchUsageData error handling', () => {
harness.cleanup();
}
});

it('falls back to credentials file when CLAUDE_CONFIG_DIR is set and keychain is unavailable', () => {
const harness = createProbeHarness();

try {
const home = harness.createTokenHome('creds-file-fallback');

// Run with CLAUDE_CONFIG_DIR set but no security binary in PATH.
// The config-dir-specific keychain lookup and default keychain lookup
// both fail, so it should fall back to the credentials file.
const result = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'success',
nowMs,
pathDir: '/nonexistent',
responseBody: successResponseBody
});

expect(result.first).toEqual({
sessionUsage: 42,
sessionResetAt: '2030-01-01T00:00:00.000Z',
weeklyUsage: 17,
weeklyResetAt: '2030-01-07T00:00:00.000Z'
});
expect(result.requestCount).toBe(1);
expect(result.requestHost).toBe('api.anthropic.com');
} finally {
harness.cleanup();
}
});
});
43 changes: 33 additions & 10 deletions src/utils/usage-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as https from 'https';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { createHash } from 'node:crypto';
import * as os from 'os';
import * as path from 'path';
import { z } from 'zod';
Expand All @@ -15,8 +16,18 @@ import { UsageErrorSchema } from './usage-types';

// Cache configuration
const CACHE_DIR = path.join(os.homedir(), '.cache', 'ccstatusline');
const CACHE_FILE = path.join(CACHE_DIR, 'usage.json');
const LOCK_FILE = path.join(CACHE_DIR, 'usage.lock');

function getUsageCachePath(): string {
const configDir = path.resolve(getClaudeConfigDir());
const configHash = createHash('sha256').update(configDir).digest('hex').slice(0, 16);
return path.join(CACHE_DIR, `usage-${configHash}.json`);
}

function getUsageLockPath(): string {
const configDir = path.resolve(getClaudeConfigDir());
const configHash = createHash('sha256').update(configDir).digest('hex').slice(0, 16);
return path.join(CACHE_DIR, `usage-${configHash}.lock`);
}
const CACHE_MAX_AGE = 180; // seconds
const LOCK_MAX_AGE = 30; // rate limit: only try API once per 30 seconds
const DEFAULT_RATE_LIMIT_BACKOFF = 300; // seconds
Expand Down Expand Up @@ -301,19 +312,30 @@ function readUsageTokenFromCredentialsFile(): string | null {
}
}

// Claude Code stores per-profile keychain entries with the service name
// "Claude Code-credentials-{first 8 hex chars of SHA-256(configDir)}".
function getMacKeychainServiceForConfigDir(): string {
const configDir = path.resolve(getClaudeConfigDir());
const suffix = createHash('sha256').update(configDir).digest('hex').slice(0, 8);
return `${MACOS_USAGE_CREDENTIALS_SERVICE}-${suffix}`;
}

export function getUsageToken(): string | null {
if (process.platform !== 'darwin') {
return readUsageTokenFromCredentialsFile();
}

return readUsageTokenFromMacKeychainService(MACOS_USAGE_CREDENTIALS_SERVICE)
// Try the keychain entry specific to the current config dir first, then fall
// back to the default entry and the mtime-sorted candidate scan.
return readUsageTokenFromMacKeychainService(getMacKeychainServiceForConfigDir())
?? readUsageTokenFromMacKeychainService(MACOS_USAGE_CREDENTIALS_SERVICE)
?? readUsageTokenFromMacKeychainCandidates()
?? readUsageTokenFromCredentialsFile();
}

function readStaleUsageCache(): UsageData | null {
try {
return parseCachedUsageData(fs.readFileSync(CACHE_FILE, 'utf8'));
return parseCachedUsageData(fs.readFileSync(getUsageCachePath(), 'utf8'));
} catch {
return null;
}
Expand All @@ -322,7 +344,7 @@ function readStaleUsageCache(): UsageData | null {
function writeUsageLock(blockedUntil: number, error: UsageLockError): void {
try {
ensureCacheDirExists();
fs.writeFileSync(LOCK_FILE, JSON.stringify({ blockedUntil, error }));
fs.writeFileSync(getUsageLockPath(), JSON.stringify({ blockedUntil, error }));
} catch {
// Ignore lock file errors
}
Expand All @@ -332,7 +354,7 @@ function readActiveUsageLock(now: number): { blockedUntil: number; error: UsageL
let hasValidJsonLock = false;

try {
const parsed = parseJsonWithSchema(fs.readFileSync(LOCK_FILE, 'utf8'), UsageLockSchema);
const parsed = parseJsonWithSchema(fs.readFileSync(getUsageLockPath(), 'utf8'), UsageLockSchema);
if (parsed) {
hasValidJsonLock = true;
if (parsed.blockedUntil > now) {
Expand All @@ -352,7 +374,7 @@ function readActiveUsageLock(now: number): { blockedUntil: number; error: UsageL
}

try {
const lockStat = fs.statSync(LOCK_FILE);
const lockStat = fs.statSync(getUsageLockPath());
const lockMtime = Math.floor(lockStat.mtimeMs / 1000);
const blockedUntil = lockMtime + LOCK_MAX_AGE;
if (blockedUntil > now) {
Expand Down Expand Up @@ -491,10 +513,11 @@ export async function fetchUsageData(): Promise<UsageData> {

// Check file cache
try {
const stat = fs.statSync(CACHE_FILE);
const usageCachePath = getUsageCachePath();
const stat = fs.statSync(usageCachePath);
const fileAge = now - Math.floor(stat.mtimeMs / 1000);
if (fileAge < CACHE_MAX_AGE) {
const fileData = parseCachedUsageData(fs.readFileSync(CACHE_FILE, 'utf8'));
const fileData = parseCachedUsageData(fs.readFileSync(usageCachePath, 'utf8'));
if (fileData && !fileData.error) {
return cacheUsageData(fileData, now);
}
Expand Down Expand Up @@ -546,7 +569,7 @@ export async function fetchUsageData(): Promise<UsageData> {
// Save to cache
try {
ensureCacheDirExists();
fs.writeFileSync(CACHE_FILE, JSON.stringify(usageData));
fs.writeFileSync(getUsageCachePath(), JSON.stringify(usageData));
} catch {
// Ignore cache write errors
}
Expand Down