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 +3 -0
- package/dist/cli/editor-targets.d.ts +90 -0
- package/dist/cli/editor-targets.js +124 -0
- package/dist/cli/help-i18n.js +2 -0
- package/dist/cli/i18n/en.d.ts +2 -0
- package/dist/cli/i18n/en.js +2 -0
- package/dist/cli/i18n/resources.d.ts +4 -0
- package/dist/cli/i18n/zh-CN.d.ts +2 -0
- package/dist/cli/i18n/zh-CN.js +2 -0
- package/dist/cli/index.js +5 -0
- package/dist/cli/setup.js +26 -36
- package/dist/cli/uninstall.d.ts +32 -0
- package/dist/cli/uninstall.js +458 -0
- package/package.json +1 -1
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
|
+
}
|
package/dist/cli/help-i18n.js
CHANGED
|
@@ -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',
|
package/dist/cli/i18n/en.d.ts
CHANGED
|
@@ -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";
|
package/dist/cli/i18n/en.js
CHANGED
|
@@ -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;
|
package/dist/cli/i18n/zh-CN.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli/i18n/zh-CN.js
CHANGED
|
@@ -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 =
|
|
190
|
+
const { file: mcpPath, keyPath } = mcpTarget('cursor');
|
|
202
191
|
try {
|
|
203
|
-
const ok = await mergeJsoncFile(mcpPath,
|
|
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 =
|
|
211
|
+
const { file: mcpPath, keyPath } = mcpTarget('claude');
|
|
223
212
|
try {
|
|
224
|
-
const ok = await mergeJsoncFile(mcpPath,
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
470
|
+
const { file: mcpPath, keyPath } = mcpTarget('antigravity');
|
|
481
471
|
try {
|
|
482
|
-
const ok = await mergeJsoncFile(mcpPath,
|
|
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 =
|
|
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
|
|
530
|
-
const settingsPath =
|
|
531
|
-
const destHooksDir =
|
|
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',
|
|
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 =
|
|
622
|
+
const { file: configPath, keyPath } = mcpTarget('opencode');
|
|
633
623
|
try {
|
|
634
|
-
const ok = await mergeJsoncFile(configPath,
|
|
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 `[
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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