gitnexus 1.6.7-rc.7 → 1.6.7-rc.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -159,6 +159,7 @@ Your AI agent gets these tools automatically:
159
159
 
160
160
  ```bash
161
161
  gitnexus setup # Configure MCP for your editors (one-time)
162
+ gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks (add --force to apply)
162
163
  gitnexus analyze [path] # Index a repository (or update stale index)
163
164
  gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
164
165
  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
196
197
  gitnexus group status <name> # Check staleness of repos in a group
197
198
  ```
198
199
 
200
+ > **`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.
201
+
199
202
  ## Remote Embeddings
200
203
 
201
204
  Set these env vars to use a remote OpenAI-compatible `/v1/embeddings` endpoint instead of the local model:
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Editor targets — the single source of truth for *where* GitNexus writes its
3
+ * per-editor configuration and *how* its entries are identified.
4
+ *
5
+ * `setup` (writes these) and `uninstall` (removes them) both consume this
6
+ * module so the two stay structurally in lock-step: add or change a target
7
+ * here and both sides follow. This is declarative metadata only — file
8
+ * locations, JSON key paths, hook event names, command needles, and script
9
+ * directories, plus the shared `detectIndentation` formatting helper. The
10
+ * format-specific read/write logic (JSONC merge, TOML upsert, OpenCode's flat
11
+ * command array, Gemini's hook schema) deliberately stays in setup.ts /
12
+ * uninstall.ts.
13
+ *
14
+ * The `setup → uninstall` round-trip integration test verifies the two
15
+ * implementations remain behaviourally symmetrical on top of this shared
16
+ * structure.
17
+ */
18
+ export type EditorId = 'cursor' | 'claude' | 'antigravity' | 'opencode' | 'codex';
19
+ /** An editor whose MCP config is a JSONC document (server keyed by name). */
20
+ export interface McpJsoncTarget {
21
+ id: EditorId;
22
+ label: string;
23
+ /** Absolute path to the editor's MCP config file. */
24
+ file: string;
25
+ /**
26
+ * JSON path of the gitnexus server entry within that file. Typed as
27
+ * `string[]` (all our keys are object keys) so it satisfies both setup's
28
+ * `mergeJsoncFile(string[])` and uninstall's `removeJsoncKey(JSONPath)`
29
+ * without either side needing a cast.
30
+ */
31
+ keyPath: string[];
32
+ }
33
+ /** Codex stores MCP config as a TOML table, not JSONC. */
34
+ export interface CodexMcpTarget {
35
+ id: 'codex';
36
+ label: string;
37
+ /** Absolute path to ~/.codex/config.toml. */
38
+ configFile: string;
39
+ /** The TOML table header (without brackets) setup writes / uninstall strips. */
40
+ tomlSection: string;
41
+ }
42
+ export interface SkillTarget {
43
+ id: EditorId;
44
+ label: string;
45
+ /** Absolute path to the editor's skills directory. */
46
+ dir: string;
47
+ }
48
+ export interface HookTarget {
49
+ id: EditorId;
50
+ label: string;
51
+ /** Absolute path to the editor's settings file (JSONC). */
52
+ settingsFile: string;
53
+ /** Hook event arrays that may hold a gitnexus entry. */
54
+ events: string[];
55
+ /** Substring identifying the gitnexus command within a hook entry. */
56
+ needle: string;
57
+ /** Absolute path to the bundled hook-script directory setup writes. */
58
+ scriptDir: string;
59
+ }
60
+ export interface EditorTargets {
61
+ /** JSONC-format MCP entries: Cursor, Claude Code, Antigravity, OpenCode. */
62
+ mcpJsonc: McpJsoncTarget[];
63
+ /** Codex MCP (TOML). */
64
+ codex: CodexMcpTarget;
65
+ /** Skill install directories, one per editor that supports skills. */
66
+ skills: SkillTarget[];
67
+ /** Hook registrations + their bundled script directories. */
68
+ hooks: HookTarget[];
69
+ }
70
+ /**
71
+ * Resolve all editor targets for the given home directory. Defaults to
72
+ * `os.homedir()`; call sites pass it through so tests can point HOME at a temp
73
+ * dir. Paths are computed at call time (not module load) so a test setting
74
+ * `process.env.HOME` before invoking sees the right locations.
75
+ */
76
+ export declare function getEditorTargets(home?: string): EditorTargets;
77
+ /** Look up a single JSONC MCP target by editor id (throws if unknown). */
78
+ export declare function mcpTarget(id: EditorId, home?: string): McpJsoncTarget;
79
+ /** Look up a single skill target by editor id (throws if unknown). */
80
+ export declare function skillTarget(id: EditorId, home?: string): SkillTarget;
81
+ /** Look up a single hook target by editor id (throws if unknown). */
82
+ export declare function hookTarget(id: EditorId, home?: string): HookTarget;
83
+ /**
84
+ * Detect indentation style from file content so JSONC edits preserve the file's
85
+ * existing formatting. Shared by setup (writes) and uninstall (removes).
86
+ */
87
+ export declare function detectIndentation(raw: string): {
88
+ tabSize: number;
89
+ insertSpaces: boolean;
90
+ };
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Editor targets — the single source of truth for *where* GitNexus writes its
3
+ * per-editor configuration and *how* its entries are identified.
4
+ *
5
+ * `setup` (writes these) and `uninstall` (removes them) both consume this
6
+ * module so the two stay structurally in lock-step: add or change a target
7
+ * here and both sides follow. This is declarative metadata only — file
8
+ * locations, JSON key paths, hook event names, command needles, and script
9
+ * directories, plus the shared `detectIndentation` formatting helper. The
10
+ * format-specific read/write logic (JSONC merge, TOML upsert, OpenCode's flat
11
+ * command array, Gemini's hook schema) deliberately stays in setup.ts /
12
+ * uninstall.ts.
13
+ *
14
+ * The `setup → uninstall` round-trip integration test verifies the two
15
+ * implementations remain behaviourally symmetrical on top of this shared
16
+ * structure.
17
+ */
18
+ import os from 'os';
19
+ import path from 'path';
20
+ /**
21
+ * Resolve all editor targets for the given home directory. Defaults to
22
+ * `os.homedir()`; call sites pass it through so tests can point HOME at a temp
23
+ * dir. Paths are computed at call time (not module load) so a test setting
24
+ * `process.env.HOME` before invoking sees the right locations.
25
+ */
26
+ export function getEditorTargets(home = os.homedir()) {
27
+ const mcpJsonc = [
28
+ {
29
+ id: 'cursor',
30
+ label: 'Cursor',
31
+ file: path.join(home, '.cursor', 'mcp.json'),
32
+ keyPath: ['mcpServers', 'gitnexus'],
33
+ },
34
+ {
35
+ id: 'claude',
36
+ label: 'Claude Code',
37
+ file: path.join(home, '.claude.json'),
38
+ keyPath: ['mcpServers', 'gitnexus'],
39
+ },
40
+ {
41
+ id: 'antigravity',
42
+ label: 'Antigravity',
43
+ file: path.join(home, '.gemini', 'antigravity', 'mcp_config.json'),
44
+ keyPath: ['mcpServers', 'gitnexus'],
45
+ },
46
+ {
47
+ id: 'opencode',
48
+ label: 'OpenCode',
49
+ file: path.join(home, '.config', 'opencode', 'opencode.json'),
50
+ // OpenCode nests servers under `mcp`, not `mcpServers`.
51
+ keyPath: ['mcp', 'gitnexus'],
52
+ },
53
+ ];
54
+ const codex = {
55
+ id: 'codex',
56
+ label: 'Codex',
57
+ configFile: path.join(home, '.codex', 'config.toml'),
58
+ tomlSection: 'mcp_servers.gitnexus',
59
+ };
60
+ const skills = [
61
+ { id: 'claude', label: 'Claude Code', dir: path.join(home, '.claude', 'skills') },
62
+ {
63
+ id: 'antigravity',
64
+ label: 'Antigravity',
65
+ dir: path.join(home, '.gemini', 'antigravity', 'skills'),
66
+ },
67
+ { id: 'cursor', label: 'Cursor', dir: path.join(home, '.cursor', 'skills') },
68
+ { id: 'opencode', label: 'OpenCode', dir: path.join(home, '.config', 'opencode', 'skills') },
69
+ // Codex reads skills from ~/.agents/skills (not ~/.codex).
70
+ { id: 'codex', label: 'Codex', dir: path.join(home, '.agents', 'skills') },
71
+ ];
72
+ const hooks = [
73
+ {
74
+ id: 'claude',
75
+ label: 'Claude Code',
76
+ settingsFile: path.join(home, '.claude', 'settings.json'),
77
+ events: ['PreToolUse', 'PostToolUse'],
78
+ needle: 'gitnexus-hook',
79
+ scriptDir: path.join(home, '.claude', 'hooks', 'gitnexus'),
80
+ },
81
+ {
82
+ id: 'antigravity',
83
+ label: 'Antigravity',
84
+ settingsFile: path.join(home, '.gemini', 'settings.json'),
85
+ events: ['AfterTool'],
86
+ needle: 'gitnexus-antigravity-hook',
87
+ scriptDir: path.join(home, '.gemini', 'config', 'hooks', 'gitnexus'),
88
+ },
89
+ ];
90
+ return { mcpJsonc, codex, skills, hooks };
91
+ }
92
+ /** Look up a single JSONC MCP target by editor id (throws if unknown). */
93
+ export function mcpTarget(id, home) {
94
+ const t = getEditorTargets(home).mcpJsonc.find((m) => m.id === id);
95
+ if (!t)
96
+ throw new Error(`No JSONC MCP target for editor "${id}"`);
97
+ return t;
98
+ }
99
+ /** Look up a single skill target by editor id (throws if unknown). */
100
+ export function skillTarget(id, home) {
101
+ const t = getEditorTargets(home).skills.find((s) => s.id === id);
102
+ if (!t)
103
+ throw new Error(`No skill target for editor "${id}"`);
104
+ return t;
105
+ }
106
+ /** Look up a single hook target by editor id (throws if unknown). */
107
+ export function hookTarget(id, home) {
108
+ const t = getEditorTargets(home).hooks.find((h) => h.id === id);
109
+ if (!t)
110
+ throw new Error(`No hook target for editor "${id}"`);
111
+ return t;
112
+ }
113
+ /**
114
+ * Detect indentation style from file content so JSONC edits preserve the file's
115
+ * existing formatting. Shared by setup (writes) and uninstall (removes).
116
+ */
117
+ export function detectIndentation(raw) {
118
+ const firstIndented = raw.match(/^( +|\t)/m);
119
+ if (!firstIndented)
120
+ return { tabSize: 2, insertSpaces: true };
121
+ if (firstIndented[1] === '\t')
122
+ return { tabSize: 1, insertSpaces: false };
123
+ return { tabSize: firstIndented[1].length, insertSpaces: true };
124
+ }
@@ -9,6 +9,7 @@ const TITLE_KEYS = {
9
9
  const COMMAND_DESCRIPTION_KEYS = {
10
10
  '': 'help.description.root',
11
11
  setup: 'help.command.setup.description',
12
+ uninstall: 'help.command.uninstall.description',
12
13
  analyze: 'help.command.analyze.description',
13
14
  index: 'help.command.index.description',
14
15
  serve: 'help.command.serve.description',
@@ -65,6 +66,7 @@ const OPTION_DESCRIPTION_KEYS = {
65
66
  'index|--allow-non-git': 'help.option.index.allowNonGit',
66
67
  'serve|-p, --port <port>': 'help.option.port',
67
68
  'serve|--host <host>': 'help.option.serve.host',
69
+ 'uninstall|-f, --force': 'help.option.uninstall.force',
68
70
  'clean|-f, --force': 'help.option.force.confirmation',
69
71
  'clean|--all': 'help.option.clean.all',
70
72
  'clean|--lbug-sidecars': 'help.option.clean.lbugSidecars',
@@ -99,6 +99,7 @@ export declare const en: {
99
99
  readonly 'help.option.help': "display help for command";
100
100
  readonly 'help.option.version': "output the version number";
101
101
  readonly 'help.command.setup.description': "One-time setup: configure MCP for Cursor, Claude Code, OpenCode, Codex";
102
+ readonly 'help.command.uninstall.description': "Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors";
102
103
  readonly 'help.command.analyze.description': "Index a repository (full analysis)";
103
104
  readonly 'help.command.index.description': "Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)";
104
105
  readonly 'help.command.serve.description': "Start local HTTP server for web UI connection";
@@ -153,6 +154,7 @@ export declare const en: {
153
154
  readonly 'help.option.port': "Port number";
154
155
  readonly 'help.option.serve.host': "Bind address (default: 127.0.0.1, use 0.0.0.0 for remote access)";
155
156
  readonly 'help.option.force.confirmation': "Skip confirmation prompt";
157
+ readonly 'help.option.uninstall.force': "Apply the changes (default is a dry-run preview)";
156
158
  readonly 'help.option.clean.all': "Clean all indexed repos";
157
159
  readonly 'help.option.clean.lbugSidecars': "Clean quarantined LadybugDB missing-shadow WAL sidecars";
158
160
  readonly 'help.option.wiki.force': "Force full regeneration even if up to date";
@@ -99,6 +99,7 @@ export const en = {
99
99
  'help.option.help': 'display help for command',
100
100
  'help.option.version': 'output the version number',
101
101
  'help.command.setup.description': 'One-time setup: configure MCP for Cursor, Claude Code, OpenCode, Codex',
102
+ 'help.command.uninstall.description': 'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors',
102
103
  'help.command.analyze.description': 'Index a repository (full analysis)',
103
104
  'help.command.index.description': 'Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)',
104
105
  'help.command.serve.description': 'Start local HTTP server for web UI connection',
@@ -153,6 +154,7 @@ export const en = {
153
154
  'help.option.port': 'Port number',
154
155
  'help.option.serve.host': 'Bind address (default: 127.0.0.1, use 0.0.0.0 for remote access)',
155
156
  'help.option.force.confirmation': 'Skip confirmation prompt',
157
+ 'help.option.uninstall.force': 'Apply the changes (default is a dry-run preview)',
156
158
  'help.option.clean.all': 'Clean all indexed repos',
157
159
  'help.option.clean.lbugSidecars': 'Clean quarantined LadybugDB missing-shadow WAL sidecars',
158
160
  'help.option.wiki.force': 'Force full regeneration even if up to date',
@@ -100,6 +100,7 @@ export declare const cliResources: {
100
100
  readonly 'help.option.help': "display help for command";
101
101
  readonly 'help.option.version': "output the version number";
102
102
  readonly 'help.command.setup.description': "One-time setup: configure MCP for Cursor, Claude Code, OpenCode, Codex";
103
+ readonly 'help.command.uninstall.description': "Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors";
103
104
  readonly 'help.command.analyze.description': "Index a repository (full analysis)";
104
105
  readonly 'help.command.index.description': "Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)";
105
106
  readonly 'help.command.serve.description': "Start local HTTP server for web UI connection";
@@ -154,6 +155,7 @@ export declare const cliResources: {
154
155
  readonly 'help.option.port': "Port number";
155
156
  readonly 'help.option.serve.host': "Bind address (default: 127.0.0.1, use 0.0.0.0 for remote access)";
156
157
  readonly 'help.option.force.confirmation': "Skip confirmation prompt";
158
+ readonly 'help.option.uninstall.force': "Apply the changes (default is a dry-run preview)";
157
159
  readonly 'help.option.clean.all': "Clean all indexed repos";
158
160
  readonly 'help.option.clean.lbugSidecars': "Clean quarantined LadybugDB missing-shadow WAL sidecars";
159
161
  readonly 'help.option.wiki.force': "Force full regeneration even if up to date";
@@ -311,6 +313,7 @@ export declare const cliResources: {
311
313
  'help.option.help': string;
312
314
  'help.option.version': string;
313
315
  'help.command.setup.description': string;
316
+ 'help.command.uninstall.description': string;
314
317
  'help.command.analyze.description': string;
315
318
  'help.command.index.description': string;
316
319
  'help.command.serve.description': string;
@@ -365,6 +368,7 @@ export declare const cliResources: {
365
368
  'help.option.port': string;
366
369
  'help.option.serve.host': string;
367
370
  'help.option.force.confirmation': string;
371
+ 'help.option.uninstall.force': string;
368
372
  'help.option.clean.all': string;
369
373
  'help.option.clean.lbugSidecars': string;
370
374
  'help.option.wiki.force': string;
@@ -99,6 +99,7 @@ export declare const zhCN: {
99
99
  'help.option.help': string;
100
100
  'help.option.version': string;
101
101
  'help.command.setup.description': string;
102
+ 'help.command.uninstall.description': string;
102
103
  'help.command.analyze.description': string;
103
104
  'help.command.index.description': string;
104
105
  'help.command.serve.description': string;
@@ -153,6 +154,7 @@ export declare const zhCN: {
153
154
  'help.option.port': string;
154
155
  'help.option.serve.host': string;
155
156
  'help.option.force.confirmation': string;
157
+ 'help.option.uninstall.force': string;
156
158
  'help.option.clean.all': string;
157
159
  'help.option.clean.lbugSidecars': string;
158
160
  'help.option.wiki.force': string;
@@ -99,6 +99,7 @@ export const zhCN = {
99
99
  'help.option.help': '显示命令帮助',
100
100
  'help.option.version': '输出版本号',
101
101
  'help.command.setup.description': '一次性设置:为 Cursor、Claude Code、OpenCode、Codex 配置 MCP',
102
+ 'help.command.uninstall.description': '撤销 `setup`:从所有检测到的编辑器中移除 GitNexus 的 MCP 配置、技能和钩子',
102
103
  'help.command.analyze.description': '索引仓库(完整分析)',
103
104
  'help.command.index.description': '将现有 .gitnexus/ 文件夹注册到全局注册表(无需重新分析)',
104
105
  'help.command.serve.description': '启动供 Web UI 连接的本地 HTTP 服务器',
@@ -153,6 +154,7 @@ export const zhCN = {
153
154
  'help.option.port': '端口号',
154
155
  'help.option.serve.host': '绑定地址(默认:127.0.0.1;远程访问可用 0.0.0.0)',
155
156
  'help.option.force.confirmation': '跳过确认提示',
157
+ 'help.option.uninstall.force': '应用更改(默认仅为预演预览)',
156
158
  'help.option.clean.all': '清理所有已索引仓库',
157
159
  'help.option.clean.lbugSidecars': '清理已隔离的 LadybugDB missing-shadow WAL sidecar',
158
160
  'help.option.wiki.force': '即使已是最新也强制完整重新生成',
package/dist/cli/index.js CHANGED
@@ -15,6 +15,11 @@ program
15
15
  .command('setup')
16
16
  .description('One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, Codex')
17
17
  .action(createLazyAction(() => import('./setup.js'), 'setupCommand'));
18
+ program
19
+ .command('uninstall')
20
+ .description('Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors')
21
+ .option('-f, --force', 'Apply the changes (default is a dry-run preview)')
22
+ .action(createLazyAction(() => import('./uninstall.js'), 'uninstallCommand'));
18
23
  program
19
24
  .command('analyze [path]')
20
25
  .description('Index a repository (full analysis)')
package/dist/cli/setup.js CHANGED
@@ -14,6 +14,7 @@ import { promisify } from 'util';
14
14
  import { fileURLToPath } from 'url';
15
15
  import { parseTree, modify, applyEdits, parse as parseJsonc } from 'jsonc-parser';
16
16
  import { getGlobalDir } from '../storage/repo-manager.js';
17
+ import { getEditorTargets, mcpTarget, skillTarget, hookTarget, detectIndentation, } from './editor-targets.js';
17
18
  const __filename = fileURLToPath(import.meta.url);
18
19
  const __dirname = path.dirname(__filename);
19
20
  const execFileAsync = promisify(execFile);
@@ -136,18 +137,6 @@ function getOpenCodeMcpEntry() {
136
137
  }
137
138
  return { type: 'local', command: ['npx', '-y', MCP_PINNED_REF, 'mcp'] };
138
139
  }
139
- /**
140
- * Detect indentation style from file content.
141
- * Returns formatting options matching the file's existing style.
142
- */
143
- function detectIndentation(raw) {
144
- const firstIndented = raw.match(/^( +|\t)/m);
145
- if (!firstIndented)
146
- return { tabSize: 2, insertSpaces: true };
147
- if (firstIndented[1] === '\t')
148
- return { tabSize: 1, insertSpaces: false };
149
- return { tabSize: firstIndented[1].length, insertSpaces: true };
150
- }
151
140
  /**
152
141
  * Merge a key/value pair into a JSONC config file, preserving comments and formatting.
153
142
  * If the file is genuinely corrupt (not valid JSONC), leaves it untouched.
@@ -198,9 +187,9 @@ async function setupCursor(result) {
198
187
  result.skipped.push('Cursor (not installed)');
199
188
  return;
200
189
  }
201
- const mcpPath = path.join(cursorDir, 'mcp.json');
190
+ const { file: mcpPath, keyPath } = mcpTarget('cursor');
202
191
  try {
203
- const ok = await mergeJsoncFile(mcpPath, ['mcpServers', 'gitnexus'], getMcpEntry());
192
+ const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry());
204
193
  if (ok) {
205
194
  result.configured.push('Cursor');
206
195
  }
@@ -219,9 +208,9 @@ async function setupClaudeCode(result) {
219
208
  return;
220
209
  }
221
210
  // Claude Code stores MCP config in ~/.claude.json
222
- const mcpPath = path.join(os.homedir(), '.claude.json');
211
+ const { file: mcpPath, keyPath } = mcpTarget('claude');
223
212
  try {
224
- const ok = await mergeJsoncFile(mcpPath, ['mcpServers', 'gitnexus'], getMcpEntry());
213
+ const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry());
225
214
  if (ok) {
226
215
  result.configured.push('Claude Code');
227
216
  }
@@ -240,7 +229,7 @@ async function installClaudeCodeSkills(result) {
240
229
  const claudeDir = path.join(os.homedir(), '.claude');
241
230
  if (!(await dirExists(claudeDir)))
242
231
  return;
243
- const skillsDir = path.join(claudeDir, 'skills');
232
+ const skillsDir = skillTarget('claude').dir;
244
233
  try {
245
234
  const installed = await installSkillsTo(skillsDir);
246
235
  if (installed.length > 0) {
@@ -357,11 +346,12 @@ async function installClaudeCodeHooks(result) {
357
346
  const claudeDir = path.join(os.homedir(), '.claude');
358
347
  if (!(await dirExists(claudeDir)))
359
348
  return;
360
- const settingsPath = path.join(claudeDir, 'settings.json');
349
+ const claudeHook = hookTarget('claude');
350
+ const settingsPath = claudeHook.settingsFile;
361
351
  // Source hooks bundled within the gitnexus package (hooks/claude/)
362
352
  const pluginHooksPath = path.join(__dirname, '..', '..', 'hooks', 'claude');
363
353
  // Copy unified hook script to ~/.claude/hooks/gitnexus/
364
- const destHooksDir = path.join(claudeDir, 'hooks', 'gitnexus');
354
+ const destHooksDir = claudeHook.scriptDir;
365
355
  try {
366
356
  await fs.mkdir(destHooksDir, { recursive: true });
367
357
  const src = path.join(pluginHooksPath, 'gitnexus-hook.cjs');
@@ -410,7 +400,7 @@ async function installClaudeCodeHooks(result) {
410
400
  const hookEntries = [];
411
401
  // NOTE: SessionStart hooks are broken on Windows (Claude Code bug #23576).
412
402
  // Session context is delivered via CLAUDE.md / skills instead.
413
- if (!hasGitnexusHook(parsed?.hooks, 'PreToolUse')) {
403
+ if (!hasGitnexusHook(parsed?.hooks, 'PreToolUse', claudeHook.needle)) {
414
404
  hookEntries.push({
415
405
  eventName: 'PreToolUse',
416
406
  value: {
@@ -426,7 +416,7 @@ async function installClaudeCodeHooks(result) {
426
416
  },
427
417
  });
428
418
  }
429
- if (!hasGitnexusHook(parsed?.hooks, 'PostToolUse')) {
419
+ if (!hasGitnexusHook(parsed?.hooks, 'PostToolUse', claudeHook.needle)) {
430
420
  hookEntries.push({
431
421
  eventName: 'PostToolUse',
432
422
  value: {
@@ -477,9 +467,9 @@ async function setupAntigravity(result) {
477
467
  result.skipped.push('Antigravity (not installed)');
478
468
  return;
479
469
  }
480
- const mcpPath = path.join(antigravityDir, 'mcp_config.json');
470
+ const { file: mcpPath, keyPath } = mcpTarget('antigravity');
481
471
  try {
482
- const ok = await mergeJsoncFile(mcpPath, ['mcpServers', 'gitnexus'], getMcpEntry());
472
+ const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry());
483
473
  if (ok) {
484
474
  result.configured.push('Antigravity');
485
475
  }
@@ -500,7 +490,7 @@ async function installAntigravitySkills(result) {
500
490
  const antigravityDir = path.join(os.homedir(), '.gemini', 'antigravity');
501
491
  if (!(await dirExists(antigravityDir)))
502
492
  return;
503
- const skillsDir = path.join(antigravityDir, 'skills');
493
+ const skillsDir = skillTarget('antigravity').dir;
504
494
  try {
505
495
  const installed = await installSkillsTo(skillsDir);
506
496
  if (installed.length > 0) {
@@ -526,9 +516,9 @@ async function installAntigravityHooks(result) {
526
516
  const antigravityDir = path.join(os.homedir(), '.gemini', 'antigravity');
527
517
  if (!(await dirExists(antigravityDir)))
528
518
  return;
529
- const geminiDir = path.join(os.homedir(), '.gemini');
530
- const settingsPath = path.join(geminiDir, 'settings.json');
531
- const destHooksDir = path.join(geminiDir, 'config', 'hooks', 'gitnexus');
519
+ const antigravityHook = hookTarget('antigravity');
520
+ const settingsPath = antigravityHook.settingsFile;
521
+ const destHooksDir = antigravityHook.scriptDir;
532
522
  // The antigravity adapter shares its lock/probe helpers with the claude
533
523
  // adapter — same DB, same concurrency rules — so we reuse those CJS files
534
524
  // from gitnexus/hooks/claude/ rather than duplicating them.
@@ -585,7 +575,7 @@ async function installAntigravityHooks(result) {
585
575
  }
586
576
  })();
587
577
  const hookEntries = [];
588
- if (!hasGitnexusHook(parsed?.hooks, 'AfterTool', 'gitnexus-antigravity-hook')) {
578
+ if (!hasGitnexusHook(parsed?.hooks, 'AfterTool', antigravityHook.needle)) {
589
579
  // Matcher follows the Gemini CLI built-in tool naming (snake_case).
590
580
  // search_file_content / glob cover content + filename search; run_shell_command
591
581
  // catches rg/grep invocations and the git commit family for stale-index hints.
@@ -629,9 +619,9 @@ async function setupOpenCode(result) {
629
619
  result.skipped.push('OpenCode (not installed)');
630
620
  return;
631
621
  }
632
- const configPath = path.join(opencodeDir, 'opencode.json');
622
+ const { file: configPath, keyPath } = mcpTarget('opencode');
633
623
  try {
634
- const ok = await mergeJsoncFile(configPath, ['mcp', 'gitnexus'], getOpenCodeMcpEntry());
624
+ const ok = await mergeJsoncFile(configPath, keyPath, getOpenCodeMcpEntry());
635
625
  if (ok) {
636
626
  result.configured.push('OpenCode');
637
627
  }
@@ -650,7 +640,7 @@ function getCodexMcpTomlSection() {
650
640
  const entry = getMcpEntry();
651
641
  const command = JSON.stringify(entry.command);
652
642
  const args = `[${entry.args.map((arg) => JSON.stringify(arg)).join(', ')}]`;
653
- return `[mcp_servers.gitnexus]\ncommand = ${command}\nargs = ${args}\n`;
643
+ return `[${getEditorTargets().codex.tomlSection}]\ncommand = ${command}\nargs = ${args}\n`;
654
644
  }
655
645
  /**
656
646
  * Append GitNexus MCP server config to Codex's config.toml if missing.
@@ -663,7 +653,7 @@ async function upsertCodexConfigToml(configPath) {
663
653
  catch {
664
654
  existing = '';
665
655
  }
666
- if (existing.includes('[mcp_servers.gitnexus]')) {
656
+ if (existing.includes(`[${getEditorTargets().codex.tomlSection}]`)) {
667
657
  return;
668
658
  }
669
659
  const section = getCodexMcpTomlSection();
@@ -690,7 +680,7 @@ async function setupCodex(result) {
690
680
  // Fallback for environments where `codex` binary isn't on PATH.
691
681
  }
692
682
  try {
693
- const configPath = path.join(codexDir, 'config.toml');
683
+ const configPath = getEditorTargets().codex.configFile;
694
684
  await upsertCodexConfigToml(configPath);
695
685
  result.configured.push('Codex (MCP added to ~/.codex/config.toml)');
696
686
  }
@@ -794,7 +784,7 @@ async function installCursorSkills(result) {
794
784
  const cursorDir = path.join(os.homedir(), '.cursor');
795
785
  if (!(await dirExists(cursorDir)))
796
786
  return;
797
- const skillsDir = path.join(cursorDir, 'skills');
787
+ const skillsDir = skillTarget('cursor').dir;
798
788
  try {
799
789
  const installed = await installSkillsTo(skillsDir);
800
790
  if (installed.length > 0) {
@@ -812,7 +802,7 @@ async function installOpenCodeSkills(result) {
812
802
  const opencodeDir = path.join(os.homedir(), '.config', 'opencode');
813
803
  if (!(await dirExists(opencodeDir)))
814
804
  return;
815
- const skillsDir = path.join(opencodeDir, 'skills');
805
+ const skillsDir = skillTarget('opencode').dir;
816
806
  try {
817
807
  const installed = await installSkillsTo(skillsDir);
818
808
  if (installed.length > 0) {
@@ -830,7 +820,7 @@ async function installCodexSkills(result) {
830
820
  const codexDir = path.join(os.homedir(), '.codex');
831
821
  if (!(await dirExists(codexDir)))
832
822
  return;
833
- const skillsDir = path.join(os.homedir(), '.agents', 'skills');
823
+ const skillsDir = skillTarget('codex').dir;
834
824
  try {
835
825
  const installed = await installSkillsTo(skillsDir);
836
826
  if (installed.length > 0) {
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Uninstall Command
3
+ *
4
+ * Reverses `gitnexus setup`: removes the GitNexus MCP server entries,
5
+ * skills, and hooks that setup writes into each detected AI editor's
6
+ * global configuration. The set of targets (paths, key paths, hook events,
7
+ * needles, script dirs) is shared with setup.ts via editor-targets.ts, so the
8
+ * two stay in lock-step.
9
+ *
10
+ * Surgical and idempotent: only gitnexus-owned keys/entries/dirs are
11
+ * removed. Unrelated user config (other MCP servers, other hooks, JSONC
12
+ * comments, indentation) is preserved. Files that are absent or that
13
+ * never contained a gitnexus entry are left untouched.
14
+ *
15
+ * Ownership is by name: skill directories are matched by the bundled gitnexus
16
+ * skill names, MCP entries by the `gitnexus` key, hooks by the gitnexus command
17
+ * needle. There is no per-install provenance marker yet (a user dir that
18
+ * happens to share a bundled skill name, or files a user added inside an
19
+ * installed skill dir, are matched purely by name) — which is why uninstall is
20
+ * a dry-run preview by default and prints the exact paths it will remove.
21
+ * Richer provenance tracking is a tracked follow-up.
22
+ *
23
+ * Intentionally NOT done here (printed as hints instead, since both are
24
+ * destructive in ways setup never caused):
25
+ * - per-repo indexes → `gitnexus clean --all`
26
+ * - the global npm package → `npm uninstall -g gitnexus`
27
+ *
28
+ * Default is a dry-run preview; pass --force to apply.
29
+ */
30
+ export declare const uninstallCommand: (options?: {
31
+ force?: boolean;
32
+ }) => Promise<void>;
@@ -0,0 +1,458 @@
1
+ /**
2
+ * Uninstall Command
3
+ *
4
+ * Reverses `gitnexus setup`: removes the GitNexus MCP server entries,
5
+ * skills, and hooks that setup writes into each detected AI editor's
6
+ * global configuration. The set of targets (paths, key paths, hook events,
7
+ * needles, script dirs) is shared with setup.ts via editor-targets.ts, so the
8
+ * two stay in lock-step.
9
+ *
10
+ * Surgical and idempotent: only gitnexus-owned keys/entries/dirs are
11
+ * removed. Unrelated user config (other MCP servers, other hooks, JSONC
12
+ * comments, indentation) is preserved. Files that are absent or that
13
+ * never contained a gitnexus entry are left untouched.
14
+ *
15
+ * Ownership is by name: skill directories are matched by the bundled gitnexus
16
+ * skill names, MCP entries by the `gitnexus` key, hooks by the gitnexus command
17
+ * needle. There is no per-install provenance marker yet (a user dir that
18
+ * happens to share a bundled skill name, or files a user added inside an
19
+ * installed skill dir, are matched purely by name) — which is why uninstall is
20
+ * a dry-run preview by default and prints the exact paths it will remove.
21
+ * Richer provenance tracking is a tracked follow-up.
22
+ *
23
+ * Intentionally NOT done here (printed as hints instead, since both are
24
+ * destructive in ways setup never caused):
25
+ * - per-repo indexes → `gitnexus clean --all`
26
+ * - the global npm package → `npm uninstall -g gitnexus`
27
+ *
28
+ * Default is a dry-run preview; pass --force to apply.
29
+ */
30
+ import fs from 'fs/promises';
31
+ import path from 'path';
32
+ import { execFile } from 'child_process';
33
+ import { promisify } from 'util';
34
+ import { fileURLToPath } from 'url';
35
+ import { parseTree, modify, applyEdits, findNodeAtLocation, parse as parseJsonc, } from 'jsonc-parser';
36
+ import { getEditorTargets, detectIndentation } from './editor-targets.js';
37
+ const __filename = fileURLToPath(import.meta.url);
38
+ const __dirname = path.dirname(__filename);
39
+ const execFileAsync = promisify(execFile);
40
+ /**
41
+ * Remove a single key (by JSON path) from a JSONC file, preserving the
42
+ * surrounding comments and formatting. Returns:
43
+ * - 'missing': file does not exist
44
+ * - 'absent': file exists but the key isn't there (nothing to do)
45
+ * - 'corrupt': file isn't valid JSONC — left untouched on purpose
46
+ * - 'removed': the key was present (and removed unless dryRun)
47
+ */
48
+ async function removeJsoncKey(filePath, keyPath, dryRun) {
49
+ let raw;
50
+ try {
51
+ raw = await fs.readFile(filePath, 'utf-8');
52
+ }
53
+ catch {
54
+ return 'missing';
55
+ }
56
+ if (raw.trim().length === 0)
57
+ return 'absent';
58
+ const parseErrors = [];
59
+ const tree = parseTree(raw, parseErrors);
60
+ if (!tree || tree.type !== 'object' || parseErrors.length > 0)
61
+ return 'corrupt';
62
+ if (!findNodeAtLocation(tree, keyPath))
63
+ return 'absent';
64
+ if (!dryRun) {
65
+ const formattingOptions = detectIndentation(raw);
66
+ const edits = modify(raw, keyPath, undefined, { formattingOptions });
67
+ await fs.writeFile(filePath, applyEdits(raw, edits), 'utf-8');
68
+ }
69
+ return 'removed';
70
+ }
71
+ /**
72
+ * Remove the gitnexus hook command(s) — those whose command string contains
73
+ * `commandNeedle` — from the given `eventNames` arrays in a JSONC settings
74
+ * file. Mirrors the idempotency probes in setup.ts (hasGitnexusHook /
75
+ * geminiHasGitnexusHook). Returns how many event entries contained a gitnexus
76
+ * command.
77
+ *
78
+ * Removal is element-granular to honor the "other hooks are preserved"
79
+ * contract: only the matching command object inside an entry's `hooks[]` is
80
+ * deleted. The surrounding matcher entry is removed only when it becomes
81
+ * empty (i.e. it held nothing but gitnexus commands — which is exactly what
82
+ * setup creates). A user who hand-added their own command alongside ours
83
+ * keeps it. Edits are applied highest-index-first so earlier indices stay
84
+ * valid across edits.
85
+ */
86
+ async function removeHookEntries(filePath, eventNames, commandNeedle, dryRun) {
87
+ let raw;
88
+ try {
89
+ raw = await fs.readFile(filePath, 'utf-8');
90
+ }
91
+ catch {
92
+ return { status: 'missing', count: 0 };
93
+ }
94
+ if (raw.trim().length === 0)
95
+ return { status: 'absent', count: 0 };
96
+ const parseErrors = [];
97
+ const tree = parseTree(raw, parseErrors);
98
+ if (!tree || tree.type !== 'object' || parseErrors.length > 0) {
99
+ return { status: 'corrupt', count: 0 };
100
+ }
101
+ const parsed = parseJsonc(raw);
102
+ const formattingOptions = detectIndentation(raw);
103
+ let current = raw;
104
+ let total = 0;
105
+ const isGitnexusHook = (hh) => typeof hh?.command === 'string' && hh.command.includes(commandNeedle);
106
+ for (const eventName of eventNames) {
107
+ const entries = parsed?.hooks?.[eventName];
108
+ if (!Array.isArray(entries))
109
+ continue;
110
+ // Walk entries high → low so removing a later one never shifts the
111
+ // index of an earlier one.
112
+ for (let entryIdx = entries.length - 1; entryIdx >= 0; entryIdx--) {
113
+ const entry = entries[entryIdx];
114
+ if (!Array.isArray(entry?.hooks))
115
+ continue;
116
+ const hookIdxs = [];
117
+ entry.hooks.forEach((hh, hi) => {
118
+ if (isGitnexusHook(hh))
119
+ hookIdxs.push(hi);
120
+ });
121
+ if (hookIdxs.length === 0)
122
+ continue;
123
+ total += 1;
124
+ if (dryRun)
125
+ continue;
126
+ if (hookIdxs.length === entry.hooks.length) {
127
+ // The entry held only gitnexus command(s) — drop the whole entry.
128
+ const edits = modify(current, ['hooks', eventName, entryIdx], undefined, {
129
+ formattingOptions,
130
+ });
131
+ current = applyEdits(current, edits);
132
+ }
133
+ else {
134
+ // The entry also holds user command(s) — delete only ours, keep
135
+ // the rest. Highest hook index first to keep lower indices valid.
136
+ for (const hi of hookIdxs.reverse()) {
137
+ const edits = modify(current, ['hooks', eventName, entryIdx, 'hooks', hi], undefined, {
138
+ formattingOptions,
139
+ });
140
+ current = applyEdits(current, edits);
141
+ }
142
+ }
143
+ }
144
+ }
145
+ if (total === 0)
146
+ return { status: 'absent', count: 0 };
147
+ if (!dryRun)
148
+ await fs.writeFile(filePath, current, 'utf-8');
149
+ return { status: 'removed', count: total };
150
+ }
151
+ /**
152
+ * Remove a directory tree if it exists. Returns true when something was
153
+ * (or would be) removed.
154
+ */
155
+ async function removeDir(dirPath, dryRun) {
156
+ try {
157
+ await fs.access(dirPath);
158
+ }
159
+ catch {
160
+ return false;
161
+ }
162
+ if (!dryRun)
163
+ await fs.rm(dirPath, { recursive: true, force: true });
164
+ return true;
165
+ }
166
+ /**
167
+ * The exact set of skill directory names setup installs, derived from the
168
+ * bundled `skills/` source the same way installSkillsTo does (flat
169
+ * `{name}.md` and `{name}/SKILL.md` layouts). Deriving the set — rather
170
+ * than globbing `gitnexus-*` — ensures we never delete a user's own
171
+ * similarly-named skill folder.
172
+ */
173
+ async function listGitnexusSkillNames() {
174
+ const skillsRoot = process.env.GITNEXUS_TEST_SKILLS_ROOT ?? path.join(__dirname, '..', '..', 'skills');
175
+ const names = new Set();
176
+ try {
177
+ const entries = await fs.readdir(skillsRoot, { withFileTypes: true });
178
+ for (const entry of entries) {
179
+ if (entry.isFile() && entry.name.endsWith('.md')) {
180
+ // Guard against a bare `.md` file: basename('.md', '.md') === '',
181
+ // which would later resolve to the skills dir itself and wipe it.
182
+ const base = path.basename(entry.name, '.md');
183
+ if (base)
184
+ names.add(base);
185
+ }
186
+ else if (entry.isDirectory()) {
187
+ try {
188
+ await fs.access(path.join(skillsRoot, entry.name, 'SKILL.md'));
189
+ names.add(entry.name);
190
+ }
191
+ catch {
192
+ // Not a skill directory — skip.
193
+ }
194
+ }
195
+ }
196
+ }
197
+ catch {
198
+ return [];
199
+ }
200
+ return [...names];
201
+ }
202
+ /**
203
+ * Remove the gitnexus skill directories from a target skills folder. Returns
204
+ * the absolute paths that were removed (or would be removed in dryRun) so the
205
+ * caller can show the user exactly what is affected.
206
+ */
207
+ async function removeSkillsFrom(targetDir, skillNames, dryRun) {
208
+ const removed = [];
209
+ for (const name of skillNames) {
210
+ // Defense in depth: an empty/relative/absolute name would resolve back to
211
+ // targetDir (or escape it) and wipe unrelated content. Only act on a
212
+ // plain child directory name.
213
+ if (!name ||
214
+ name.includes('/') ||
215
+ name.includes('\\') ||
216
+ name === '.' ||
217
+ name === '..' ||
218
+ path.isAbsolute(name)) {
219
+ continue;
220
+ }
221
+ const dir = path.join(targetDir, name);
222
+ if (await removeDir(dir, dryRun))
223
+ removed.push(dir);
224
+ }
225
+ return removed;
226
+ }
227
+ /**
228
+ * Remove the `[mcp_servers.gitnexus]` table — and any of its descendant
229
+ * sub-tables (`[mcp_servers.gitnexus.env]`, `[[mcp_servers.gitnexus.x]]`) —
230
+ * from Codex's config.toml. Used only as a fallback when the `codex` binary
231
+ * isn't on PATH; the CLI's `codex mcp remove` is preferred.
232
+ *
233
+ * Hand-rolled (no TOML dependency), but careful about the cases a naive
234
+ * line-scan gets wrong:
235
+ * - descendant sub-tables of the section are also removed (else they'd be
236
+ * left dangling, referencing a server that no longer exists);
237
+ * - `[...]`-shaped lines inside a multiline string (`"""`/`'''`) are NOT
238
+ * treated as table headers;
239
+ * - unrelated whitespace/formatting elsewhere in the file is left intact
240
+ * (no global blank-line reflow). Only a single blank separator line
241
+ * directly above the removed section is dropped.
242
+ */
243
+ function stripTomlSection(raw, sectionName) {
244
+ const header = `[${sectionName}]`;
245
+ const childTable = `[${sectionName}.`;
246
+ const childArray = `[[${sectionName}.`;
247
+ // Capture group 1 is the bracket token only, so a trailing inline comment
248
+ // (`[mcp_servers.gitnexus] # note`) is stripped before classification —
249
+ // otherwise an exact `=== header` check fails and the section is left behind.
250
+ const headerRe = /^(\[\[?[^[\]]+\]\]?)\s*(#.*)?$/;
251
+ const isSectionHeader = (token) => token === header || token.startsWith(childTable) || token.startsWith(childArray);
252
+ // Return the multiline-string delimiter still OPEN at the end of `line`,
253
+ // given the state at its start (null = outside any multiline string). Scans
254
+ // left→right so the delimiter that actually opens first wins — a line with an
255
+ // odd count of BOTH `"""` and `'''` (e.g. `x = '''has """ inside`) no longer
256
+ // mis-picks the wrong delimiter and desyncs the scanner.
257
+ const multilineStateAfter = (line, startState) => {
258
+ let state = startState;
259
+ let i = 0;
260
+ while (i < line.length) {
261
+ if (state) {
262
+ const close = line.indexOf(state, i);
263
+ if (close === -1)
264
+ return state; // still open at end of line
265
+ i = close + state.length;
266
+ state = null;
267
+ }
268
+ else {
269
+ const a = line.indexOf('"""', i);
270
+ const b = line.indexOf("'''", i);
271
+ if (a === -1 && b === -1)
272
+ return null;
273
+ const useA = b === -1 || (a !== -1 && a < b);
274
+ state = useA ? '"""' : "'''";
275
+ i = (useA ? a : b) + 3;
276
+ }
277
+ }
278
+ return state;
279
+ };
280
+ const lines = raw.split(/\r?\n/);
281
+ const out = [];
282
+ let skipping = false;
283
+ let mlDelim = null;
284
+ for (const line of lines) {
285
+ if (mlDelim) {
286
+ // Inside a multiline string: brackets here are data, not headers.
287
+ mlDelim = multilineStateAfter(line, mlDelim);
288
+ if (!skipping)
289
+ out.push(line);
290
+ continue;
291
+ }
292
+ const trimmed = line.trim();
293
+ const headerMatch = trimmed.match(headerRe);
294
+ if (headerMatch) {
295
+ if (isSectionHeader(headerMatch[1])) {
296
+ // Drop a single blank separator line immediately above the section.
297
+ if (!skipping && out.length > 0 && out[out.length - 1].trim() === '')
298
+ out.pop();
299
+ skipping = true;
300
+ continue;
301
+ }
302
+ // A non-descendant header ends the section.
303
+ skipping = false;
304
+ out.push(line);
305
+ continue;
306
+ }
307
+ // Track whether this (non-header) line opens a multiline string so a
308
+ // bracketed line inside it isn't mistaken for a header.
309
+ mlDelim = multilineStateAfter(line, null);
310
+ if (!skipping)
311
+ out.push(line);
312
+ }
313
+ // Preserve the file's line endings: a CRLF (Windows) config.toml should not
314
+ // be silently rewritten to LF. Rejoin with the dominant EOL of the input.
315
+ const eol = raw.includes('\r\n') ? '\r\n' : '\n';
316
+ let result = out.join(eol);
317
+ if (!result.endsWith(eol))
318
+ result += eol;
319
+ return result;
320
+ }
321
+ async function uninstallCodex(result, dryRun, configPath, tomlSection) {
322
+ let raw;
323
+ try {
324
+ raw = await fs.readFile(configPath, 'utf-8');
325
+ }
326
+ catch {
327
+ result.skipped.push('Codex MCP (not configured)');
328
+ return;
329
+ }
330
+ if (!raw.includes(`[${tomlSection}]`)) {
331
+ result.skipped.push('Codex MCP (not configured)');
332
+ return;
333
+ }
334
+ if (dryRun) {
335
+ result.removed.push(`Codex MCP server — [${tomlSection}] in ${configPath}`);
336
+ return;
337
+ }
338
+ // Prefer the official CLI (mirrors setup's `codex mcp add`); fall back
339
+ // to editing config.toml directly when the binary isn't on PATH.
340
+ try {
341
+ await execFileAsync('codex', ['mcp', 'remove', 'gitnexus'], {
342
+ shell: process.platform === 'win32',
343
+ windowsHide: true,
344
+ timeout: 10000,
345
+ });
346
+ result.removed.push("Codex MCP server — via 'codex mcp remove gitnexus'");
347
+ return;
348
+ }
349
+ catch {
350
+ // Fall through to manual edit.
351
+ }
352
+ try {
353
+ await fs.writeFile(configPath, stripTomlSection(raw, tomlSection), 'utf-8');
354
+ result.removed.push(`Codex MCP server — [${tomlSection}] in ${configPath}`);
355
+ }
356
+ catch (err) {
357
+ result.errors.push(`Codex: ${err.message}`);
358
+ }
359
+ }
360
+ // ─── Main command ──────────────────────────────────────────────────
361
+ export const uninstallCommand = async (options) => {
362
+ const dryRun = !options?.force;
363
+ const targets = getEditorTargets();
364
+ console.log('');
365
+ console.log(' GitNexus Uninstall');
366
+ console.log(' ==================');
367
+ console.log('');
368
+ if (dryRun) {
369
+ console.log(' Dry run — nothing will be changed. Re-run with --force to apply.');
370
+ console.log('');
371
+ }
372
+ const result = { removed: [], skipped: [], errors: [] };
373
+ // ─── MCP server entries (JSONC editors) ──────────────────────────
374
+ for (const target of targets.mcpJsonc) {
375
+ try {
376
+ const status = await removeJsoncKey(target.file, target.keyPath, dryRun);
377
+ if (status === 'removed')
378
+ result.removed.push(`${target.label} MCP server — ${target.keyPath.join('.')} in ${target.file}`);
379
+ else if (status === 'corrupt')
380
+ result.errors.push(`${target.label}: ${path.basename(target.file)} is corrupt — left untouched`);
381
+ else
382
+ result.skipped.push(`${target.label} MCP (not configured)`);
383
+ }
384
+ catch (err) {
385
+ result.errors.push(`${target.label}: ${err.message}`);
386
+ }
387
+ }
388
+ await uninstallCodex(result, dryRun, targets.codex.configFile, targets.codex.tomlSection);
389
+ // ─── Hooks ───────────────────────────────────────────────────────
390
+ for (const hook of targets.hooks) {
391
+ try {
392
+ const { status, count } = await removeHookEntries(hook.settingsFile, hook.events, hook.needle, dryRun);
393
+ if (status === 'removed')
394
+ result.removed.push(`${hook.label} hooks (${count}) — ${hook.settingsFile}`);
395
+ else if (status === 'corrupt')
396
+ result.errors.push(`${hook.label} hooks: ${path.basename(hook.settingsFile)} is corrupt — left untouched`);
397
+ // Don't delete the hook script while a registered entry may still point
398
+ // at it (corrupt = we couldn't parse/remove the entry) — that would
399
+ // leave the editor invoking a missing script on every matched tool call.
400
+ if (status !== 'corrupt' && (await removeDir(hook.scriptDir, dryRun)))
401
+ result.removed.push(`${hook.label} hook scripts — ${hook.scriptDir}`);
402
+ }
403
+ catch (err) {
404
+ result.errors.push(`${hook.label} hooks: ${err.message}`);
405
+ }
406
+ }
407
+ // ─── Skills ──────────────────────────────────────────────────────
408
+ // Skill directories are identified by the bundled gitnexus skill names; the
409
+ // exact paths are listed below so the user can see what will be removed.
410
+ const skillNames = await listGitnexusSkillNames();
411
+ for (const target of targets.skills) {
412
+ try {
413
+ const removedDirs = await removeSkillsFrom(target.dir, skillNames, dryRun);
414
+ for (const dir of removedDirs)
415
+ result.removed.push(`${target.label} skill — ${dir}`);
416
+ }
417
+ catch (err) {
418
+ result.errors.push(`${target.label} skills: ${err.message}`);
419
+ }
420
+ }
421
+ // ─── Report ──────────────────────────────────────────────────────
422
+ const verb = dryRun ? 'Would remove' : 'Removed';
423
+ if (result.removed.length > 0) {
424
+ console.log(` ${verb}:`);
425
+ for (const name of result.removed)
426
+ console.log(` - ${name}`);
427
+ }
428
+ else {
429
+ console.log(' Nothing to remove — GitNexus is not configured in any detected editor.');
430
+ }
431
+ if (result.skipped.length > 0) {
432
+ console.log('');
433
+ console.log(' Skipped:');
434
+ for (const name of result.skipped)
435
+ console.log(` - ${name}`);
436
+ }
437
+ if (result.errors.length > 0) {
438
+ console.log('');
439
+ console.log(' Errors:');
440
+ for (const err of result.errors)
441
+ console.log(` ! ${err}`);
442
+ // Signal partial failure to callers/CI without aborting the remaining
443
+ // cleanup (which has already run by this point).
444
+ process.exitCode = 1;
445
+ }
446
+ console.log('');
447
+ console.log(' Note: skill directories are matched by bundled gitnexus skill name. If you');
448
+ console.log(' customized files inside an installed skill dir, back them up before --force.');
449
+ console.log('');
450
+ console.log(' Not removed automatically:');
451
+ console.log(' - Per-repo indexes — run: gitnexus clean --all');
452
+ console.log(' - The global npm package — run: npm uninstall -g gitnexus');
453
+ if (dryRun && result.removed.length > 0) {
454
+ console.log('');
455
+ console.log(' Re-run with --force to apply the changes above.');
456
+ }
457
+ console.log('');
458
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.7-rc.7",
3
+ "version": "1.6.7-rc.8",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",