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
4 changes: 3 additions & 1 deletion src/ccstatusline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ async function renderMultipleLines(data: StatusJSON) {
const hasCompactionWidget = lines.some(line => line.some(item => item.type === 'compaction-counter'));
const hasThinkingEffortWidget = lines.some(line => line.some(item => item.type === 'thinking-effort'));
const hasSessionNameWidget = lines.some(line => line.some(item => item.type === 'session-name'));
const hasSkillsWidget = lines.some(line => line.some(item => item.type === 'skills'));
const needsTranscriptThinkingEffort = hasThinkingEffortWidget
&& (!data.effort || !('level' in data.effort));
const requestedSpeedWindows = new Set<number>();
Expand Down Expand Up @@ -143,7 +144,8 @@ async function renderMultipleLines(data: StatusJSON) {
const windowedSpeedMetrics = transcriptAnalysis?.speedMetricsCollection?.windowed ?? null;

let skillsMetrics: SkillsMetrics | null = null;
if (data.session_id) {
// Only the skills widget reads these; skip the per-session log read otherwise.
if (hasSkillsWidget && data.session_id) {
skillsMetrics = getSkillsMetrics(data.session_id);
}

Expand Down
143 changes: 143 additions & 0 deletions src/utils/__tests__/claude-account-email.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';

import {
getClaudeAccountEmail,
type AccountEmailDeps
} from '../claude-account-email';

let tempDir: string;
let claudeJsonPath: string;
let cachePath: string;
let deps: AccountEmailDeps;
let readSpy: ReturnType<typeof vi.fn>;

function writeClaudeJson(content: string, mtimeSeconds?: number): void {
fs.writeFileSync(claudeJsonPath, content, 'utf-8');
if (mtimeSeconds !== undefined) {
fs.utimesSync(claudeJsonPath, mtimeSeconds, mtimeSeconds);
}
}

function claudeJsonReads(): number {
return readSpy.mock.calls.filter(call => call[0] === claudeJsonPath).length;
}

describe('getClaudeAccountEmail', () => {
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-account-email-'));
claudeJsonPath = path.join(tempDir, '.claude.json');
cachePath = path.join(tempDir, 'cache', 'claude-account-email.json');
readSpy = vi.fn((p: string) => fs.readFileSync(p, 'utf-8'));
deps = {
statSync: (p: string) => fs.statSync(p),
readFileSync: readSpy as unknown as (p: string) => string,
writeFileSync: (p: string, data: string) => { fs.writeFileSync(p, data, 'utf-8'); },
renameSync: (from: string, to: string) => { fs.renameSync(from, to); },
mkdirSync: (p: string) => { fs.mkdirSync(p, { recursive: true }); },
getClaudeJsonPath: () => claudeJsonPath,
getCachePath: () => cachePath
};
});

afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});

it('returns null without caching when .claude.json is missing', () => {
expect(getClaudeAccountEmail(deps)).toBeNull();
expect(fs.existsSync(cachePath)).toBe(false);
});

it('parses .claude.json once and serves repeat calls from the cache', () => {
writeClaudeJson(JSON.stringify({ oauthAccount: { emailAddress: 'user@example.com' } }));

expect(getClaudeAccountEmail(deps)).toBe('user@example.com');
expect(getClaudeAccountEmail(deps)).toBe('user@example.com');
expect(claudeJsonReads()).toBe(1);
});

it('caches a missing email as a hit', () => {
writeClaudeJson(JSON.stringify({ projects: {} }));

expect(getClaudeAccountEmail(deps)).toBeNull();
expect(getClaudeAccountEmail(deps)).toBeNull();
expect(claudeJsonReads()).toBe(1);
});

it('treats non-string and empty emails as missing', () => {
writeClaudeJson(JSON.stringify({ oauthAccount: { emailAddress: 12345 } }), 1000);
expect(getClaudeAccountEmail(deps)).toBeNull();

writeClaudeJson(JSON.stringify({ oauthAccount: { emailAddress: '' } }), 2000);
expect(getClaudeAccountEmail(deps)).toBeNull();
});

it('re-reads when the mtime changes', () => {
writeClaudeJson(JSON.stringify({ oauthAccount: { emailAddress: 'a@example.com' } }), 1000);
expect(getClaudeAccountEmail(deps)).toBe('a@example.com');

// Same size, different mtime.
writeClaudeJson(JSON.stringify({ oauthAccount: { emailAddress: 'b@example.com' } }), 2000);
expect(getClaudeAccountEmail(deps)).toBe('b@example.com');
expect(claudeJsonReads()).toBe(2);
});

it('re-reads when the size changes', () => {
writeClaudeJson(JSON.stringify({ oauthAccount: { emailAddress: 'a@example.com' } }), 1000);
expect(getClaudeAccountEmail(deps)).toBe('a@example.com');

// Same mtime, different size.
writeClaudeJson(JSON.stringify({ oauthAccount: { emailAddress: 'longer@example.com' } }), 1000);
expect(getClaudeAccountEmail(deps)).toBe('longer@example.com');
expect(claudeJsonReads()).toBe(2);
});

it('re-reads when the cache was written for a different .claude.json path', () => {
writeClaudeJson(JSON.stringify({ oauthAccount: { emailAddress: 'a@example.com' } }), 1000);
expect(getClaudeAccountEmail(deps)).toBe('a@example.com');

const otherPath = path.join(tempDir, 'other.json');
fs.copyFileSync(claudeJsonPath, otherPath);
fs.utimesSync(otherPath, 1000, 1000);
fs.writeFileSync(otherPath, JSON.stringify({ oauthAccount: { emailAddress: 'b@example.com' } }), 'utf-8');
fs.utimesSync(otherPath, 1000, 1000);
claudeJsonPath = otherPath;

expect(getClaudeAccountEmail(deps)).toBe('b@example.com');
});

it('treats a corrupt cache file as a miss', () => {
writeClaudeJson(JSON.stringify({ oauthAccount: { emailAddress: 'user@example.com' } }));
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
fs.writeFileSync(cachePath, '{not json', 'utf-8');

expect(getClaudeAccountEmail(deps)).toBe('user@example.com');
expect(getClaudeAccountEmail(deps)).toBe('user@example.com');
expect(claudeJsonReads()).toBe(1);
});

it('does not cache a parse failure, so a later valid file is picked up', () => {
writeClaudeJson('{not json', 1000);
expect(getClaudeAccountEmail(deps)).toBeNull();
expect(getClaudeAccountEmail(deps)).toBeNull();
expect(claudeJsonReads()).toBe(2);
expect(fs.existsSync(cachePath)).toBe(false);
});

it('still returns the email when the cache cannot be written', () => {
writeClaudeJson(JSON.stringify({ oauthAccount: { emailAddress: 'user@example.com' } }));
deps.writeFileSync = () => { throw new Error('EROFS'); };

expect(getClaudeAccountEmail(deps)).toBe('user@example.com');
});
});
122 changes: 122 additions & 0 deletions src/utils/claude-account-email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';

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

