diff --git a/.env.example b/.env.example index 33d0167a..8c6085aa 100644 --- a/.env.example +++ b/.env.example @@ -421,3 +421,16 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here # WEB_CUSTOM_ALLOW_HTTP=false — set "true" to allow http:// URLs # WEB_CUSTOM_ALLOW_PRIVATE=false — set "true" to target localhost/private IPs # (needed for self-hosted SearXNG) + +# ── Config directory override ─────────────────────────────────────── +# +# By default openclaude stores per-user state under ~/.openclaude +# (and falls back to ~/.claude for installs that pre-date the rename). +# Set this to point openclaude at a different directory — useful for +# isolating profiles or sharing config across machines. +# +# OPENCLAUDE_CONFIG_DIR=/path/to/dir — preferred name +# CLAUDE_CONFIG_DIR=/path/to/dir — legacy alias (still works) +# +# When both are set with different values, OPENCLAUDE_CONFIG_DIR wins +# and a warning is logged once per process. diff --git a/src/utils/env.ts b/src/utils/env.ts index 44c95c28..b2d162f9 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -3,7 +3,11 @@ import { homedir } from 'os' import { join } from 'path' import { fileSuffixForOauthConfig } from '../constants/oauth.js' import { isRunningWithBun } from './bundledMode.js' -import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js' +import { + getClaudeConfigHomeDir, + isEnvTruthy, + resolveConfigDirEnv, +} from './envUtils.js' import { findExecutable } from './findExecutable.js' import { getFsImplementation } from './fsOperations.js' import { which } from './which.js' @@ -22,7 +26,11 @@ export const getGlobalClaudeFile = memoize((): string => { } const oauthSuffix = fileSuffixForOauthConfig() - const configDir = process.env.CLAUDE_CONFIG_DIR || homedir() + const configDir = + resolveConfigDirEnv({ + openClaudeConfigDir: process.env.OPENCLAUDE_CONFIG_DIR, + legacyConfigDir: process.env.CLAUDE_CONFIG_DIR, + }) ?? homedir() // Default to .openclaude.json. Fall back to .claude.json only if the new // file doesn't exist yet and the legacy one does (same migration pattern diff --git a/src/utils/envUtils.ts b/src/utils/envUtils.ts index fe0e811b..1ad91cb0 100644 --- a/src/utils/envUtils.ts +++ b/src/utils/envUtils.ts @@ -3,6 +3,39 @@ import { existsSync } from 'fs' import { homedir } from 'os' import { join } from 'path' +/** + * Resolves the override env value for the config home directory. + * `OPENCLAUDE_CONFIG_DIR` is preferred — `CLAUDE_CONFIG_DIR` is the legacy + * Anthropic name kept working for backward compatibility. When both are set + * and disagree, `OPENCLAUDE_CONFIG_DIR` wins and we warn once so the user + * can clean up. Exported for tests. + */ +let warnedAboutConflictingConfigDirEnvs = false + +export function resolveConfigDirEnv(options?: { + openClaudeConfigDir?: string + legacyConfigDir?: string + warn?: (message: string) => void +}): string | undefined { + const open = options?.openClaudeConfigDir + const legacy = options?.legacyConfigDir + if (open && legacy && open !== legacy && !warnedAboutConflictingConfigDirEnvs) { + warnedAboutConflictingConfigDirEnvs = true + options?.warn?.( + `Both OPENCLAUDE_CONFIG_DIR and CLAUDE_CONFIG_DIR are set to different values. Using OPENCLAUDE_CONFIG_DIR=${open}; ignoring CLAUDE_CONFIG_DIR=${legacy}.`, + ) + } + return open || legacy || undefined +} + +/** + * Test-only escape hatch — resets the once-per-process conflict warning so + * unit tests can re-trigger it. + */ +export function __resetConfigDirEnvWarningForTesting(): void { + warnedAboutConflictingConfigDirEnvs = false +} + export function resolveClaudeConfigHomeDir(options?: { configDirEnv?: string homeDir?: string @@ -30,13 +63,21 @@ export function resolveClaudeConfigHomeDir(options?: { return openClaudeDir.normalize('NFC') } -// Memoized: 150+ callers, many on hot paths. Keyed off CLAUDE_CONFIG_DIR so -// tests that change the env var get a fresh value without explicit cache.clear. +// Memoized: 150+ callers, many on hot paths. Keyed off both override env +// vars so tests that change either get a fresh value without explicit +// cache.clear. export const getClaudeConfigHomeDir = memoize( (): string => resolveClaudeConfigHomeDir({ - configDirEnv: process.env.CLAUDE_CONFIG_DIR, + configDirEnv: resolveConfigDirEnv({ + openClaudeConfigDir: process.env.OPENCLAUDE_CONFIG_DIR, + legacyConfigDir: process.env.CLAUDE_CONFIG_DIR, + warn: message => { + // eslint-disable-next-line no-console + console.warn(`[openclaude] ${message}`) + }, + }), }), - () => process.env.CLAUDE_CONFIG_DIR, + () => `${process.env.OPENCLAUDE_CONFIG_DIR ?? ''}|${process.env.CLAUDE_CONFIG_DIR ?? ''}`, ) export function getTeamsDir(): string { diff --git a/src/utils/openclaudePaths.test.ts b/src/utils/openclaudePaths.test.ts index 42246e58..e072122f 100644 --- a/src/utils/openclaudePaths.test.ts +++ b/src/utils/openclaudePaths.test.ts @@ -51,7 +51,8 @@ describe('OpenClaude paths', () => { ).toBe(join(homedir(), '.claude')) }) - test('uses CLAUDE_CONFIG_DIR override when provided', async () => { + test('uses CLAUDE_CONFIG_DIR override when provided (legacy)', async () => { + delete process.env.OPENCLAUDE_CONFIG_DIR process.env.CLAUDE_CONFIG_DIR = '/tmp/custom-openclaude' const { getClaudeConfigHomeDir, resolveClaudeConfigHomeDir } = await importFreshEnvUtils() @@ -64,6 +65,83 @@ describe('OpenClaude paths', () => { ).toBe('/tmp/custom-openclaude') }) + test('OPENCLAUDE_CONFIG_DIR overrides the default (issue #454)', async () => { + delete process.env.CLAUDE_CONFIG_DIR + process.env.OPENCLAUDE_CONFIG_DIR = '/tmp/oc-config-only' + const { getClaudeConfigHomeDir } = await importFreshEnvUtils() + + expect(getClaudeConfigHomeDir()).toBe('/tmp/oc-config-only') + }) + + test('OPENCLAUDE_CONFIG_DIR wins when both env vars are set with different values', async () => { + process.env.OPENCLAUDE_CONFIG_DIR = '/tmp/oc-wins' + process.env.CLAUDE_CONFIG_DIR = '/tmp/legacy-loses' + const { getClaudeConfigHomeDir } = await importFreshEnvUtils() + + expect(getClaudeConfigHomeDir()).toBe('/tmp/oc-wins') + }) + + test('CLAUDE_CONFIG_DIR is still honored when OPENCLAUDE_CONFIG_DIR is unset', async () => { + delete process.env.OPENCLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = '/tmp/legacy-only' + const { getClaudeConfigHomeDir } = await importFreshEnvUtils() + + expect(getClaudeConfigHomeDir()).toBe('/tmp/legacy-only') + }) + + test('empty OPENCLAUDE_CONFIG_DIR falls through to CLAUDE_CONFIG_DIR', async () => { + process.env.OPENCLAUDE_CONFIG_DIR = '' + process.env.CLAUDE_CONFIG_DIR = '/tmp/legacy-fallback' + const { getClaudeConfigHomeDir } = await importFreshEnvUtils() + + expect(getClaudeConfigHomeDir()).toBe('/tmp/legacy-fallback') + }) + + test('resolveConfigDirEnv prefers OPENCLAUDE over CLAUDE and warns on conflict', async () => { + const { resolveConfigDirEnv, __resetConfigDirEnvWarningForTesting } = + await importFreshEnvUtils() + __resetConfigDirEnvWarningForTesting() + + const warnings: string[] = [] + const result = resolveConfigDirEnv({ + openClaudeConfigDir: '/a', + legacyConfigDir: '/b', + warn: m => warnings.push(m), + }) + + expect(result).toBe('/a') + expect(warnings.length).toBe(1) + expect(warnings[0]).toContain('OPENCLAUDE_CONFIG_DIR=/a') + expect(warnings[0]).toContain('CLAUDE_CONFIG_DIR=/b') + }) + + test('resolveConfigDirEnv does not warn when both env vars agree', async () => { + const { resolveConfigDirEnv, __resetConfigDirEnvWarningForTesting } = + await importFreshEnvUtils() + __resetConfigDirEnvWarningForTesting() + + const warnings: string[] = [] + const result = resolveConfigDirEnv({ + openClaudeConfigDir: '/same', + legacyConfigDir: '/same', + warn: m => warnings.push(m), + }) + + expect(result).toBe('/same') + expect(warnings).toEqual([]) + }) + + test('resolveConfigDirEnv returns undefined when neither env var is set', async () => { + const { resolveConfigDirEnv } = await importFreshEnvUtils() + + expect( + resolveConfigDirEnv({ + openClaudeConfigDir: undefined, + legacyConfigDir: undefined, + }), + ).toBeUndefined() + }) + test('project and local settings paths use .openclaude', async () => { const { getRelativeSettingsFilePathForSource } = await importFreshSettings() diff --git a/src/utils/secureStorage/macOsKeychainHelpers.ts b/src/utils/secureStorage/macOsKeychainHelpers.ts index f03547c1..ea47f5d2 100644 --- a/src/utils/secureStorage/macOsKeychainHelpers.ts +++ b/src/utils/secureStorage/macOsKeychainHelpers.ts @@ -34,7 +34,8 @@ export function getSecureStorageServiceName( serviceSuffix: string = '', ): string { const configDir = getClaudeConfigHomeDir() - const isDefaultDir = !process.env.CLAUDE_CONFIG_DIR + const isDefaultDir = + !process.env.OPENCLAUDE_CONFIG_DIR && !process.env.CLAUDE_CONFIG_DIR // Use a hash of the config dir path to create a unique but stable suffix // Only add suffix for non-default directories to maintain backwards compatibility diff --git a/src/utils/swarm/spawnUtils.ts b/src/utils/swarm/spawnUtils.ts index 7b7e2299..4f8fdcfb 100644 --- a/src/utils/swarm/spawnUtils.ts +++ b/src/utils/swarm/spawnUtils.ts @@ -117,7 +117,8 @@ const TEAMMATE_ENV_VARS = [ 'MISTRAL_BASE_URL', // Custom API endpoint 'ANTHROPIC_BASE_URL', - // Config directory override + // Config directory override (preferred name + legacy alias) + 'OPENCLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIR', // CCR marker — teammates need this for CCR-aware code paths. Auth finds // its own way via /home/claude/.claude/remote/.oauth_token regardless;