diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index ffa708197..a40a37ba3 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -38,12 +38,16 @@ function setHome(dir: string): { restore: () => void } { APPDATA: process.env.APPDATA, XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, HERMES_HOME: process.env.HERMES_HOME, + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, }; process.env.HOME = dir; process.env.USERPROFILE = dir; process.env.APPDATA = path.join(dir, '.config'); process.env.XDG_CONFIG_HOME = path.join(dir, '.config'); delete process.env.HERMES_HOME; + // Clear CLAUDE_CONFIG_DIR so a custom profile in the real environment + // doesn't leak into tests that assume the default ~/.claude profile. + delete process.env.CLAUDE_CONFIG_DIR; return { restore() { if (prev.HOME === undefined) delete process.env.HOME; else process.env.HOME = prev.HOME; @@ -51,6 +55,7 @@ function setHome(dir: string): { restore: () => void } { if (prev.APPDATA === undefined) delete process.env.APPDATA; else process.env.APPDATA = prev.APPDATA; if (prev.XDG_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = prev.XDG_CONFIG_HOME; if (prev.HERMES_HOME === undefined) delete process.env.HERMES_HOME; else process.env.HERMES_HOME = prev.HERMES_HOME; + if (prev.CLAUDE_CONFIG_DIR === undefined) delete process.env.CLAUDE_CONFIG_DIR; else process.env.CLAUDE_CONFIG_DIR = prev.CLAUDE_CONFIG_DIR; }, }; } @@ -921,6 +926,31 @@ describe('Installer targets — partial-state idempotency', () => { expect(cfg.mcpServers.codegraph).toBeDefined(); }); + it('claude: global install honors CLAUDE_CONFIG_DIR, leaving ~/.claude untouched', () => { + const claude = getTarget('claude')!; + const profile = path.join(tmpHome, '.claude-gc'); + const prev = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = profile; + try { + claude.install('global', { autoAllow: true }); + + // MCP entry, settings.json, and CLAUDE.md all land in the custom + // profile dir — Claude Code keeps .claude.json *inside* it when the + // env var is set. + const mcp = JSON.parse(fs.readFileSync(path.join(profile, '.claude.json'), 'utf-8')); + expect(mcp.mcpServers.codegraph).toBeDefined(); + expect(fs.existsSync(path.join(profile, 'settings.json'))).toBe(true); + expect(fs.existsSync(path.join(profile, 'CLAUDE.md'))).toBe(true); + + // The default profile must be left alone. + expect(fs.existsSync(path.join(tmpHome, '.claude.json'))).toBe(false); + expect(fs.existsSync(path.join(tmpHome, '.claude'))).toBe(false); + } finally { + if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = prev; + } + }); + it('claude: local install migrates a legacy ./.claude.json codegraph entry into ./.mcp.json', () => { const claude = getTarget('claude')!; const legacy = path.join(tmpCwd, '.claude.json'); diff --git a/src/installer/targets/claude.ts b/src/installer/targets/claude.ts index bdfe6e6fb..0446a0fde 100644 --- a/src/installer/targets/claude.ts +++ b/src/installer/targets/claude.ts @@ -41,18 +41,36 @@ import { CODEGRAPH_SECTION_START, } from '../instructions-template'; +/** + * Root of the global Claude Code profile. Honors CLAUDE_CONFIG_DIR so + * non-default profiles (e.g. `~/.claude-gc`) get configured instead of + * `~/.claude` — Claude Code reads settings.json, CLAUDE.md, and (when + * the env var is set) .claude.json from this directory. Only affects the + * global location; project-local files are unaffected by the env var. + */ +function globalConfigDir(): string { + const cfg = process.env.CLAUDE_CONFIG_DIR; + return cfg && cfg.trim().length > 0 + ? path.resolve(cfg) + : path.join(os.homedir(), '.claude'); +} function configDir(loc: Location): string { return loc === 'global' - ? path.join(os.homedir(), '.claude') + ? globalConfigDir() : path.join(process.cwd(), '.claude'); } function mcpJsonPath(loc: Location): string { - // global → ~/.claude.json (user scope: visible in every project). + // global → $CLAUDE_CONFIG_DIR/.claude.json when that env var is set + // (Claude Code keeps .claude.json inside a custom profile dir), else + // ~/.claude.json in HOME (the default profile keeps it beside, not + // inside, ~/.claude). User scope: visible in every project. // local → ./.mcp.json (project scope: the ONLY project-level MCP - // file Claude Code reads — NOT ./.claude.json, which it ignores). - return loc === 'global' - ? path.join(os.homedir(), '.claude.json') - : path.join(process.cwd(), '.mcp.json'); + // file Claude Code reads — NOT ./.claude.json, which it ignores). + if (loc !== 'global') return path.join(process.cwd(), '.mcp.json'); + const cfg = process.env.CLAUDE_CONFIG_DIR; + return cfg && cfg.trim().length > 0 + ? path.join(path.resolve(cfg), '.claude.json') + : path.join(os.homedir(), '.claude.json'); } /** * Where pre-#207 installers wrote the local MCP entry. Claude Code