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
21 changes: 21 additions & 0 deletions src/utils/__tests__/claude-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { DEFAULT_SETTINGS } from '../../types/Settings';
import {
CCSTATUSLINE_COMMANDS,
getClaudeCodeVersion,
getClaudeJsonPath,
getClaudeSettingsPath,
getExistingStatusLine,
getRefreshInterval,
Expand Down Expand Up @@ -128,6 +129,26 @@ describe('isKnownCommand', () => {
});
});

describe('Claude config paths', () => {
it('should resolve .claude.json inside CLAUDE_CONFIG_DIR when configured', () => {
expect(getClaudeJsonPath()).toBe(path.join(testClaudeConfigDir, '.claude.json'));
});

it('should resolve .claude.json beside the default Claude config dir when CLAUDE_CONFIG_DIR is unset', () => {
delete process.env.CLAUDE_CONFIG_DIR;

expect(getClaudeJsonPath()).toBe(path.join(os.homedir(), '.claude.json'));
});

it('should use default .claude.json path when CLAUDE_CONFIG_DIR points to a file', () => {
const invalidConfigDir = path.join(testClaudeConfigDir, 'not-a-dir');
fs.writeFileSync(invalidConfigDir, 'not a directory', 'utf-8');
process.env.CLAUDE_CONFIG_DIR = invalidConfigDir;

expect(getClaudeJsonPath()).toBe(path.join(os.homedir(), '.claude.json'));
});
});

describe('buildCommand via installStatusLine', () => {
it('should use base command when no custom config path', async () => {
initConfigPath();
Expand Down
17 changes: 17 additions & 0 deletions src/utils/claude-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,23 @@ export function getClaudeConfigDir(): string {
return path.join(os.homedir(), '.claude');
}

/**
* Gets the full path to Claude Code's .claude.json account state file.
*
* Claude Code stores this as a sibling of the default ~/.claude directory, but
* inside CLAUDE_CONFIG_DIR when a valid config directory override is active.
*/
export function getClaudeJsonPath(): string {
const configDir = getClaudeConfigDir();
const envConfigDir = process.env.CLAUDE_CONFIG_DIR;

if (envConfigDir && configDir === path.resolve(envConfigDir)) {
return path.join(configDir, '.claude.json');
}

return path.join(path.dirname(configDir), '.claude.json');
}

/**
* Gets the full path to the Claude settings.json file.
*/
Expand Down
15 changes: 3 additions & 12 deletions src/widgets/ClaudeAccountEmail.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import * as fs from 'fs';
import * as path from 'path';

import type { RenderContext } from '../types/RenderContext';
import type { Settings } from '../types/Settings';
Expand All @@ -8,7 +7,7 @@ import type {
WidgetEditorDisplay,
WidgetItem
} from '../types/Widget';
import { getClaudeConfigDir } from '../utils/claude-settings';
import { getClaudeJsonPath } from '../utils/claude-settings';

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

Expand All @@ -27,19 +26,11 @@ export class ClaudeAccountEmailWidget implements Widget {
}

try {
const configDir = getClaudeConfigDir();
const claudeJsonPath = path.join(configDir, '..', '.claude.json');
const resolved = path.resolve(claudeJsonPath);

if (!fs.existsSync(resolved)) {
return null;
}

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

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

Expand Down
49 changes: 49 additions & 0 deletions src/widgets/__tests__/ClaudeAccountEmail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,53 @@ describe('ClaudeAccountEmailWidget', () => {

expect(render()).toBeNull();
});

it('reads from $CLAUDE_CONFIG_DIR/.claude.json when CLAUDE_CONFIG_DIR is set', () => {
// Regression test for #317: the widget previously read $HOME/.claude.json
// regardless of CLAUDE_CONFIG_DIR, causing all profiles to show the same email.
const customConfigDir = path.join(tempHomeDir, '.claude-work');
fs.mkdirSync(customConfigDir, { recursive: true });
process.env.CLAUDE_CONFIG_DIR = customConfigDir;

const claudeJsonPath = path.join(customConfigDir, '.claude.json');
fs.writeFileSync(
claudeJsonPath,
JSON.stringify({ oauthAccount: { emailAddress: 'work@example.com' } }),
'utf-8'
);

expect(render()).toBe('Account: work@example.com');
});

it('does not fall back to $HOME/.claude.json when CLAUDE_CONFIG_DIR points to a dir without one', () => {
// Verifies the fix for #317 doesn't silently leak the wrong profile's
// email when the configured profile has no .claude.json yet.
const customConfigDir = path.join(tempHomeDir, '.claude-empty');
fs.mkdirSync(customConfigDir, { recursive: true });
process.env.CLAUDE_CONFIG_DIR = customConfigDir;

// Plant a .claude.json in $HOME — the widget must NOT pick this up
vi.spyOn(os, 'homedir').mockReturnValue(tempHomeDir);
fs.writeFileSync(
path.join(tempHomeDir, '.claude.json'),
JSON.stringify({ oauthAccount: { emailAddress: 'leak@example.com' } }),
'utf-8'
);

expect(render()).toBeNull();
});

it('returns null when emailAddress is not a string', () => {
delete process.env.CLAUDE_CONFIG_DIR;
vi.spyOn(os, 'homedir').mockReturnValue(tempHomeDir);

const claudeJsonPath = path.join(tempHomeDir, '.claude.json');
fs.writeFileSync(
claudeJsonPath,
JSON.stringify({ oauthAccount: { emailAddress: 12345 } }),
'utf-8'
);

expect(render()).toBeNull();
});
});