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
+211
View File
@@ -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);
});
});