feat(setup): add CodeBuddy and Qoder coding-agent integrations (#2368)

* feat(setup): add CodeBuddy and Qoder coding-agent integrations

Adds Tencent CodeBuddy and Alibaba Qoder to gitnexus setup/uninstall,
fitted to the editor-targets registry and --coding-agent selection.

- CodeBuddy: MCP entry written into the first existing file of its
  documented priority chain (~/.codebuddy/.mcp.json recommended,
  ~/.codebuddy/mcp.json deprecated, ~/.codebuddy.json legacy) so a
  populated deprecated config is never shadowed; skills to
  ~/.codebuddy/skills/ (https://www.codebuddy.ai/docs/cli/mcp)
- Qoder: MCP entry in ~/.qoder.json, skills to ~/.qoder/skills/
  (https://docs.qoder.com/cli/using-cli, /extensions/skills)
- editor-targets gains optional legacyFiles; uninstall sweeps them
- roster strings updated (CLI help, i18n en/zh-CN, READMEs); en/zh-CN
  setup descriptions were stale (missing Antigravity) and are refreshed

Supersedes and credits PR #1030 by @zykai0302, re-fitted to the
post-#2168 selective-agent architecture with documented config paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(cli): assert stable zh-CN setup-description fragment

* fix(setup): surface non-ENOENT config read/stat failures instead of clobbering

* fix(setup): report corrupt legacy MCP files informationally during uninstall

* test(setup): cover multi-candidate uninstall sweep combinations

* fix(setup): detect CodeBuddy/Qoder installs via existing MCP config files

* fix(setup): skip empty and non-file candidates in the MCP config chain

* docs: add CodeBuddy and Qoder manual MCP configuration sections

* test(ci): run the setup-uninstall round-trip in the cross-platform matrix

* fix(setup): never claim "not configured" when uninstall recorded errors

* refactor(cli): share the isEnoent predicate via editor-targets

* refactor(setup): share chain-file install detection between CodeBuddy and Qoder

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Gergő Magyar 2026-07-04 10:54:50 +01:00 committed by GitHub
parent 4227194ad7
commit 6252aa745f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1153 additions and 59 deletions

View file

@ -201,6 +201,8 @@ flowchart TB
| **Antigravity** (Google) | Yes | Yes | Yes (AfterTool, [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/))[¹](#fn-antigravity-hooks) | **Full** |
| **Codex** | Yes | Yes | — | MCP + Skills |
| **OpenCode** | Yes | Yes | — | MCP + Skills |
| **CodeBuddy** (Tencent) | Yes | Yes | — | MCP + Skills |
| **Qoder** (Alibaba) | Yes | Yes | — | MCP + Skills |
| **Windsurf** | Yes | — | — | MCP |
> **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that detect a stale index after commits and prompt the agent to reindex.
@ -276,6 +278,32 @@ args = ["-y", "gitnexus@latest", "mcp"]
}
```
**CodeBuddy** (Tencent) — priority chain, edit the **first non-empty file that exists**: `~/.codebuddy/.mcp.json` (recommended) → `~/.codebuddy/mcp.json` (deprecated) → `~/.codebuddy.json` (legacy). CodeBuddy reads only the first existing file, so adding servers to a higher-priority file than the one currently in use would hide the servers below it. Create `~/.codebuddy/.mcp.json` only if none exist:
```json
{
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@latest", "mcp"]
}
}
}
```
**Qoder** (Alibaba) — `~/.qoder.json`:
```json
{
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@latest", "mcp"]
}
}
}
```
</details>
## CLI Reference

View file

@ -2,7 +2,7 @@
**Graph-powered code intelligence for AI agents.** Index any codebase into a knowledge graph, then query it via MCP or CLI.
Works with **Cursor**, **Claude Code**, **Antigravity** (Google), **Codex**, **Windsurf**, **Cline**, **OpenCode**, and any MCP-compatible tool.
Works with **Cursor**, **Claude Code**, **Antigravity** (Google), **Codex**, **Windsurf**, **Cline**, **OpenCode**, **CodeBuddy** (Tencent), **Qoder** (Alibaba), and any MCP-compatible tool.
[![npm version](https://img.shields.io/npm/v/gitnexus.svg)](https://www.npmjs.com/package/gitnexus)
[![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
| **Antigravity** (Google) | Yes | Yes | Yes (AfterTool, [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/)) | **Full** |
| **Codex** | Yes | Yes | — | MCP + Skills |
| **OpenCode** | Yes | Yes | — | MCP + Skills |
| **CodeBuddy** (Tencent) | Yes | Yes | — | MCP + Skills |
| **Qoder** (Alibaba) | Yes | Yes | — | MCP + Skills |
| **Windsurf** | Yes | — | — | MCP |
> **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`:
}
```
### CodeBuddy
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`:
```json
{
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@latest", "mcp"]
}
}
}
```
### Qoder
Add to `~/.qoder.json`:
```json
{
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@latest", "mcp"]
}
}
}
```
## How It Works
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
Your AI agent gets **17 tools** (15 per-repo + 2 group) automatically:
| Tool | What It Does |
| ---------------- | ----------------------------------------------------------------------- |
| `list_repos` | Discover all indexed repositories (paginated — `limit`/`offset`) |
| `query` | Process-grouped hybrid search (BM25 + semantic + RRF) |
| `context` | 360-degree symbol view — categorized refs, process participation |
| `impact` | Blast radius analysis with depth grouping and confidence |
| `trace` | Shortest directed path between two symbols (call + class-member edges) |
| `detect_changes` | Git-diff impact — maps changed lines to affected processes |
| `check` | Read-only structural checks against the indexed graph |
| `rename` | Multi-file coordinated rename with graph + text search |
| `cypher` | Raw Cypher graph queries |
| `route_map` | API route map — which components fetch which endpoints, and handlers |
| `tool_map` | MCP/RPC tool definitions — where they're defined and handled |
| `shape_check` | Validate API response shapes against consumers' property accesses |
| `api_impact` | Pre-change impact report for an API route handler |
| `explain` | Explain persisted taint findings (source→sink flows, `--pdg` indexes) |
| `pdg_query` | Query control/data dependence at statement level (`--pdg` indexes) |
| `group_list` | List configured repository groups |
| `group_sync` | Rebuild a group's Contract Registry and cross-repo links |
| ---------------- | ---------------------------------------------------------------------- |
| `list_repos` | Discover all indexed repositories (paginated — `limit`/`offset`) |
| `query` | Process-grouped hybrid search (BM25 + semantic + RRF) |
| `context` | 360-degree symbol view — categorized refs, process participation |
| `impact` | Blast radius analysis with depth grouping and confidence |
| `trace` | Shortest directed path between two symbols (call + class-member edges) |
| `detect_changes` | Git-diff impact — maps changed lines to affected processes |
| `check` | Read-only structural checks against the indexed graph |
| `rename` | Multi-file coordinated rename with graph + text search |
| `cypher` | Raw Cypher graph queries |
| `route_map` | API route map — which components fetch which endpoints, and handlers |
| `tool_map` | MCP/RPC tool definitions — where they're defined and handled |
| `shape_check` | Validate API response shapes against consumers' property accesses |
| `api_impact` | Pre-change impact report for an API route handler |
| `explain` | Explain persisted taint findings (source→sink flows, `--pdg` indexes) |
| `pdg_query` | Query control/data dependence at statement level (`--pdg` indexes) |
| `group_list` | List configured repository groups |
| `group_sync` | Rebuild a group's Contract Registry and cross-repo links |
> 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`.
@ -383,13 +415,13 @@ GitNexus uses optional DuckDB extensions for BM25 and vector search. The `gitnex
Configure the behavior with these environment variables:
| Variable | Values | Default | Effect |
| -------------------------------------------- | ---------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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. |
| `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS` | positive integer | `15000` | Wall-clock budget for the out-of-process `INSTALL` child before it is killed. |
| `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. |
| Variable | Values | Default | Effect |
| -------------------------------------------- | ---------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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. |
| `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS` | positive integer | `15000` | Wall-clock budget for the out-of-process `INSTALL` child before it is killed. |
| `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. |
| `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. |
| `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. |
| `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. |
```bash
# 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
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.
| Variable | Default | Effect |
| ------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------- |
| `GITNEXUS_KEEP_LOCAL_VALUE_SYMBOLS` | unset | Set to `1`/`true` to keep inert block-local value symbols instead of pruning them. |
| Variable | Default | Effect |
| ----------------------------------- | ------- | ---------------------------------------------------------------------------------- |
| `GITNEXUS_KEEP_LOCAL_VALUE_SYMBOLS` | unset | Set to `1`/`true` to keep inert block-local value symbols instead of pruning them. |
Programmatic callers can pass `keepLocalValueSymbols: true` in `PipelineOptions` instead of setting the env var.

View file

@ -30,6 +30,7 @@ const PLATFORM_LOGIC = [
'test/unit/setup-jsonc.test.ts',
'test/unit/setup-codex.test.ts',
'test/unit/setup-antigravity.test.ts',
'test/integration/setup-uninstall-roundtrip.test.ts',
'test/unit/resolve-invocation.test.ts',
'test/unit/platform-capabilities.test.ts',
'test/unit/worker-pool-windows-quarantine.test.ts',

View file

@ -2,7 +2,7 @@
* AI Context Generator
*
* Creates AGENTS.md and CLAUDE.md with full inline GitNexus context.
* AGENTS.md is the standard read by Cursor, Windsurf, OpenCode, Codex, Cline, etc.
* AGENTS.md is the standard read by Cursor, Windsurf, OpenCode, Codex, Cline, CodeBuddy, Qoder, etc.
* CLAUDE.md is for Claude Code which only reads that file.
*/

View file

@ -19,7 +19,14 @@
import os from 'os';
import path from 'path';
export type EditorId = 'cursor' | 'claude' | 'antigravity' | 'opencode' | 'codex';
export type EditorId =
| 'cursor'
| 'claude'
| 'antigravity'
| 'opencode'
| 'codebuddy'
| 'qoder'
| 'codex';
/** An editor whose MCP config is a JSONC document (server keyed by name). */
export interface McpJsoncTarget {
@ -34,6 +41,14 @@ export interface McpJsoncTarget {
* without either side needing a cast.
*/
keyPath: string[];
/**
* Older config locations the editor still reads when `file` is absent
* (CodeBuddy reads only the FIRST existing file in its priority chain).
* Setup writes into the first existing candidate of [file, ...legacyFiles]
* so it never shadows a user's servers living in a deprecated file;
* uninstall sweeps all of them.
*/
legacyFiles?: string[];
}
/** Codex stores MCP config as a TOML table, not JSONC. */
@ -67,7 +82,7 @@ export interface HookTarget {
}
export interface EditorTargets {
/** JSONC-format MCP entries: Cursor, Claude Code, Antigravity, OpenCode. */
/** JSONC-format MCP entries: Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder. */
mcpJsonc: McpJsoncTarget[];
/** Codex MCP (TOML). */
codex: CodexMcpTarget;
@ -110,6 +125,26 @@ export function getEditorTargets(home: string = os.homedir()): EditorTargets {
// OpenCode nests servers under `mcp`, not `mcpServers`.
keyPath: ['mcp', 'gitnexus'],
},
{
id: 'codebuddy',
label: 'CodeBuddy',
// Recommended user-scope path per https://www.codebuddy.ai/docs/cli/mcp;
// CodeBuddy reads only the first existing file in this priority chain.
file: path.join(home, '.codebuddy', '.mcp.json'),
legacyFiles: [
path.join(home, '.codebuddy', 'mcp.json'), // deprecated
path.join(home, '.codebuddy.json'), // legacy
],
keyPath: ['mcpServers', 'gitnexus'],
},
{
id: 'qoder',
label: 'Qoder',
// Qoder's documented user-scope MCP config (https://docs.qoder.com/cli/using-cli);
// the IDE manages MCP via its Settings UI with no documented file path.
file: path.join(home, '.qoder.json'),
keyPath: ['mcpServers', 'gitnexus'],
},
];
const codex: CodexMcpTarget = {
@ -128,6 +163,10 @@ export function getEditorTargets(home: string = os.homedir()): EditorTargets {
},
{ id: 'cursor', label: 'Cursor', dir: path.join(home, '.cursor', 'skills') },
{ id: 'opencode', label: 'OpenCode', dir: path.join(home, '.config', 'opencode', 'skills') },
{ id: 'codebuddy', label: 'CodeBuddy', dir: path.join(home, '.codebuddy', 'skills') },
// Qoder skills live at ~/.qoder/skills/{name}/SKILL.md
// (https://docs.qoder.com/extensions/skills).
{ id: 'qoder', label: 'Qoder', dir: path.join(home, '.qoder', 'skills') },
// Codex reads skills from ~/.agents/skills (not ~/.codex).
{ id: 'codex', label: 'Codex', dir: path.join(home, '.agents', 'skills') },
];
@ -175,6 +214,16 @@ export function hookTarget(id: EditorId, home?: string): HookTarget {
return t;
}
/**
* True when err is a Node fs error with code ENOENT (file/dir absent).
* Shared by setup and uninstall: both must swallow ONLY absence when reading
* editor configs any other read/stat failure (EACCES, EIO) is surfaced so an
* unreadable config is never treated as empty and rewritten gitnexus-only.
*/
export function isEnoent(err: unknown): boolean {
return (err as NodeJS.ErrnoException)?.code === 'ENOENT';
}
/**
* Detect indentation style from file content so JSONC edits preserve the file's
* existing formatting. Shared by setup (writes) and uninstall (removes).

View file

@ -117,7 +117,7 @@ export const en = {
'help.option.help': 'display help for command',
'help.option.version': 'output the version number',
'help.command.setup.description':
'One-time setup: configure MCP for Cursor, Claude Code, OpenCode, Codex',
'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex',
'help.command.uninstall.description':
'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors',
'help.command.analyze.description': 'Index a repository (full analysis)',

View file

@ -119,7 +119,8 @@ export const zhCN = {
'help.command.help.description': '显示命令帮助',
'help.option.help': '显示命令帮助',
'help.option.version': '输出版本号',
'help.command.setup.description': '一次性设置:为 Cursor、Claude Code、OpenCode、Codex 配置 MCP',
'help.command.setup.description':
'一次性设置:为 Cursor、Claude Code、Antigravity、OpenCode、CodeBuddy、Qoder、Codex 配置 MCP',
'help.command.uninstall.description':
'撤销 `setup`:从所有检测到的编辑器中移除 GitNexus 的 MCP 配置、技能和钩子',
'help.command.analyze.description': '索引仓库(完整分析)',

View file

@ -24,7 +24,7 @@ program.name('gitnexus').description('GitNexus local CLI and MCP server').versio
program
.command('setup')
.description(
'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, Codex',
'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex',
)
.option(
'-c, --coding-agent <agents>',

View file

@ -21,6 +21,7 @@ import {
skillTarget,
hookTarget,
detectIndentation,
isEnoent,
type EditorId,
} from './editor-targets.js';
@ -91,6 +92,8 @@ const CODING_AGENT_IDS = {
claude: 'claude',
antigravity: 'antigravity',
opencode: 'opencode',
codebuddy: 'codebuddy',
qoder: 'qoder',
codex: 'codex',
} as const satisfies Record<EditorId, EditorId>;
const SUPPORTED_CODING_AGENTS = Object.values(CODING_AGENT_IDS);
@ -213,7 +216,12 @@ async function mergeJsoncFile(
let raw: string;
try {
raw = await fs.readFile(filePath, 'utf-8');
} catch {
} catch (err) {
// Only an absent file means "start fresh". Any other read failure (EACCES,
// EIO, cloud-placeholder faults) must not be treated as empty — the write
// below would replace the user's existing config with a gitnexus-only
// document and report success. Rethrow into the per-editor catch instead.
if (!isEnoent(err)) throw err;
raw = '';
}
@ -252,6 +260,39 @@ async function dirExists(dirPath: string): Promise<boolean> {
}
}
/**
* Detection probe: is there a non-empty regular file at this path?
* Swallows ALL errors (like dirExists) detection gates run outside the
* per-editor try blocks, so a rethrowing probe would abort setup for every
* remaining editor. Size > 0 keeps detection aligned with the config-chain
* resolver: an empty config file is not evidence of an install, and treating
* it as one would route the write to a fresh file whose mkdir manufactures
* the editor's directory.
*/
async function isNonEmptyFile(filePath: string): Promise<boolean> {
try {
const stat = await fs.stat(filePath);
return stat.isFile() && stat.size > 0;
} catch {
return false;
}
}
/**
* Detection probe: does any file in the target's MCP config chain look like an
* install trace? Always walks [file, ...legacyFiles] so an editor gaining
* legacyFiles later is automatically covered (CodeBuddy and Qoder share this
* a per-editor copy is how the root-config-only detection gap crept in, see
* PR #2368 review I4).
*/
async function anyChainConfigFile(target: {
file: string;
legacyFiles?: string[];
}): Promise<boolean> {
const hits = await Promise.all([target.file, ...(target.legacyFiles ?? [])].map(isNonEmptyFile));
return hits.includes(true);
}
// ─── Editor-specific setup ─────────────────────────────────────────
async function setupCursor(result: SetupResult): Promise<void> {
@ -346,7 +387,11 @@ async function mergeHooksJsonc(
let raw: string;
try {
raw = await fs.readFile(filePath, 'utf-8');
} catch {
} catch (err) {
// Same contract as mergeJsoncFile: an unreadable (non-ENOENT) settings
// file must not be rewritten as hooks-only — that would destroy every
// user setting in it. Rethrow into the hook installer's catch.
if (!isEnoent(err)) throw err;
raw = '';
}
@ -786,6 +831,123 @@ async function setupOpenCode(result: SetupResult): Promise<void> {
}
}
/**
* Resolve which config file in a target's [file, ...legacyFiles] priority
* chain setup should write into: the first that exists, else the recommended
* `file`. CodeBuddy reads only the first existing file in its chain, so
* creating the recommended file above a populated deprecated one would shadow
* the user's existing MCP servers.
*/
async function resolveMcpConfigFile(target: {
file: string;
legacyFiles?: string[];
}): Promise<string> {
for (const candidate of [target.file, ...(target.legacyFiles ?? [])]) {
try {
const stat = await fs.stat(candidate);
// Non-empty regular files only: a 0-byte recommended file must not
// shadow a populated deprecated one (mergeJsoncFile treats empty as a
// fresh document anyway), and directories are never config candidates.
if (stat.isFile() && stat.size > 0) return candidate;
} catch (err) {
// ENOENT = candidate absent — try the next one. Anything else (EACCES
// on the file or a parent) is surfaced: silently skipping could route
// the write to a lower-priority file the editor never reads.
if (!isEnoent(err)) throw err;
}
}
return target.file;
}
async function setupCodeBuddy(result: SetupResult): Promise<void> {
const codebuddyDir = path.join(os.homedir(), '.codebuddy');
const target = mcpTarget('codebuddy');
// Installed = the config dir exists OR any registered MCP config file does.
// A user whose only trace is a root-level config (e.g. a legacy
// ~/.codebuddy.json) still gets configured — uninstall already handles that
// shape, so setup skipping it was an asymmetry (PR #2368 review I4).
if (!(await dirExists(codebuddyDir)) && !(await anyChainConfigFile(target))) {
result.skipped.push('CodeBuddy (not installed)');
return;
}
try {
const configFile = await resolveMcpConfigFile(target);
const ok = await mergeJsoncFile(configFile, target.keyPath, getMcpEntry());
if (ok) {
result.configured.push('CodeBuddy');
} else {
result.errors.push(
`CodeBuddy: ${path.basename(configFile)} is corrupt — skipping to preserve existing content`,
);
}
} catch (err) {
result.errors.push(`CodeBuddy: ${err instanceof Error ? err.message : String(err)}`);
}
}
async function setupQoder(result: SetupResult): Promise<void> {
const qoderDir = path.join(os.homedir(), '.qoder');
const target = mcpTarget('qoder');
const { file: mcpPath, keyPath } = target;
// Same chain-aware detection as CodeBuddy: ~/.qoder.json alone counts.
if (!(await dirExists(qoderDir)) && !(await anyChainConfigFile(target))) {
result.skipped.push('Qoder (not installed)');
return;
}
try {
const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry());
if (ok) {
result.configured.push('Qoder');
} else {
result.errors.push('Qoder: .qoder.json is corrupt — skipping to preserve existing content');
}
} catch (err) {
result.errors.push(`Qoder: ${err instanceof Error ? err.message : String(err)}`);
}
}
/**
* Install global CodeBuddy skills to ~/.codebuddy/skills/
* (https://www.codebuddy.ai/docs/cli/skills — same SKILL.md layout as Claude Code).
*/
async function installCodeBuddySkills(result: SetupResult): Promise<void> {
const codebuddyDir = path.join(os.homedir(), '.codebuddy');
if (!(await dirExists(codebuddyDir))) return;
const skillsDir = skillTarget('codebuddy').dir;
try {
const installed = await installSkillsTo(skillsDir);
if (installed.length > 0) {
result.configured.push(
`CodeBuddy skills (${installed.length} skills → ~/.codebuddy/skills/)`,
);
}
} catch (err) {
result.errors.push(`CodeBuddy skills: ${err instanceof Error ? err.message : String(err)}`);
}
}
/**
* Install global Qoder skills to ~/.qoder/skills/
* (https://docs.qoder.com/extensions/skills — same SKILL.md layout as Claude Code).
*/
async function installQoderSkills(result: SetupResult): Promise<void> {
const qoderDir = path.join(os.homedir(), '.qoder');
if (!(await dirExists(qoderDir))) return;
const skillsDir = skillTarget('qoder').dir;
try {
const installed = await installSkillsTo(skillsDir);
if (installed.length > 0) {
result.configured.push(`Qoder skills (${installed.length} skills → ~/.qoder/skills/)`);
}
} catch (err) {
result.errors.push(`Qoder skills: ${err instanceof Error ? err.message : String(err)}`);
}
}
/**
* Build a TOML section for Codex MCP config (~/.codex/config.toml).
*/
@ -803,7 +965,11 @@ async function upsertCodexConfigToml(configPath: string): Promise<void> {
let existing = '';
try {
existing = await fs.readFile(configPath, 'utf-8');
} catch {
} catch (err) {
// TOML variant of the mergeJsoncFile contract: treating a non-ENOENT read
// failure as an empty config would rewrite config.toml with only the
// gitnexus section. Rethrow into setupCodex's catch.
if (!isEnoent(err)) throw err;
existing = '';
}
@ -1025,6 +1191,8 @@ export const setupCommand = async (options?: { codingAgent?: string[] | string }
if (selected.has('claude')) await setupClaudeCode(result);
if (selected.has('antigravity')) await setupAntigravity(result);
if (selected.has('opencode')) await setupOpenCode(result);
if (selected.has('codebuddy')) await setupCodeBuddy(result);
if (selected.has('qoder')) await setupQoder(result);
if (selected.has('codex')) await setupCodex(result);
// Install global skills for platforms that support them
@ -1038,6 +1206,8 @@ export const setupCommand = async (options?: { codingAgent?: string[] | string }
}
if (selected.has('cursor')) await installCursorSkills(result);
if (selected.has('opencode')) await installOpenCodeSkills(result);
if (selected.has('codebuddy')) await installCodeBuddySkills(result);
if (selected.has('qoder')) await installQoderSkills(result);
if (selected.has('codex')) await installCodexSkills(result);
// Print results

View file

@ -42,7 +42,7 @@ import {
type ParseError,
type JSONPath,
} from 'jsonc-parser';
import { getEditorTargets, detectIndentation } from './editor-targets.js';
import { getEditorTargets, detectIndentation, isEnoent } from './editor-targets.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@ -52,6 +52,13 @@ interface UninstallResult {
removed: string[];
skipped: string[];
errors: string[];
/**
* A corrupt LEGACY chain file was seen. It is reported informationally
* (skipped, no exit code), but it makes "not configured" unknowable, so the
* final report must not claim it. Kept as a first-class flag the report
* must never re-derive this by sniffing skipped-entry message text.
*/
corruptLegacy: boolean;
}
type RemovalStatus = 'removed' | 'absent' | 'corrupt' | 'missing';
@ -72,7 +79,12 @@ async function removeJsoncKey(
let raw: string;
try {
raw = await fs.readFile(filePath, 'utf-8');
} catch {
} catch (err) {
// ENOENT = genuinely not configured. Any other read failure (EACCES,
// locks) must not report 'missing' — the file may hold a real gitnexus
// entry the dry-run would then deny exists. Rethrow into the caller's
// per-file catch.
if (!isEnoent(err)) throw err;
return 'missing';
}
@ -116,7 +128,11 @@ async function removeHookEntries(
let raw: string;
try {
raw = await fs.readFile(filePath, 'utf-8');
} catch {
} catch (err) {
// Masking a non-ENOENT read failure as 'missing' would also let the
// caller delete the hook scriptDir while the unreadable settings file
// still references it. Rethrow into the hook uninstaller's catch.
if (!isEnoent(err)) throw err;
return { status: 'missing', count: 0 };
}
@ -365,8 +381,14 @@ async function uninstallCodex(
let raw: string;
try {
raw = await fs.readFile(configPath, 'utf-8');
} catch {
result.skipped.push('Codex MCP (not configured)');
} catch (err) {
// Catch locally: this call site has no surrounding try, so a rethrow
// would abort the hooks/skills cleanup that runs after Codex.
if (isEnoent(err)) {
result.skipped.push('Codex MCP (not configured)');
} else {
result.errors.push(`Codex: ${err instanceof Error ? err.message : String(err)}`);
}
return;
}
@ -417,23 +439,54 @@ export const uninstallCommand = async (options?: { force?: boolean }) => {
console.log('');
}
const result: UninstallResult = { removed: [], skipped: [], errors: [] };
const result: UninstallResult = { removed: [], skipped: [], errors: [], corruptLegacy: false };
// ─── MCP server entries (JSONC editors) ──────────────────────────
// Sweep legacyFiles too: setup writes into the first existing file of the
// editor's priority chain, so the gitnexus entry may live in a deprecated
// location (e.g. CodeBuddy's ~/.codebuddy/mcp.json).
for (const target of targets.mcpJsonc) {
try {
const status = await removeJsoncKey(target.file, target.keyPath, dryRun);
if (status === 'removed')
result.removed.push(
`${target.label} MCP server — ${target.keyPath.join('.')} in ${target.file}`,
);
else if (status === 'corrupt')
result.errors.push(
`${target.label}: ${path.basename(target.file)} is corrupt — left untouched`,
);
else result.skipped.push(`${target.label} MCP (not configured)`);
} catch (err: any) {
result.errors.push(`${target.label}: ${err.message}`);
let removedAny = false;
let erroredAny = false;
let corruptLegacyAny = false;
for (const file of [target.file, ...(target.legacyFiles ?? [])]) {
try {
const status = await removeJsoncKey(file, target.keyPath, dryRun);
if (status === 'removed') {
removedAny = true;
result.removed.push(
`${target.label} MCP server — ${target.keyPath.join('.')} in ${file}`,
);
} else if (status === 'corrupt') {
if (file === target.file) {
// The primary path is where gitnexus itself writes — corruption
// there is an error worth failing the command over.
erroredAny = true;
result.errors.push(
`${target.label}: ${path.basename(file)} is corrupt — left untouched`,
);
} else {
// Legacy chain files are vendor locations gitnexus may never have
// touched (e.g. a corrupt ~/.codebuddy.json from an old install).
// Report informationally without failing uninstall. Deliberate
// asymmetry: an UNREADABLE (non-ENOENT) legacy file still errors —
// that's an environmental problem worth surfacing, while corrupt-
// but-readable proves there is no removable gitnexus entry.
corruptLegacyAny = true;
result.corruptLegacy = true;
result.skipped.push(
`${target.label} MCP (legacy ${path.basename(file)} is corrupt — left untouched)`,
);
}
}
} catch (err) {
erroredAny = true;
result.errors.push(`${target.label}: ${err instanceof Error ? err.message : String(err)}`);
}
}
// A corrupt legacy file makes "not configured" unknowable — suppress it.
if (!removedAny && !erroredAny && !corruptLegacyAny) {
result.skipped.push(`${target.label} MCP (not configured)`);
}
}
@ -482,6 +535,11 @@ export const uninstallCommand = async (options?: { force?: boolean }) => {
if (result.removed.length > 0) {
console.log(` ${verb}:`);
for (const name of result.removed) console.log(` - ${name}`);
} else if (result.errors.length > 0 || result.corruptLegacy) {
// Errors (corrupt primary files, unreadable configs) or corrupt legacy
// configs make "not configured" unknowable — claiming it right above an
// Errors block would be a contradiction users learn to distrust.
console.log(' Nothing removed.');
} else {
console.log(' Nothing to remove — GitNexus is not configured in any detected editor.');
}

View file

@ -82,7 +82,7 @@ describe('setup → uninstall round-trip', () => {
process.env.USERPROFILE = tempHome;
// Mark every editor as "installed" so setup configures all of them.
for (const dir of ['.cursor', '.claude', '.codex']) {
for (const dir of ['.cursor', '.claude', '.codex', '.codebuddy', '.qoder']) {
await fs.mkdir(path.join(tempHome, dir), { recursive: true });
}
await fs.mkdir(path.join(tempHome, '.gemini', 'antigravity'), { recursive: true });
@ -178,6 +178,60 @@ describe('setup → uninstall round-trip', () => {
}
});
it('round-trips a CodeBuddy entry living in the home-level legacy ~/.codebuddy.json (chain position 3)', async () => {
const targets = getEditorTargets(tempHome);
const codebuddy = targets.mcpJsonc.find((t) => t.id === 'codebuddy')!;
const legacyHomeFile = codebuddy.legacyFiles![1];
await fs.writeFile(
legacyHomeFile,
JSON.stringify({ mcpServers: { mine: { command: 'mine' } } }),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const cfg = await readJsonc(legacyHomeFile);
expect(valueAtPath(cfg, codebuddy.keyPath)).toBeDefined();
expect(await exists(codebuddy.file)).toBe(false);
const { uninstallCommand } = await import('../../src/cli/uninstall.js');
await uninstallCommand({ force: true });
const after = await readJsonc(legacyHomeFile);
expect(valueAtPath(after, codebuddy.keyPath)).toBeUndefined();
expect(after.mcpServers.mine).toEqual({ command: 'mine' });
});
it('round-trips a CodeBuddy entry living in the deprecated mcp.json (legacyFiles sweep)', async () => {
const targets = getEditorTargets(tempHome);
const codebuddy = targets.mcpJsonc.find((t) => t.id === 'codebuddy')!;
const deprecatedFile = codebuddy.legacyFiles![0];
// A populated deprecated config makes setup write there (CodeBuddy reads
// only the first existing file in its chain), not the recommended path.
await fs.writeFile(
deprecatedFile,
JSON.stringify({ mcpServers: { mine: { command: 'mine' } } }),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const cfg = await readJsonc(deprecatedFile);
expect(valueAtPath(cfg, codebuddy.keyPath)).toBeDefined();
expect(await exists(codebuddy.file)).toBe(false);
const { uninstallCommand } = await import('../../src/cli/uninstall.js');
await uninstallCommand({ force: true });
const after = await readJsonc(deprecatedFile);
expect(valueAtPath(after, codebuddy.keyPath)).toBeUndefined();
expect(after.mcpServers.mine).toEqual({ command: 'mine' });
});
it('uninstall preserves a co-located user MCP server and hook', async () => {
const targets = getEditorTargets(tempHome);
const { setupCommand } = await import('../../src/cli/setup.js');

View file

@ -130,7 +130,12 @@ describe('CLI help surface', () => {
expect(result.stdout).toContain('-h, --help 显示命令帮助');
expect(result.stdout).toContain('命令:');
expect(result.stdout).toContain('setup');
expect(result.stdout).toContain('一次性设置:为 Cursor、Claude Code、OpenCode、Codex 配置 MCP');
// Stable fragments rather than the full editor roster: the roster grows
// over time (see PR #2368), and the dynamic test below ("localizes every
// registered CLI command...") already fails on any untranslated
// description, so freezing the roster here only creates churn.
expect(result.stdout).toContain('一次性设置');
expect(result.stdout).toContain('配置 MCP');
expect(result.stdout).toContain('detect-changes|detect_changes [options]');
expect(result.stdout).toContain('将 git diff hunk 映射到已索引符号和受影响执行流程');
expect(result.stdout).not.toContain('GitNexus local CLI and MCP server');

View file

@ -49,6 +49,24 @@ describe('setupCommand coding-agent selection', () => {
await fs.rm(tempHome, { recursive: true, force: true });
});
it('explicit -c codebuddy succeeds when only a legacy root config exists (no dot-dir)', async () => {
const legacy = path.join(tempHome, '.codebuddy.json');
await fs.writeFile(
legacy,
JSON.stringify({ mcpServers: { other: { command: 'foo' } } }),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand({ codingAgent: ['codebuddy'] });
const config = JSON.parse(await fs.readFile(legacy, 'utf-8'));
expect(config.mcpServers.gitnexus).toBeDefined();
expect(config.mcpServers.other).toEqual({ command: 'foo' });
// Explicit selection that configures something must not exit 1.
expect(process.exitCode).not.toBe(1);
});
it('configures only the requested coding agent', async () => {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand({ codingAgent: ['opencode'] });
@ -82,7 +100,9 @@ describe('setupCommand coding-agent selection', () => {
expect(process.exitCode).toBe(1);
expect(stderr).toHaveBeenCalledWith(
expect.stringContaining('Valid values: cursor, claude, antigravity, opencode, codex'),
expect.stringContaining(
'Valid values: cursor, claude, antigravity, opencode, codebuddy, qoder, codex',
),
);
await expect(
fs.access(path.join(tempHome, '.config', 'opencode', 'opencode.json')),

View file

@ -418,6 +418,430 @@ describe('setupClaudeCode', () => {
});
});
describe('setupCodeBuddy', () => {
let tempHome: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
const recommendedPath = () => path.join(tempHome, '.codebuddy', '.mcp.json');
const deprecatedPath = () => path.join(tempHome, '.codebuddy', 'mcp.json');
const legacyPath = () => path.join(tempHome, '.codebuddy.json');
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-codebuddy-setup-'));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
// Only create ~/.codebuddy — no other editor directories so their
// setup functions skip and don't pollute assertions.
await fs.mkdir(path.join(tempHome, '.codebuddy'), { recursive: true });
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
process.env.HOME = originalHome;
process.env.USERPROFILE = originalUserProfile;
await fs.rm(tempHome, { recursive: true, force: true });
});
it('creates the recommended ~/.codebuddy/.mcp.json when no config exists', async () => {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const config = JSON.parse(await fs.readFile(recommendedPath(), 'utf-8'));
// Entry shape (binary vs npx vs cmd-wrapper) is covered by the Claude
// suite; here we only care that it landed in the recommended file.
expect(config.mcpServers.gitnexus).toBeDefined();
await expect(fs.access(deprecatedPath())).rejects.toThrow();
});
it('writes into an existing deprecated ~/.codebuddy/mcp.json instead of shadowing it', async () => {
// CodeBuddy reads only the FIRST existing file in its priority chain
// (.mcp.json > mcp.json > ~/.codebuddy.json). Creating .mcp.json above a
// populated mcp.json would make the user's other servers disappear.
await fs.writeFile(
deprecatedPath(),
JSON.stringify({ mcpServers: { other: { command: 'foo' } } }),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const config = JSON.parse(await fs.readFile(deprecatedPath(), 'utf-8'));
expect(config.mcpServers.other).toEqual({ command: 'foo' });
expect(config.mcpServers.gitnexus).toBeDefined();
await expect(fs.access(recommendedPath())).rejects.toThrow();
});
it('writes into a legacy ~/.codebuddy.json when it is the only config file (dir present)', async () => {
await fs.writeFile(
legacyPath(),
JSON.stringify({ mcpServers: { other: { command: 'foo' } } }),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const config = JSON.parse(await fs.readFile(legacyPath(), 'utf-8'));
expect(config.mcpServers.other).toEqual({ command: 'foo' });
expect(config.mcpServers.gitnexus).toBeDefined();
await expect(fs.access(recommendedPath())).rejects.toThrow();
});
it('prefers the recommended file over deprecated ones when both exist', async () => {
await fs.writeFile(recommendedPath(), JSON.stringify({ mcpServers: {} }), 'utf-8');
await fs.writeFile(
deprecatedPath(),
JSON.stringify({ mcpServers: { other: { command: 'foo' } } }),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const recommended = JSON.parse(await fs.readFile(recommendedPath(), 'utf-8'));
expect(recommended.mcpServers.gitnexus).toBeDefined();
const deprecated = JSON.parse(await fs.readFile(deprecatedPath(), 'utf-8'));
expect(deprecated.mcpServers.gitnexus).toBeUndefined();
});
it('configures via a legacy ~/.codebuddy.json even when ~/.codebuddy/ is absent', async () => {
await fs.rm(path.join(tempHome, '.codebuddy'), { recursive: true, force: true });
await fs.writeFile(
legacyPath(),
JSON.stringify({ mcpServers: { other: { command: 'foo' } } }),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const config = JSON.parse(await fs.readFile(legacyPath(), 'utf-8'));
expect(config.mcpServers.other).toEqual({ command: 'foo' });
expect(config.mcpServers.gitnexus).toBeDefined();
// MCP-only shape: neither the recommended file nor the directory (and thus
// no skills tree) may be manufactured.
await expect(fs.access(path.join(tempHome, '.codebuddy'))).rejects.toThrow();
});
it('stays "not installed" when the only trace is a 0-byte legacy file (no dir manufactured)', async () => {
await fs.rm(path.join(tempHome, '.codebuddy'), { recursive: true, force: true });
await fs.writeFile(legacyPath(), '', 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
expect(await fs.readFile(legacyPath(), 'utf-8')).toBe('');
await expect(fs.access(path.join(tempHome, '.codebuddy'))).rejects.toThrow();
});
it('skips a 0-byte recommended file so it cannot shadow a populated deprecated one', async () => {
await fs.writeFile(recommendedPath(), '', 'utf-8');
await fs.writeFile(
deprecatedPath(),
JSON.stringify({ mcpServers: { other: { command: 'foo' } } }),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const deprecated = JSON.parse(await fs.readFile(deprecatedPath(), 'utf-8'));
expect(deprecated.mcpServers.other).toEqual({ command: 'foo' });
expect(deprecated.mcpServers.gitnexus).toBeDefined();
// The empty recommended file is left exactly as it was.
expect(await fs.readFile(recommendedPath(), 'utf-8')).toBe('');
});
it('skips a directory-shaped candidate and writes the next chain file', async () => {
await fs.mkdir(deprecatedPath(), { recursive: true });
await fs.writeFile(
legacyPath(),
JSON.stringify({ mcpServers: { other: { command: 'foo' } } }),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const legacy = JSON.parse(await fs.readFile(legacyPath(), 'utf-8'));
expect(legacy.mcpServers.other).toEqual({ command: 'foo' });
expect(legacy.mcpServers.gitnexus).toBeDefined();
// The directory is untouched and the recommended file was not created
// above the chain (only chain-resolution decided the destination).
expect((await fs.stat(deprecatedPath())).isDirectory()).toBe(true);
await expect(fs.access(recommendedPath())).rejects.toThrow();
});
it('reports a corrupt deprecated file without creating the recommended file above it', async () => {
const corrupt = '{ this is not valid json !!!';
await fs.writeFile(deprecatedPath(), corrupt, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
expect(await fs.readFile(deprecatedPath(), 'utf-8')).toBe(corrupt);
// Creating .mcp.json above the corrupt file would shadow it once fixed.
await expect(fs.access(recommendedPath())).rejects.toThrow();
});
it('skips when ~/.codebuddy directory does not exist', async () => {
await fs.rm(path.join(tempHome, '.codebuddy'), { recursive: true, force: true });
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
await expect(fs.access(recommendedPath())).rejects.toThrow();
await expect(fs.access(legacyPath())).rejects.toThrow();
});
it('leaves a corrupt config untouched', async () => {
const corrupt = '{ this is not valid json !!!';
await fs.writeFile(recommendedPath(), corrupt, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
expect(await fs.readFile(recommendedPath(), 'utf-8')).toBe(corrupt);
});
});
describe('setupQoder', () => {
let tempHome: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
const configPath = () => path.join(tempHome, '.qoder.json');
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-qoder-setup-'));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
// Only create ~/.qoder — no other editor directories so their
// setup functions skip and don't pollute assertions.
await fs.mkdir(path.join(tempHome, '.qoder'), { recursive: true });
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
process.env.HOME = originalHome;
process.env.USERPROFILE = originalUserProfile;
await fs.rm(tempHome, { recursive: true, force: true });
});
it('writes the MCP entry to ~/.qoder.json', async () => {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const config = JSON.parse(await fs.readFile(configPath(), 'utf-8'));
// Entry shape is covered by the Claude suite; assert placement only.
expect(config.mcpServers.gitnexus).toBeDefined();
});
it('preserves existing keys in ~/.qoder.json', async () => {
await fs.writeFile(
configPath(),
JSON.stringify({ existingKey: 'keep-me', mcpServers: { other: { command: 'foo' } } }),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const config = JSON.parse(await fs.readFile(configPath(), 'utf-8'));
expect(config.existingKey).toBe('keep-me');
expect(config.mcpServers.other).toEqual({ command: 'foo' });
expect(config.mcpServers.gitnexus).toBeDefined();
});
it('configures via ~/.qoder.json even when ~/.qoder/ is absent', async () => {
await fs.rm(path.join(tempHome, '.qoder'), { recursive: true, force: true });
await fs.writeFile(
configPath(),
JSON.stringify({ mcpServers: { other: { command: 'foo' } } }),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const config = JSON.parse(await fs.readFile(configPath(), 'utf-8'));
expect(config.mcpServers.other).toEqual({ command: 'foo' });
expect(config.mcpServers.gitnexus).toBeDefined();
await expect(fs.access(path.join(tempHome, '.qoder'))).rejects.toThrow();
});
it('skips when ~/.qoder directory does not exist', async () => {
await fs.rm(path.join(tempHome, '.qoder'), { recursive: true, force: true });
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
await expect(fs.access(configPath())).rejects.toThrow();
});
it('leaves a corrupt ~/.qoder.json untouched', async () => {
const corrupt = '{ this is not valid json !!!';
await fs.writeFile(configPath(), corrupt, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
expect(await fs.readFile(configPath(), 'utf-8')).toBe(corrupt);
});
});
describe('setup — non-ENOENT read/stat failures are surfaced, not masked', () => {
let tempHome: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
const errnoError = (code: string) =>
Object.assign(new Error(`${code}: simulated failure`), { code });
const logLines = () =>
vi
.mocked(console.log)
.mock.calls.map((call) => call.join(' '))
.join('\n');
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-enoent-narrow-'));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
process.env.HOME = originalHome;
process.env.USERPROFILE = originalUserProfile;
await fs.rm(tempHome, { recursive: true, force: true });
});
it('does not clobber an unreadable MCP config and still configures other editors', async () => {
await fs.mkdir(path.join(tempHome, '.codebuddy'), { recursive: true });
await fs.mkdir(path.join(tempHome, '.cursor'), { recursive: true });
const codebuddyMcp = path.join(tempHome, '.codebuddy', '.mcp.json');
const raw = JSON.stringify({ mcpServers: { mine: { command: 'mine' } } });
await fs.writeFile(codebuddyMcp, raw, 'utf-8');
// Readable-by-stat but unreadable-by-read (the reproduced clobber shape).
const realReadFile = fs.readFile;
vi.spyOn(fs, 'readFile').mockImplementation(((file: any, ...rest: any[]) => {
if (String(file) === codebuddyMcp) return Promise.reject(errnoError('EACCES'));
return (realReadFile as any)(file, ...rest);
}) as typeof fs.readFile);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
vi.mocked(fs.readFile).mockRestore();
// The populated config survives byte-identical instead of becoming
// a gitnexus-only document reported as success.
expect(await fs.readFile(codebuddyMcp, 'utf-8')).toBe(raw);
expect(logLines()).toContain('CodeBuddy: EACCES');
const cursorCfg = JSON.parse(
await fs.readFile(path.join(tempHome, '.cursor', 'mcp.json'), 'utf-8'),
);
expect(cursorCfg.mcpServers.gitnexus).toBeDefined();
});
it('surfaces a chain-candidate stat failure instead of writing a lower-priority file', async () => {
await fs.mkdir(path.join(tempHome, '.codebuddy'), { recursive: true });
const legacy = path.join(tempHome, '.codebuddy.json');
const raw = JSON.stringify({ mcpServers: { mine: { command: 'mine' } } });
await fs.writeFile(legacy, raw, 'utf-8');
const recommended = path.join(tempHome, '.codebuddy', '.mcp.json');
const realStat = fs.stat;
vi.spyOn(fs, 'stat').mockImplementation(((file: any, ...rest: any[]) => {
if (String(file) === recommended) return Promise.reject(errnoError('EACCES'));
return (realStat as any)(file, ...rest);
}) as typeof fs.stat);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
vi.mocked(fs.stat).mockRestore();
expect(logLines()).toContain('CodeBuddy: EACCES');
// Neither silently routed to the legacy file nor created the recommended one.
expect(await fs.readFile(legacy, 'utf-8')).toBe(raw);
await expect(fs.access(recommended)).rejects.toThrow();
});
it('does not rewrite an unreadable settings.json as hooks-only (fail closed)', async () => {
await fs.mkdir(path.join(tempHome, '.claude'), { recursive: true });
const settingsPath = path.join(tempHome, '.claude', 'settings.json');
const raw = JSON.stringify({ mySetting: true, hooks: { PreToolUse: [] } });
await fs.writeFile(settingsPath, raw, 'utf-8');
const realReadFile = fs.readFile;
vi.spyOn(fs, 'readFile').mockImplementation(((file: any, ...rest: any[]) => {
if (String(file) === settingsPath) return Promise.reject(errnoError('EACCES'));
return (realReadFile as any)(file, ...rest);
}) as typeof fs.readFile);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
vi.mocked(fs.readFile).mockRestore();
// The user's settings survive; the hook installer reports instead of
// replacing the whole file with a hooks-only document.
expect(await fs.readFile(settingsPath, 'utf-8')).toBe(raw);
expect(logLines()).toContain('Claude Code hooks: EACCES');
});
it('reports a Codex error instead of rewriting an unreadable config.toml', async () => {
await fs.mkdir(path.join(tempHome, '.codex'), { recursive: true });
const configPath = path.join(tempHome, '.codex', 'config.toml');
const raw = '[mcp_servers.other]\ncommand = "other"\n';
await fs.writeFile(configPath, raw, 'utf-8');
// Force the TOML fallback (default execFile mock succeeds → CLI path).
execFileMock.mockImplementationOnce((...args: any[]) => {
const callback = args.at(-1);
if (typeof callback === 'function') callback(new Error('codex not found'), '', '');
});
const realReadFile = fs.readFile;
vi.spyOn(fs, 'readFile').mockImplementation(((file: any, ...rest: any[]) => {
if (String(file) === configPath) return Promise.reject(errnoError('EACCES'));
return (realReadFile as any)(file, ...rest);
}) as typeof fs.readFile);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
vi.mocked(fs.readFile).mockRestore();
expect(await fs.readFile(configPath, 'utf-8')).toBe(raw);
expect(logLines()).toContain('Codex: EACCES');
});
});
describe('formatHookCommand (hook command escaping, #1945)', () => {
let mod: typeof import('../../src/cli/setup.js');

View file

@ -558,4 +558,256 @@ describe('uninstallCommand', () => {
await expect(fs.access(path.join(opencodeSkills, 'gitnexus-dir-skill'))).rejects.toThrow();
await expect(fs.access(path.join(opencodeSkills, 'keep-me'))).resolves.toBeUndefined();
});
// ── corrupt legacy chain files are informational, not failures ──
it('reports a corrupt legacy ~/.codebuddy.json informationally and exits 0 (--force)', async () => {
const legacy = path.join(tempHome, '.codebuddy.json');
const corrupt = '{ not valid json !!!';
await fs.writeFile(legacy, corrupt, 'utf-8');
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
expect(await fs.readFile(legacy, 'utf-8')).toBe(corrupt);
expect(process.exitCode).not.toBe(1);
expect(logLines()).toContain(
'CodeBuddy MCP (legacy .codebuddy.json is corrupt — left untouched)',
);
// Configuration status is unknowable — neither claim may appear.
expect(logLines()).not.toContain('CodeBuddy MCP (not configured)');
expect(logLines()).not.toContain('not configured in any detected editor');
});
it('reports a corrupt legacy ~/.codebuddy.json informationally in dry-run too', async () => {
const legacy = path.join(tempHome, '.codebuddy.json');
const corrupt = '{ not valid json !!!';
await fs.writeFile(legacy, corrupt, 'utf-8');
const uninstallCommand = await importUninstall();
await uninstallCommand(); // dry-run
expect(await fs.readFile(legacy, 'utf-8')).toBe(corrupt);
expect(process.exitCode).not.toBe(1);
expect(logLines()).toContain(
'CodeBuddy MCP (legacy .codebuddy.json is corrupt — left untouched)',
);
});
it('still errors and exits 1 when the PRIMARY config file is corrupt', async () => {
const recommended = path.join(tempHome, '.codebuddy', '.mcp.json');
await fs.mkdir(path.dirname(recommended), { recursive: true });
const corrupt = '{ not valid json !!!';
await fs.writeFile(recommended, corrupt, 'utf-8');
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
expect(await fs.readFile(recommended, 'utf-8')).toBe(corrupt);
expect(logLines()).toContain('.mcp.json is corrupt — left untouched');
expect(process.exitCode).toBe(1);
});
it('does not claim "not configured" when the only finding is a corrupt PRIMARY file', async () => {
// Qoder has no legacyFiles — its only config is the primary ~/.qoder.json.
const qoderJson = path.join(tempHome, '.qoder.json');
const corrupt = '{ not valid json !!!';
await fs.writeFile(qoderJson, corrupt, 'utf-8');
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
expect(await fs.readFile(qoderJson, 'utf-8')).toBe(corrupt);
expect(process.exitCode).toBe(1);
expect(logLines()).toContain('.qoder.json is corrupt — left untouched');
// The error makes configuration status unknowable — the reassuring
// headline must not print right above the Errors block.
expect(logLines()).not.toContain('not configured in any detected editor');
expect(logLines()).toContain('Nothing removed.');
});
it('still errors and exits 1 when a legacy file is UNREADABLE (intentional asymmetry)', async () => {
const legacy = path.join(tempHome, '.codebuddy.json');
const raw = JSON.stringify({ mcpServers: { gitnexus: { command: 'gitnexus' } } });
await fs.writeFile(legacy, raw, 'utf-8');
const realReadFile = fs.readFile;
vi.spyOn(fs, 'readFile').mockImplementation(((file: any, ...rest: any[]) => {
if (String(file) === legacy) {
return Promise.reject(
Object.assign(new Error('EACCES: simulated failure'), { code: 'EACCES' }),
);
}
return (realReadFile as any)(file, ...rest);
}) as typeof fs.readFile);
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
vi.mocked(fs.readFile).mockRestore();
// Corrupt-but-readable proves no removable entry; unreadable proves
// nothing — an environmental problem worth failing over.
expect(await fs.readFile(legacy, 'utf-8')).toBe(raw);
expect(logLines()).toContain('CodeBuddy: EACCES');
expect(process.exitCode).toBe(1);
});
// ── multi-candidate sweep combinations ──
it('removes a gitnexus entry from BOTH chain files when present in both', async () => {
const recommended = path.join(tempHome, '.codebuddy', '.mcp.json');
const legacy = path.join(tempHome, '.codebuddy.json');
await fs.mkdir(path.dirname(recommended), { recursive: true });
await fs.writeFile(
recommended,
JSON.stringify({
mcpServers: { gitnexus: { command: 'gitnexus' }, keepA: { command: 'a' } },
}),
'utf-8',
);
await fs.writeFile(
legacy,
JSON.stringify({
mcpServers: { gitnexus: { command: 'gitnexus' }, keepB: { command: 'b' } },
}),
'utf-8',
);
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
const rec = JSON.parse(await fs.readFile(recommended, 'utf-8'));
const leg = JSON.parse(await fs.readFile(legacy, 'utf-8'));
expect(rec.mcpServers.gitnexus).toBeUndefined();
expect(rec.mcpServers.keepA).toEqual({ command: 'a' });
expect(leg.mcpServers.gitnexus).toBeUndefined();
expect(leg.mcpServers.keepB).toEqual({ command: 'b' });
// One removal line per file.
expect(logLines()).toContain(`in ${recommended}`);
expect(logLines()).toContain(`in ${legacy}`);
expect(process.exitCode).not.toBe(1);
});
it('does not abort the sweep on a corrupt legacy file: later chain entries are still removed', async () => {
const deprecated = path.join(tempHome, '.codebuddy', 'mcp.json');
const legacy = path.join(tempHome, '.codebuddy.json');
await fs.mkdir(path.dirname(deprecated), { recursive: true });
const corrupt = '{ not valid json !!!';
await fs.writeFile(deprecated, corrupt, 'utf-8');
await fs.writeFile(
legacy,
JSON.stringify({ mcpServers: { gitnexus: { command: 'gitnexus' }, mine: { command: 'm' } } }),
'utf-8',
);
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
expect(await fs.readFile(deprecated, 'utf-8')).toBe(corrupt);
const leg = JSON.parse(await fs.readFile(legacy, 'utf-8'));
expect(leg.mcpServers.gitnexus).toBeUndefined();
expect(leg.mcpServers.mine).toEqual({ command: 'm' });
expect(logLines()).toContain('CodeBuddy MCP (legacy mcp.json is corrupt — left untouched)');
expect(process.exitCode).not.toBe(1);
});
// ── ENOENT narrowing: non-ENOENT read failures must surface, not mask ──
const errnoError = (code: string) =>
Object.assign(new Error(`${code}: simulated failure`), { code });
const logLines = () =>
vi
.mocked(console.log)
.mock.calls.map((call) => call.join(' '))
.join('\n');
it('reports an error (not "not configured") when an MCP config read fails with EACCES', async () => {
const claudeJson = path.join(tempHome, '.claude.json');
const raw = JSON.stringify({
mcpServers: { gitnexus: { command: 'gitnexus', args: ['mcp'] } },
});
await fs.writeFile(claudeJson, raw, 'utf-8');
const realReadFile = fs.readFile;
vi.spyOn(fs, 'readFile').mockImplementation(((file: any, ...rest: any[]) => {
if (String(file) === claudeJson) return Promise.reject(errnoError('EACCES'));
return (realReadFile as any)(file, ...rest);
}) as typeof fs.readFile);
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
vi.mocked(fs.readFile).mockRestore();
// The file may hold a real gitnexus entry — reporting "not configured"
// would make the dry-run users trust lie about it.
expect(await fs.readFile(claudeJson, 'utf-8')).toBe(raw);
expect(logLines()).toContain('Claude Code: EACCES');
expect(logLines()).not.toContain('Claude Code MCP (not configured)');
expect(logLines()).not.toContain('not configured in any detected editor');
expect(process.exitCode).toBe(1);
});
it('keeps the hook-script dir when settings.json is unreadable (EACCES)', async () => {
const settingsPath = path.join(tempHome, '.claude', 'settings.json');
await fs.mkdir(path.join(tempHome, '.claude'), { recursive: true });
const raw = JSON.stringify({
hooks: {
PreToolUse: [
{
matcher: 'Bash',
hooks: [{ type: 'command', command: 'node ".../gitnexus-hook.cjs"' }],
},
],
},
});
await fs.writeFile(settingsPath, raw, 'utf-8');
const hookDir = path.join(tempHome, '.claude', 'hooks', 'gitnexus');
await fs.mkdir(hookDir, { recursive: true });
await fs.writeFile(path.join(hookDir, 'gitnexus-hook.cjs'), '// hook', 'utf-8');
const realReadFile = fs.readFile;
vi.spyOn(fs, 'readFile').mockImplementation(((file: any, ...rest: any[]) => {
if (String(file) === settingsPath) return Promise.reject(errnoError('EACCES'));
return (realReadFile as any)(file, ...rest);
}) as typeof fs.readFile);
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
vi.mocked(fs.readFile).mockRestore();
// Masking the failure as 'missing' would delete the scriptDir while the
// unreadable settings file still references the hook.
expect(await fs.readFile(settingsPath, 'utf-8')).toBe(raw);
await expect(fs.access(hookDir)).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
});
it('records a Codex read error and still cleans up other targets', async () => {
const configPath = path.join(tempHome, '.codex', 'config.toml');
await fs.mkdir(path.dirname(configPath), { recursive: true });
const raw = ['[mcp_servers.gitnexus]', 'command = "gitnexus"', ''].join('\n');
await fs.writeFile(configPath, raw, 'utf-8');
const skillsDir = path.join(tempHome, '.claude', 'skills', 'gitnexus-cli');
await fs.mkdir(skillsDir, { recursive: true });
await fs.writeFile(path.join(skillsDir, 'SKILL.md'), '# y', 'utf-8');
const realReadFile = fs.readFile;
vi.spyOn(fs, 'readFile').mockImplementation(((file: any, ...rest: any[]) => {
if (String(file) === configPath) return Promise.reject(errnoError('EACCES'));
return (realReadFile as any)(file, ...rest);
}) as typeof fs.readFile);
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
vi.mocked(fs.readFile).mockRestore();
// uninstallCodex catches locally: the failure is recorded but the
// hooks/skills cleanup that runs after Codex still executes.
expect(await fs.readFile(configPath, 'utf-8')).toBe(raw);
expect(logLines()).toContain('Codex: EACCES');
await expect(fs.access(skillsDir)).rejects.toThrow();
expect(process.exitCode).toBe(1);
});
});