Просмотр исходного кода

fix(installer): honor CLAUDE_CONFIG_DIR and CODEX_HOME (#1627) (#1783)

* fix(installer): honor CODEX_HOME for the Codex global install (#1627)

Codex resolves its user layer from `CODEX_HOME` and only falls back to
`~/.codex`. The target hardcoded the fallback, so a user on a custom profile
got a correct install into a directory Codex never reads — the MCP entry, the
AGENTS.md block, and detect() all pointed at the wrong profile, and the
failure is silent.

Resolve the global config dir from `CODEX_HOME` when set and non-blank,
mirroring what the copilot-cli target already does for `COPILOT_HOME`. Only
the user layer moves; the project layer (#1531) stays anchored to the project.

The test harness now also clears `CODEX_HOME` in setHome() alongside
HERMES_HOME/COPILOT_HOME — without that, the existing codex tests fail on a
developer machine that has the variable exported.

Note this is only half of #1627: the CLAUDE_CONFIG_DIR half is already
covered by the open PR #1029, which this deliberately does not touch.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(installer): honor CLAUDE_CONFIG_DIR for global Claude installs

Build on #1633 by @maxmilian and port the CLAUDE_CONFIG_DIR approach
from #1029 by @borfast onto the current installer. Keep the CODEX_HOME
cherry-pick cbb08231 intact.

Resolve non-blank Claude profile paths with path.resolve. Put the global
MCP JSON inside a custom profile while preserving ~/.claude.json for the
default profile. Settings, instructions, detection, and uninstall follow
the selected profile; local installs keep their existing paths.

Clear and restore CLAUDE_CONFIG_DIR in the setHome test harness. Cover
absolute and relative profiles, idempotency, unset/empty/blank fallback,
default-profile preservation, detection/uninstall, and local installs.
Combine the Unreleased note for both environment variables.

Thanks @seanchann for reporting the issue.

Validation on Linux with Node 22.19.0:
- npx tsc -p tsconfig.json
- npx vitest run __tests__/installer-targets.test.ts: 245 passed, 3 skipped
- Reproduced both failures against main e720f6ca; the rebuilt CLI writes
  all files into CLAUDE_CONFIG_DIR and CODEX_HOME with no ~/.claude,
  ~/.claude.json, or ~/.codex created.

Fixes #1627

---------

Co-authored-by: Max Hsu <maxmilian@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
Colby Mchenry 3 часов назад
Родитель
Сommit
2f8cce5c57
4 измененных файлов с 246 добавлено и 9 удалено
  1. 2 0
      CHANGELOG.md
  2. 211 0
      __tests__/installer-targets.test.ts
  3. 21 5
      src/installer/targets/claude.ts
  4. 12 4
      src/installer/targets/codex.ts

+ 2 - 0
CHANGELOG.md

@@ -145,6 +145,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 - Indexing no longer checks whether files outside your project exist. A relative import that points above the project directory (`../../something`) made CodeGraph probe that location on disk while resolving it. Nothing outside the project was ever read, and no such file was ever added to the index or linked to, but the check itself should not have happened — such an import now simply resolves to nothing. Symlinks inside your project that point at code kept elsewhere are unaffected and still index as before. Thanks @ErQrYfkrju. (#1631)
 
+- `codegraph install` now honors `CLAUDE_CONFIG_DIR` and `CODEX_HOME` for global Claude Code and Codex setup so CodeGraph loads in your chosen profile (thanks @seanchann; #1627).
+
 #### Screens, links and navigation
 
 - **Where the app goes after login is a fork, not two always-es.** A navigation whose destination comes back from a helper — `router.replace(await resolvePostLoginRoute())` over `return (await hasSeenWelcome(…)) ? '/home/' : '/welcome/'` — drew both screens with no condition, reading as if the welcome screen always shows. The two arms share a line, and only a column can tell them apart; each synthesized edge now carries its literal's own position, so the guard reader says which arm it is: `WHEN await hasSeenWelcome(…)` → home, and its negation → welcome. And the scan starts at the helper's body, so a literal-union return type — `Promise<'/welcome/' | '/home/'>`, whose routes are string literals too, written first — no longer stands in for the navigation itself. Re-index after upgrading to pick the positions up.

+ 211 - 0
__tests__/installer-targets.test.ts

@@ -40,6 +40,8 @@ function setHome(dir: string): { restore: () => void } {
     XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
     HERMES_HOME: process.env.HERMES_HOME,
     COPILOT_HOME: process.env.COPILOT_HOME,
+    CODEX_HOME: process.env.CODEX_HOME,
+    CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
   };
   process.env.HOME = dir;
   process.env.USERPROFILE = dir;
@@ -47,6 +49,8 @@ function setHome(dir: string): { restore: () => void } {
   process.env.XDG_CONFIG_HOME = path.join(dir, '.config');
   delete process.env.HERMES_HOME;
   delete process.env.COPILOT_HOME;
+  delete process.env.CODEX_HOME;
+  delete process.env.CLAUDE_CONFIG_DIR;
   return {
     restore() {
       if (prev.HOME === undefined) delete process.env.HOME; else process.env.HOME = prev.HOME;
@@ -55,6 +59,8 @@ function setHome(dir: string): { restore: () => void } {
       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.COPILOT_HOME === undefined) delete process.env.COPILOT_HOME; else process.env.COPILOT_HOME = prev.COPILOT_HOME;
+      if (prev.CODEX_HOME === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = prev.CODEX_HOME;
+      if (prev.CLAUDE_CONFIG_DIR === undefined) delete process.env.CLAUDE_CONFIG_DIR; else process.env.CLAUDE_CONFIG_DIR = prev.CLAUDE_CONFIG_DIR;
     },
   };
 }
@@ -2665,3 +2671,208 @@ describe('Installer targets — Copilot family', () => {
     expect(jetbrains.detect('global').alreadyConfigured).toBe(true);
   });
 });
+
+describe('Installer targets — Claude CLAUDE_CONFIG_DIR override (#1627)', () => {
+  let tmpHome: string;
+  let tmpCwd: string;
+  let origCwd: string;
+  let homeRestore: { restore: () => void };
+
+  beforeEach(() => {
+    tmpHome = mkTmpDir('home');
+    tmpCwd = mkTmpDir('cwd');
+    origCwd = process.cwd();
+    process.chdir(tmpCwd);
+    homeRestore = setHome(tmpHome);
+  });
+
+  afterEach(() => {
+    homeRestore.restore();
+    process.chdir(origCwd);
+    fs.rmSync(tmpHome, { recursive: true, force: true });
+    fs.rmSync(tmpCwd, { recursive: true, force: true });
+  });
+
+  it.each(['absolute', 'relative'])('global install honors %s CLAUDE_CONFIG_DIR paths', (kind) => {
+    const custom = path.join(tmpHome, 'claude profile');
+    process.env.CLAUDE_CONFIG_DIR = kind === 'relative' ? path.relative(tmpCwd, custom) : custom;
+
+    const claude = getTarget('claude')!;
+    const result = claude.install('global', { autoAllow: true });
+    const paths = [
+      path.join(custom, '.claude.json'),
+      path.join(custom, 'settings.json'),
+      path.join(custom, 'CLAUDE.md'),
+    ] as const;
+
+    expect(result.files.map((f) => f.path)).toEqual(paths);
+    const mcp = JSON.parse(fs.readFileSync(paths[0], 'utf-8'));
+    expect(mcp.mcpServers.codegraph.alwaysLoad).toBe(true);
+    const settings = JSON.parse(fs.readFileSync(paths[1], 'utf-8'));
+    expect(settings.permissions.allow).toContain('mcp__codegraph__*');
+    expect(fs.readFileSync(paths[2], 'utf-8')).toContain('codegraph explore');
+    expect(claude.describePaths('global')).toEqual(paths);
+    expect(claude.printConfig('global')).toContain(`# Add to ${paths[0]}`);
+
+    const before = paths.map((p) => fs.readFileSync(p, 'utf-8'));
+    expect(claude.install('global', { autoAllow: true }).files.every((f) => f.action === 'unchanged')).toBe(true);
+    expect(paths.map((p) => fs.readFileSync(p, 'utf-8'))).toEqual(before);
+    expect(fs.existsSync(path.join(tmpHome, '.claude'))).toBe(false);
+    expect(fs.existsSync(path.join(tmpHome, '.claude.json'))).toBe(false);
+  });
+
+  it('detect and uninstall follow CLAUDE_CONFIG_DIR without touching the default profile', () => {
+    const claude = getTarget('claude')!;
+    claude.install('global', { autoAllow: true });
+    const defaults = claude.describePaths('global');
+    const before = defaults.map((p) => fs.readFileSync(p, 'utf-8'));
+
+    const custom = path.join(tmpHome, 'claude-profile');
+    process.env.CLAUDE_CONFIG_DIR = custom;
+    const mcpPath = path.join(custom, '.claude.json');
+    expect(claude.detect('global')).toEqual({
+      installed: false, alreadyConfigured: false, configPath: mcpPath,
+    });
+
+    claude.install('global', { autoAllow: true });
+    expect(claude.detect('global')).toEqual({
+      installed: true, alreadyConfigured: true, configPath: mcpPath,
+    });
+
+    const removed = claude.uninstall('global');
+    expect(removed.files).toEqual([
+      { path: mcpPath, action: 'removed' },
+      { path: path.join(custom, 'settings.json'), action: 'removed' },
+      { path: path.join(custom, 'CLAUDE.md'), action: 'removed' },
+    ]);
+    expect(JSON.parse(fs.readFileSync(mcpPath, 'utf-8')).mcpServers).toBeUndefined();
+    expect(JSON.parse(fs.readFileSync(path.join(custom, 'settings.json'), 'utf-8')).permissions).toBeUndefined();
+    expect(fs.existsSync(path.join(custom, 'CLAUDE.md'))).toBe(false);
+    expect(claude.detect('global').alreadyConfigured).toBe(false);
+    expect(defaults.map((p) => fs.readFileSync(p, 'utf-8'))).toEqual(before);
+  });
+
+  it.each([undefined, '', '   '])('falls back to the default profile when CLAUDE_CONFIG_DIR is %j', (override) => {
+    if (override !== undefined) process.env.CLAUDE_CONFIG_DIR = override;
+    const claude = getTarget('claude')!;
+    const result = claude.install('global', { autoAllow: true });
+
+    expect(result.files.map((f) => f.path)).toEqual([
+      path.join(tmpHome, '.claude.json'),
+      path.join(tmpHome, '.claude', 'settings.json'),
+      path.join(tmpHome, '.claude', 'CLAUDE.md'),
+    ]);
+    expect(JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude.json'), 'utf-8')).mcpServers.codegraph).toBeDefined();
+    expect(fs.existsSync(path.join(tmpHome, '.claude', 'settings.json'))).toBe(true);
+    expect(fs.existsSync(path.join(tmpHome, '.claude', 'CLAUDE.md'))).toBe(true);
+    // Claude Code keeps the default MCP JSON beside ~/.claude, not inside it.
+    expect(fs.existsSync(path.join(tmpHome, '.claude', '.claude.json'))).toBe(false);
+  });
+
+  it('leaves local install, detect, and uninstall unaffected by CLAUDE_CONFIG_DIR', () => {
+    const custom = path.join(tmpHome, 'claude-profile');
+    process.env.CLAUDE_CONFIG_DIR = custom;
+    const claude = getTarget('claude')!;
+    const result = claude.install('local', { autoAllow: true });
+    const mcpPath = path.join(tmpCwd, '.mcp.json');
+
+    expect(result.files.map((f) => f.path)).toEqual([
+      mcpPath,
+      path.join(tmpCwd, '.claude', 'settings.json'),
+      path.join(tmpCwd, '.claude', 'CLAUDE.md'),
+    ]);
+    expect(JSON.parse(fs.readFileSync(mcpPath, 'utf-8')).mcpServers.codegraph).toBeDefined();
+    expect(claude.detect('local')).toEqual({
+      installed: true, alreadyConfigured: true, configPath: mcpPath,
+    });
+    claude.uninstall('local');
+    expect(claude.detect('local').alreadyConfigured).toBe(false);
+    expect(fs.existsSync(custom)).toBe(false);
+    expect(fs.existsSync(path.join(tmpHome, '.claude'))).toBe(false);
+    expect(fs.existsSync(path.join(tmpHome, '.claude.json'))).toBe(false);
+  });
+});
+
+describe('Installer targets — Codex CODEX_HOME override (#1627)', () => {
+  let tmpHome: string;
+  let tmpCwd: string;
+  let origCwd: string;
+  let homeRestore: { restore: () => void };
+
+  beforeEach(() => {
+    tmpHome = mkTmpDir('home');
+    tmpCwd = mkTmpDir('cwd');
+    origCwd = process.cwd();
+    process.chdir(tmpCwd);
+    homeRestore = setHome(tmpHome);
+  });
+
+  afterEach(() => {
+    homeRestore.restore();
+    process.chdir(origCwd);
+    fs.rmSync(tmpHome, { recursive: true, force: true });
+    fs.rmSync(tmpCwd, { recursive: true, force: true });
+  });
+
+  const defaultDir = () => path.join(tmpHome, '.codex');
+
+  it('global install writes to $CODEX_HOME, not ~/.codex', () => {
+    const custom = path.join(tmpHome, 'codex-profile');
+    process.env.CODEX_HOME = custom;
+
+    const codex = getTarget('codex')!;
+    const result = codex.install('global', { autoAllow: false });
+
+    const toml = result.files.find((f) => f.path.endsWith('config.toml'))!;
+    expect(path.resolve(toml.path)).toBe(path.resolve(path.join(custom, 'config.toml')));
+    expect(fs.readFileSync(path.join(custom, 'config.toml'), 'utf-8')).toContain('[mcp_servers.codegraph]');
+    // The global AGENTS.md follows the config dir.
+    expect(fs.existsSync(path.join(custom, 'AGENTS.md'))).toBe(true);
+    // Nothing of ours may land in the default profile Codex is not reading.
+    expect(fs.existsSync(defaultDir())).toBe(false);
+  });
+
+  it('detect and uninstall follow $CODEX_HOME too', () => {
+    const custom = path.join(tmpHome, 'codex-profile');
+    process.env.CODEX_HOME = custom;
+    const codex = getTarget('codex')!;
+
+    expect(codex.detect('global').alreadyConfigured).toBe(false);
+    codex.install('global', { autoAllow: false });
+
+    const detected = codex.detect('global');
+    expect(detected.alreadyConfigured).toBe(true);
+    expect(path.resolve(detected.configPath!)).toBe(path.resolve(path.join(custom, 'config.toml')));
+
+    const removed = codex.uninstall('global');
+    expect(path.resolve(removed.files.find((f) => f.path.endsWith('config.toml'))!.path))
+      .toBe(path.resolve(path.join(custom, 'config.toml')));
+    // Our table was the only content, so the file goes with it.
+    expect(fs.existsSync(path.join(custom, 'config.toml'))).toBe(false);
+  });
+
+  it('falls back to ~/.codex when CODEX_HOME is unset or blank', () => {
+    const codex = getTarget('codex')!;
+    codex.install('global', { autoAllow: false });
+    expect(fs.existsSync(path.join(defaultDir(), 'config.toml'))).toBe(true);
+
+    fs.rmSync(defaultDir(), { recursive: true, force: true });
+    process.env.CODEX_HOME = '   '; // set-but-empty must not become the config dir
+    codex.install('global', { autoAllow: false });
+    expect(fs.existsSync(path.join(defaultDir(), 'config.toml'))).toBe(true);
+  });
+
+  it('leaves the local install alone — CODEX_HOME is the user layer only (#1531)', () => {
+    const custom = path.join(tmpHome, 'codex-profile');
+    process.env.CODEX_HOME = custom;
+
+    const codex = getTarget('codex')!;
+    const result = codex.install('local', { autoAllow: false });
+
+    const paths = result.files.map((f) => f.path.replace(/\\/g, '/'));
+    expect(paths.some((p) => p.endsWith('/.codex/config.toml'))).toBe(true);
+    expect(fs.existsSync(path.join(process.cwd(), '.codex', 'config.toml'))).toBe(true);
+    // The project layer lives beside the project, never under the user profile.
+    expect(fs.existsSync(path.join(custom, 'config.toml'))).toBe(false);
+  });
+});

+ 21 - 5
src/installer/targets/claude.ts

@@ -10,6 +10,9 @@
  *   - Instructions to `~/.claude/CLAUDE.md` (global) or
  *     `./.claude/CLAUDE.md` (local).
  *
+ * A non-blank `CLAUDE_CONFIG_DIR` moves all three global files into
+ * that profile directory, including `.claude.json` (#1627).
+ *
  * Earlier versions wrote the local MCP entry to `./.claude.json` — a
  * file Claude Code never reads — so the server silently never loaded
  * until the user manually renamed it to `.mcp.json` (issue #207). We
@@ -53,18 +56,31 @@ function getClaudeMcpServerConfig() {
   return { ...getMcpServerConfig(), alwaysLoad: true };
 }
 
+/**
+ * Root of the global Claude Code profile. Settings and instructions follow
+ * CLAUDE_CONFIG_DIR; local installs stay anchored to the project (#1627).
+ */
+function globalConfigDir(): string {
+  const override = process.env.CLAUDE_CONFIG_DIR;
+  return override && override.trim().length > 0
+    ? path.resolve(override)
+    : 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 for a custom profile, else
+  // ~/.claude.json (beside ~/.claude, not inside it). User scope: 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');
+  if (loc !== 'global') return path.join(process.cwd(), '.mcp.json');
+  const override = process.env.CLAUDE_CONFIG_DIR;
+  return override && override.trim().length > 0
+    ? path.join(path.resolve(override), '.claude.json')
+    : path.join(os.homedir(), '.claude.json');
 }
 /**
  * Where pre-#207 installers wrote the local MCP entry. Claude Code

+ 12 - 4
src/installer/targets/codex.ts

@@ -7,7 +7,8 @@
  *   - Instructions to `AGENTS.md`.
  *
  * Both locations are supported (#1531):
- *   - global: `~/.codex/config.toml` + `~/.codex/AGENTS.md`
+ *   - global: `$CODEX_HOME/config.toml` + `$CODEX_HOME/AGENTS.md`,
+ *     falling back to `~/.codex` when the env var is unset (#1627)
  *   - local:  `<cwd>/.codex/config.toml` + `<cwd>/AGENTS.md`
  *
  * Codex has a first-class project config layer: `.codex/config.toml`
@@ -53,9 +54,16 @@ import { buildTomlTable, removeTomlTable, upsertTomlTable } from './toml';
 const TOML_HEADER = 'mcp_servers.codegraph';
 
 function configDir(loc: Location): string {
-  return loc === 'global'
-    ? path.join(os.homedir(), '.codex')
-    : path.join(process.cwd(), '.codex');
+  if (loc !== 'global') return path.join(process.cwd(), '.codex');
+  // Codex resolves its user layer from `CODEX_HOME` and only falls back to
+  // `~/.codex` (#1627). Installing to the fallback while Codex reads the
+  // override is a silent no-op: the files are written, and Codex never looks
+  // at them. Same resolution the copilot-cli target already does for
+  // `COPILOT_HOME`. Only the user layer moves — the project layer below is
+  // anchored to the project, not the profile.
+  const override = process.env.CODEX_HOME;
+  if (override && override.trim().length > 0) return override;
+  return path.join(os.homedir(), '.codex');
 }
 function tomlConfigPath(loc: Location): string {
   return path.join(configDir(loc), 'config.toml');