const CACHE_SCHEMA_VERSION = 1 as const;

interface ClaudeJson { oauthAccount?: { emailAddress?: string } }

interface AccountEmailCacheEntry {
version: typeof CACHE_SCHEMA_VERSION;
path: string;
size: number;
mtimeMs: number;
email: string | null;
}

export interface AccountEmailDeps {
statSync: (path: string) => { size: number; mtimeMs: number };
readFileSync: (path: string) => string;
writeFileSync: (path: string, data: string) => void;
renameSync: (from: string, to: string) => void;
mkdirSync: (path: string) => void;
getClaudeJsonPath: () => string;
getCachePath: () => string;
}

const defaultDeps: AccountEmailDeps = {
statSync: (p: string) => fs.statSync(p),
readFileSync: (p: string) => fs.readFileSync(p, 'utf-8'),
writeFileSync: (p: string, data: string) => { fs.writeFileSync(p, data, { encoding: 'utf-8', mode: 0o600 }); },
renameSync: (from: string, to: string) => { fs.renameSync(from, to); },
mkdirSync: (p: string) => { fs.mkdirSync(p, { recursive: true }); },
getClaudeJsonPath,
// Resolved per call (not at import) so a changed/mocked home is honoured.
getCachePath: () => path.join(os.homedir(), '.cache', 'ccstatusline', 'claude-account-email.json')
};

