Merge branch 'main' into feat/tree-sitter-prebuilds

This commit is contained in:
Gergő Magyar 2026-06-09 11:35:08 +01:00 • committed by GitHub
commit ee78d02b0e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1535 additions and 35 deletions

View file

@ -225,6 +225,7 @@ args = ["-y", "gitnexus@latest", "mcp"]
```bash
gitnexus setup # Configure MCP for your editors (one-time)
gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks (add --force to apply)
gitnexus analyze [path] # Index a repository (or update stale index)
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
@ -261,6 +262,8 @@ gitnexus group query <name> <q> # Search execution flows across all repos in a
gitnexus group status <name> # Check staleness of repos in a group
```
> **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove.
If `analyze` reports a worker parse timeout on a large or unusual repository, it keeps running and falls back safely. To give slow worker jobs more time, use `gitnexus analyze --worker-timeout 60` or set `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000`. For very large files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget.
#### Embeddings node limit

View file

@ -159,6 +159,7 @@ Your AI agent gets these tools automatically:
```bash
gitnexus setup # Configure MCP for your editors (one-time)
gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks (add --force to apply)
gitnexus analyze [path] # Index a repository (or update stale index)
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
@ -196,6 +197,8 @@ gitnexus group query <name> <q> # Search execution flows across all repos in a
gitnexus group status <name> # Check staleness of repos in a group
```
> **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove.
## Remote Embeddings
Set these env vars to use a remote OpenAI-compatible `/v1/embeddings` endpoint instead of the local model:

View file

@ -0,0 +1,187 @@
/**
* Editor targets — the single source of truth for *where* GitNexus writes its
* per-editor configuration and *how* its entries are identified.
*
* `setup` (writes these) and `uninstall` (removes them) both consume this
* module so the two stay structurally in lock-step: add or change a target
* here and both sides follow. This is declarative metadata only — file
* locations, JSON key paths, hook event names, command needles, and script
* directories, plus the shared `detectIndentation` formatting helper. The
* format-specific read/write logic (JSONC merge, TOML upsert, OpenCode's flat
* command array, Gemini's hook schema) deliberately stays in setup.ts /
* uninstall.ts.
*
* The `setup → uninstall` round-trip integration test verifies the two
* implementations remain behaviourally symmetrical on top of this shared
* structure.
*/
import os from 'os';
import path from 'path';
export type EditorId = 'cursor' | 'claude' | 'antigravity' | 'opencode' | 'codex';
/** An editor whose MCP config is a JSONC document (server keyed by name). */
export interface McpJsoncTarget {
id: EditorId;
label: string;
/** Absolute path to the editor's MCP config file. */
file: string;
/**
* JSON path of the gitnexus server entry within that file. Typed as
* `string[]` (all our keys are object keys) so it satisfies both setup's
* `mergeJsoncFile(string[])` and uninstall's `removeJsoncKey(JSONPath)`
* without either side needing a cast.
*/
keyPath: string[];
}
/** Codex stores MCP config as a TOML table, not JSONC. */
export interface CodexMcpTarget {
id: 'codex';
label: string;
/** Absolute path to ~/.codex/config.toml. */
configFile: string;
/** The TOML table header (without brackets) setup writes / uninstall strips. */
tomlSection: string;
}
export interface SkillTarget {
id: EditorId;
label: string;
/** Absolute path to the editor's skills directory. */
dir: string;
}
export interface HookTarget {
id: EditorId;
label: string;
/** Absolute path to the editor's settings file (JSONC). */
settingsFile: string;
/** Hook event arrays that may hold a gitnexus entry. */
events: string[];
/** Substring identifying the gitnexus command within a hook entry. */
needle: string;
/** Absolute path to the bundled hook-script directory setup writes. */
scriptDir: string;
}
export interface EditorTargets {
/** JSONC-format MCP entries: Cursor, Claude Code, Antigravity, OpenCode. */
mcpJsonc: McpJsoncTarget[];
/** Codex MCP (TOML). */
codex: CodexMcpTarget;
/** Skill install directories, one per editor that supports skills. */
skills: SkillTarget[];
/** Hook registrations + their bundled script directories. */
hooks: HookTarget[];
}
/**
* 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.
*/
export function getEditorTargets(home: string = os.homedir()): EditorTargets {
const mcpJsonc: McpJsoncTarget[] = [
{
id: 'cursor',
label: 'Cursor',
file: path.join(home, '.cursor', 'mcp.json'),
keyPath: ['mcpServers', 'gitnexus'],
},
{
id: 'claude',
label: 'Claude Code',
file: path.join(home, '.claude.json'),
keyPath: ['mcpServers', 'gitnexus'],
},
{
id: 'antigravity',
label: 'Antigravity',
file: path.join(home, '.gemini', 'antigravity', 'mcp_config.json'),
keyPath: ['mcpServers', 'gitnexus'],
},
{
id: 'opencode',
label: 'OpenCode',
file: path.join(home, '.config', 'opencode', 'opencode.json'),
// OpenCode nests servers under `mcp`, not `mcpServers`.
keyPath: ['mcp', 'gitnexus'],
},
];
const codex: CodexMcpTarget = {
id: 'codex',
label: 'Codex',
configFile: path.join(home, '.codex', 'config.toml'),
tomlSection: 'mcp_servers.gitnexus',
};
const skills: SkillTarget[] = [
{ id: 'claude', label: 'Claude Code', dir: path.join(home, '.claude', 'skills') },
{
id: 'antigravity',
label: 'Antigravity',
dir: path.join(home, '.gemini', 'antigravity', 'skills'),
},
{ id: 'cursor', label: 'Cursor', dir: path.join(home, '.cursor', 'skills') },
{ id: 'opencode', label: 'OpenCode', dir: path.join(home, '.config', 'opencode', 'skills') },
// Codex reads skills from ~/.agents/skills (not ~/.codex).
{ id: 'codex', label: 'Codex', dir: path.join(home, '.agents', 'skills') },
];
const hooks: HookTarget[] = [
{
id: 'claude',
label: 'Claude Code',
settingsFile: path.join(home, '.claude', 'settings.json'),
events: ['PreToolUse', 'PostToolUse'],
needle: 'gitnexus-hook',
scriptDir: path.join(home, '.claude', 'hooks', 'gitnexus'),
},
{
id: 'antigravity',
label: 'Antigravity',
settingsFile: path.join(home, '.gemini', 'settings.json'),
events: ['AfterTool'],
needle: 'gitnexus-antigravity-hook',
scriptDir: path.join(home, '.gemini', 'config', 'hooks', 'gitnexus'),
},
];
return { mcpJsonc, codex, skills, hooks };
}
/** 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);
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);
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);
if (!t) throw new Error(`No hook target for editor "${id}"`);
return t;
}
/**
* Detect indentation style from file content so JSONC edits preserve the file's
* existing formatting. Shared by setup (writes) and uninstall (removes).
*/
export function detectIndentation(raw: string): { tabSize: number; insertSpaces: boolean } {
const firstIndented = raw.match(/^( +|\t)/m);
if (!firstIndented) return { tabSize: 2, insertSpaces: true };
if (firstIndented[1] === '\t') return { tabSize: 1, insertSpaces: false };
return { tabSize: firstIndented[1].length, insertSpaces: true };
}

View file

