mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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:
parent
4227194ad7
commit
6252aa745f
15 changed files with 1153 additions and 59 deletions
28
README.md
28
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
[](https://www.npmjs.com/package/gitnexus)
|
||||
[](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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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': '索引仓库(完整分析)',
|
||||
|
|
|
|||
|
|
@ -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>',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue