diff --git a/README.md b/README.md index c9877312a..6b7cadf8f 100644 --- a/README.md +++ b/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"] + } + } +} +``` + ## CLI Reference diff --git a/gitnexus/README.md b/gitnexus/README.md index 8f24fea3f..e82bf87be 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -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. diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index c4ec8d734..00f4c4629 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -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', diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 299b4b7a3..b1c4a6194 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.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. */ diff --git a/gitnexus/src/cli/editor-targets.ts b/gitnexus/src/cli/editor-targets.ts index e00cf9778..8429f98ad 100644 --- a/gitnexus/src/cli/editor-targets.ts +++ b/gitnexus/src/cli/editor-targets.ts @@ -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). diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index d23743a61..f3cccbe87 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -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)', diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 44437b1f3..504a00a7e 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -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': '索引仓库(完整分析)', diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 7b6c17717..0edcb3259 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -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 ', diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index 6ea5d7d10..de1edc002 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -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; 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 { } } +/** + * 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 { + 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 { + const hits = await Promise.all([target.file, ...(target.legacyFiles ?? [])].map(isNonEmptyFile)); + return hits.includes(true); +} + // ─── Editor-specific setup ───────────────────────────────────────── async function setupCursor(result: SetupResult): Promise { @@ -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 { } } +/** + * 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 { + 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 { + 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 { + 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 { + 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 { + 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 { 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 diff --git a/gitnexus/src/cli/uninstall.ts b/gitnexus/src/cli/uninstall.ts index b67e9fcdc..e52a16b41 100644 --- a/gitnexus/src/cli/uninstall.ts +++ b/gitnexus/src/cli/uninstall.ts @@ -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.'); } diff --git a/gitnexus/test/integration/setup-uninstall-roundtrip.test.ts b/gitnexus/test/integration/setup-uninstall-roundtrip.test.ts index fc6ba0f5d..b72774372 100644 --- a/gitnexus/test/integration/setup-uninstall-roundtrip.test.ts +++ b/gitnexus/test/integration/setup-uninstall-roundtrip.test.ts @@ -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'); diff --git a/gitnexus/test/unit/cli-index-help.test.ts b/gitnexus/test/unit/cli-index-help.test.ts index 04a386541..4767d2867 100644 --- a/gitnexus/test/unit/cli-index-help.test.ts +++ b/gitnexus/test/unit/cli-index-help.test.ts @@ -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'); diff --git a/gitnexus/test/unit/setup-selection.test.ts b/gitnexus/test/unit/setup-selection.test.ts index 99920002f..7978ec2dc 100644 --- a/gitnexus/test/unit/setup-selection.test.ts +++ b/gitnexus/test/unit/setup-selection.test.ts @@ -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')), diff --git a/gitnexus/test/unit/setup.test.ts b/gitnexus/test/unit/setup.test.ts index bee3f19a2..d434b4a99 100644 --- a/gitnexus/test/unit/setup.test.ts +++ b/gitnexus/test/unit/setup.test.ts @@ -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'); diff --git a/gitnexus/test/unit/uninstall.test.ts b/gitnexus/test/unit/uninstall.test.ts index 5efc34f64..b616236e1 100644 --- a/gitnexus/test/unit/uninstall.test.ts +++ b/gitnexus/test/unit/uninstall.test.ts @@ -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); + }); });