@ -12,6 +12,7 @@ const TITLE_KEYS = {
const COMMAND_DESCRIPTION_KEYS = {
'': 'help.description.root',
setup: 'help.command.setup.description',
uninstall: 'help.command.uninstall.description',
analyze: 'help.command.analyze.description',
index: 'help.command.index.description',
serve: 'help.command.serve.description',
@ -69,6 +70,7 @@ const OPTION_DESCRIPTION_KEYS = {
'index|--allow-non-git': 'help.option.index.allowNonGit',
'serve|-p, --port <port>': 'help.option.port',
'serve|--host <host>': 'help.option.serve.host',
'uninstall|-f, --force': 'help.option.uninstall.force',
'clean|-f, --force': 'help.option.force.confirmation',
'clean|--all': 'help.option.clean.all',
'clean|--lbug-sidecars': 'help.option.clean.lbugSidecars',

View file

@ -106,6 +106,8 @@ export const en = {
'help.option.version': 'output the version number',
'help.command.setup.description':
'One-time setup: configure MCP for Cursor, Claude Code, OpenCode, Codex',
'help.command.uninstall.description':
'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors',
'help.command.analyze.description': 'Index a repository (full analysis)',
'help.command.index.description':
'Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)',
@ -185,6 +187,7 @@ export const en = {
'help.option.port': 'Port number',
'help.option.serve.host': 'Bind address (default: 127.0.0.1, use 0.0.0.0 for remote access)',
'help.option.force.confirmation': 'Skip confirmation prompt',
'help.option.uninstall.force': 'Apply the changes (default is a dry-run preview)',
'help.option.clean.all': 'Clean all indexed repos',
'help.option.clean.lbugSidecars': 'Clean quarantined LadybugDB missing-shadow WAL sidecars',
'help.option.wiki.force': 'Force full regeneration even if up to date',

View file

@ -108,6 +108,8 @@ export const zhCN = {
'help.option.help': '显示命令帮助',
'help.option.version': '输出版本号',
'help.command.setup.description': '一次性设置:为 Cursor、Claude Code、OpenCode、Codex 配置 MCP',
'help.command.uninstall.description':
'撤销 `setup`:从所有检测到的编辑器中移除 GitNexus 的 MCP 配置、技能和钩子',
'help.command.analyze.description': '索引仓库(完整分析)',
'help.command.index.description': '将现有 .gitnexus/ 文件夹注册到全局注册表(无需重新分析)',
'help.command.serve.description': '启动供 Web UI 连接的本地 HTTP 服务器',
@ -174,6 +176,7 @@ export const zhCN = {
'help.option.port': '端口号',
'help.option.serve.host': '绑定地址(默认:127.0.0.1;远程访问可用 0.0.0.0)',
'help.option.force.confirmation': '跳过确认提示',
'help.option.uninstall.force': '应用更改(默认仅为预演预览)',
'help.option.clean.all': '清理所有已索引仓库',
'help.option.clean.lbugSidecars': '清理已隔离的 LadybugDB missing-shadow WAL sidecar',
'help.option.wiki.force': '即使已是最新也强制完整重新生成',

View file

@ -23,6 +23,14 @@ program
)
.action(createLazyAction(() => import('./setup.js'), 'setupCommand'));
program
.command('uninstall')
.description(
'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors',
)
.option('-f, --force', 'Apply the changes (default is a dry-run preview)')
.action(createLazyAction(() => import('./uninstall.js'), 'uninstallCommand'));
program
.command('analyze [path]')
.description('Index a repository (full analysis)')

View file

@ -15,6 +15,13 @@ import { promisify } from 'util';
import { fileURLToPath } from 'url';
import { parseTree, modify, applyEdits, ParseError, parse as parseJsonc } from 'jsonc-parser';
import { getGlobalDir } from '../storage/repo-manager.js';
import {
getEditorTargets,
mcpTarget,
skillTarget,
hookTarget,
detectIndentation,
} from './editor-targets.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@ -162,17 +169,6 @@ function getOpenCodeMcpEntry() {
return { type: 'local', command: ['npx', '-y', MCP_PINNED_REF, 'mcp'] };
}
/**
* Detect indentation style from file content.
* Returns formatting options matching the file's existing style.
*/
function detectIndentation(raw: string): { tabSize: number; insertSpaces: boolean } {
const firstIndented = raw.match(/^( +|\t)/m);
if (!firstIndented) return { tabSize: 2, insertSpaces: true };
if (firstIndented[1] === '\t') return { tabSize: 1, insertSpaces: false };
return { tabSize: firstIndented[1].length, insertSpaces: true };
}
/**
* Merge a key/value pair into a JSONC config file, preserving comments and formatting.
* If the file is genuinely corrupt (not valid JSONC), leaves it untouched.
@ -233,9 +229,9 @@ async function setupCursor(result: SetupResult): Promise<void> {
return;
}
const mcpPath = path.join(cursorDir, 'mcp.json');
const { file: mcpPath, keyPath } = mcpTarget('cursor');
try {
const ok = await mergeJsoncFile(mcpPath, ['mcpServers', 'gitnexus'], getMcpEntry());
const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry());
if (ok) {
result.configured.push('Cursor');
} else {
@ -254,9 +250,9 @@ async function setupClaudeCode(result: SetupResult): Promise<void> {
}
// Claude Code stores MCP config in ~/.claude.json
const mcpPath = path.join(os.homedir(), '.claude.json');
const { file: mcpPath, keyPath } = mcpTarget('claude');
try {
const ok = await mergeJsoncFile(mcpPath, ['mcpServers', 'gitnexus'], getMcpEntry());
const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry());
if (ok) {
result.configured.push('Claude Code');
} else {
@ -276,7 +272,7 @@ async function installClaudeCodeSkills(result: SetupResult): Promise<void> {
const claudeDir = path.join(os.homedir(), '.claude');
if (!(await dirExists(claudeDir))) return;
const skillsDir = path.join(claudeDir, 'skills');
const skillsDir = skillTarget('claude').dir;
try {
const installed = await installSkillsTo(skillsDir);
if (installed.length > 0) {
@ -422,13 +418,14 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
const claudeDir = path.join(os.homedir(), '.claude');
if (!(await dirExists(claudeDir))) return;
const settingsPath = path.join(claudeDir, 'settings.json');
const claudeHook = hookTarget('claude');
const settingsPath = claudeHook.settingsFile;
// Source hooks bundled within the gitnexus package (hooks/claude/)
const pluginHooksPath = path.join(__dirname, '..', '..', 'hooks', 'claude');
// Copy unified hook script to ~/.claude/hooks/gitnexus/
const destHooksDir = path.join(claudeDir, 'hooks', 'gitnexus');
const destHooksDir = claudeHook.scriptDir;
try {
await fs.mkdir(destHooksDir, { recursive: true });
@ -494,7 +491,7 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
// NOTE: SessionStart hooks are broken on Windows (Claude Code bug #23576).
// Session context is delivered via CLAUDE.md / skills instead.
if (!hasGitnexusHook(parsed?.hooks, 'PreToolUse')) {
if (!hasGitnexusHook(parsed?.hooks, 'PreToolUse', claudeHook.needle)) {
hookEntries.push({
eventName: 'PreToolUse',
value: {
@ -510,7 +507,7 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
},
});
}
if (!hasGitnexusHook(parsed?.hooks, 'PostToolUse')) {
if (!hasGitnexusHook(parsed?.hooks, 'PostToolUse', claudeHook.needle)) {
hookEntries.push({
eventName: 'PostToolUse',
value: {
@ -566,9 +563,9 @@ async function setupAntigravity(result: SetupResult): Promise<void> {
return;
}
const mcpPath = path.join(antigravityDir, 'mcp_config.json');
const { file: mcpPath, keyPath } = mcpTarget('antigravity');
try {
const ok = await mergeJsoncFile(mcpPath, ['mcpServers', 'gitnexus'], getMcpEntry());
const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry());
if (ok) {
result.configured.push('Antigravity');
} else {
@ -590,7 +587,7 @@ async function installAntigravitySkills(result: SetupResult): Promise<void> {
const antigravityDir = path.join(os.homedir(), '.gemini', 'antigravity');
if (!(await dirExists(antigravityDir))) return;
const skillsDir = path.join(antigravityDir, 'skills');
const skillsDir = skillTarget('antigravity').dir;
try {
const installed = await installSkillsTo(skillsDir);
if (installed.length > 0) {
@ -618,9 +615,9 @@ async function installAntigravityHooks(result: SetupResult): Promise<void> {
const antigravityDir = path.join(os.homedir(), '.gemini', 'antigravity');
if (!(await dirExists(antigravityDir))) return;
const geminiDir = path.join(os.homedir(), '.gemini');
const settingsPath = path.join(geminiDir, 'settings.json');
const destHooksDir = path.join(geminiDir, 'config', 'hooks', 'gitnexus');
const antigravityHook = hookTarget('antigravity');
const settingsPath = antigravityHook.settingsFile;
const destHooksDir = antigravityHook.scriptDir;
// The antigravity adapter shares its lock/probe helpers with the claude
// adapter — same DB, same concurrency rules — so we reuse those CJS files
@ -694,7 +691,7 @@ async function installAntigravityHooks(result: SetupResult): Promise<void> {
const hookEntries: Array<{ eventName: string; value: unknown }> = [];
if (!hasGitnexusHook(parsed?.hooks, 'AfterTool', 'gitnexus-antigravity-hook')) {
if (!hasGitnexusHook(parsed?.hooks, 'AfterTool', antigravityHook.needle)) {
// Matcher follows the Gemini CLI built-in tool naming (snake_case).
// search_file_content / glob cover content + filename search; run_shell_command
// catches rg/grep invocations and the git commit family for stale-index hints.
@ -742,9 +739,9 @@ async function setupOpenCode(result: SetupResult): Promise<void> {
return;
}
const configPath = path.join(opencodeDir, 'opencode.json');
const { file: configPath, keyPath } = mcpTarget('opencode');
try {
const ok = await mergeJsoncFile(configPath, ['mcp', 'gitnexus'], getOpenCodeMcpEntry());
const ok = await mergeJsoncFile(configPath, keyPath, getOpenCodeMcpEntry());
if (ok) {
result.configured.push('OpenCode');
} else {
@ -764,7 +761,7 @@ function getCodexMcpTomlSection(): string {
const entry = getMcpEntry();
const command = JSON.stringify(entry.command);
const args = `[${entry.args.map((arg) => JSON.stringify(arg)).join(', ')}]`;
return `[mcp_servers.gitnexus]\ncommand = ${command}\nargs = ${args}\n`;
return `[${getEditorTargets().codex.tomlSection}]\ncommand = ${command}\nargs = ${args}\n`;
}
/**
@ -778,7 +775,7 @@ async function upsertCodexConfigToml(configPath: string): Promise<void> {
existing = '';
}
if (existing.includes('[mcp_servers.gitnexus]')) {
if (existing.includes(`[${getEditorTargets().codex.tomlSection}]`)) {
return;
}
@ -809,7 +806,7 @@ async function setupCodex(result: SetupResult): Promise<void> {
}
try {
const configPath = path.join(codexDir, 'config.toml');
const configPath = getEditorTargets().codex.configFile;
await upsertCodexConfigToml(configPath);
result.configured.push('Codex (MCP added to ~/.codex/config.toml)');
} catch (err: any) {
@ -920,7 +917,7 @@ async function installCursorSkills(result: SetupResult): Promise<void> {
const cursorDir = path.join(os.homedir(), '.cursor');
if (!(await dirExists(cursorDir))) return;
const skillsDir = path.join(cursorDir, 'skills');
const skillsDir = skillTarget('cursor').dir;
try {
const installed = await installSkillsTo(skillsDir);
if (installed.length > 0) {
@ -938,7 +935,7 @@ async function installOpenCodeSkills(result: SetupResult): Promise<void> {
const opencodeDir = path.join(os.homedir(), '.config', 'opencode');
if (!(await dirExists(opencodeDir))) return;
const skillsDir = path.join(opencodeDir, 'skills');
const skillsDir = skillTarget('opencode').dir;
try {
const installed = await installSkillsTo(skillsDir);
if (installed.length > 0) {
@ -958,7 +955,7 @@ async function installCodexSkills(result: SetupResult): Promise<void> {
const codexDir = path.join(os.homedir(), '.codex');
if (!(await dirExists(codexDir))) return;
const skillsDir = path.join(os.homedir(), '.agents', 'skills');
const skillsDir = skillTarget('codex').dir;
try {
const installed = await installSkillsTo(skillsDir);
if (installed.length > 0) {

View file

@ -0,0 +1,518 @@
/**
* Uninstall Command
*
* Reverses `gitnexus setup`: removes the GitNexus MCP server entries,
* skills, and hooks that setup writes into each detected AI editor's
* global configuration. The set of targets (paths, key paths, hook events,
* needles, script dirs) is shared with setup.ts via editor-targets.ts, so the
* two stay in lock-step.
*
* Surgical and idempotent: only gitnexus-owned keys/entries/dirs are
* removed. Unrelated user config (other MCP servers, other hooks, JSONC
* comments, indentation) is preserved. Files that are absent or that
* never contained a gitnexus entry are left untouched.
*
* Ownership is by name: skill directories are matched by the bundled gitnexus
* skill names, MCP entries by the `gitnexus` key, hooks by the gitnexus command
* needle. There is no per-install provenance marker yet (a user dir that
* happens to share a bundled skill name, or files a user added inside an
* installed skill dir, are matched purely by name) — which is why uninstall is
* a dry-run preview by default and prints the exact paths it will remove.
* Richer provenance tracking is a tracked follow-up.
*
* Intentionally NOT done here (printed as hints instead, since both are
* destructive in ways setup never caused):
* - per-repo indexes → `gitnexus clean --all`
* - the global npm package → `npm uninstall -g gitnexus`
*
* Default is a dry-run preview; pass --force to apply.
*/
import fs from 'fs/promises';
import path from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { fileURLToPath } from 'url';
import {
parseTree,
modify,
applyEdits,
findNodeAtLocation,
parse as parseJsonc,
type ParseError,
type JSONPath,
} from 'jsonc-parser';
import { getEditorTargets, detectIndentation } from './editor-targets.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const execFileAsync = promisify(execFile);
interface UninstallResult {
removed: string[];
skipped: string[];
errors: string[];
}
type RemovalStatus = 'removed' | 'absent' | 'corrupt' | 'missing';
/**
* Remove a single key (by JSON path) from a JSONC file, preserving the
* surrounding comments and formatting. Returns:
* - 'missing': file does not exist
* - 'absent': file exists but the key isn't there (nothing to do)
* - 'corrupt': file isn't valid JSONC — left untouched on purpose
* - 'removed': the key was present (and removed unless dryRun)
*/
async function removeJsoncKey(
filePath: string,
keyPath: JSONPath,
dryRun: boolean,
): Promise<RemovalStatus> {
let raw: string;
try {
raw = await fs.readFile(filePath, 'utf-8');
} catch {
return 'missing';
}
if (raw.trim().length === 0) return 'absent';
const parseErrors: ParseError[] = [];
const tree = parseTree(raw, parseErrors);
if (!tree || tree.type !== 'object' || parseErrors.length > 0) return 'corrupt';
if (!findNodeAtLocation(tree, keyPath)) return 'absent';
if (!dryRun) {
const formattingOptions = detectIndentation(raw);
const edits = modify(raw, keyPath, undefined, { formattingOptions });
await fs.writeFile(filePath, applyEdits(raw, edits), 'utf-8');
}
return 'removed';
}
/**
* Remove the gitnexus hook command(s) — those whose command string contains
* `commandNeedle` — from the given `eventNames` arrays in a JSONC settings
* file. Mirrors the idempotency probes in setup.ts (hasGitnexusHook /
* geminiHasGitnexusHook). Returns how many event entries contained a gitnexus
* command.
*
* Removal is element-granular to honor the "other hooks are preserved"
* contract: only the matching command object inside an entry's `hooks[]` is
* deleted. The surrounding matcher entry is removed only when it becomes
* empty (i.e. it held nothing but gitnexus commands — which is exactly what
* setup creates). A user who hand-added their own command alongside ours
* keeps it. Edits are applied highest-index-first so earlier indices stay
* valid across edits.
*/
async function removeHookEntries(
filePath: string,
eventNames: string[],
commandNeedle: string,
dryRun: boolean,
): Promise<{ status: RemovalStatus; count: number }> {
let raw: string;
try {
raw = await fs.readFile(filePath, 'utf-8');
} catch {
return { status: 'missing', count: 0 };
}
if (raw.trim().length === 0) return { status: 'absent', count: 0 };
const parseErrors: ParseError[] = [];
const tree = parseTree(raw, parseErrors);
if (!tree || tree.type !== 'object' || parseErrors.length > 0) {
return { status: 'corrupt', count: 0 };
}
const parsed = parseJsonc(raw);
const formattingOptions = detectIndentation(raw);
let current = raw;
let total = 0;
const isGitnexusHook = (hh: any): boolean =>
typeof hh?.command === 'string' && hh.command.includes(commandNeedle);
for (const eventName of eventNames) {
const entries = parsed?.hooks?.[eventName];
if (!Array.isArray(entries)) continue;
// Walk entries high → low so removing a later one never shifts the
// index of an earlier one.
for (let entryIdx = entries.length - 1; entryIdx >= 0; entryIdx--) {
const entry = entries[entryIdx];
if (!Array.isArray(entry?.hooks)) continue;
const hookIdxs: number[] = [];
entry.hooks.forEach((hh: any, hi: number) => {
if (isGitnexusHook(hh)) hookIdxs.push(hi);
});
if (hookIdxs.length === 0) continue;
total += 1;
if (dryRun) continue;
if (hookIdxs.length === entry.hooks.length) {
// The entry held only gitnexus command(s) — drop the whole entry.
const edits = modify(current, ['hooks', eventName, entryIdx], undefined, {
formattingOptions,
});
current = applyEdits(current, edits);
} else {
// The entry also holds user command(s) — delete only ours, keep
// the rest. Highest hook index first to keep lower indices valid.
for (const hi of hookIdxs.reverse()) {
const edits = modify(current, ['hooks', eventName, entryIdx, 'hooks', hi], undefined, {
formattingOptions,
});
current = applyEdits(current, edits);
}
}
}
}
if (total === 0) return { status: 'absent', count: 0 };
if (!dryRun) await fs.writeFile(filePath, current, 'utf-8');
return { status: 'removed', count: total };
}
/**
* Remove a directory tree if it exists. Returns true when something was
* (or would be) removed.
*/
async function removeDir(dirPath: string, dryRun: boolean): Promise<boolean> {
try {
await fs.access(dirPath);
} catch {
return false;
}
if (!dryRun) await fs.rm(dirPath, { recursive: true, force: true });
return true;
}
/**
* The exact set of skill directory names setup installs, derived from the
* bundled `skills/` source the same way installSkillsTo does (flat
* `{name}.md` and `{name}/SKILL.md` layouts). Deriving the set — rather
* than globbing `gitnexus-*` — ensures we never delete a user's own
* similarly-named skill folder.
*/
async function listGitnexusSkillNames(): Promise<string[]> {
const skillsRoot =
process.env.GITNEXUS_TEST_SKILLS_ROOT ?? path.join(__dirname, '..', '..', 'skills');
const names = new Set<string>();
try {
const entries = await fs.readdir(skillsRoot, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith('.md')) {
// Guard against a bare `.md` file: basename('.md', '.md') === '',
// which would later resolve to the skills dir itself and wipe it.
const base = path.basename(entry.name, '.md');
if (base) names.add(base);
} else if (entry.isDirectory()) {
try {
await fs.access(path.join(skillsRoot, entry.name, 'SKILL.md'));
names.add(entry.name);
} catch {
// Not a skill directory — skip.
}
}
}
} catch {
return [];
}
return [...names];
}
/**
* Remove the gitnexus skill directories from a target skills folder. Returns
* the absolute paths that were removed (or would be removed in dryRun) so the
* caller can show the user exactly what is affected.
*/
async function removeSkillsFrom(
targetDir: string,
skillNames: string[],
dryRun: boolean,
): Promise<string[]> {
const removed: string[] = [];
for (const name of skillNames) {
// Defense in depth: an empty/relative/absolute name would resolve back to
// targetDir (or escape it) and wipe unrelated content. Only act on a
// plain child directory name.
if (
!name ||
name.includes('/') ||
name.includes('\\') ||
name === '.' ||
name === '..' ||
path.isAbsolute(name)
) {
continue;
}
const dir = path.join(targetDir, name);
if (await removeDir(dir, dryRun)) removed.push(dir);
}
return removed;
}
/**
* Remove the `[mcp_servers.gitnexus]` table — and any of its descendant
* sub-tables (`[mcp_servers.gitnexus.env]`, `[[mcp_servers.gitnexus.x]]`) —
* from Codex's config.toml. Used only as a fallback when the `codex` binary
* isn't on PATH; the CLI's `codex mcp remove` is preferred.
*
* Hand-rolled (no TOML dependency), but careful about the cases a naive
* line-scan gets wrong:
* - descendant sub-tables of the section are also removed (else they'd be
* left dangling, referencing a server that no longer exists);
* - `[...]`-shaped lines inside a multiline string (`"""`/`'''`) are NOT
* treated as table headers;
* - unrelated whitespace/formatting elsewhere in the file is left intact
* (no global blank-line reflow). Only a single blank separator line
* directly above the removed section is dropped.
*/
function stripTomlSection(raw: string, sectionName: string): string {
const header = `[${sectionName}]`;
const childTable = `[${sectionName}.`;
const childArray = `[[${sectionName}.`;
// Capture group 1 is the bracket token only, so a trailing inline comment
// (`[mcp_servers.gitnexus] # note`) is stripped before classification —
// otherwise an exact `=== header` check fails and the section is left behind.
const headerRe = /^(\[\[?[^[\]]+\]\]?)\s*(#.*)?$/;
const isSectionHeader = (token: string): boolean =>
token === header || token.startsWith(childTable) || token.startsWith(childArray);
// Return the multiline-string delimiter still OPEN at the end of `line`,
// given the state at its start (null = outside any multiline string). Scans
// left→right so the delimiter that actually opens first wins — a line with an
// odd count of BOTH `"""` and `'''` (e.g. `x = '''has """ inside`) no longer
// mis-picks the wrong delimiter and desyncs the scanner.
const multilineStateAfter = (line: string, startState: string | null): string | null => {
let state = startState;
let i = 0;
while (i < line.length) {
if (state) {
const close = line.indexOf(state, i);
if (close === -1) return state; // still open at end of line
i = close + state.length;
state = null;
} else {
const a = line.indexOf('"""', i);
const b = line.indexOf("'''", i);
if (a === -1 && b === -1) return null;
const useA = b === -1 || (a !== -1 && a < b);
state = useA ? '"""' : "'''";
i = (useA ? a : b) + 3;
}
}
return state;
};
const lines = raw.split(/\r?\n/);
const out: string[] = [];
let skipping = false;
let mlDelim: string | null = null;
for (const line of lines) {
if (mlDelim) {
// Inside a multiline string: brackets here are data, not headers.
mlDelim = multilineStateAfter(line, mlDelim);
if (!skipping) out.push(line);
continue;
}
const trimmed = line.trim();
const headerMatch = trimmed.match(headerRe);
if (headerMatch) {
if (isSectionHeader(headerMatch[1])) {
// Drop a single blank separator line immediately above the section.
if (!skipping && out.length > 0 && out[out.length - 1].trim() === '') out.pop();
skipping = true;
continue;
}
// A non-descendant header ends the section.
skipping = false;
out.push(line);
continue;
}
// Track whether this (non-header) line opens a multiline string so a
// bracketed line inside it isn't mistaken for a header.
mlDelim = multilineStateAfter(line, null);
if (!skipping) out.push(line);
}
// Preserve the file's line endings: a CRLF (Windows) config.toml should not
// be silently rewritten to LF. Rejoin with the dominant EOL of the input.
const eol = raw.includes('\r\n') ? '\r\n' : '\n';
let result = out.join(eol);
if (!result.endsWith(eol)) result += eol;
return result;
}
async function uninstallCodex(
result: UninstallResult,
dryRun: boolean,
configPath: string,
tomlSection: string,
): Promise<void> {
let raw: string;
try {
raw = await fs.readFile(configPath, 'utf-8');
} catch {
result.skipped.push('Codex MCP (not configured)');
return;
}
if (!raw.includes(`[${tomlSection}]`)) {
result.skipped.push('Codex MCP (not configured)');
return;
}
if (dryRun) {
result.removed.push(`Codex MCP server — [${tomlSection}] in ${configPath}`);
return;
}
// Prefer the official CLI (mirrors setup's `codex mcp add`); fall back
// to editing config.toml directly when the binary isn't on PATH.
try {
await execFileAsync('codex', ['mcp', 'remove', 'gitnexus'], {
shell: process.platform === 'win32',
windowsHide: true,
timeout: 10000,
});
result.removed.push("Codex MCP server — via 'codex mcp remove gitnexus'");
return;
} catch {
// Fall through to manual edit.
}
try {
await fs.writeFile(configPath, stripTomlSection(raw, tomlSection), 'utf-8');
result.removed.push(`Codex MCP server — [${tomlSection}] in ${configPath}`);
} catch (err: any) {
result.errors.push(`Codex: ${err.message}`);
}
}
// ─── Main command ──────────────────────────────────────────────────
export const uninstallCommand = async (options?: { force?: boolean }) => {
const dryRun = !options?.force;
const targets = getEditorTargets();
console.log('');
console.log(' GitNexus Uninstall');
console.log(' ==================');
console.log('');
if (dryRun) {
console.log(' Dry run — nothing will be changed. Re-run with --force to apply.');
console.log('');
}
const result: UninstallResult = { removed: [], skipped: [], errors: [] };
// ─── MCP server entries (JSONC editors) ──────────────────────────
for (const target of targets.mcpJsonc) {
try {
const status = await removeJsoncKey(target.file, target.keyPath, dryRun);
if (status === 'removed')
result.removed.push(
`${target.label} MCP server — ${target.keyPath.join('.')} in ${target.file}`,
);
else if (status === 'corrupt')
result.errors.push(
`${target.label}: ${path.basename(target.file)} is corrupt — left untouched`,
);
else result.skipped.push(`${target.label} MCP (not configured)`);
} catch (err: any) {
result.errors.push(`${target.label}: ${err.message}`);
}
}
await uninstallCodex(result, dryRun, targets.codex.configFile, targets.codex.tomlSection);
// ─── Hooks ───────────────────────────────────────────────────────
for (const hook of targets.hooks) {
try {
const { status, count } = await removeHookEntries(
hook.settingsFile,
hook.events,
hook.needle,
dryRun,
);
if (status === 'removed')
result.removed.push(`${hook.label} hooks (${count}) — ${hook.settingsFile}`);
else if (status === 'corrupt')
result.errors.push(
`${hook.label} hooks: ${path.basename(hook.settingsFile)} is corrupt — left untouched`,
);
// Don't delete the hook script while a registered entry may still point
// at it (corrupt = we couldn't parse/remove the entry) — that would
// leave the editor invoking a missing script on every matched tool call.
if (status !== 'corrupt' && (await removeDir(hook.scriptDir, dryRun)))
result.removed.push(`${hook.label} hook scripts — ${hook.scriptDir}`);
} catch (err: any) {
result.errors.push(`${hook.label} hooks: ${err.message}`);
}
}
// ─── Skills ──────────────────────────────────────────────────────
// Skill directories are identified by the bundled gitnexus skill names; the
// exact paths are listed below so the user can see what will be removed.
const skillNames = await listGitnexusSkillNames();
for (const target of targets.skills) {
try {
const removedDirs = await removeSkillsFrom(target.dir, skillNames, dryRun);
for (const dir of removedDirs) result.removed.push(`${target.label} skill — ${dir}`);
} catch (err: any) {
result.errors.push(`${target.label} skills: ${err.message}`);
}
}
// ─── Report ──────────────────────────────────────────────────────
const verb = dryRun ? 'Would remove' : 'Removed';
if (result.removed.length > 0) {
console.log(` ${verb}:`);
for (const name of result.removed) console.log(` - ${name}`);
} else {
console.log(' Nothing to remove — GitNexus is not configured in any detected editor.');
}
if (result.skipped.length > 0) {
console.log('');
console.log(' Skipped:');
for (const name of result.skipped) console.log(` - ${name}`);
}
if (result.errors.length > 0) {
console.log('');
console.log(' Errors:');
for (const err of result.errors) console.log(` ! ${err}`);
// Signal partial failure to callers/CI without aborting the remaining
// cleanup (which has already run by this point).
process.exitCode = 1;
}
console.log('');
console.log(' Note: skill directories are matched by bundled gitnexus skill name. If you');
console.log(' customized files inside an installed skill dir, back them up before --force.');
console.log('');
console.log(' Not removed automatically:');
console.log(' - Per-repo indexes — run: gitnexus clean --all');
console.log(' - The global npm package — run: npm uninstall -g gitnexus');
if (dryRun && result.removed.length > 0) {
console.log('');
console.log(' Re-run with --force to apply the changes above.');
}
console.log('');
};

View file

@ -0,0 +1,215 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { parse as parseJsonc } from 'jsonc-parser';
import { getEditorTargets } from '../../src/cli/editor-targets.js';
// Force the Codex path through the TOML fallback (no `codex` binary) so the
// round-trip is observable on config.toml, and make `which/where gitnexus`
// miss so getMcpEntry uses the npx form. Mirrors the unit-test mocks.
const execFileMock = vi.fn((...args: any[]) => {
const callback = args.at(-1);
if (typeof callback === 'function') callback(new Error('not available'), '', '');
});
const execFileSyncMock = vi.fn(() => {
throw new Error('not found');
});
vi.mock('child_process', () => ({
execFile: execFileMock,
execFileSync: execFileSyncMock,
}));
/** Read a value at a JSON key path, or undefined if any segment is missing. */
function valueAtPath(obj: any, keyPath: string[]): unknown {
return keyPath.reduce((o: any, k) => (o == null ? undefined : o[k]), obj);
}
/** Does any of `events` hold a hook entry whose command contains `needle`? */
function hasHookNeedle(settings: any, events: string[], needle: string): boolean {
return events.some(
(ev) =>
Array.isArray(settings?.hooks?.[ev]) &&
settings.hooks[ev].some(
(entry: any) =>
Array.isArray(entry?.hooks) &&
entry.hooks.some(
(h: any) => typeof h?.command === 'string' && h.command.includes(needle),
),
),
);
}
async function exists(p: string): Promise<boolean> {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
async function readJsonc(p: string): Promise<any> {
return parseJsonc(await fs.readFile(p, 'utf-8'));
}
/**
* setup → uninstall round-trip. This is the drift tripwire for #2062: it
* iterates over getEditorTargets() (the shared source of truth that both
* setup.ts and uninstall.ts consume), so if one side gains/loses/relocates a
* target without the other following, this fails in CI — in both directions.
*/
describe('setup → uninstall round-trip', () => {
let tempHome: string;
let skillsRoot: string;
const saved: Record<string, string | undefined> = {};
let savedExitCode: typeof process.exitCode;
// Two fixture skills exercise both source layouts (flat + directory).
const flatSkill = 'gitnexus-roundtrip-flat';
const dirSkill = 'gitnexus-roundtrip-dir';
const skillNames = [flatSkill, dirSkill];
beforeEach(async () => {
vi.clearAllMocks();
saved.HOME = process.env.HOME;
saved.USERPROFILE = process.env.USERPROFILE;
saved.SKILLS = process.env.GITNEXUS_TEST_SKILLS_ROOT;
savedExitCode = process.exitCode;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-roundtrip-'));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
// Mark every editor as "installed" so setup configures all of them.
for (const dir of ['.cursor', '.claude', '.codex']) {
await fs.mkdir(path.join(tempHome, dir), { recursive: true });
}
await fs.mkdir(path.join(tempHome, '.gemini', 'antigravity'), { recursive: true });
await fs.mkdir(path.join(tempHome, '.config', 'opencode'), { recursive: true });
// Fixture skills consumed by both setup (install) and uninstall (derive).
skillsRoot = path.join(tempHome, 'pkg-skills');
await fs.mkdir(path.join(skillsRoot, dirSkill), { recursive: true });
await fs.writeFile(
path.join(skillsRoot, `${flatSkill}.md`),
`---\nname: ${flatSkill}\ndescription: flat\n---\n\n# Flat`,
'utf-8',
);
await fs.writeFile(
path.join(skillsRoot, dirSkill, 'SKILL.md'),
`---\nname: ${dirSkill}\ndescription: dir\n---\n\n# Dir`,
'utf-8',
);
process.env.GITNEXUS_TEST_SKILLS_ROOT = skillsRoot;
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
process.env.HOME = saved.HOME;
process.env.USERPROFILE = saved.USERPROFILE;
if (saved.SKILLS === undefined) delete process.env.GITNEXUS_TEST_SKILLS_ROOT;
else process.env.GITNEXUS_TEST_SKILLS_ROOT = saved.SKILLS;
process.exitCode = savedExitCode;
await fs.rm(tempHome, { recursive: true, force: true });
});
it('setup writes every target and uninstall removes all of them', async () => {
const targets = getEditorTargets(tempHome);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
// ── After setup: every target artifact is present ──
for (const t of targets.mcpJsonc) {
const cfg = await readJsonc(t.file);
expect(valueAtPath(cfg, t.keyPath), `setup should write ${t.label} MCP`).toBeDefined();
}
expect(await fs.readFile(targets.codex.configFile, 'utf-8')).toContain(
`[${targets.codex.tomlSection}]`,
);
for (const t of targets.skills) {
for (const name of skillNames) {
expect(
await exists(path.join(t.dir, name, 'SKILL.md')),
`setup should install ${name} into ${t.label}`,
).toBe(true);
}
}
for (const h of targets.hooks) {
const settings = await readJsonc(h.settingsFile);
expect(
hasHookNeedle(settings, h.events, h.needle),
`setup should register ${h.label} hook`,
).toBe(true);
expect(await exists(h.scriptDir), `setup should install ${h.label} hook scripts`).toBe(true);
}
// ── Round-trip: uninstall removes everything setup wrote ──
const { uninstallCommand } = await import('../../src/cli/uninstall.js');
await uninstallCommand({ force: true });
for (const t of targets.mcpJsonc) {
const cfg = await readJsonc(t.file);
expect(valueAtPath(cfg, t.keyPath), `uninstall should remove ${t.label} MCP`).toBeUndefined();
}
expect(await fs.readFile(targets.codex.configFile, 'utf-8')).not.toContain(
`[${targets.codex.tomlSection}]`,
);
for (const t of targets.skills) {
for (const name of skillNames) {
expect(
await exists(path.join(t.dir, name)),
`uninstall should remove ${name} from ${t.label}`,
).toBe(false);
}
}
for (const h of targets.hooks) {
const settings = await readJsonc(h.settingsFile);
expect(
hasHookNeedle(settings, h.events, h.needle),
`uninstall should remove ${h.label} hook`,
).toBe(false);
expect(await exists(h.scriptDir), `uninstall should remove ${h.label} hook scripts`).toBe(
false,
);
}
});
it('uninstall preserves a co-located user MCP server and hook', async () => {
const targets = getEditorTargets(tempHome);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
// Add a user-owned MCP server alongside gitnexus in Cursor's config, and a
// user hook alongside gitnexus in Claude's PreToolUse.
const cursor = targets.mcpJsonc.find((t) => t.id === 'cursor')!;
const cursorCfg = await readJsonc(cursor.file);
cursorCfg.mcpServers.mine = { command: 'mine' };
await fs.writeFile(cursor.file, JSON.stringify(cursorCfg, null, 2), 'utf-8');
const claudeHook = targets.hooks.find((h) => h.id === 'claude')!;
const settings = await readJsonc(claudeHook.settingsFile);
settings.hooks.PreToolUse.push({
matcher: 'Read',
hooks: [{ type: 'command', command: 'my-own-hook' }],
});
await fs.writeFile(claudeHook.settingsFile, JSON.stringify(settings, null, 2), 'utf-8');
const { uninstallCommand } = await import('../../src/cli/uninstall.js');
await uninstallCommand({ force: true });
const afterCursor = await readJsonc(cursor.file);
expect(afterCursor.mcpServers.gitnexus).toBeUndefined();
expect(afterCursor.mcpServers.mine).toEqual({ command: 'mine' });
const afterSettings = await readJsonc(claudeHook.settingsFile);
const userHookSurvives = afterSettings.hooks.PreToolUse.some((e: any) =>
e.hooks?.some((h: any) => h.command === 'my-own-hook'),
);
expect(userHookSurvives).toBe(true);
expect(hasHookNeedle(afterSettings, claudeHook.events, claudeHook.needle)).toBe(false);
});
});

View file

@ -0,0 +1,561 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
// Codex uninstall shells out to `codex mcp remove`; make it fail by default
// so the TOML-strip fallback path is exercised in tests that don't override it.
const execFileMock = vi.fn((...args: any[]) => {
const callback = args.at(-1);
if (typeof callback === 'function') {
callback(new Error('codex not found'), '', '');
}
});
vi.mock('child_process', () => ({
execFile: execFileMock,
}));
describe('uninstallCommand', () => {
let tempHome: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
let originalSkillsRoot: string | undefined;
let originalExitCode: typeof process.exitCode;
let skillsRoot: string;
const importUninstall = async () => (await import('../../src/cli/uninstall.js')).uninstallCommand;
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
originalSkillsRoot = process.env.GITNEXUS_TEST_SKILLS_ROOT;
originalExitCode = process.exitCode;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-uninstall-'));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
// Stage a fixture skills source so listGitnexusSkillNames() resolves
// deterministically without depending on __dirname under Vitest.
skillsRoot = path.join(tempHome, 'pkg-skills');
await fs.mkdir(skillsRoot, { recursive: true });
await fs.writeFile(path.join(skillsRoot, 'gitnexus-exploring.md'), '# explore', 'utf-8');
await fs.writeFile(path.join(skillsRoot, 'gitnexus-cli.md'), '# cli', 'utf-8');
process.env.GITNEXUS_TEST_SKILLS_ROOT = skillsRoot;
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
process.env.HOME = originalHome;
process.env.USERPROFILE = originalUserProfile;
if (originalSkillsRoot === undefined) delete process.env.GITNEXUS_TEST_SKILLS_ROOT;
else process.env.GITNEXUS_TEST_SKILLS_ROOT = originalSkillsRoot;
// The command sets process.exitCode=1 on partial failure; restore it so a
// test exercising that path doesn't make the whole vitest run exit non-zero.
process.exitCode = originalExitCode;
await fs.rm(tempHome, { recursive: true, force: true });
});
it('removes the gitnexus MCP entry from ~/.claude.json, preserving others', async () => {
const claudeJson = path.join(tempHome, '.claude.json');
await fs.writeFile(
claudeJson,
JSON.stringify({
existingKey: 'keep-me',
mcpServers: {
gitnexus: { command: 'gitnexus', args: ['mcp'] },
other: { command: 'foo' },
},
}),
'utf-8',
);
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
const config = JSON.parse(await fs.readFile(claudeJson, 'utf-8'));
expect(config.mcpServers.gitnexus).toBeUndefined();
expect(config.mcpServers.other).toEqual({ command: 'foo' });
expect(config.existingKey).toBe('keep-me');
});
it('dry run (no --force) leaves files untouched', async () => {
const claudeJson = path.join(tempHome, '.claude.json');
const raw = JSON.stringify({
mcpServers: { gitnexus: { command: 'gitnexus', args: ['mcp'] } },
});
await fs.writeFile(claudeJson, raw, 'utf-8');
const uninstallCommand = await importUninstall();
await uninstallCommand();
expect(await fs.readFile(claudeJson, 'utf-8')).toBe(raw);
});
it('removes gitnexus hook entries and the hook-script dir, preserving other hooks', async () => {
const settingsPath = path.join(tempHome, '.claude', 'settings.json');
await fs.mkdir(path.join(tempHome, '.claude'), { recursive: true });
await fs.writeFile(
settingsPath,
JSON.stringify({
hooks: {
PreToolUse: [
{
matcher: 'Bash',
hooks: [{ type: 'command', command: 'node ".../gitnexus-hook.cjs"' }],
},
{ matcher: 'Read', hooks: [{ type: 'command', command: 'my-own-hook' }] },
],
PostToolUse: [
{
matcher: 'Bash',
hooks: [{ type: 'command', command: 'node ".../gitnexus-hook.cjs"' }],
},
],
},
}),
'utf-8',
);
const hookDir = path.join(tempHome, '.claude', '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 config = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
expect(config.hooks.PreToolUse).toHaveLength(1);
expect(config.hooks.PreToolUse[0].hooks[0].command).toBe('my-own-hook');
expect(config.hooks.PostToolUse).toHaveLength(0);
await expect(fs.access(hookDir)).rejects.toThrow();
});
it('removes installed gitnexus skill directories from ~/.claude/skills', async () => {
const skillsDir = path.join(tempHome, '.claude', 'skills');
await fs.mkdir(path.join(skillsDir, 'gitnexus-exploring'), { recursive: true });
await fs.writeFile(path.join(skillsDir, 'gitnexus-exploring', 'SKILL.md'), '# x', 'utf-8');
await fs.mkdir(path.join(skillsDir, 'gitnexus-cli'), { recursive: true });
await fs.writeFile(path.join(skillsDir, 'gitnexus-cli', 'SKILL.md'), '# y', 'utf-8');
// A user's own skill that must survive.
await fs.mkdir(path.join(skillsDir, 'my-skill'), { recursive: true });
await fs.writeFile(path.join(skillsDir, 'my-skill', 'SKILL.md'), '# mine', 'utf-8');
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
await expect(fs.access(path.join(skillsDir, 'gitnexus-exploring'))).rejects.toThrow();
await expect(fs.access(path.join(skillsDir, 'gitnexus-cli'))).rejects.toThrow();
await expect(fs.access(path.join(skillsDir, 'my-skill'))).resolves.toBeUndefined();
});
it('strips the [mcp_servers.gitnexus] section from Codex config.toml, keeping other tables', async () => {
const codexDir = path.join(tempHome, '.codex');
await fs.mkdir(codexDir, { recursive: true });
const configPath = path.join(codexDir, 'config.toml');
await fs.writeFile(
configPath,
[
'[mcp_servers.other]',
'command = "other"',
'args = ["mcp"]',
'',
'[mcp_servers.gitnexus]',
'command = "gitnexus"',
'args = ["mcp"]',
'',
].join('\n'),
'utf-8',
);
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
const result = await fs.readFile(configPath, 'utf-8');
expect(result).not.toContain('[mcp_servers.gitnexus]');
expect(result).toContain('[mcp_servers.other]');
expect(result).toContain('command = "other"');
});
it('leaves a corrupt JSON config untouched', async () => {
const claudeJson = path.join(tempHome, '.claude.json');
const corrupt = '{ not valid json !!!';
await fs.writeFile(claudeJson, corrupt, 'utf-8');
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
expect(await fs.readFile(claudeJson, 'utf-8')).toBe(corrupt);
});
it('is a no-op when nothing is configured', async () => {
const uninstallCommand = await importUninstall();
await expect(uninstallCommand({ force: true })).resolves.toBeUndefined();
});
// ── #1: empty derived skill name must not wipe the whole skills dir ──
it('does not wipe the skills dir when the source has a bare ".md" file', async () => {
// A stray bare ".md" → basename('.md', '.md') === '' → would resolve to
// the skills dir itself if unguarded.
await fs.writeFile(path.join(skillsRoot, '.md'), 'stray', 'utf-8');
const skillsDir = path.join(tempHome, '.claude', 'skills');
await fs.mkdir(path.join(skillsDir, 'gitnexus-exploring'), { recursive: true });
await fs.writeFile(path.join(skillsDir, 'gitnexus-exploring', 'SKILL.md'), '# x', 'utf-8');
await fs.mkdir(path.join(skillsDir, 'user-skill'), { recursive: true });
await fs.writeFile(path.join(skillsDir, 'user-skill', 'SKILL.md'), '# mine', 'utf-8');
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
// The skills dir and the user skill survive; only the gitnexus skill went.
await expect(fs.access(skillsDir)).resolves.toBeUndefined();
await expect(fs.access(path.join(skillsDir, 'user-skill'))).resolves.toBeUndefined();
await expect(fs.access(path.join(skillsDir, 'gitnexus-exploring'))).rejects.toThrow();
});
// ── #2: corrupt settings.json must not orphan the hook script ──
it('keeps the hook-script dir when settings.json is corrupt (avoids dangling hook)', async () => {
const settingsPath = path.join(tempHome, '.claude', 'settings.json');
await fs.mkdir(path.join(tempHome, '.claude'), { recursive: true });
const corrupt = '{ not valid json !!!';
await fs.writeFile(settingsPath, corrupt, 'utf-8');
const hookDir = path.join(tempHome, '.claude', '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 });
// Entry couldn't be removed → script dir must stay, and we flag failure.
expect(await fs.readFile(settingsPath, 'utf-8')).toBe(corrupt);
await expect(fs.access(hookDir)).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
});
// ── #3: element-granular hook removal preserves a co-located user hook ──
it('removes only the gitnexus command from a shared hook entry, keeping user commands', async () => {
const settingsPath = path.join(tempHome, '.claude', 'settings.json');
await fs.mkdir(path.join(tempHome, '.claude'), { recursive: true });
await fs.writeFile(
settingsPath,
JSON.stringify({
hooks: {
PreToolUse: [
{
matcher: 'Bash',
hooks: [
{ type: 'command', command: 'node ".../gitnexus-hook.cjs"' },
{ type: 'command', command: 'my-own-inline-hook' },
],
},
],
},
}),
'utf-8',
);
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
const config = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
// Entry survives because it still holds the user's command.
expect(config.hooks.PreToolUse).toHaveLength(1);
expect(config.hooks.PreToolUse[0].hooks).toHaveLength(1);
expect(config.hooks.PreToolUse[0].hooks[0].command).toBe('my-own-inline-hook');
});
// ── OpenCode MCP uses a distinct keyPath: ['mcp', 'gitnexus'] ──
it('removes the gitnexus entry from OpenCode opencode.json (mcp.gitnexus), preserving others', async () => {
const opencodeJson = path.join(tempHome, '.config', 'opencode', 'opencode.json');
await fs.mkdir(path.dirname(opencodeJson), { recursive: true });
await fs.writeFile(
opencodeJson,
JSON.stringify({
mcp: {
gitnexus: { type: 'local', command: ['gitnexus', 'mcp'] },
other: { type: 'local', command: ['foo'] },
},
}),
'utf-8',
);
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
const config = JSON.parse(await fs.readFile(opencodeJson, 'utf-8'));
expect(config.mcp.gitnexus).toBeUndefined();
expect(config.mcp.other).toEqual({ type: 'local', command: ['foo'] });
});
// ── Antigravity MCP + hooks (AfterTool / gitnexus-antigravity-hook) ──
it('removes Antigravity MCP and AfterTool hooks plus the adapter script dir', async () => {
const mcpPath = path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json');
await fs.mkdir(path.dirname(mcpPath), { recursive: true });
await fs.writeFile(
mcpPath,
JSON.stringify({ mcpServers: { gitnexus: { command: 'gitnexus', args: ['mcp'] } } }),
'utf-8',
);
const geminiSettings = path.join(tempHome, '.gemini', 'settings.json');
await fs.writeFile(
geminiSettings,
JSON.stringify({
hooks: {
AfterTool: [
{
matcher: 'search_file_content|glob|run_shell_command',
hooks: [{ type: 'command', command: 'node ".../gitnexus-antigravity-hook.cjs"' }],
},
{ matcher: 'glob', hooks: [{ type: 'command', command: 'user-after-tool' }] },
],
},
}),
'utf-8',
);
const adapterDir = path.join(tempHome, '.gemini', 'config', 'hooks', 'gitnexus');
await fs.mkdir(adapterDir, { recursive: true });
await fs.writeFile(path.join(adapterDir, 'gitnexus-antigravity-hook.cjs'), '// a', 'utf-8');
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
expect(JSON.parse(await fs.readFile(mcpPath, 'utf-8')).mcpServers.gitnexus).toBeUndefined();
const settings = JSON.parse(await fs.readFile(geminiSettings, 'utf-8'));
expect(settings.hooks.AfterTool).toHaveLength(1);
expect(settings.hooks.AfterTool[0].hooks[0].command).toBe('user-after-tool');
await expect(fs.access(adapterDir)).rejects.toThrow();
});
// ── Codex success path: prefer `codex mcp remove`, don't touch the file ──
it('uses `codex mcp remove` when the binary succeeds and leaves config.toml untouched', async () => {
execFileMock.mockImplementationOnce((...args: any[]) => {
const callback = args.at(-1);
if (typeof callback === 'function') callback(null, '', '');
});
const configPath = path.join(tempHome, '.codex', 'config.toml');
await fs.mkdir(path.dirname(configPath), { recursive: true });
const raw = ['[mcp_servers.gitnexus]', 'command = "gitnexus"', 'args = ["mcp"]', ''].join('\n');
await fs.writeFile(configPath, raw, 'utf-8');
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
expect(execFileMock).toHaveBeenCalledWith(
'codex',
['mcp', 'remove', 'gitnexus'],
expect.any(Object),
expect.any(Function),
);
// CLI path handles removal; we must not also rewrite the file.
expect(await fs.readFile(configPath, 'utf-8')).toBe(raw);
});
// ── #5: TOML fallback also strips descendant sub-tables ──
it('strips a [mcp_servers.gitnexus.env] sub-table along with the parent', async () => {
const configPath = path.join(tempHome, '.codex', 'config.toml');
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
[
'[mcp_servers.other]',
'command = "other"',
'',
'[mcp_servers.gitnexus]',
'command = "gitnexus"',
'',
'[mcp_servers.gitnexus.env]',
'FOO = "bar"',
'',
'[mcp_servers.zeta]',
'command = "zeta"',
'',
].join('\n'),
'utf-8',
);
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
const out = await fs.readFile(configPath, 'utf-8');
expect(out).not.toContain('[mcp_servers.gitnexus]');
expect(out).not.toContain('[mcp_servers.gitnexus.env]');
expect(out).not.toContain('FOO = "bar"');
expect(out).toContain('[mcp_servers.other]');
expect(out).toContain('[mcp_servers.zeta]');
expect(out).toContain('command = "zeta"');
});
// ── #5: TOML fallback ignores a [header] inside a multiline string ──
it('does not truncate a multiline string that contains a [mcp_servers.gitnexus] literal', async () => {
const configPath = path.join(tempHome, '.codex', 'config.toml');
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
[
'[mcp_servers.other]',
'note = """',
'[mcp_servers.gitnexus]',
'not a real header',
'"""',
'command = "other"',
'',
'[mcp_servers.gitnexus]',
'command = "gitnexus"',
'',
].join('\n'),
'utf-8',
);
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
const out = await fs.readFile(configPath, 'utf-8');
// The multiline literal is preserved...
expect(out).toContain('not a real header');
expect(out).toContain('command = "other"');
// ...but the real section's body is gone.
expect(out).not.toContain('command = "gitnexus"');
});
// ── #5 (regression): a multiline line containing an odd count of BOTH
// delimiters must not desync the scanner (it previously stuck in multiline
// mode and failed to strip the real section). ──
it('strips the real section even when a multiline string mixes \'\'\' and """ on one line', async () => {
const configPath = path.join(tempHome, '.codex', 'config.toml');
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
[
'[mcp_servers.other]',
// Opens a ''' literal; the """ on this same line is data, not an opener.
'note = \'\'\'has """ inside',
'still in string [mcp_servers.gitnexus]',
"'''",
'command = "other"',
'',
'[mcp_servers.gitnexus]',
'command = "gitnexus"',
'',
].join('\n'),
'utf-8',
);
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
const out = await fs.readFile(configPath, 'utf-8');
// The multiline literal (incl. the fake header line) is preserved...
expect(out).toContain('still in string [mcp_servers.gitnexus]');
expect(out).toContain('command = "other"');
// ...and the real section was actually removed (the bug left it behind).
expect(out).not.toContain('command = "gitnexus"');
});
// ── sweep: a section header with a trailing inline comment is still stripped ──
it('strips a [mcp_servers.gitnexus] header that has a trailing inline comment', async () => {
const configPath = path.join(tempHome, '.codex', 'config.toml');
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
[
'[mcp_servers.other]',
'command = "other"',
'',
'[mcp_servers.gitnexus] # GitNexus MCP',
'command = "gitnexus"',
'',
].join('\n'),
'utf-8',
);
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
const out = await fs.readFile(configPath, 'utf-8');
expect(out).not.toContain('mcp_servers.gitnexus');
expect(out).not.toContain('command = "gitnexus"');
expect(out).toContain('[mcp_servers.other]');
});
// ── sweep: CRLF config.toml keeps its line endings (no silent LF rewrite) ──
it('preserves CRLF line endings when stripping the Codex section', async () => {
const configPath = path.join(tempHome, '.codex', 'config.toml');
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
[
'[mcp_servers.other]',
'command = "other"',
'',
'[mcp_servers.gitnexus]',
'command = "gitnexus"',
'',
].join('\r\n'),
'utf-8',
);
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
const out = await fs.readFile(configPath, 'utf-8');
expect(out).not.toContain('[mcp_servers.gitnexus]');
expect(out).toContain('[mcp_servers.other]');
expect(out).toContain('\r\n');
// No bare LF: every newline is part of a CRLF.
expect(out).not.toMatch(/[^\r]\n/);
});
// ── dry-run leaves hooks and skills intact ──
it('dry run does not remove hooks, hook scripts, or skills', async () => {
const settingsPath = path.join(tempHome, '.claude', 'settings.json');
await fs.mkdir(path.join(tempHome, '.claude'), { recursive: true });
const settingsRaw = JSON.stringify({
hooks: {
PreToolUse: [
{
matcher: 'Bash',
hooks: [{ type: 'command', command: 'node ".../gitnexus-hook.cjs"' }],
},
],
},
});
await fs.writeFile(settingsPath, settingsRaw, 'utf-8');
const hookDir = path.join(tempHome, '.claude', 'hooks', 'gitnexus');
await fs.mkdir(hookDir, { recursive: true });
const skillsDir = path.join(tempHome, '.claude', 'skills', 'gitnexus-cli');
await fs.mkdir(skillsDir, { recursive: true });
await fs.writeFile(path.join(skillsDir, 'SKILL.md'), '# y', 'utf-8');
const uninstallCommand = await importUninstall();
await uninstallCommand(); // no --force
expect(await fs.readFile(settingsPath, 'utf-8')).toBe(settingsRaw);
await expect(fs.access(hookDir)).resolves.toBeUndefined();
await expect(fs.access(skillsDir)).resolves.toBeUndefined();
});
// ── skills: directory-layout source ({name}/SKILL.md) is recognized ──
it('removes a directory-layout skill ({name}/SKILL.md) from a target', async () => {
await fs.mkdir(path.join(skillsRoot, 'gitnexus-dir-skill'), { recursive: true });
await fs.writeFile(path.join(skillsRoot, 'gitnexus-dir-skill', 'SKILL.md'), '# d', 'utf-8');
// Use a non-Claude target to also exercise a second skill destination.
const opencodeSkills = path.join(tempHome, '.config', 'opencode', 'skills');
await fs.mkdir(path.join(opencodeSkills, 'gitnexus-dir-skill'), { recursive: true });
await fs.writeFile(path.join(opencodeSkills, 'gitnexus-dir-skill', 'SKILL.md'), '# d', 'utf-8');
await fs.mkdir(path.join(opencodeSkills, 'keep-me'), { recursive: true });
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
await expect(fs.access(path.join(opencodeSkills, 'gitnexus-dir-skill'))).rejects.toThrow();
await expect(fs.access(path.join(opencodeSkills, 'keep-me'))).resolves.toBeUndefined();
});
});