diff --git a/README.md b/README.md
index a761716e7..d7931b11c 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/gitnexus/src/cli/editor-targets.ts b/gitnexus/src/cli/editor-targets.ts
index 212a4c400..ac8ec9a59 100644
--- a/gitnexus/src/cli/editor-targets.ts
+++ b/gitnexus/src/cli/editor-targets.ts
@@ -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;
}
diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts
index f03c58aa3..6049c74b8 100644
--- a/gitnexus/src/cli/setup.ts
+++ b/gitnexus/src/cli/setup.ts
@@ -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 {
}
async function setupClaudeCode(result: SetupResult): Promise {
- 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 {
}
/**
- * 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 {
- 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
diff --git a/gitnexus/test/unit/editor-targets.test.ts b/gitnexus/test/unit/editor-targets.test.ts
new file mode 100644
index 000000000..8dfff6e80
--- /dev/null
+++ b/gitnexus/test/unit/editor-targets.test.ts
@@ -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 = (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);
+ });
+});
diff --git a/gitnexus/test/unit/setup.test.ts b/gitnexus/test/unit/setup.test.ts
index 942211ae3..4cff8ab8e 100644
--- a/gitnexus/test/unit/setup.test.ts
+++ b/gitnexus/test/unit/setup.test.ts
@@ -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');
diff --git a/gitnexus/test/unit/uninstall.test.ts b/gitnexus/test/unit/uninstall.test.ts
index ad20eb5ca..5435ca669 100644
--- a/gitnexus/test/unit/uninstall.test.ts
+++ b/gitnexus/test/unit/uninstall.test.ts
@@ -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');
diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts
index 8bb6ff502..b5f1d8f04 100644
--- a/gitnexus/vitest.config.ts
+++ b/gitnexus/vitest.config.ts
@@ -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