From 1716bf7c1e128ba7d373bc11a199e46e503dd6ac Mon Sep 17 00:00:00 2001 From: Nilotpal Kashyap <87768618+NilotpalK@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:20:18 +0530 Subject: [PATCH] feat(cli): add `gitnexus uninstall` to reverse setup (#2060) (#2062) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): add `gitnexus uninstall` to reverse setup (#2060) `gitnexus uninstall` was documented in #168 but never implemented, so the CLI rejected it with "error: unknown command 'uninstall'" (#2060). Add an `uninstall` command that reverses `gitnexus setup` target-by-target: removes the GitNexus MCP server entries (Cursor, Claude Code, Antigravity, OpenCode, Codex), the installed skill directories, and the Claude Code / Antigravity hook entries plus their bundled hook scripts. Edits are surgical and idempotent — only gitnexus-owned keys/entries/dirs are touched, and JSONC comments/indentation are preserved. Defaults to a dry-run preview; `--force` applies. Per-repo indexes and the global npm package are left alone with printed hints, since both are destructive in ways setup never caused. Adds i18n entries (en + zh-CN), help wiring, README/CHANGELOG docs, and unit tests covering MCP/hook/skill/Codex-TOML removal, dry-run, corrupt-file safety, and the no-op case. * changelog changes * changelog changes * fix(cli): harden uninstall against data-loss edge cases (review #2062) Address review findings on the uninstall command: - Empty derived skill name no longer wipes the whole skills dir: a bare '.md' source file would make basename() return '', resolving to the skills dir itself. Skip empty names in derivation and reject empty/'.'/'..'/separator names in removeSkillsFrom. - Corrupt settings.json no longer orphans the hook: gate the hook-script dir removal on status !== 'corrupt' so we don't delete a script while a still-registered entry points at it (Claude + Antigravity blocks). - Hook removal is now element-granular: delete only the gitnexus command inside an entry's hooks[], removing the whole entry only when it becomes empty. Preserves a user command co-located in the same entry. - Fallback TOML stripper: also remove descendant sub-tables ([mcp_servers.gitnexus.env]), track multiline strings so a bracketed line inside a value isn't treated as a header, and stop reflowing unrelated blank lines. - Set process.exitCode=1 on partial failure; add a 10s timeout to 'codex mcp remove'. Tests expanded 7 -> 17: empty-skill guard, corrupt-settings hook preservation, shared-entry hook removal, OpenCode MCP keyPath, Antigravity MCP + AfterTool hooks, codex-remove success path, TOML sub-table + multiline-string cases, dry-run for hooks/skills, and the directory-layout skill branch. * refactor(cli): share setup/uninstall target map + harden TOML fallback (review #2062) Maintainer review follow-ups: - Extract editor target identities into editor-targets.ts (MCP paths/keyPaths, Codex TOML section, skill dirs, hook settings/events/needles/script dirs, shared detectIndentation). Both setup.ts and uninstall.ts consume it, so a target change updates both sides — killing the silent drift hazard. - Add a setup -> uninstall round-trip integration test that iterates getEditorTargets(): setup writes every target, uninstall removes all of them, and a co-located user MCP server + user hook survive. Drift tripwire in both directions. - Preview now prints the exact paths it would remove; command output + README state skills are matched by bundled gitnexus skill name. (Provenance marker deferred to a tracked follow-up.) Hardening of the hand-rolled Codex TOML fallback (found in code review): - Strip a section header that has a trailing inline comment (was matched as a header but failed the exact classify check -> section left behind while reported removed). - Preserve CRLF line endings instead of rewriting the whole file to LF. - Fix multiline-string scan: a line with an odd count of BOTH """ and ''' no longer mis-picks the delimiter and desyncs the scanner (left->right scan). - removeSkillsFrom guard also rejects absolute names. Regression tests added for each. Full setup/uninstall suite green. --------- Co-authored-by: Gergő Magyar --- README.md | 3 + gitnexus/README.md | 3 + gitnexus/src/cli/editor-targets.ts | 187 ++++++ gitnexus/src/cli/help-i18n.ts | 2 + gitnexus/src/cli/i18n/en.ts | 3 + gitnexus/src/cli/i18n/zh-CN.ts | 3 + gitnexus/src/cli/index.ts | 8 + gitnexus/src/cli/setup.ts | 67 +-- gitnexus/src/cli/uninstall.ts | 518 ++++++++++++++++ .../setup-uninstall-roundtrip.test.ts | 215 +++++++ gitnexus/test/unit/uninstall.test.ts | 561 ++++++++++++++++++ 11 files changed, 1535 insertions(+), 35 deletions(-) create mode 100644 gitnexus/src/cli/editor-targets.ts create mode 100644 gitnexus/src/cli/uninstall.ts create mode 100644 gitnexus/test/integration/setup-uninstall-roundtrip.test.ts create mode 100644 gitnexus/test/unit/uninstall.test.ts diff --git a/README.md b/README.md index 44876243c..4abcbc0ff 100644 --- a/README.md +++ b/README.md @@ -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 # Search execution flows across all repos in a gitnexus group status # 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 diff --git a/gitnexus/README.md b/gitnexus/README.md index 7c84087ea..8c3349037 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -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 # Search execution flows across all repos in a gitnexus group status # 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: diff --git a/gitnexus/src/cli/editor-targets.ts b/gitnexus/src/cli/editor-targets.ts new file mode 100644 index 000000000..e00cf9778 --- /dev/null +++ b/gitnexus/src/cli/editor-targets.ts @@ -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 }; +} diff --git a/gitnexus/src/cli/help-i18n.ts b/gitnexus/src/cli/help-i18n.ts index 5fb42dbe1..1d6304f4c 100644 --- a/gitnexus/src/cli/help-i18n.ts +++ b/gitnexus/src/cli/help-i18n.ts @@ -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 ': 'help.option.port', 'serve|--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', diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index 040008570..d9d70238e 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -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', diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 6d1efb77a..e63a9249c 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -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': '即使已是最新也强制完整重新生成', diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index a2a88bd83..0f801615d 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -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)') diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index 0aa112e6a..907b7f885 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -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 { 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 { } // 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 { 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 { 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 { // 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 { }, }); } - 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 { 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 { 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 { 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 { 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 { 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 { existing = ''; } - if (existing.includes('[mcp_servers.gitnexus]')) { + if (existing.includes(`[${getEditorTargets().codex.tomlSection}]`)) { return; } @@ -809,7 +806,7 @@ async function setupCodex(result: SetupResult): Promise { } 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 { 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 { 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 { 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) { diff --git a/gitnexus/src/cli/uninstall.ts b/gitnexus/src/cli/uninstall.ts new file mode 100644 index 000000000..b67e9fcdc --- /dev/null +++ b/gitnexus/src/cli/uninstall.ts @@ -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 { + 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 { + 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 { + const skillsRoot = + process.env.GITNEXUS_TEST_SKILLS_ROOT ?? path.join(__dirname, '..', '..', 'skills'); + + const names = new Set(); + 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 { + 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 { + 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(''); +}; diff --git a/gitnexus/test/integration/setup-uninstall-roundtrip.test.ts b/gitnexus/test/integration/setup-uninstall-roundtrip.test.ts new file mode 100644 index 000000000..fc6ba0f5d --- /dev/null +++ b/gitnexus/test/integration/setup-uninstall-roundtrip.test.ts @@ -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 { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} + +async function readJsonc(p: string): Promise { + 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 = {}; + 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); + }); +}); diff --git a/gitnexus/test/unit/uninstall.test.ts b/gitnexus/test/unit/uninstall.test.ts new file mode 100644 index 000000000..5efc34f64 --- /dev/null +++ b/gitnexus/test/unit/uninstall.test.ts @@ -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(); + }); +});