fix(setup): honour CLAUDE_CONFIG_DIR for Claude Code config paths

With CLAUDE_CONFIG_DIR set, Claude Code reads its user-scope MCP servers
from $CLAUDE_CONFIG_DIR/.claude.json and its settings, skills and hooks
from $CLAUDE_CONFIG_DIR. setup and uninstall hardcoded ~/.claude.json and
~/.claude/, so on a relocated install the MCP entry, skills and hooks
landed where Claude Code never looks, or setup reported Claude Code as
"not installed" when ~/.claude did not exist.

Add claudeConfigPaths() to editor-targets and route every Claude path the
factory builds through it; setup's two presence gates use the same root.
uninstall only goes through the factory, so it follows automatically.
Empty means unset; a relative value resolves against the cwd.

vitest pins CLAUDE_CONFIG_DIR to '' so a developer's own value cannot
point the HOME-sandboxed setup tests at their real Claude config.
This commit is contained in:
Christian C. Berclaz 2026-10-02 15:52:01 +02:00
parent 702eb9326a
commit 26d47551ec
No known key found for this signature in database
8 changed files with 247 additions and 24 deletions

View file

@ -241,6 +241,8 @@ When a repo contains an `.agents/` directory, the standard and generated skills
| **Qoder** (Alibaba) | Yes | Yes | — | MCP + Skills |
| **Windsurf** | Yes | — | — | MCP |
> **Relocated Claude Code config:** if `CLAUDE_CONFIG_DIR` is set, `gitnexus setup` and `gitnexus uninstall` use it the way Claude Code does — the MCP entry goes to `$CLAUDE_CONFIG_DIR/.claude.json` and skills, hooks and `settings.json` under `$CLAUDE_CONFIG_DIR` — instead of `~/.claude.json` and `~/.claude/`. Run them from a shell where the variable has the value Claude Code sees.
> **Full** means MCP tools + agent skills + hooks that enrich searches with graph context. **Claude Code** and **Codex** go deepest: their PreToolUse hooks enrich the search before it runs, and their PostToolUse hooks also detect a stale index after commits and prompt the agent to reindex. **Cursor**, **Antigravity**, and **Factory** augment from a post-tool hook only, so they enrich the result rather than the query and do not carry the stale-index hint.
<a id="fn-antigravity-hooks"></a>

View file

