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>
This commit is contained in:
Colby Mchenry
2026-09-08 12:17:06 -05:00
committed by GitHub
co-authored by Max Hsu Claude Colby McHenry
parent e720f6ca53
commit 2f8cce5c57
4 changed files with 246 additions and 9 deletions
+21 -5
View File
@@ -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
View File
@@ -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');