mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
Merge 7713ba341b into 16d7e9477b
This commit is contained in:
commit
ff27697f71
7 changed files with 243 additions and 24 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
61
gitnexus/test/unit/editor-targets.test.ts
Normal file
61
gitnexus/test/unit/editor-targets.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue