gitnexus 1.6.9 → 1.6.10-rc.1

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Graph-powered code intelligence for AI agents.** Index any codebase into a knowledge graph, then query it via MCP or CLI.
4
4
 
5
- Works with **Cursor**, **Claude Code**, **Antigravity** (Google), **Codex**, **Windsurf**, **Cline**, **OpenCode**, and any MCP-compatible tool.
5
+ Works with **Cursor**, **Claude Code**, **Antigravity** (Google), **Codex**, **Windsurf**, **Cline**, **OpenCode**, **CodeBuddy** (Tencent), **Qoder** (Alibaba), and any MCP-compatible tool.
6
6
 
7
7
  [![npm version](https://img.shields.io/npm/v/gitnexus.svg)](https://www.npmjs.com/package/gitnexus)
8
8
  [![License: PolyForm Noncommercial](https://img.shields.io/badge/License-PolyForm%20Noncommercial-blue.svg)](https://polyformproject.org/licenses/noncommercial/1.0.0/)
@@ -45,6 +45,8 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
45
45
  | **Antigravity** (Google) | Yes | Yes | Yes (AfterTool, [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/)) | **Full** |
46
46
  | **Codex** | Yes | Yes | — | MCP + Skills |
47
47
  | **OpenCode** | Yes | Yes | — | MCP + Skills |
48
+ | **CodeBuddy** (Tencent) | Yes | Yes | — | MCP + Skills |
49
+ | **Qoder** (Alibaba) | Yes | Yes | — | MCP + Skills |
48
50
  | **Windsurf** | Yes | — | — | MCP |
49
51
 
50
52
  > **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that automatically enrich grep/glob/bash calls with knowledge graph context + PostToolUse hooks that detect a stale index after commits and prompt the agent to reindex.
@@ -105,6 +107,36 @@ Add to `~/.config/opencode/config.json`:
105
107
  }
106
108
  ```
107
109
 
110
+ ### CodeBuddy
111
+
112
+ CodeBuddy reads only the **first existing file** in its config priority chain: `~/.codebuddy/.mcp.json` (recommended) → `~/.codebuddy/mcp.json` (deprecated) → `~/.codebuddy.json` (legacy). Edit the first non-empty file that exists — creating a higher-priority file would hide the servers in the ones below it. If none exist, create `~/.codebuddy/.mcp.json`:
113
+
114
+ ```json
115
+ {
116
+ "mcpServers": {
117
+ "gitnexus": {
118
+ "command": "npx",
119
+ "args": ["-y", "gitnexus@latest", "mcp"]
120
+ }
121
+ }
122
+ }
123
+ ```
124
+
125
+ ### Qoder
126
+
127
+ Add to `~/.qoder.json`:
128
+
129
+ ```json
130
+ {
131
+ "mcpServers": {
132
+ "gitnexus": {
133
+ "command": "npx",
134
+ "args": ["-y", "gitnexus@latest", "mcp"]
135
+ }
136
+ }
137
+ }
138
+ ```
139
+
108
140
  ## How It Works
109
141
 
110
142
  GitNexus builds a complete knowledge graph of your codebase through a multi-phase indexing pipeline:
@@ -125,24 +157,24 @@ The result is a **LadybugDB graph database** stored locally in `.gitnexus/` with
125
157
  Your AI agent gets **17 tools** (15 per-repo + 2 group) automatically:
126
158
 
127
159
  | Tool | What It Does |
128
- | ---------------- | ----------------------------------------------------------------------- |
129
- | `list_repos` | Discover all indexed repositories (paginated — `limit`/`offset`) |
130
- | `query` | Process-grouped hybrid search (BM25 + semantic + RRF) |
131
- | `context` | 360-degree symbol view — categorized refs, process participation |
132
- | `impact` | Blast radius analysis with depth grouping and confidence |
133
- | `trace` | Shortest directed path between two symbols (call + class-member edges) |
134
- | `detect_changes` | Git-diff impact — maps changed lines to affected processes |
135
- | `check` | Read-only structural checks against the indexed graph |
136
- | `rename` | Multi-file coordinated rename with graph + text search |
137
- | `cypher` | Raw Cypher graph queries |
138
- | `route_map` | API route map — which components fetch which endpoints, and handlers |
139
- | `tool_map` | MCP/RPC tool definitions — where they're defined and handled |
140
- | `shape_check` | Validate API response shapes against consumers' property accesses |
141
- | `api_impact` | Pre-change impact report for an API route handler |
142
- | `explain` | Explain persisted taint findings (source→sink flows, `--pdg` indexes) |
143
- | `pdg_query` | Query control/data dependence at statement level (`--pdg` indexes) |
144
- | `group_list` | List configured repository groups |
145
- | `group_sync` | Rebuild a group's Contract Registry and cross-repo links |
160
+ | ---------------- | ---------------------------------------------------------------------- |
161
+ | `list_repos` | Discover all indexed repositories (paginated — `limit`/`offset`) |
162
+ | `query` | Process-grouped hybrid search (BM25 + semantic + RRF) |
163
+ | `context` | 360-degree symbol view — categorized refs, process participation |
164
+ | `impact` | Blast radius analysis with depth grouping and confidence |
165
+ | `trace` | Shortest directed path between two symbols (call + class-member edges) |
166
+ | `detect_changes` | Git-diff impact — maps changed lines to affected processes |
167
+ | `check` | Read-only structural checks against the indexed graph |
168
+ | `rename` | Multi-file coordinated rename with graph + text search |
169
+ | `cypher` | Raw Cypher graph queries |
170
+ | `route_map` | API route map — which components fetch which endpoints, and handlers |
171
+ | `tool_map` | MCP/RPC tool definitions — where they're defined and handled |
172
+ | `shape_check` | Validate API response shapes against consumers' property accesses |
173
+ | `api_impact` | Pre-change impact report for an API route handler |
174
+ | `explain` | Explain persisted taint findings (source→sink flows, `--pdg` indexes) |
175
+ | `pdg_query` | Query control/data dependence at statement level (`--pdg` indexes) |
176
+ | `group_list` | List configured repository groups |
177
+ | `group_sync` | Rebuild a group's Contract Registry and cross-repo links |
146
178
 
147
179
  > With one indexed repo, the `repo` param is optional. With multiple, specify which: `query({search_query: "auth", repo: "my-app"})`. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`; omitting it queries the workspace index, which follows your checked-out working tree. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
148
180
 
@@ -383,13 +415,13 @@ GitNexus uses optional DuckDB extensions for BM25 and vector search. The `gitnex
383
415
 
384
416
  Configure the behavior with these environment variables:
385
417
 
386
- | Variable | Values | Default | Effect |
387
- | -------------------------------------------- | ---------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
388
- | `GITNEXUS_LBUG_EXTENSION_INSTALL` | `auto`, `load-only`, `never` | `auto` | `auto` runs one bounded INSTALL if LOAD fails. `load-only` only uses already-installed extensions (recommended for offline / firewalled environments). `never` skips optional extensions entirely. |
389
- | `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS` | positive integer | `15000` | Wall-clock budget for the out-of-process `INSTALL` child before it is killed. |
390
- | `GITNEXUS_FTS_STEMMER` | supported LadybugDB stemmer | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` when that better matches repository comments and identifiers. Re-run `gitnexus analyze --repair-fts` after changing it. |
418
+ | Variable | Values | Default | Effect |
419
+ | -------------------------------------------- | ---------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
420
+ | `GITNEXUS_LBUG_EXTENSION_INSTALL` | `auto`, `load-only`, `never` | `auto` | `auto` runs one bounded INSTALL if LOAD fails. `load-only` only uses already-installed extensions (recommended for offline / firewalled environments). `never` skips optional extensions entirely. |
421
+ | `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS` | positive integer | `15000` | Wall-clock budget for the out-of-process `INSTALL` child before it is killed. |
422
+ | `GITNEXUS_FTS_STEMMER` | supported LadybugDB stemmer | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` when that better matches repository comments and identifiers. Re-run `gitnexus analyze --repair-fts` after changing it. |
391
423
  | `GITNEXUS_FTS_CJK_SEGMENTATION` | `none`, `bigram` | `none` | `bigram` inserts overlapping character-bigram boundaries into Chinese/Japanese Han-ideograph spans in `content`/`description` before FTS indexing, so LadybugDB's space-only tokenizer can see sub-phrase word boundaries. Scoped to CJK Unified Ideographs only — Japanese Hiragana/Katakana and Korean Hangul are not currently segmented. Unlike `GITNEXUS_FTS_STEMMER`, this rewrites stored text — enabling it on an already-indexed repo requires a full `gitnexus analyze --force`; neither `--repair-fts` nor a plain incremental `analyze` applies it to previously-indexed files. Set the same value wherever `analyze` and search-serving processes (CLI query, MCP server, web server) run. |
392
- | `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | integer `>= -1` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold during analyze (bytes). Auto-checkpoint remains enabled; `-1` keeps Ladybug's stock ~16 MiB. Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. |
424
+ | `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | integer `>= -1` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold during analyze (bytes). Auto-checkpoint remains enabled; `-1` keeps Ladybug's stock ~16 MiB. Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. |
393
425
 
394
426
  ```bash
395
427
  # Offline/airgapped: never reach the network for extensions
@@ -464,9 +496,9 @@ Three env vars expose the pool's resilience layers (respawn budget, cumulative-t
464
496
 
465
497
  After scope resolution, analyze prunes inert block-local value symbols (a function-local `const`/`let`/`var` that ends up with only its structural `File→DEFINES` edge) to keep the graph focused on cross-symbol relationships. Module/file-scope symbols, class members, and any local with a real edge are always kept.
466
498
 
467
- | Variable | Default | Effect |
468
- | ------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------- |
469
- | `GITNEXUS_KEEP_LOCAL_VALUE_SYMBOLS` | unset | Set to `1`/`true` to keep inert block-local value symbols instead of pruning them. |
499
+ | Variable | Default | Effect |
500
+ | ----------------------------------- | ------- | ---------------------------------------------------------------------------------- |
501
+ | `GITNEXUS_KEEP_LOCAL_VALUE_SYMBOLS` | unset | Set to `1`/`true` to keep inert block-local value symbols instead of pruning them. |
470
502
 
471
503
  Programmatic callers can pass `keepLocalValueSymbols: true` in `PipelineOptions` instead of setting the env var.
472
504
 
@@ -2,7 +2,7 @@
2
2
  * AI Context Generator
3
3
  *
4
4
  * Creates AGENTS.md and CLAUDE.md with full inline GitNexus context.
5
- * AGENTS.md is the standard read by Cursor, Windsurf, OpenCode, Codex, Cline, etc.
5
+ * AGENTS.md is the standard read by Cursor, Windsurf, OpenCode, Codex, Cline, CodeBuddy, Qoder, etc.
6
6
  * CLAUDE.md is for Claude Code which only reads that file.
7
7
  */
8
8
  import { type GeneratedSkillInfo } from './skill-gen.js';
@@ -2,7 +2,7 @@
2
2
  * AI Context Generator
3
3
  *
4
4
  * Creates AGENTS.md and CLAUDE.md with full inline GitNexus context.
5
- * AGENTS.md is the standard read by Cursor, Windsurf, OpenCode, Codex, Cline, etc.
5
+ * AGENTS.md is the standard read by Cursor, Windsurf, OpenCode, Codex, Cline, CodeBuddy, Qoder, etc.
6
6
  * CLAUDE.md is for Claude Code which only reads that file.
7
7
  */
8
8
  import fs from 'fs/promises';
@@ -15,7 +15,7 @@
15
15
  * implementations remain behaviourally symmetrical on top of this shared
16
16
  * structure.
17
17
  */
18
- export type EditorId = 'cursor' | 'claude' | 'antigravity' | 'opencode' | 'codex';
18
+ export type EditorId = 'cursor' | 'claude' | 'antigravity' | 'opencode' | 'codebuddy' | 'qoder' | 'codex';
19
19
  /** An editor whose MCP config is a JSONC document (server keyed by name). */
20
20
  export interface McpJsoncTarget {
21
21
  id: EditorId;
@@ -29,6 +29,14 @@ export interface McpJsoncTarget {
29
29
  * without either side needing a cast.
30
30
  */
31
31
  keyPath: string[];
32
+ /**
33
+ * Older config locations the editor still reads when `file` is absent
34
+ * (CodeBuddy reads only the FIRST existing file in its priority chain).
35
+ * Setup writes into the first existing candidate of [file, ...legacyFiles]
36
+ * so it never shadows a user's servers living in a deprecated file;
37
+ * uninstall sweeps all of them.
38
+ */
39
+ legacyFiles?: string[];
32
40
  }
33
41
  /** Codex stores MCP config as a TOML table, not JSONC. */
34
42
  export interface CodexMcpTarget {
@@ -58,7 +66,7 @@ export interface HookTarget {
58
66
  scriptDir: string;
59
67
  }
60
68
  export interface EditorTargets {
61
- /** JSONC-format MCP entries: Cursor, Claude Code, Antigravity, OpenCode. */
69
+ /** JSONC-format MCP entries: Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder. */
62
70
  mcpJsonc: McpJsoncTarget[];
63
71
  /** Codex MCP (TOML). */
64
72
  codex: CodexMcpTarget;
@@ -80,6 +88,13 @@ export declare function mcpTarget(id: EditorId, home?: string): McpJsoncTarget;
80
88
  export declare function skillTarget(id: EditorId, home?: string): SkillTarget;
81
89
  /** Look up a single hook target by editor id (throws if unknown). */
82
90
  export declare function hookTarget(id: EditorId, home?: string): HookTarget;
91
+ /**
92
+ * True when err is a Node fs error with code ENOENT (file/dir absent).
93
+ * Shared by setup and uninstall: both must swallow ONLY absence when reading
94
+ * editor configs — any other read/stat failure (EACCES, EIO) is surfaced so an
95
+ * unreadable config is never treated as empty and rewritten gitnexus-only.
96
+ */
97
+ export declare function isEnoent(err: unknown): boolean;
83
98
  /**
84
99
  * Detect indentation style from file content so JSONC edits preserve the file's
85
100
  * existing formatting. Shared by setup (writes) and uninstall (removes).
@@ -50,6 +50,26 @@ export function getEditorTargets(home = os.homedir()) {
50
50
  // OpenCode nests servers under `mcp`, not `mcpServers`.
51
51
  keyPath: ['mcp', 'gitnexus'],
52
52
  },
53
+ {
54
+ id: 'codebuddy',
55
+ label: 'CodeBuddy',
56
+ // Recommended user-scope path per https://www.codebuddy.ai/docs/cli/mcp;
57
+ // CodeBuddy reads only the first existing file in this priority chain.
58
+ file: path.join(home, '.codebuddy', '.mcp.json'),
59
+ legacyFiles: [
60
+ path.join(home, '.codebuddy', 'mcp.json'), // deprecated
61
+ path.join(home, '.codebuddy.json'), // legacy
62
+ ],
63
+ keyPath: ['mcpServers', 'gitnexus'],
64
+ },
65
+ {
66
+ id: 'qoder',
67
+ label: 'Qoder',
68
+ // Qoder's documented user-scope MCP config (https://docs.qoder.com/cli/using-cli);
69
+ // the IDE manages MCP via its Settings UI with no documented file path.
70
+ file: path.join(home, '.qoder.json'),
71
+ keyPath: ['mcpServers', 'gitnexus'],
72
+ },
53
73
  ];
54
74
  const codex = {
55
75
  id: 'codex',
@@ -66,6 +86,10 @@ export function getEditorTargets(home = os.homedir()) {
66
86
  },
67
87
  { id: 'cursor', label: 'Cursor', dir: path.join(home, '.cursor', 'skills') },
68
88
  { id: 'opencode', label: 'OpenCode', dir: path.join(home, '.config', 'opencode', 'skills') },
89
+ { id: 'codebuddy', label: 'CodeBuddy', dir: path.join(home, '.codebuddy', 'skills') },
90
+ // Qoder skills live at ~/.qoder/skills/{name}/SKILL.md
91
+ // (https://docs.qoder.com/extensions/skills).
92
+ { id: 'qoder', label: 'Qoder', dir: path.join(home, '.qoder', 'skills') },
69
93
  // Codex reads skills from ~/.agents/skills (not ~/.codex).
70
94
  { id: 'codex', label: 'Codex', dir: path.join(home, '.agents', 'skills') },
71
95
  ];
@@ -110,6 +134,15 @@ export function hookTarget(id, home) {
110
134
  throw new Error(`No hook target for editor "${id}"`);
111
135
  return t;
112
136
  }
137
+ /**
138
+ * True when err is a Node fs error with code ENOENT (file/dir absent).
139
+ * Shared by setup and uninstall: both must swallow ONLY absence when reading
140
+ * editor configs — any other read/stat failure (EACCES, EIO) is surfaced so an
141
+ * unreadable config is never treated as empty and rewritten gitnexus-only.
142
+ */
143
+ export function isEnoent(err) {
144
+ return err?.code === 'ENOENT';
145
+ }
113
146
  /**
114
147
  * Detect indentation style from file content so JSONC edits preserve the file's
115
148
  * existing formatting. Shared by setup (writes) and uninstall (removes).
@@ -108,7 +108,7 @@ export declare const en: {
108
108
  readonly 'help.command.help.description': "display help for command";
109
109
  readonly 'help.option.help': "display help for command";
110
110
  readonly 'help.option.version': "output the version number";
111
- readonly 'help.command.setup.description': "One-time setup: configure MCP for Cursor, Claude Code, OpenCode, Codex";
111
+ readonly 'help.command.setup.description': "One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex";
112
112
  readonly 'help.command.uninstall.description': "Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors";
113
113
  readonly 'help.command.analyze.description': "Index a repository (full analysis)";
114
114
  readonly 'help.command.index.description': "Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)";
@@ -108,7 +108,7 @@ export const en = {
108
108
  'help.command.help.description': 'display help for command',
109
109
  'help.option.help': 'display help for command',
110
110
  'help.option.version': 'output the version number',
111
- 'help.command.setup.description': 'One-time setup: configure MCP for Cursor, Claude Code, OpenCode, Codex',
111
+ 'help.command.setup.description': 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex',
112
112
  'help.command.uninstall.description': 'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors',
113
113
  'help.command.analyze.description': 'Index a repository (full analysis)',
114
114
  'help.command.index.description': 'Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)',
@@ -109,7 +109,7 @@ export declare const cliResources: {
109
109
  readonly 'help.command.help.description': "display help for command";
110
110
  readonly 'help.option.help': "display help for command";
111
111
  readonly 'help.option.version': "output the version number";
112
- readonly 'help.command.setup.description': "One-time setup: configure MCP for Cursor, Claude Code, OpenCode, Codex";
112
+ readonly 'help.command.setup.description': "One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex";
113
113
  readonly 'help.command.uninstall.description': "Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors";
114
114
  readonly 'help.command.analyze.description': "Index a repository (full analysis)";
115
115
  readonly 'help.command.index.description': "Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)";
@@ -108,7 +108,7 @@ export const zhCN = {
108
108
  'help.command.help.description': '显示命令帮助',
109
109
  'help.option.help': '显示命令帮助',
110
110
  'help.option.version': '输出版本号',
111
- 'help.command.setup.description': '一次性设置:为 Cursor、Claude Code、OpenCode、Codex 配置 MCP',
111
+ 'help.command.setup.description': '一次性设置:为 Cursor、Claude Code、Antigravity、OpenCode、CodeBuddy、Qoder、Codex 配置 MCP',
112
112
  'help.command.uninstall.description': '撤销 `setup`:从所有检测到的编辑器中移除 GitNexus 的 MCP 配置、技能和钩子',
113
113
  'help.command.analyze.description': '索引仓库(完整分析)',
114
114
  'help.command.index.description': '将现有 .gitnexus/ 文件夹注册到全局注册表(无需重新分析)',
package/dist/cli/index.js CHANGED
@@ -17,7 +17,7 @@ function collectCodingAgents(value, previous) {
17
17
  program.name('gitnexus').description('GitNexus local CLI and MCP server').version(pkg.version);
18
18
  program
19
19
  .command('setup')
20
- .description('One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, Codex')
20
+ .description('One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex')
21
21
  .option('-c, --coding-agent <agents>', 'Configure only these coding agents (comma-separated or repeatable)', collectCodingAgents)
22
22
  .action(createLazyAction(() => import('./setup.js'), 'setupCommand'));
23
23
  program
package/dist/cli/setup.js CHANGED
@@ -14,7 +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
+ import { getEditorTargets, mcpTarget, skillTarget, hookTarget, detectIndentation, isEnoent, } from './editor-targets.js';
18
18
  const __filename = fileURLToPath(import.meta.url);
19
19
  const __dirname = path.dirname(__filename);
20
20
  const execFileAsync = promisify(execFile);
@@ -66,6 +66,8 @@ const CODING_AGENT_IDS = {
66
66
  claude: 'claude',
67
67
  antigravity: 'antigravity',
68
68
  opencode: 'opencode',
69
+ codebuddy: 'codebuddy',
70
+ qoder: 'qoder',
69
71
  codex: 'codex',
70
72
  };
71
73
  const SUPPORTED_CODING_AGENTS = Object.values(CODING_AGENT_IDS);
@@ -173,7 +175,13 @@ async function mergeJsoncFile(filePath, keyPath, value) {
173
175
  try {
174
176
  raw = await fs.readFile(filePath, 'utf-8');
175
177
  }
176
- catch {
178
+ catch (err) {
179
+ // Only an absent file means "start fresh". Any other read failure (EACCES,
180
+ // EIO, cloud-placeholder faults) must not be treated as empty — the write
181
+ // below would replace the user's existing config with a gitnexus-only
182
+ // document and report success. Rethrow into the per-editor catch instead.
183
+ if (!isEnoent(err))
184
+ throw err;
177
185
  raw = '';
178
186
  }
179
187
  if (raw.trim().length === 0) {
@@ -207,6 +215,35 @@ async function dirExists(dirPath) {
207
215
  return false;
208
216
  }
209
217
  }
218
+ /**
219
+ * Detection probe: is there a non-empty regular file at this path?
220
+ * Swallows ALL errors (like dirExists) — detection gates run outside the
221
+ * per-editor try blocks, so a rethrowing probe would abort setup for every
222
+ * remaining editor. Size > 0 keeps detection aligned with the config-chain
223
+ * resolver: an empty config file is not evidence of an install, and treating
224
+ * it as one would route the write to a fresh file whose mkdir manufactures
225
+ * the editor's directory.
226
+ */
227
+ async function isNonEmptyFile(filePath) {
228
+ try {
229
+ const stat = await fs.stat(filePath);
230
+ return stat.isFile() && stat.size > 0;
231
+ }
232
+ catch {
233
+ return false;
234
+ }
235
+ }
236
+ /**
237
+ * Detection probe: does any file in the target's MCP config chain look like an
238
+ * install trace? Always walks [file, ...legacyFiles] so an editor gaining
239
+ * legacyFiles later is automatically covered (CodeBuddy and Qoder share this —
240
+ * a per-editor copy is how the root-config-only detection gap crept in, see
241
+ * PR #2368 review I4).
242
+ */
243
+ async function anyChainConfigFile(target) {
244
+ const hits = await Promise.all([target.file, ...(target.legacyFiles ?? [])].map(isNonEmptyFile));
245
+ return hits.includes(true);
246
+ }
210
247
  // ─── Editor-specific setup ─────────────────────────────────────────
211
248
  async function setupCursor(result) {
212
249
  const cursorDir = path.join(os.homedir(), '.cursor');
@@ -287,7 +324,12 @@ async function mergeHooksJsonc(filePath, entries) {
287
324
  try {
288
325
  raw = await fs.readFile(filePath, 'utf-8');
289
326
  }
290
- catch {
327
+ catch (err) {
328
+ // Same contract as mergeJsoncFile: an unreadable (non-ENOENT) settings
329
+ // file must not be rewritten as hooks-only — that would destroy every
330
+ // user setting in it. Rethrow into the hook installer's catch.
331
+ if (!isEnoent(err))
332
+ throw err;
291
333
  raw = '';
292
334
  }
293
335
  if (raw.trim().length === 0) {
@@ -660,6 +702,118 @@ async function setupOpenCode(result) {
660
702
  result.errors.push(`OpenCode: ${err.message}`);
661
703
  }
662
704
  }
705
+ /**
706
+ * Resolve which config file in a target's [file, ...legacyFiles] priority
707
+ * chain setup should write into: the first that exists, else the recommended
708
+ * `file`. CodeBuddy reads only the first existing file in its chain, so
709
+ * creating the recommended file above a populated deprecated one would shadow
710
+ * the user's existing MCP servers.
711
+ */
712
+ async function resolveMcpConfigFile(target) {
713
+ for (const candidate of [target.file, ...(target.legacyFiles ?? [])]) {
714
+ try {
715
+ const stat = await fs.stat(candidate);
716
+ // Non-empty regular files only: a 0-byte recommended file must not
717
+ // shadow a populated deprecated one (mergeJsoncFile treats empty as a
718
+ // fresh document anyway), and directories are never config candidates.
719
+ if (stat.isFile() && stat.size > 0)
720
+ return candidate;
721
+ }
722
+ catch (err) {
723
+ // ENOENT = candidate absent — try the next one. Anything else (EACCES
724
+ // on the file or a parent) is surfaced: silently skipping could route
725
+ // the write to a lower-priority file the editor never reads.
726
+ if (!isEnoent(err))
727
+ throw err;
728
+ }
729
+ }
730
+ return target.file;
731
+ }
732
+ async function setupCodeBuddy(result) {
733
+ const codebuddyDir = path.join(os.homedir(), '.codebuddy');
734
+ const target = mcpTarget('codebuddy');
735
+ // Installed = the config dir exists OR any registered MCP config file does.
736
+ // A user whose only trace is a root-level config (e.g. a legacy
737
+ // ~/.codebuddy.json) still gets configured — uninstall already handles that
738
+ // shape, so setup skipping it was an asymmetry (PR #2368 review I4).
739
+ if (!(await dirExists(codebuddyDir)) && !(await anyChainConfigFile(target))) {
740
+ result.skipped.push('CodeBuddy (not installed)');
741
+ return;
742
+ }
743
+ try {
744
+ const configFile = await resolveMcpConfigFile(target);
745
+ const ok = await mergeJsoncFile(configFile, target.keyPath, getMcpEntry());
746
+ if (ok) {
747
+ result.configured.push('CodeBuddy');
748
+ }
749
+ else {
750
+ result.errors.push(`CodeBuddy: ${path.basename(configFile)} is corrupt — skipping to preserve existing content`);
751
+ }
752
+ }
753
+ catch (err) {
754
+ result.errors.push(`CodeBuddy: ${err instanceof Error ? err.message : String(err)}`);
755
+ }
756
+ }
757
+ async function setupQoder(result) {
758
+ const qoderDir = path.join(os.homedir(), '.qoder');
759
+ const target = mcpTarget('qoder');
760
+ const { file: mcpPath, keyPath } = target;
761
+ // Same chain-aware detection as CodeBuddy: ~/.qoder.json alone counts.
762
+ if (!(await dirExists(qoderDir)) && !(await anyChainConfigFile(target))) {
763
+ result.skipped.push('Qoder (not installed)');
764
+ return;
765
+ }
766
+ try {
767
+ const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry());
768
+ if (ok) {
769
+ result.configured.push('Qoder');
770
+ }
771
+ else {
772
+ result.errors.push('Qoder: .qoder.json is corrupt — skipping to preserve existing content');
773
+ }
774
+ }
775
+ catch (err) {
776
+ result.errors.push(`Qoder: ${err instanceof Error ? err.message : String(err)}`);
777
+ }
778
+ }
779
+ /**
780
+ * Install global CodeBuddy skills to ~/.codebuddy/skills/
781
+ * (https://www.codebuddy.ai/docs/cli/skills — same SKILL.md layout as Claude Code).
782
+ */
783
+ async function installCodeBuddySkills(result) {
784
+ const codebuddyDir = path.join(os.homedir(), '.codebuddy');
785
+ if (!(await dirExists(codebuddyDir)))
786
+ return;
787
+ const skillsDir = skillTarget('codebuddy').dir;
788
+ try {
789
+ const installed = await installSkillsTo(skillsDir);
790
+ if (installed.length > 0) {
791
+ result.configured.push(`CodeBuddy skills (${installed.length} skills → ~/.codebuddy/skills/)`);
792
+ }
793
+ }
794
+ catch (err) {
795
+ result.errors.push(`CodeBuddy skills: ${err instanceof Error ? err.message : String(err)}`);
796
+ }
797
+ }
798
+ /**
799
+ * Install global Qoder skills to ~/.qoder/skills/
800
+ * (https://docs.qoder.com/extensions/skills — same SKILL.md layout as Claude Code).
801
+ */
802
+ async function installQoderSkills(result) {
803
+ const qoderDir = path.join(os.homedir(), '.qoder');
804
+ if (!(await dirExists(qoderDir)))
805
+ return;
806
+ const skillsDir = skillTarget('qoder').dir;
807
+ try {
808
+ const installed = await installSkillsTo(skillsDir);
809
+ if (installed.length > 0) {
810
+ result.configured.push(`Qoder skills (${installed.length} skills → ~/.qoder/skills/)`);
811
+ }
812
+ }
813
+ catch (err) {
814
+ result.errors.push(`Qoder skills: ${err instanceof Error ? err.message : String(err)}`);
815
+ }
816
+ }
663
817
  /**
664
818
  * Build a TOML section for Codex MCP config (~/.codex/config.toml).
665
819
  */
@@ -677,7 +831,12 @@ async function upsertCodexConfigToml(configPath) {
677
831
  try {
678
832
  existing = await fs.readFile(configPath, 'utf-8');
679
833
  }
680
- catch {
834
+ catch (err) {
835
+ // TOML variant of the mergeJsoncFile contract: treating a non-ENOENT read
836
+ // failure as an empty config would rewrite config.toml with only the
837
+ // gitnexus section. Rethrow into setupCodex's catch.
838
+ if (!isEnoent(err))
839
+ throw err;
681
840
  existing = '';
682
841
  }
683
842
  if (existing.includes(`[${getEditorTargets().codex.tomlSection}]`)) {
@@ -885,6 +1044,10 @@ export const setupCommand = async (options) => {
885
1044
  await setupAntigravity(result);
886
1045
  if (selected.has('opencode'))
887
1046
  await setupOpenCode(result);
1047
+ if (selected.has('codebuddy'))
1048
+ await setupCodeBuddy(result);
1049
+ if (selected.has('qoder'))
1050
+ await setupQoder(result);
888
1051
  if (selected.has('codex'))
889
1052
  await setupCodex(result);
890
1053
  // Install global skills for platforms that support them
@@ -900,6 +1063,10 @@ export const setupCommand = async (options) => {
900
1063
  await installCursorSkills(result);
901
1064
  if (selected.has('opencode'))
902
1065
  await installOpenCodeSkills(result);
1066
+ if (selected.has('codebuddy'))
1067
+ await installCodeBuddySkills(result);
1068
+ if (selected.has('qoder'))
1069
+ await installQoderSkills(result);
903
1070
  if (selected.has('codex'))
904
1071
  await installCodexSkills(result);
905
1072
  // Print results
@@ -33,7 +33,7 @@ import { execFile } from 'child_process';
33
33
  import { promisify } from 'util';
34
34
  import { fileURLToPath } from 'url';
35
35
  import { parseTree, modify, applyEdits, findNodeAtLocation, parse as parseJsonc, } from 'jsonc-parser';
36
- import { getEditorTargets, detectIndentation } from './editor-targets.js';
36
+ import { getEditorTargets, detectIndentation, isEnoent } from './editor-targets.js';
37
37
  const __filename = fileURLToPath(import.meta.url);
38
38
  const __dirname = path.dirname(__filename);
39
39
  const execFileAsync = promisify(execFile);
@@ -50,7 +50,13 @@ async function removeJsoncKey(filePath, keyPath, dryRun) {
50
50
  try {
51
51
  raw = await fs.readFile(filePath, 'utf-8');
52
52
  }
53
- catch {
53
+ catch (err) {
54
+ // ENOENT = genuinely not configured. Any other read failure (EACCES,
55
+ // locks) must not report 'missing' — the file may hold a real gitnexus
56
+ // entry the dry-run would then deny exists. Rethrow into the caller's
57
+ // per-file catch.
58
+ if (!isEnoent(err))
59
+ throw err;
54
60
  return 'missing';
55
61
  }
56
62
  if (raw.trim().length === 0)
@@ -88,7 +94,12 @@ async function removeHookEntries(filePath, eventNames, commandNeedle, dryRun) {
88
94
  try {
89
95
  raw = await fs.readFile(filePath, 'utf-8');
90
96
  }
91
- catch {
97
+ catch (err) {
98
+ // Masking a non-ENOENT read failure as 'missing' would also let the
99
+ // caller delete the hook scriptDir while the unreadable settings file
100
+ // still references it. Rethrow into the hook uninstaller's catch.
101
+ if (!isEnoent(err))
102
+ throw err;
92
103
  return { status: 'missing', count: 0 };
93
104
  }
94
105
  if (raw.trim().length === 0)
@@ -323,8 +334,15 @@ async function uninstallCodex(result, dryRun, configPath, tomlSection) {
323
334
  try {
324
335
  raw = await fs.readFile(configPath, 'utf-8');
325
336
  }
326
- catch {
327
- result.skipped.push('Codex MCP (not configured)');
337
+ catch (err) {
338
+ // Catch locally: this call site has no surrounding try, so a rethrow
339
+ // would abort the hooks/skills cleanup that runs after Codex.
340
+ if (isEnoent(err)) {
341
+ result.skipped.push('Codex MCP (not configured)');
342
+ }
343
+ else {
344
+ result.errors.push(`Codex: ${err instanceof Error ? err.message : String(err)}`);
345
+ }
328
346
  return;
329
347
  }
330
348
  if (!raw.includes(`[${tomlSection}]`)) {
@@ -369,20 +387,50 @@ export const uninstallCommand = async (options) => {
369
387
  console.log(' Dry run — nothing will be changed. Re-run with --force to apply.');
370
388
  console.log('');
371
389
  }
372
- const result = { removed: [], skipped: [], errors: [] };
390
+ const result = { removed: [], skipped: [], errors: [], corruptLegacy: false };
373
391
  // ─── MCP server entries (JSONC editors) ──────────────────────────
392
+ // Sweep legacyFiles too: setup writes into the first existing file of the
393
+ // editor's priority chain, so the gitnexus entry may live in a deprecated
394
+ // location (e.g. CodeBuddy's ~/.codebuddy/mcp.json).
374
395
  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)`);
396
+ let removedAny = false;
397
+ let erroredAny = false;
398
+ let corruptLegacyAny = false;
399
+ for (const file of [target.file, ...(target.legacyFiles ?? [])]) {
400
+ try {
401
+ const status = await removeJsoncKey(file, target.keyPath, dryRun);
402
+ if (status === 'removed') {
403
+ removedAny = true;
404
+ result.removed.push(`${target.label} MCP server — ${target.keyPath.join('.')} in ${file}`);
405
+ }
406
+ else if (status === 'corrupt') {
407
+ if (file === target.file) {
408
+ // The primary path is where gitnexus itself writes — corruption
409
+ // there is an error worth failing the command over.
410
+ erroredAny = true;
411
+ result.errors.push(`${target.label}: ${path.basename(file)} is corrupt — left untouched`);
412
+ }
413
+ else {
414
+ // Legacy chain files are vendor locations gitnexus may never have
415
+ // touched (e.g. a corrupt ~/.codebuddy.json from an old install).
416
+ // Report informationally without failing uninstall. Deliberate
417
+ // asymmetry: an UNREADABLE (non-ENOENT) legacy file still errors —
418
+ // that's an environmental problem worth surfacing, while corrupt-
419
+ // but-readable proves there is no removable gitnexus entry.
420
+ corruptLegacyAny = true;
421
+ result.corruptLegacy = true;
422
+ result.skipped.push(`${target.label} MCP (legacy ${path.basename(file)} is corrupt — left untouched)`);
423
+ }
424
+ }
425
+ }
426
+ catch (err) {
427
+ erroredAny = true;
428
+ result.errors.push(`${target.label}: ${err instanceof Error ? err.message : String(err)}`);
429
+ }
383
430
  }
384
- catch (err) {
385
- result.errors.push(`${target.label}: ${err.message}`);
431
+ // A corrupt legacy file makes "not configured" unknowable — suppress it.
432
+ if (!removedAny && !erroredAny && !corruptLegacyAny) {
433
+ result.skipped.push(`${target.label} MCP (not configured)`);
386
434
  }
387
435
  }
388
436
  await uninstallCodex(result, dryRun, targets.codex.configFile, targets.codex.tomlSection);
@@ -425,6 +473,12 @@ export const uninstallCommand = async (options) => {
425
473
  for (const name of result.removed)
426
474
  console.log(` - ${name}`);
427
475
  }
476
+ else if (result.errors.length > 0 || result.corruptLegacy) {
477
+ // Errors (corrupt primary files, unreadable configs) or corrupt legacy
478
+ // configs make "not configured" unknowable — claiming it right above an
479
+ // Errors block would be a contradiction users learn to distrust.
480
+ console.log(' Nothing removed.');
481
+ }
428
482
  else {
429
483
  console.log(' Nothing to remove — GitNexus is not configured in any detected editor.');
430
484
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.9",
3
+ "version": "1.6.10-rc.1",
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",
@@ -30,6 +30,7 @@ const PLATFORM_LOGIC = [
30
30
  'test/unit/setup-jsonc.test.ts',
31
31
  'test/unit/setup-codex.test.ts',
32
32
  'test/unit/setup-antigravity.test.ts',
33
+ 'test/integration/setup-uninstall-roundtrip.test.ts',
33
34
  'test/unit/resolve-invocation.test.ts',
34
35
  'test/unit/platform-capabilities.test.ts',
35
36
  'test/unit/worker-pool-windows-quarantine.test.ts',