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