From a7c6a5f4b8d5d02988a7b7a4dae2a3230e9c6f37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Santos?= <4837+borfast@users.noreply.github.com> Date: Sun, 28 Jun 2026 00:15:28 +0100 Subject: [PATCH] installer: honor CLAUDE_CONFIG_DIR for global Claude Code installs The Claude Code target hardcoded `~/.claude` and `~/.claude.json`, so `codegraph install --location global` always wrote to the default profile even when the user runs Claude Code against a custom profile via the `CLAUDE_CONFIG_DIR` env var (e.g. `~/.claude-gc`). The MCP server, permissions, and instructions all landed in a profile Claude Code never reads, so codegraph silently never loaded. Resolve the global config dir from `CLAUDE_CONFIG_DIR` when set, falling back to `~/.claude`. The `.claude.json` MCP file follows Claude Code's own rule: it lives *inside* the profile dir when `CLAUDE_CONFIG_DIR` is set, but beside `~/.claude` (in `$HOME`) for the default profile. Project-local installs are unaffected. Also clear `CLAUDE_CONFIG_DIR` in the installer-targets test harness so a custom profile in the real environment can't leak into tests that assume the default profile, and add a test covering the new behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- __tests__/installer-targets.test.ts | 30 +++++++++++++++++++++++++++++ src/installer/targets/claude.ts | 30 +++++++++++++++++++++++------ 2 files changed, 54 insertions(+), 6 deletions(-) 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