function readCacheEntry(deps: AccountEmailDeps): AccountEmailCacheEntry | null {
try {
const parsed = JSON.parse(deps.readFileSync(deps.getCachePath())) as unknown;
if (typeof parsed !== 'object' || parsed === null) {
return null;
}

const entry = parsed as Record<string, unknown>;
if (entry.version !== CACHE_SCHEMA_VERSION
|| typeof entry.path !== 'string'
|| typeof entry.size !== 'number'
|| typeof entry.mtimeMs !== 'number'
|| (typeof entry.email !== 'string' && entry.email !== null)) {
return null;
}

return entry as unknown as AccountEmailCacheEntry;
} catch {
// Missing or corrupt cache is a miss, never a failure.
return null;
}
}

function writeCacheEntry(entry: AccountEmailCacheEntry, deps: AccountEmailDeps): void {
try {
const cachePath = deps.getCachePath();
deps.mkdirSync(path.dirname(cachePath));
const tempPath = `${cachePath}.${process.pid}.tmp`;
deps.writeFileSync(tempPath, JSON.stringify(entry));
deps.renameSync(tempPath, cachePath);
} catch {
// Best-effort cache; the statusline must render regardless.
}
}

/**
* Returns the logged-in account email from `.claude.json`, or null when it is
* missing, empty, not a string, or the file is absent/unparseable.
*
* `.claude.json` also holds Claude Code's per-project history, so it can grow
* to megabytes and parsing it on every render is the dominant cost of the
* widget. The extracted email is therefore cached keyed on the file's
* path + size + mtime, and the file is only re-read when one of those changes.
* Stat happens before the read, so a write that lands in between leaves a key
* older than the content and simply causes one extra re-read next time.
* Parse failures are not cached, so they are retried on every render as before.
*/
export function getClaudeAccountEmail(deps: AccountEmailDeps = defaultDeps): string | null {
const claudeJsonPath = deps.getClaudeJsonPath();

let stat: { size: number; mtimeMs: number };
try {
stat = deps.statSync(claudeJsonPath);
} catch {
return null;
}

const cached = readCacheEntry(deps);
if (cached?.path === claudeJsonPath
&& cached.size === stat.size
&& cached.mtimeMs === stat.mtimeMs) {
return cached.email;
}

let email: string | null;
try {
const data = JSON.parse(deps.readFileSync(claudeJsonPath)) as ClaudeJson;
const value = data.oauthAccount?.emailAddress;
email = typeof value === 'string' && value.length > 0 ? value : null;
} catch {
return null;
}

writeCacheEntry({
version: CACHE_SCHEMA_VERSION,
path: claudeJsonPath,
size: stat.size,
mtimeMs: stat.mtimeMs,
email
}, deps);

return email;
}
21 changes: 5 additions & 16 deletions src/widgets/ClaudeAccountEmail.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
import * as fs from 'node:fs';

import type { RenderContext } from '../types/RenderContext';
import type { Settings } from '../types/Settings';
import type {
Widget,
WidgetEditorDisplay,
WidgetItem
} from '../types/Widget';
import { getClaudeJsonPath } from '../utils/claude-settings';
import { getClaudeAccountEmail } from '../utils/claude-account-email';

import { formatRawOrLabeledValue } from './shared/raw-or-labeled';

const LABEL = 'Account: ';

interface ClaudeJson { oauthAccount?: { emailAddress?: string } }

export class ClaudeAccountEmailWidget implements Widget {
getDefaultColor(): string { return 'blue'; }
getDescription(): string { return 'Displays the email of the currently logged-in Claude account'; }
Expand All @@ -30,19 +26,12 @@ export class ClaudeAccountEmailWidget implements Widget {
return formatRawOrLabeledValue(item, this.getLabelPrefix(), 'you@example.com');
}

try {
const content = fs.readFileSync(getClaudeJsonPath(), 'utf-8');
const data = JSON.parse(content) as ClaudeJson;
const email = data.oauthAccount?.emailAddress;

if (typeof email !== 'string' || email.length === 0) {
return null;
}

return formatRawOrLabeledValue(item, this.getLabelPrefix(), email);
} catch {
const email = getClaudeAccountEmail();
if (email === null) {
return null;
}

return formatRawOrLabeledValue(item, this.getLabelPrefix(), email);
}

supportsRawValue(): boolean { return true; }
Expand Down
2 changes: 2 additions & 0 deletions src/widgets/__tests__/ClaudeAccountEmail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ describe('ClaudeAccountEmailWidget', () => {
beforeEach(() => {
vi.restoreAllMocks();
tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-claude-account-email-'));
// Keep the widget's email cache out of the real ~/.cache.
process.env.HOME = tempHomeDir;
});

afterEach(() => {
Expand Down
Loading