@ -4,6 +4,10 @@ All notable changes to GitNexus will be documented in this file.
## [Unreleased]
### Fixed
- **`setup` / `uninstall` honour `CLAUDE_CONFIG_DIR`** — with the variable set, Claude Code reads its MCP servers from `$CLAUDE_CONFIG_DIR/.claude.json` and its settings, skills and hooks from `$CLAUDE_CONFIG_DIR`, but GitNexus wrote to `~/.claude.json` and `~/.claude/`, so the install was invisible to Claude Code (or skipped as "not installed" when `~/.claude` did not exist). Both commands now resolve the same root Claude Code does; with the variable unset nothing changes
### Changed
- **MCP `query` / `context` / `impact` / `cypher` always attach a ref-carrying `staleness` field** — object results include it even when `status` is `current`. Absence is no longer the freshness signal: read `staleness.status` (`behind`/`diverged` vs `current`/`unknown`) and `branch`/`lastCommit` for which index answered. `list_repos` and the HTTP repo routes are unchanged (still omit `staleness` when current; the ref is top-level) (#3291, #3293)

View file

@ -93,13 +93,48 @@ export interface EditorTargets {
hooks: HookTarget[];
}
/** Where Claude Code keeps its config root and its user-scope MCP file. */
export interface ClaudeConfigPaths {
/** Config root: settings.json, skills/, hooks/. */
dir: string;
/** User-scope MCP config (`mcpServers`). */
mcpFile: string;
}
/**
* Resolve all editor targets for the given home directory. Defaults to
* `os.homedir()`; call sites pass it through so tests can point HOME at a temp
* dir. Paths are computed at call time (not module load) so a test setting
* `process.env.HOME` before invoking sees the right locations.
* Resolve Claude Code's config locations. By default the root is `~/.claude`
* and the MCP file sits beside it as `~/.claude.json`. When `CLAUDE_CONFIG_DIR`
* is set, Claude Code reads both from that directory instead (the MCP file
* becomes `$CLAUDE_CONFIG_DIR/.claude.json`), so writing to the HOME defaults
* would install into files Claude Code never reads. An empty value counts as
* unset; a relative one is resolved against the working directory.
*/
export function getEditorTargets(home: string = os.homedir()): EditorTargets {
export function claudeConfigPaths(
home: string = os.homedir(),
env: NodeJS.ProcessEnv = process.env,
): ClaudeConfigPaths {
const relocated = env.CLAUDE_CONFIG_DIR;
if (relocated) {
const dir = path.resolve(relocated);
return { dir, mcpFile: path.join(dir, '.claude.json') };
}
return { dir: path.join(home, '.claude'), mcpFile: path.join(home, '.claude.json') };
}
/**
* Resolve all editor targets for the given home directory and environment.
* Defaults to `os.homedir()` and `process.env`; call sites pass them through so
* tests can point HOME at a temp dir. Paths are computed at call time (not
* module load) so a test setting `process.env.HOME` before invoking sees the
* right locations. The environment carries per-editor config-dir overrides
* (`CLAUDE_CONFIG_DIR`).
*/
export function getEditorTargets(
home: string = os.homedir(),
env: NodeJS.ProcessEnv = process.env,
): EditorTargets {
const claude = claudeConfigPaths(home, env);
const mcpJsonc: McpJsoncTarget[] = [
{
id: 'cursor',
@ -110,7 +145,7 @@ export function getEditorTargets(home: string = os.homedir()): EditorTargets {
{
id: 'claude',
label: 'Claude Code',
file: path.join(home, '.claude.json'),
file: claude.mcpFile,
keyPath: ['mcpServers', 'gitnexus'],
},
{
@ -171,7 +206,7 @@ export function getEditorTargets(home: string = os.homedir()): EditorTargets {
};
const skills: SkillTarget[] = [
{ id: 'claude', label: 'Claude Code', dir: path.join(home, '.claude', 'skills') },
{ id: 'claude', label: 'Claude Code', dir: path.join(claude.dir, 'skills') },
{
id: 'antigravity',
label: 'Antigravity',
@ -194,10 +229,10 @@ export function getEditorTargets(home: string = os.homedir()): EditorTargets {
{
id: 'claude',
label: 'Claude Code',
settingsFile: path.join(home, '.claude', 'settings.json'),
settingsFile: path.join(claude.dir, 'settings.json'),
events: ['PreToolUse', 'PostToolUse'],
needle: 'gitnexus-hook',
scriptDir: path.join(home, '.claude', 'hooks', 'gitnexus'),
scriptDir: path.join(claude.dir, 'hooks', 'gitnexus'),
},
{
id: 'codex',
@ -224,22 +259,22 @@ export function getEditorTargets(home: string = os.homedir()): EditorTargets {
}
/** Look up a single JSONC MCP target by editor id (throws if unknown). */
export function mcpTarget(id: EditorId, home?: string): McpJsoncTarget {
const t = getEditorTargets(home).mcpJsonc.find((m) => m.id === id);
export function mcpTarget(id: EditorId, home?: string, env?: NodeJS.ProcessEnv): McpJsoncTarget {
const t = getEditorTargets(home, env).mcpJsonc.find((m) => m.id === id);
if (!t) throw new Error(`No JSONC MCP target for editor "${id}"`);
return t;
}
/** Look up a single skill target by editor id (throws if unknown). */
export function skillTarget(id: EditorId, home?: string): SkillTarget {
const t = getEditorTargets(home).skills.find((s) => s.id === id);
export function skillTarget(id: EditorId, home?: string, env?: NodeJS.ProcessEnv): SkillTarget {
const t = getEditorTargets(home, env).skills.find((s) => s.id === id);
if (!t) throw new Error(`No skill target for editor "${id}"`);
return t;
}
/** Look up a single hook target by editor id (throws if unknown). */
export function hookTarget(id: EditorId, home?: string): HookTarget {
const t = getEditorTargets(home).hooks.find((h) => h.id === id);
export function hookTarget(id: EditorId, home?: string, env?: NodeJS.ProcessEnv): HookTarget {
const t = getEditorTargets(home, env).hooks.find((h) => h.id === id);
if (!t) throw new Error(`No hook target for editor "${id}"`);
return t;
}

View file

@ -16,6 +16,7 @@ import { parseTree, modify, applyEdits, ParseError, parse as parseJsonc } from '
import { packageVersion } from '../core/package-version.js';
import { getGlobalDir } from '../storage/repo-manager.js';
import {
claudeConfigPaths,
getEditorTargets,
mcpTarget,
skillTarget,
@ -318,13 +319,13 @@ async function setupCursor(result: SetupResult): Promise<void> {
}
async function setupClaudeCode(result: SetupResult): Promise<void> {
const claudeDir = path.join(os.homedir(), '.claude');
if (!(await dirExists(claudeDir))) {
// Gate on the config root Claude Code actually reads ($CLAUDE_CONFIG_DIR or ~/.claude).
if (!(await dirExists(claudeConfigPaths().dir))) {
result.skipped.push('Claude Code (not installed)');
return;
}
// Claude Code stores MCP config in ~/.claude.json
// Claude Code stores MCP config in ~/.claude.json ($CLAUDE_CONFIG_DIR/.claude.json when set)
const { file: mcpPath, keyPath } = mcpTarget('claude');
try {
const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry());
@ -341,17 +342,16 @@ async function setupClaudeCode(result: SetupResult): Promise<void> {
}
/**
* Install GitNexus skills to ~/.claude/skills/ for Claude Code.
* Install GitNexus skills to ~/.claude/skills/ (or $CLAUDE_CONFIG_DIR/skills/) for Claude Code.
*/
async function installClaudeCodeSkills(result: SetupResult): Promise<void> {
const claudeDir = path.join(os.homedir(), '.claude');
if (!(await dirExists(claudeDir))) return;
if (!(await dirExists(claudeConfigPaths().dir))) return;
const skillsDir = skillTarget('claude').dir;
try {
const installed = await installSkillsTo(skillsDir);
if (installed.length > 0) {
result.configured.push(`Claude Code skills (${installed.length} skills → ~/.claude/skills/)`);
result.configured.push(`Claude Code skills (${installed.length} skills → ${skillsDir})`);
}
} catch (err: any) {
result.errors.push(`Claude Code skills: ${err.message}`);
@ -492,7 +492,8 @@ export async function copyHookHelpers(
/**
* Install GitNexus hooks for editors that use Claude Code's hooks schema.
*
* Claude Code registers hooks in ~/.claude/settings.json; Codex uses a
* Claude Code registers hooks in ~/.claude/settings.json (under $CLAUDE_CONFIG_DIR when
* set); Codex uses a
* dedicated ~/.codex/hooks.json with the identical {hooks: {Event: [...]}}
* JSON shape, stdin payload, and hookSpecificOutput response contract
* (https://developers.openai.com/codex/hooks), so both runtimes share this

View file

@ -0,0 +1,61 @@
import path from 'path';
import { describe, expect, it } from 'vitest';
import {
claudeConfigPaths,
getEditorTargets,
hookTarget,
mcpTarget,
skillTarget,
} from '../../src/cli/editor-targets.js';
const HOME = path.resolve('/home/user');
describe('claudeConfigPaths', () => {
it('defaults to ~/.claude with the MCP file beside it in HOME', () => {
expect(claudeConfigPaths(HOME, {})).toEqual({
dir: path.join(HOME, '.claude'),
mcpFile: path.join(HOME, '.claude.json'),
});
});
it('moves both the config dir and .claude.json under CLAUDE_CONFIG_DIR', () => {
const relocated = path.resolve('/cfg/claude');
expect(claudeConfigPaths(HOME, { CLAUDE_CONFIG_DIR: relocated })).toEqual({
dir: relocated,
mcpFile: path.join(relocated, '.claude.json'),
});
});
it('treats an empty CLAUDE_CONFIG_DIR as unset', () => {
expect(claudeConfigPaths(HOME, { CLAUDE_CONFIG_DIR: '' })).toEqual(claudeConfigPaths(HOME, {}));
});
it('resolves a relative CLAUDE_CONFIG_DIR against the working directory', () => {
const { dir, mcpFile } = claudeConfigPaths(HOME, { CLAUDE_CONFIG_DIR: 'rel/claude' });
expect(dir).toBe(path.resolve('rel/claude'));
expect(mcpFile).toBe(path.join(path.resolve('rel/claude'), '.claude.json'));
});
});
describe('getEditorTargets — Claude Code under CLAUDE_CONFIG_DIR', () => {
const relocated = path.resolve('/cfg/claude');
const env = { CLAUDE_CONFIG_DIR: relocated };
it('routes the MCP file, skills, settings and hook scripts to the relocated root', () => {
expect(mcpTarget('claude', HOME, env).file).toBe(path.join(relocated, '.claude.json'));
expect(skillTarget('claude', HOME, env).dir).toBe(path.join(relocated, 'skills'));
const hooks = hookTarget('claude', HOME, env);
expect(hooks.settingsFile).toBe(path.join(relocated, 'settings.json'));
expect(hooks.scriptDir).toBe(path.join(relocated, 'hooks', 'gitnexus'));
});
it('leaves every other editor rooted at HOME', () => {
const relocatedTargets = getEditorTargets(HOME, env);
const defaultTargets = getEditorTargets(HOME, {});
const others = <T extends { id: string }>(list: T[]) => list.filter((t) => t.id !== 'claude');
expect(others(relocatedTargets.mcpJsonc)).toEqual(others(defaultTargets.mcpJsonc));
expect(others(relocatedTargets.skills)).toEqual(others(defaultTargets.skills));
expect(others(relocatedTargets.hooks)).toEqual(others(defaultTargets.hooks));
expect(relocatedTargets.codex).toEqual(defaultTargets.codex);
});
});

View file

@ -114,6 +114,71 @@ describe('setupClaudeCode', () => {
await expect(fs.access(path.join(tempHome, '.claude.json'))).rejects.toThrow();
});
describe('with CLAUDE_CONFIG_DIR set', () => {
let configDir: string;
beforeEach(async () => {
// Claude Code reads only the relocated root, so the HOME defaults must
// not exist for these cases: anything written there would be invisible.
await fs.rm(path.join(tempHome, '.claude'), { recursive: true, force: true });
configDir = path.join(tempHome, 'relocated', 'claude');
await fs.mkdir(configDir, { recursive: true });
process.env.CLAUDE_CONFIG_DIR = configDir;
});
afterEach(() => {
// vitest.config.ts pins it to '' so a developer shell's value never leaks in.
process.env.CLAUDE_CONFIG_DIR = '';
});
it('writes the MCP entry to $CLAUDE_CONFIG_DIR/.claude.json, not ~/.claude.json', async () => {
setPlatform('linux');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const config = JSON.parse(await fs.readFile(path.join(configDir, '.claude.json'), 'utf-8'));
expect(config.mcpServers.gitnexus).toEqual({
command: 'npx',
args: ['-y', MCP_PINNED_REF, 'mcp'],
});
await expect(fs.access(path.join(tempHome, '.claude.json'))).rejects.toThrow();
await expect(fs.access(path.join(tempHome, '.claude'))).rejects.toThrow();
});
it('installs skills and hooks under $CLAUDE_CONFIG_DIR', async () => {
setPlatform('linux');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const skills = await fs.readdir(path.join(configDir, 'skills'));
expect(skills.length).toBeGreaterThan(0);
await expect(
fs.access(path.join(configDir, 'hooks', 'gitnexus', 'gitnexus-hook.cjs')),
).resolves.toBeUndefined();
const settings = JSON.parse(
await fs.readFile(path.join(configDir, 'settings.json'), 'utf-8'),
);
expect(JSON.stringify(settings.hooks.PreToolUse)).toContain(
path.join(configDir, 'hooks', 'gitnexus').replace(/\\/g, '/'),
);
expect(logLines()).toContain(path.join(configDir, 'skills'));
});
it('skips Claude Code when $CLAUDE_CONFIG_DIR does not exist, even if ~/.claude does', async () => {
await fs.rm(configDir, { recursive: true, force: true });
await fs.mkdir(path.join(tempHome, '.claude'), { recursive: true });
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
await expect(fs.access(path.join(configDir, '.claude.json'))).rejects.toThrow();
await expect(fs.access(path.join(tempHome, '.claude.json'))).rejects.toThrow();
expect(await fs.readdir(path.join(tempHome, '.claude'))).toEqual([]);
});
});
it('preserves existing keys in ~/.claude.json', async () => {
setPlatform('linux');

View file

@ -157,6 +157,57 @@ describe('uninstallCommand', () => {
await expect(fs.access(path.join(skillsDir, 'my-skill'))).resolves.toBeUndefined();
});
it('removes the MCP entry, hooks and skills from $CLAUDE_CONFIG_DIR when it is set', async () => {
const configDir = path.join(tempHome, 'relocated', 'claude');
process.env.CLAUDE_CONFIG_DIR = configDir;
try {
await fs.mkdir(path.join(configDir, 'skills', 'gitnexus-cli'), { recursive: true });
await fs.writeFile(
path.join(configDir, 'skills', 'gitnexus-cli', 'SKILL.md'),
'# y',
'utf-8',
);
await fs.writeFile(
path.join(configDir, '.claude.json'),
JSON.stringify({ keep: 1, mcpServers: { gitnexus: { command: 'npx' } } }),
'utf-8',
);
await fs.writeFile(
path.join(configDir, 'settings.json'),
JSON.stringify({
hooks: {
PreToolUse: [
{
matcher: 'Bash',
hooks: [{ type: 'command', command: 'node ".../gitnexus-hook.cjs"' }],
},
],
},
}),
'utf-8',
);
const hookDir = path.join(configDir, 'hooks', 'gitnexus');
await fs.mkdir(hookDir, { recursive: true });
await fs.writeFile(path.join(hookDir, 'gitnexus-hook.cjs'), '// hook', 'utf-8');
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
const mcp = JSON.parse(await fs.readFile(path.join(configDir, '.claude.json'), 'utf-8'));
expect(mcp.keep).toBe(1);
expect(mcp.mcpServers?.gitnexus).toBeUndefined();
const settings = JSON.parse(
await fs.readFile(path.join(configDir, 'settings.json'), 'utf-8'),
);
expect(settings.hooks.PreToolUse).toHaveLength(0);
await expect(fs.access(hookDir)).rejects.toThrow();
await expect(fs.access(path.join(configDir, 'skills', 'gitnexus-cli'))).rejects.toThrow();
} finally {
// vitest.config.ts pins it to '' so a developer shell's value never leaks in.
process.env.CLAUDE_CONFIG_DIR = '';
}
});
// ── renamed skills: the legacy dir name must still be uninstalled ──
it('removes a legacy renamed skill dir (gitnexus-pr-review) absent from the bundled source', async () => {
const skillsDir = path.join(tempHome, '.claude', 'skills');

View file

@ -16,7 +16,11 @@ export default defineConfig({
// respawn behavior itself delete GITNEXUS_MEMORY in their own setup.
// Tests assert the English CLI contract unless a case opts into another
// language explicitly. Do not inherit a developer shell's CLI locale.
env: { GITNEXUS_MEMORY: 'off', GITNEXUS_LANG: 'en' },
// CLAUDE_CONFIG_DIR relocates Claude Code's config root, and setup/uninstall
// honour it: inheriting a developer's value would point setup tests that
// sandbox HOME at that developer's real config. Empty means unset; cases
// that exercise the relocation set it themselves.
env: { GITNEXUS_MEMORY: 'off', GITNEXUS_LANG: 'en', CLAUDE_CONFIG_DIR: '' },
// N-API destructors can crash worker forks on macOS during process exit.
// This is independent of the QueryResult lifetime fix in @ladybugdb/core 0.15.2 —
// it's a vitest forks + native addon interaction where destructors run in