mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-14 16:52:09 +08:00
feat(installer): nine more agents: Bob, Droid, Windsurf, Qwen, dsh, Grok, Kimi, Qoder, Pi (#3113)
codegraph install now sets up IBM Bob, Factory Droid, Windsurf, Qwen Code, DeepSeek Harness, Grok Build, Kimi Code, the Qoder CLI and Pi. Each target is one new file plus a registry entry, and each path and format follows the agent's own docs. These are our own implementations of contributor PRs 642, 674, 2525, 952, 968, 2511, 1591, 1645, 1859, 2066 and 2112, credited in the CHANGELOG. Where a PR and the docs disagreed, the docs won: - Bob's global file is ~/.bob/settings/mcp.json; a lone mcp_settings.json, the name IBM's watsonx.data guide uses, is still honored. Its entry has no type. - Qwen honors QWEN_HOME, Kimi KIMI_CODE_HOME, and Kimi gets the AGENTS.md block its docs describe. dsh gets the block too, in $DSH_HOME/AGENTS.md. - Qoder's docs name no IDE file, so the target is the Qoder CLI: ~/.qoder/settings.json and .qoder/settings.local.json, not .qoder/mcp.json. - docs.windsurf.com now redirects to Devin's docs. The stable channel's file stays documented, and Devin imports it by default. What the targets share: - json-config.ts edits mcpServers.codegraph in place with jsonc-parser, so comments and formatting survive, for the seven JSON agents. A file left empty by uninstall is deleted through removeFileUnlessLinked. - An entry already there is the user's, in all nine. A re-install or refresh only fills in missing keys, so a pinned cwd or command, a disabled switch and Pi's exposure all survive. This is what replaces the cwd reset in PR 1591. - writeTomlTable and deleteTomlTable move out of codex.ts, so Grok gets the same conflict handling as Codex and never defines the table twice. - configDirFromEnv reads the agents' home overrides, and the line helpers move from hermes.ts to shared.ts for the dsh patch editor. Validation: installer-targets.test.ts has 624 tests, 614 passing and 10 POSIX-gated. New tests pin each target's path and entry at both locations, comments, CRLF, a BOM, unparseable files, user-owned entries, TOML conflicts and 18 dsh cases. The real CLI ran install, re-install, refresh and uninstall for all nine against a private home, and 20 dsh and Grok file shapes through PyYAML and tomllib, with no failures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
002dd4d517
commit
61e1621813
@@ -102,11 +102,13 @@ Defined in `src/types.ts`. Both extractors and resolvers must use these exact st
|
||||
`src/installer/` is the entry point for `codegraph install` (and the bare `codegraph`/`npx @colbymchenry/codegraph` invocation). Architecture:
|
||||
|
||||
- `targets/registry.ts` lists every supported agent.
|
||||
- `targets/types.ts` defines the `AgentTarget` interface — adding another agent (Continue, Zed, Windsurf…) is **one new file in `targets/` + one entry in `registry.ts`**. Each target owns its config-file location and MCP-server JSON/TOML/JSONC writing. (Claude, Codex, Gemini and opencode also write a short instructions-file block — see below.)
|
||||
- Current targets: `claude.ts`, `cursor.ts`, `codex.ts`, `opencode.ts`, `hermes.ts`, `gemini.ts`, `antigravity.ts`, `kiro.ts`, `copilot-vscode.ts`, `copilot-cli.ts`, `copilot-jetbrains.ts`.
|
||||
- `targets/toml.ts` is a hand-rolled TOML serializer scoped to `[mcp_servers.codegraph]` (used by Codex). Sibling tables and `[[array_of_tables]]` are preserved verbatim. No new dependency.
|
||||
- `targets/types.ts` defines the `AgentTarget` interface — adding another agent (Continue, Zed…) is **one new file in `targets/` + one entry in `registry.ts`**. Each target owns its config-file location and MCP-server JSON/TOML/JSONC writing. (Claude, Codex, Gemini, opencode, Droid, Qwen, DSH, Grok, Kimi and Pi also write a short instructions-file block — see below.)
|
||||
- Current targets: `claude.ts`, `cursor.ts`, `codex.ts`, `opencode.ts`, `hermes.ts`, `gemini.ts`, `antigravity.ts`, `kiro.ts`, `copilot-vscode.ts`, `copilot-cli.ts`, `copilot-jetbrains.ts`, `bob.ts`, `droid.ts`, `windsurf.ts`, `qwen.ts`, `dsh.ts`, `grok.ts`, `kimi.ts`, `qoder.ts`, `pi.ts`.
|
||||
- `targets/toml.ts` is a hand-rolled TOML serializer scoped to `[mcp_servers.codegraph]` (used by Codex and Grok, through `writeTomlTable` / `deleteTomlTable`). Sibling tables and `[[array_of_tables]]` are preserved verbatim. No new dependency.
|
||||
- `targets/json-config.ts` edits `mcpServers.codegraph` in place through `jsonc-parser`, for Bob, Droid, Windsurf, Qwen, Kimi, Qoder and Pi. An entry already in the file is the user's: agents write switches into it and users pin a `command` or `cwd`, so a re-install or `--refresh` only fills in missing keys. DSH's and Grok's writers keep to the same rule. Use it for a new agent with that file shape; a file with nothing left in it is deleted on uninstall, through `removeFileUnlessLinked`.
|
||||
- `*_HOME`-style overrides (`QWEN_HOME`, `KIMI_CODE_HOME`, `GROK_HOME`, `DSH_HOME`, `QODER_CONFIG_DIR`, `PI_CODING_AGENT_DIR`) go through `configDirFromEnv` in `targets/shared.ts`; each must also be cleared in `__tests__/setup-home-sandbox.ts` and in the suite's `setHome`.
|
||||
- opencode reads `opencode.jsonc` by default; the installer prefers existing `.jsonc`, falls back to `.json`, and creates `.jsonc` for greenfield installs. Edits are surgical via `jsonc-parser` so user comments and formatting survive install/re-install/uninstall round-trips. The MCP entry is OpenCode 2's native `mcp.servers.codegraph` with `disabled: false` and `codemode: false` (so `codegraph_explore` stays on the native tool list); a pre-#1698 `mcp.codegraph` + `enabled` entry is migrated on re-install and removed by uninstall.
|
||||
- `instructions-template.ts` holds `CODEGRAPH_INSTRUCTIONS_BLOCK`, a deliberately short marker-fenced (`<!-- CODEGRAPH_START -->`/`<!-- CODEGRAPH_END -->`) `## CodeGraph` block. The Claude, Codex, Gemini and opencode targets write it into their instructions file (`CLAUDE.md` / `~/.codex/AGENTS.md` / `~/.gemini/GEMINI.md` / `~/.config/opencode/AGENTS.md`) via `upsertInstructionsEntry`, and `uninstall` removes it. History: #529 removed the old full playbook because it duplicated the MCP `initialize` instructions; #704 brought back this small block because the instructions file reaches two audiences `initialize` cannot — Task-tool subagents and non-MCP harnesses (the `codegraph explore` CLI). Cursor and Kiro write no block; their `install` only strips one a previous install left behind. Keep the block to command names and the two surfaces; tool guidance itself still belongs in `server-instructions.ts`.
|
||||
- `instructions-template.ts` holds `CODEGRAPH_INSTRUCTIONS_BLOCK`, a deliberately short marker-fenced (`<!-- CODEGRAPH_START -->`/`<!-- CODEGRAPH_END -->`) `## CodeGraph` block. The Claude, Codex, Gemini, opencode, Droid, Qwen, DSH, Grok, Kimi and Pi targets write it into their instructions file (`CLAUDE.md` / `~/.codex/AGENTS.md` / `~/.gemini/GEMINI.md` / `~/.config/opencode/AGENTS.md` / `~/.factory/AGENTS.md` / `~/.qwen/QWEN.md` / `~/.dsh/AGENTS.md` / `~/.grok/rules/codegraph.md` / `~/.kimi-code/AGENTS.md` / `~/.pi/agent/AGENTS.md`) via `upsertInstructionsEntry`, and `uninstall` removes it. History: #529 removed the old full playbook because it duplicated the MCP `initialize` instructions; #704 brought back this small block because the instructions file reaches two audiences `initialize` cannot — Task-tool subagents and non-MCP harnesses (the `codegraph explore` CLI). Cursor and Kiro write no block; their `install` only strips one a previous install left behind. Bob, Windsurf and Qoder are MCP config only. Keep the block to command names and the two surfaces; tool guidance itself still belongs in `server-instructions.ts`.
|
||||
- All installer changes need matching coverage in `__tests__/installer-targets.test.ts` — there are ~47 parameterized contract tests covering install idempotency, sibling preservation, uninstall reverses install, byte-equal re-runs returning `unchanged`, and partial-state recovery for Codex.
|
||||
|
||||
### Cursor MCP working-directory quirk
|
||||
|
||||
@@ -20,6 +20,15 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
### New Features
|
||||
|
||||
- `codegraph files --format package` and `codegraph_files` with `format: "package"` now list a project's files by the package that owns them, the nearest `package.json` or `composer.json` above each file that names one, so a monorepo splits into its workspace members with no setup and files nothing claims get a section of their own (#1871). Thanks @LuisAlbertoMK.
|
||||
- `codegraph install` now sets up IBM Bob, for Bob IDE and Bob Shell: it adds the CodeGraph MCP server to `~/.bob/settings/mcp.json`, or to `.bob/mcp.json` in a project. Thanks @QuentinDstl.
|
||||
- `codegraph install` now sets up Factory Droid: it adds the CodeGraph MCP server to `~/.factory/mcp.json` or a project's `.factory/mcp.json`, plus a short CodeGraph section in `AGENTS.md`, and Droid picks it up without a restart. Thanks @Rubyj and @KrystianMarek.
|
||||
- `codegraph install` now sets up Windsurf: it adds the CodeGraph MCP server to `~/.codeium/windsurf/mcp_config.json`, which Devin, Windsurf's new name, imports by default. Thanks @SEPURI-SAI-KRISHNA.
|
||||
- `codegraph install` now sets up Qwen Code: it adds the CodeGraph MCP server to `settings.json`, keeping any comments in that file, plus a short CodeGraph section in `QWEN.md`, and it can auto-allow CodeGraph's tools as it does for Claude Code. Thanks @ZevGit and @LiangshouX.
|
||||
- `codegraph install` now sets up DeepSeek Harness (`dsh`): one entry in `~/.dsh/cordis.patch.yml` covers every profile, a short CodeGraph section goes in `~/.dsh/AGENTS.md`, and an entry you wrote yourself is left as it is. Thanks @hfldqwe.
|
||||
- `codegraph install` now sets up Grok Build: it adds the CodeGraph MCP server to `~/.grok/config.toml` or a project's `.grok/config.toml`, plus a short rule in `rules/codegraph.md`. Thanks @ximing.
|
||||
- `codegraph install` now sets up Kimi Code: it adds the CodeGraph MCP server to `~/.kimi-code/mcp.json` or a project's `.kimi-code/mcp.json`, plus a short CodeGraph section in `AGENTS.md`. Thanks @zhouxbalabala.
|
||||
- `codegraph install` now sets up the Qoder CLI: it adds the CodeGraph MCP server to `~/.qoder/settings.json`, or to `.qoder/settings.local.json` in a project. Thanks @greatjackzhou.
|
||||
- `codegraph install` now sets up Pi: it adds the CodeGraph MCP server to `~/.pi/agent/mcp.json` or a project's `.pi/mcp.json`, plus a short CodeGraph section in `AGENTS.md`, and `codegraph_explore` stays on the model's tool list. Thanks @roymina.
|
||||
- CodeGraph's MCP server now gives agents and editors its display name and icon, so one that shows them, like the Codex app, lists CodeGraph by name and logo instead of a plain `codegraph`. Thanks @fedosov.
|
||||
- `codegraph status` now shows how many of a language's files yielded symbols when that isn't all of them, and warns when none did, so a parser that isn't working no longer looks healthy, and `codegraph status --strict` exits non-zero on that or on an index that's missing, unfinished or out of date, so packaging and CI can gate on it (#1658). Thanks @ferrine.
|
||||
- `codegraph serve --mcp --no-telemetry` turns telemetry off for one MCP server entry, so a shared MCP config can opt out without an environment variable; a background daemon that's already running for the project keeps its own setting until it exits. Thanks @danusha2345. (#1908)
|
||||
|
||||
@@ -6,7 +6,7 @@ Already installed? Run `codegraph upgrade`
|
||||
|
||||
Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates.
|
||||
|
||||
### Supercharge Claude Code, Cursor, Codex, OpenCode, Hermes Agent, Gemini, Antigravity, Kiro, and GitHub Copilot with Semantic Code Intelligence
|
||||
### Supercharge Claude Code, Cursor, Codex, OpenCode, Hermes Agent, Gemini, Antigravity, Kiro, GitHub Copilot, and more with Semantic Code Intelligence
|
||||
|
||||
**The fastest complete code graph · surgical context · built for how agents actually work · 100% local**
|
||||
|
||||
@@ -36,6 +36,15 @@ Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates.
|
||||
[](#supported-agents)
|
||||
[](#supported-agents)
|
||||
[](#supported-agents)
|
||||
[](#supported-agents)
|
||||
[](#supported-agents)
|
||||
[](#supported-agents)
|
||||
[](#supported-agents)
|
||||
[](#supported-agents)
|
||||
[](#supported-agents)
|
||||
[](#supported-agents)
|
||||
[](#supported-agents)
|
||||
[](#supported-agents)
|
||||
|
||||
<br>
|
||||
|
||||
@@ -106,7 +115,7 @@ In a **new terminal**, run the installer to connect CodeGraph to the agents you
|
||||
codegraph install
|
||||
```
|
||||
|
||||
<sub>Detects and auto-configures Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, and GitHub Copilot (VS Code, Copilot CLI, JetBrains IDEs) — wiring the CodeGraph MCP server into each. **This is the step that connects CodeGraph to your agent;** installing the CLI in step 1 does not do it on its own. It only wires up your agent — it does **not** index any code; building each project's graph is the separate `codegraph init` in step 3. (Shortcut: `npx @colbymchenry/codegraph` downloads and runs this in one go.)</sub>
|
||||
<sub>Detects and auto-configures Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, GitHub Copilot (VS Code, Copilot CLI, JetBrains IDEs), IBM Bob, Factory Droid, Windsurf, Qwen Code, DeepSeek Harness, Grok Build, Kimi Code, Qoder CLI, and Pi — wiring the CodeGraph MCP server into each. **This is the step that connects CodeGraph to your agent;** installing the CLI in step 1 does not do it on its own. It only wires up your agent — it does **not** index any code; building each project's graph is the separate `codegraph init` in step 3. (Shortcut: `npx @colbymchenry/codegraph` downloads and runs this in one go.)</sub>
|
||||
|
||||
### 3. Initialize each project
|
||||
|
||||
@@ -392,11 +401,11 @@ npx @colbymchenry/codegraph
|
||||
```
|
||||
|
||||
The installer will:
|
||||
- Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**, **Hermes Agent**, **Gemini CLI**, **Antigravity IDE**, **Kiro**, **GitHub Copilot** (VS Code, Copilot CLI, JetBrains IDEs)
|
||||
- Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**, **Hermes Agent**, **Gemini CLI**, **Antigravity IDE**, **Kiro**, **GitHub Copilot** (VS Code, Copilot CLI, JetBrains IDEs), **IBM Bob**, **Factory Droid**, **Windsurf**, **Qwen Code**, **DeepSeek Harness**, **Grok Build**, **Kimi Code**, **Qoder CLI**, and **Pi**
|
||||
- Prompt to install `codegraph` on your PATH (so agents can launch the MCP server)
|
||||
- Ask whether configs apply to all your projects or just this one
|
||||
- Write each chosen agent's MCP server config, plus a small marker-fenced CodeGraph section in the agent's instructions file (`CLAUDE.md` / `AGENTS.md` / `GEMINI.md`) — that's how subagents and non-MCP agents learn the `codegraph explore` command, since the MCP server's own guidance only reaches the main agent. Removed cleanly by `codegraph uninstall`.
|
||||
- Set up auto-allow permissions when Claude Code is one of the targets
|
||||
- Write each chosen agent's MCP server config, plus a small marker-fenced CodeGraph section in the agent's instructions file (`CLAUDE.md` / `AGENTS.md` / `GEMINI.md` / `QWEN.md`) — that's how subagents and non-MCP agents learn the `codegraph explore` command, since the MCP server's own guidance only reaches the main agent. Removed cleanly by `codegraph uninstall`.
|
||||
- Set up auto-allow permissions when Claude Code or Qwen Code is one of the targets
|
||||
|
||||
The installer **wires up your agents only — it does not index your code.** After it finishes, build each project's graph yourself with `codegraph init` (step 3). One global `codegraph install` covers every project; you run `codegraph init` once per project.
|
||||
|
||||
@@ -418,12 +427,12 @@ codegraph install --print-config copilot-vscode # same, for Copilot in VS C
|
||||
| `--location` | `global`, `local` | prompt |
|
||||
| `--yes` | (boolean) | prompt every step |
|
||||
| `--init` | (boolean) run `codegraph init` in the current directory after wiring agents | — |
|
||||
| `--no-permissions` | (boolean) skip Claude auto-allow list | permissions on |
|
||||
| `--no-permissions` | (boolean) skip the auto-allow list (Claude Code, Qwen Code) | permissions on |
|
||||
| `--print-config <id>` | dump snippet for one agent and exit | — |
|
||||
|
||||
### 2. Restart Your Agent
|
||||
|
||||
Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent / Gemini CLI / Antigravity IDE / Kiro / VS Code, the Copilot CLI, or your JetBrains IDE for GitHub Copilot) for the MCP server to load.
|
||||
Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent / Gemini CLI / Antigravity IDE / Kiro / VS Code, the Copilot CLI, or your JetBrains IDE for GitHub Copilot / IBM Bob / Factory Droid / Windsurf / Qwen Code / DeepSeek Harness / Grok Build / Kimi Code / Qoder CLI / Pi) for the MCP server to load.
|
||||
|
||||
### 3. Initialize Projects
|
||||
|
||||
@@ -799,10 +808,11 @@ See [Get Started](#get-started) for the one-line install commands.
|
||||
|
||||
The interactive installer auto-detects and configures each of these — wiring up
|
||||
the MCP server, which delivers its own usage guidance. For Claude Code, Codex
|
||||
CLI, Gemini CLI and opencode it also writes a short marker-fenced CodeGraph
|
||||
section into the agent's instructions file (`CLAUDE.md` / `AGENTS.md` /
|
||||
`GEMINI.md`), so subagents and non-MCP agents learn the `codegraph explore`
|
||||
command:
|
||||
CLI, Gemini CLI, opencode, Factory Droid, Qwen Code, DeepSeek Harness, Grok
|
||||
Build, Kimi Code and Pi it also writes a short marker-fenced CodeGraph section
|
||||
into the agent's instructions file (`CLAUDE.md` / `AGENTS.md` / `GEMINI.md` /
|
||||
`QWEN.md`, or `rules/codegraph.md` for Grok Build), so subagents and non-MCP
|
||||
agents learn the `codegraph explore` command:
|
||||
|
||||
- **Claude Code**
|
||||
- **Cursor**
|
||||
@@ -813,6 +823,15 @@ command:
|
||||
- **Antigravity IDE**
|
||||
- **Kiro**
|
||||
- **GitHub Copilot** — Copilot Chat in VS Code (`copilot-vscode`), the Copilot CLI (`copilot-cli`), and the Copilot plugin in JetBrains IDEs (`copilot-jetbrains`)
|
||||
- **IBM Bob** (`bob`) — Bob IDE and Bob Shell: `~/.bob/settings/mcp.json`, or `.bob/mcp.json` in a project
|
||||
- **Factory Droid** (`droid`) — `~/.factory/mcp.json`, or `.factory/mcp.json`, which is meant to be committed; Droid reloads the file on its own
|
||||
- **Windsurf** (`windsurf`) — global only: `~/.codeium/windsurf/mcp_config.json`, which Devin, Windsurf's new name, imports by default
|
||||
- **Qwen Code** (`qwen`) — `settings.json` in `$QWEN_HOME` (default `~/.qwen`) or `.qwen/`, keeping any comments in it
|
||||
- **DeepSeek Harness** (`dsh`) — global only: one entry in `$DSH_HOME/cordis.patch.yml` (default `~/.dsh`) covers every profile
|
||||
- **Grok Build** (`grok`) — `$GROK_HOME/config.toml` (default `~/.grok`), or `.grok/config.toml`, which Grok uses only in a trusted folder
|
||||
- **Kimi Code** (`kimi`) — `$KIMI_CODE_HOME/mcp.json` (default `~/.kimi-code`), or `.kimi-code/mcp.json`, which Kimi Code uses only in a trusted folder
|
||||
- **Qoder CLI** (`qoder`) — `$QODER_CONFIG_DIR/settings.json` (default `~/.qoder`), or `.qoder/settings.local.json`; in the Qoder IDE, add the server under Settings > MCP
|
||||
- **Pi** (`pi`) — `mcp.json` in Pi's agent directory (default `~/.pi/agent`), or `.pi/mcp.json`, which Pi reads once you trust the project; `exposure: "direct"` keeps `codegraph_explore` on the model's tool list
|
||||
|
||||
## Supported Languages
|
||||
|
||||
@@ -919,7 +938,7 @@ MIT
|
||||
|
||||
<div align="center">
|
||||
|
||||
**Made for AI coding agents — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, and GitHub Copilot**
|
||||
**Made for AI coding agents — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, GitHub Copilot, IBM Bob, Factory Droid, Windsurf, Qwen Code, DeepSeek Harness, Grok Build, Kimi Code, Qoder CLI, and Pi**
|
||||
|
||||
[Report Bug](https://github.com/colbymchenry/codegraph/issues) · [Request Feature](https://github.com/colbymchenry/codegraph/issues)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targ
|
||||
import { uninstallTargets, refreshTargets } from '../src/installer';
|
||||
import { upsertTomlTable, removeTomlTable, buildTomlTable, tomlDefinesTable } from '../src/installer/targets/toml';
|
||||
import { cleanupLegacyHooks, writePromptHookEntry, removePromptHookEntry } from '../src/installer/targets/claude';
|
||||
import { replaceOrAppendMarkedSection, removeMarkedSection } from '../src/installer/targets/shared';
|
||||
import { configDirFromEnv, replaceOrAppendMarkedSection, removeMarkedSection } from '../src/installer/targets/shared';
|
||||
import {
|
||||
CODEGRAPH_INSTRUCTIONS_BLOCK,
|
||||
CODEGRAPH_SECTION_END,
|
||||
@@ -75,7 +75,11 @@ describe('standalone installer Windows shell guidance (#1294)', () => {
|
||||
// `$HOME` (POSIX) / `$USERPROFILE` (Windows) env vars that
|
||||
// `os.homedir()` reads first. Same trick the rest of the suite uses
|
||||
// when it needs a mock home.
|
||||
const AGENT_HOME_OVERRIDES = ['QWEN_HOME', 'DSH_HOME', 'GROK_HOME', 'KIMI_CODE_HOME', 'QODER_CONFIG_DIR', 'PI_CODING_AGENT_DIR'];
|
||||
|
||||
function setHome(dir: string): { restore: () => void } {
|
||||
const overrides = AGENT_HOME_OVERRIDES.map((name) => [name, process.env[name]] as const);
|
||||
for (const [name] of overrides) delete process.env[name];
|
||||
const prev = {
|
||||
HOME: process.env.HOME,
|
||||
USERPROFILE: process.env.USERPROFILE,
|
||||
@@ -104,6 +108,9 @@ function setHome(dir: string): { restore: () => void } {
|
||||
if (prev.COPILOT_HOME === undefined) delete process.env.COPILOT_HOME; else process.env.COPILOT_HOME = prev.COPILOT_HOME;
|
||||
if (prev.CODEX_HOME === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = prev.CODEX_HOME;
|
||||
if (prev.CLAUDE_CONFIG_DIR === undefined) delete process.env.CLAUDE_CONFIG_DIR; else process.env.CLAUDE_CONFIG_DIR = prev.CLAUDE_CONFIG_DIR;
|
||||
for (const [name, value] of overrides) {
|
||||
if (value === undefined) delete process.env[name]; else process.env[name] = value;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1380,6 +1387,9 @@ describe('Installer targets — registry', () => {
|
||||
expect(getTarget('copilot-vscode')?.id).toBe('copilot-vscode');
|
||||
expect(getTarget('copilot-cli')?.id).toBe('copilot-cli');
|
||||
expect(getTarget('copilot-jetbrains')?.id).toBe('copilot-jetbrains');
|
||||
for (const id of ['bob', 'droid', 'windsurf', 'qwen', 'dsh', 'grok', 'kimi', 'qoder', 'pi']) {
|
||||
expect(getTarget(id)?.id).toBe(id);
|
||||
}
|
||||
expect(getTarget('not-a-real-target')).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -2341,6 +2351,12 @@ describe('Installer — install --refresh keeps each agent\'s instructions file
|
||||
['codex', (home) => path.join(home, '.codex', 'AGENTS.md')],
|
||||
['gemini', (home) => path.join(home, '.gemini', 'GEMINI.md')],
|
||||
['opencode', (home) => path.join(home, '.config', 'opencode', 'AGENTS.md')],
|
||||
['droid', (home) => path.join(home, '.factory', 'AGENTS.md')],
|
||||
['qwen', (home) => path.join(home, '.qwen', 'QWEN.md')],
|
||||
['dsh', (home) => path.join(home, '.dsh', 'AGENTS.md')],
|
||||
['grok', (home) => path.join(home, '.grok', 'rules', 'codegraph.md')],
|
||||
['kimi', (home) => path.join(home, '.kimi-code', 'AGENTS.md')],
|
||||
['pi', (home) => path.join(home, '.pi', 'agent', 'AGENTS.md')],
|
||||
];
|
||||
const USER = `${CODEGRAPH_SECTION_START}\n## CodeGraph\n\n# My notes\n\nAlways run the tests first.\n\n`;
|
||||
|
||||
@@ -2572,22 +2588,30 @@ describe.skipIf(!FILE_SYMLINKS)(`Installer — a symbolic link is written throug
|
||||
const agentsMd = sharedFile('AGENTS.md');
|
||||
const codexToml = sharedFile('codex.toml');
|
||||
const copilot = sharedFile('copilot.json');
|
||||
const droid = sharedFile('droid.json');
|
||||
const grokToml = sharedFile('grok.toml');
|
||||
const dshPatch = sharedFile('cordis.patch.yml');
|
||||
link(path.join(tmpHome, '.codex', 'AGENTS.md'), agentsMd);
|
||||
link(path.join(tmpHome, '.codex', 'config.toml'), codexToml);
|
||||
link(path.join(tmpHome, '.copilot', 'mcp-config.json'), copilot);
|
||||
getTarget('codex')!.install('global', { autoAllow: false });
|
||||
getTarget('copilot-cli')!.install('global', { autoAllow: false });
|
||||
link(path.join(tmpHome, '.factory', 'mcp.json'), droid);
|
||||
link(path.join(tmpHome, '.grok', 'config.toml'), grokToml);
|
||||
link(path.join(tmpHome, '.dsh', 'cordis.patch.yml'), dshPatch);
|
||||
const ids = ['codex', 'copilot-cli', 'droid', 'grok', 'dsh'] as const;
|
||||
for (const id of ids) getTarget(id)!.install('global', { autoAllow: false });
|
||||
expect(fs.readFileSync(agentsMd, 'utf-8')).toBe(`${CODEGRAPH_INSTRUCTIONS_BLOCK}\n`);
|
||||
|
||||
getTarget('codex')!.uninstall('global');
|
||||
getTarget('copilot-cli')!.uninstall('global');
|
||||
for (const id of ids) getTarget(id)!.uninstall('global');
|
||||
|
||||
for (const p of ['.codex/AGENTS.md', '.codex/config.toml', '.copilot/mcp-config.json']) {
|
||||
for (const p of ['.codex/AGENTS.md', '.codex/config.toml', '.copilot/mcp-config.json', '.factory/mcp.json', '.grok/config.toml', '.dsh/cordis.patch.yml']) {
|
||||
expect(isLink(path.join(tmpHome, p)), p).toBe(true);
|
||||
}
|
||||
expect(fs.readFileSync(agentsMd, 'utf-8')).toBe('');
|
||||
expect(fs.readFileSync(codexToml, 'utf-8')).toBe('');
|
||||
expect(fs.readFileSync(grokToml, 'utf-8')).toBe('');
|
||||
expect(JSON.parse(fs.readFileSync(copilot, 'utf-8'))).toEqual({});
|
||||
expect(JSON.parse(fs.readFileSync(droid, 'utf-8'))).toEqual({});
|
||||
expect(fs.readFileSync(dshPatch, 'utf-8')).toBe('[]\n');
|
||||
});
|
||||
|
||||
it('a link that can\'t be written fails only its own agent in a refresh: the others are still refreshed', () => {
|
||||
@@ -3860,6 +3884,815 @@ describe('Installer targets — Codex CODEX_HOME override (#1627)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The agents that keep `mcpServers.codegraph` in a JSON file: each one's
|
||||
// documented paths and entry, and what the shared editor promises all of them.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface JsonTargetCase {
|
||||
id: string;
|
||||
/** Path of the MCP config under the home (global) or the project (local), and the entry written there. */
|
||||
global: string[];
|
||||
local: string[] | null;
|
||||
entry: Record<string, unknown>;
|
||||
/** Instructions file under the home; null when the target writes none. */
|
||||
globalInstructions: string[] | null;
|
||||
/** Instructions file under the project. */
|
||||
localInstructions: string[] | null;
|
||||
}
|
||||
|
||||
const STDIO_ENTRY = { command: 'codegraph', args: ['serve', '--mcp'] };
|
||||
|
||||
const JSON_TARGETS: JsonTargetCase[] = [
|
||||
{ id: 'bob', global: ['.bob', 'settings', 'mcp.json'], local: ['.bob', 'mcp.json'], entry: STDIO_ENTRY, globalInstructions: null, localInstructions: null },
|
||||
{ id: 'droid', global: ['.factory', 'mcp.json'], local: ['.factory', 'mcp.json'], entry: { type: 'stdio', ...STDIO_ENTRY }, globalInstructions: ['.factory', 'AGENTS.md'], localInstructions: ['AGENTS.md'] },
|
||||
{ id: 'windsurf', global: ['.codeium', 'windsurf', 'mcp_config.json'], local: null, entry: STDIO_ENTRY, globalInstructions: null, localInstructions: null },
|
||||
{ id: 'qwen', global: ['.qwen', 'settings.json'], local: ['.qwen', 'settings.json'], entry: STDIO_ENTRY, globalInstructions: ['.qwen', 'QWEN.md'], localInstructions: ['QWEN.md'] },
|
||||
{ id: 'kimi', global: ['.kimi-code', 'mcp.json'], local: ['.kimi-code', 'mcp.json'], entry: STDIO_ENTRY, globalInstructions: ['.kimi-code', 'AGENTS.md'], localInstructions: ['AGENTS.md'] },
|
||||
{ id: 'qoder', global: ['.qoder', 'settings.json'], local: ['.qoder', 'settings.local.json'], entry: STDIO_ENTRY, globalInstructions: null, localInstructions: null },
|
||||
{ id: 'pi', global: ['.pi', 'agent', 'mcp.json'], local: ['.pi', 'mcp.json'], entry: { ...STDIO_ENTRY, exposure: 'direct' }, globalInstructions: ['.pi', 'agent', 'AGENTS.md'], localInstructions: ['AGENTS.md'] },
|
||||
];
|
||||
|
||||
/** Runs each test with a throwaway home and working directory. */
|
||||
function useTmpDirs(label: string): { home: () => string; cwd: () => string } {
|
||||
let tmpHome: string;
|
||||
let tmpCwd: string;
|
||||
let origCwd: string;
|
||||
let homeRestore: { restore: () => void };
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = mkTmpDir(`${label}-home`);
|
||||
tmpCwd = mkTmpDir(`${label}-cwd`);
|
||||
origCwd = process.cwd();
|
||||
process.chdir(tmpCwd);
|
||||
homeRestore = setHome(tmpHome);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
homeRestore.restore();
|
||||
process.chdir(origCwd);
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(tmpCwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
return { home: () => tmpHome, cwd: () => tmpCwd };
|
||||
}
|
||||
|
||||
describe('Installer targets — agents with a JSON mcpServers file', () => {
|
||||
const dirs = useTmpDirs('jsoncfg');
|
||||
|
||||
for (const c of JSON_TARGETS) {
|
||||
const locations = (['global', 'local'] as const).filter((l) => c[l] !== null);
|
||||
|
||||
for (const location of locations) {
|
||||
const file = () => path.join(location === 'global' ? dirs.home() : dirs.cwd(), ...c[location]!);
|
||||
const instructions = location === 'global' ? c.globalInstructions : c.localInstructions;
|
||||
const instructionsFile = () => path.join(location === 'global' ? dirs.home() : dirs.cwd(), ...instructions!);
|
||||
|
||||
describe(`${c.id} (${location})`, () => {
|
||||
it('writes the documented entry to the documented file, and nothing else for the MCP config', () => {
|
||||
const result = getTarget(c.id)!.install(location, { autoAllow: false });
|
||||
|
||||
expect(JSON.parse(fs.readFileSync(file(), 'utf-8'))).toEqual({ mcpServers: { codegraph: c.entry } });
|
||||
expect(result.files.find((f) => f.path === file())?.action).toBe('created');
|
||||
});
|
||||
|
||||
it(instructions ? 'writes the instructions block where the agent reads it' : 'writes no instructions file', () => {
|
||||
const result = getTarget(c.id)!.install(location, { autoAllow: false });
|
||||
|
||||
if (instructions) {
|
||||
expect(fs.readFileSync(instructionsFile(), 'utf-8')).toBe(`${CODEGRAPH_INSTRUCTIONS_BLOCK}\n`);
|
||||
expect(result.files.map((f) => f.path)).toEqual([file(), instructionsFile()]);
|
||||
} else {
|
||||
expect(result.files.map((f) => f.path)).toEqual([file()]);
|
||||
}
|
||||
});
|
||||
|
||||
it('an entry the user changed stays as it is through a re-install and a refresh', () => {
|
||||
const pinned = { ...c.entry, command: '/opt/bin/codegraph', args: ['serve', '--mcp', '--path', '/work'], cwd: '/work', disabled: true, alwaysAllow: ['codegraph_explore'], exposure: 'deferred' };
|
||||
const content = `${JSON.stringify({ mcpServers: { codegraph: pinned } }, null, 2)}\n`;
|
||||
fs.mkdirSync(path.dirname(file()), { recursive: true });
|
||||
fs.writeFileSync(file(), content);
|
||||
|
||||
const target = getTarget(c.id)!;
|
||||
expect(target.detect(location).alreadyConfigured).toBe(true);
|
||||
const again = target.install(location, { autoAllow: false });
|
||||
|
||||
expect(again.files.find((f) => f.path === file())?.action).toBe('unchanged');
|
||||
expect(fs.readFileSync(file(), 'utf-8')).toBe(content);
|
||||
if (location === 'global') {
|
||||
expect(refreshTargets([target], 'global')[0]!.status).not.toBe('failed');
|
||||
expect(fs.readFileSync(file(), 'utf-8')).toBe(content);
|
||||
}
|
||||
});
|
||||
|
||||
it('an entry missing a key gets only that key', () => {
|
||||
fs.mkdirSync(path.dirname(file()), { recursive: true });
|
||||
fs.writeFileSync(file(), JSON.stringify({ mcpServers: { codegraph: { disabled: true } } }));
|
||||
|
||||
getTarget(c.id)!.install(location, { autoAllow: false });
|
||||
|
||||
expect(JSON.parse(fs.readFileSync(file(), 'utf-8')).mcpServers.codegraph).toEqual({ ...c.entry, disabled: true });
|
||||
});
|
||||
|
||||
it('the rest of the file is not reformatted, and uninstall gives its values back', () => {
|
||||
const content = '{\n "theme": "dark",\n "mcpServers": {\n "other": { "command": "x" }\n },\n "tail": [1, 2]\n}\n';
|
||||
fs.mkdirSync(path.dirname(file()), { recursive: true });
|
||||
fs.writeFileSync(file(), content);
|
||||
const target = getTarget(c.id)!;
|
||||
|
||||
target.install(location, { autoAllow: false });
|
||||
|
||||
const installed = fs.readFileSync(file(), 'utf-8');
|
||||
expect(installed).toContain('{\n "theme": "dark",\n "mcpServers": {\n');
|
||||
expect(installed).toContain(' "tail": [1, 2]\n}\n');
|
||||
expect(installed).toContain('\n "codegraph": {\n "');
|
||||
expect(JSON.parse(installed).mcpServers.other).toEqual({ command: 'x' });
|
||||
expect(JSON.parse(installed).mcpServers.codegraph).toEqual(c.entry);
|
||||
|
||||
target.uninstall(location);
|
||||
expect(JSON.parse(fs.readFileSync(file(), 'utf-8'))).toEqual(JSON.parse(content));
|
||||
});
|
||||
|
||||
it('uninstall deletes a file left with nothing in it, and is then a no-op', () => {
|
||||
const target = getTarget(c.id)!;
|
||||
target.install(location, { autoAllow: false });
|
||||
|
||||
const result = target.uninstall(location);
|
||||
|
||||
expect(result.files.find((f) => f.path === file())?.action).toBe('removed');
|
||||
expect(fs.existsSync(file())).toBe(false);
|
||||
if (instructions) expect(fs.existsSync(instructionsFile())).toBe(false);
|
||||
expect(target.uninstall(location).files.find((f) => f.path === file())?.action).toBe('not-found');
|
||||
});
|
||||
|
||||
it('a config that does not parse is backed up before it is replaced', () => {
|
||||
fs.mkdirSync(path.dirname(file()), { recursive: true });
|
||||
fs.writeFileSync(file(), '{ half a config');
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
try {
|
||||
expect(getTarget(c.id)!.detect(location).alreadyConfigured).toBe(false);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
getTarget(c.id)!.install(location, { autoAllow: false });
|
||||
expect(warn).toHaveBeenCalled();
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
expect(fs.readFileSync(`${file()}.backup`, 'utf-8')).toBe('{ half a config');
|
||||
expect(JSON.parse(fs.readFileSync(file(), 'utf-8')).mcpServers.codegraph).toEqual(c.entry);
|
||||
});
|
||||
|
||||
it('uninstall leaves a config that does not parse alone', () => {
|
||||
fs.mkdirSync(path.dirname(file()), { recursive: true });
|
||||
fs.writeFileSync(file(), '{ half a config');
|
||||
|
||||
const result = getTarget(c.id)!.uninstall(location);
|
||||
|
||||
expect(result.files.find((f) => f.path === file())?.action).toBe('not-found');
|
||||
expect(fs.readFileSync(file(), 'utf-8')).toBe('{ half a config');
|
||||
});
|
||||
|
||||
it('an mcpServers that is not an object stops the install with nothing changed', () => {
|
||||
const content = '{ "mcpServers": ["not", "an", "object"] }\n';
|
||||
fs.mkdirSync(path.dirname(file()), { recursive: true });
|
||||
fs.writeFileSync(file(), content);
|
||||
|
||||
expect(() => getTarget(c.id)!.install(location, { autoAllow: false })).toThrow(/mcpServers.*Nothing was changed/);
|
||||
expect(fs.readFileSync(file(), 'utf-8')).toBe(content);
|
||||
});
|
||||
|
||||
it('keeps CRLF line endings and a byte order mark', () => {
|
||||
const content = '\uFEFF{\r\n "theme": "dark"\r\n}\r\n';
|
||||
fs.mkdirSync(path.dirname(file()), { recursive: true });
|
||||
fs.writeFileSync(file(), content);
|
||||
|
||||
getTarget(c.id)!.install(location, { autoAllow: false });
|
||||
|
||||
const text = fs.readFileSync(file(), 'utf-8');
|
||||
expect(text.startsWith('\uFEFF')).toBe(true);
|
||||
expect(text.replace(/\r\n/g, '')).not.toContain('\n');
|
||||
expect(JSON.parse(text.slice(1)).mcpServers.codegraph).toEqual(c.entry);
|
||||
expect(fs.existsSync(`${file()}.backup`)).toBe(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it('bob: a global file under the earlier name is the one used, while mcp.json is absent', () => {
|
||||
const settings = path.join(dirs.home(), '.bob', 'settings');
|
||||
const earlier = path.join(settings, 'mcp_settings.json');
|
||||
fs.mkdirSync(settings, { recursive: true });
|
||||
fs.writeFileSync(earlier, '{\n "mcpServers": {\n "other": { "command": "x" }\n }\n}\n');
|
||||
const bob = getTarget('bob')!;
|
||||
|
||||
bob.install('global', { autoAllow: false });
|
||||
|
||||
expect(JSON.parse(fs.readFileSync(earlier, 'utf-8')).mcpServers.codegraph).toEqual(STDIO_ENTRY);
|
||||
expect(fs.existsSync(path.join(settings, 'mcp.json'))).toBe(false);
|
||||
expect(bob.detect('global').alreadyConfigured).toBe(true);
|
||||
|
||||
bob.uninstall('global');
|
||||
expect(JSON.parse(fs.readFileSync(earlier, 'utf-8'))).toEqual({ mcpServers: { other: { command: 'x' } } });
|
||||
});
|
||||
|
||||
it('bob: mcp.json wins as soon as it exists', () => {
|
||||
const settings = path.join(dirs.home(), '.bob', 'settings');
|
||||
fs.mkdirSync(settings, { recursive: true });
|
||||
fs.writeFileSync(path.join(settings, 'mcp_settings.json'), '{}\n');
|
||||
fs.writeFileSync(path.join(settings, 'mcp.json'), '{}\n');
|
||||
|
||||
getTarget('bob')!.install('global', { autoAllow: false });
|
||||
|
||||
expect(JSON.parse(fs.readFileSync(path.join(settings, 'mcp.json'), 'utf-8')).mcpServers.codegraph).toEqual(STDIO_ENTRY);
|
||||
expect(fs.readFileSync(path.join(settings, 'mcp_settings.json'), 'utf-8')).toBe('{}\n');
|
||||
});
|
||||
|
||||
it('droid: a local install says the file is meant to be committed; a global one has nothing to say', () => {
|
||||
const droid = getTarget('droid')!;
|
||||
expect(droid.install('global', { autoAllow: false }).notes).toBeUndefined();
|
||||
expect(droid.install('local', { autoAllow: false }).notes?.join(' ')).toMatch(/committed/);
|
||||
});
|
||||
|
||||
it('windsurf: global only, with a clear note', () => {
|
||||
const windsurf = getTarget('windsurf')!;
|
||||
expect(windsurf.supportsLocation('local')).toBe(false);
|
||||
expect(windsurf.detect('local')).toEqual({ installed: false, alreadyConfigured: false });
|
||||
expect(windsurf.describePaths('local')).toEqual([]);
|
||||
const result = windsurf.install('local', { autoAllow: false });
|
||||
expect(result.files).toEqual([]);
|
||||
expect(result.notes?.join(' ')).toMatch(/no project-local/);
|
||||
expect(windsurf.uninstall('local').files).toEqual([]);
|
||||
expect(windsurf.printConfig('local')).toMatch(/no project-local/);
|
||||
});
|
||||
|
||||
it('kimi, pi, qoder: a local install says the folder has to be trusted first', () => {
|
||||
for (const id of ['kimi', 'pi', 'qoder']) {
|
||||
expect(getTarget(id)!.install('local', { autoAllow: false }).notes?.join(' '), id).toMatch(/trust/i);
|
||||
expect(getTarget(id)!.install('global', { autoAllow: false }).notes?.join(' '), id).not.toMatch(/trust/i);
|
||||
}
|
||||
});
|
||||
|
||||
it('qoder: a local install stays out of the .mcp.json Claude Code shares', () => {
|
||||
const shared = path.join(dirs.cwd(), '.mcp.json');
|
||||
getTarget('claude')!.install('local', { autoAllow: false });
|
||||
const before = fs.readFileSync(shared, 'utf-8');
|
||||
const qoder = getTarget('qoder')!;
|
||||
|
||||
qoder.install('local', { autoAllow: false });
|
||||
qoder.uninstall('local');
|
||||
|
||||
expect(fs.readFileSync(shared, 'utf-8')).toBe(before);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['qwen', 'QWEN_HOME', ['settings.json', 'QWEN.md']],
|
||||
['kimi', 'KIMI_CODE_HOME', ['mcp.json', 'AGENTS.md']],
|
||||
['qoder', 'QODER_CONFIG_DIR', ['settings.json']],
|
||||
['pi', 'PI_CODING_AGENT_DIR', ['mcp.json', 'AGENTS.md']],
|
||||
['dsh', 'DSH_HOME', ['cordis.patch.yml', 'AGENTS.md']],
|
||||
['grok', 'GROK_HOME', ['config.toml', path.join('rules', 'codegraph.md')]],
|
||||
] as const)('%s: a global install follows %s', (id, name, files) => {
|
||||
process.env[name] = path.join(dirs.home(), 'moved');
|
||||
try {
|
||||
const target = getTarget(id)!;
|
||||
const result = target.install('global', { autoAllow: false });
|
||||
|
||||
const expected = files.map((f) => path.join(dirs.home(), 'moved', f));
|
||||
expect(result.files.map((f) => f.path)).toEqual(expected);
|
||||
for (const file of expected) expect(fs.existsSync(file), file).toBe(true);
|
||||
expect(fs.readdirSync(dirs.home())).toEqual(['moved']);
|
||||
expect(target.detect('global').alreadyConfigured).toBe(true);
|
||||
} finally {
|
||||
delete process.env[name];
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a blank value is unset', ' ', (home: string) => path.join(home, 'fallback')],
|
||||
['~ is the home', '~', (home: string) => home],
|
||||
['~/ starts in the home', '~/moved/here', (home: string) => path.join(home, 'moved', 'here')],
|
||||
])('configDirFromEnv: %s', (_name, value, expected) => {
|
||||
process.env.CODEGRAPH_TEST_HOME_DIR = value;
|
||||
try {
|
||||
expect(configDirFromEnv('CODEGRAPH_TEST_HOME_DIR', path.join(dirs.home(), 'fallback'))).toBe(expected(dirs.home()));
|
||||
} finally {
|
||||
delete process.env.CODEGRAPH_TEST_HOME_DIR;
|
||||
}
|
||||
});
|
||||
|
||||
it('configDirFromEnv: a relative path starts in the working directory', () => {
|
||||
process.env.CODEGRAPH_TEST_HOME_DIR = path.join('some', 'dir');
|
||||
try {
|
||||
expect(configDirFromEnv('CODEGRAPH_TEST_HOME_DIR', 'unused')).toBe(path.join(process.cwd(), 'some', 'dir'));
|
||||
} finally {
|
||||
delete process.env.CODEGRAPH_TEST_HOME_DIR;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Installer targets — settings files that hold comments', () => {
|
||||
const dirs = useTmpDirs('jsonc');
|
||||
|
||||
const SETTINGS = [
|
||||
'{',
|
||||
' // my setup',
|
||||
' "general": { "language": "zh" }, // keep Chinese replies',
|
||||
' /* providers below */',
|
||||
' "model": { "name": "qwen3-max" },',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
for (const [id, relative] of [['qwen', ['.qwen', 'settings.json']], ['qoder', ['.qoder', 'settings.json']]] as const) {
|
||||
it(`${id}: comments survive install, re-install and uninstall, and detect sees through them`, () => {
|
||||
const file = path.join(dirs.home(), ...relative);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, SETTINGS);
|
||||
const target = getTarget(id)!;
|
||||
expect(target.detect('global').alreadyConfigured).toBe(false);
|
||||
|
||||
target.install('global', { autoAllow: false });
|
||||
|
||||
const installed = fs.readFileSync(file, 'utf-8');
|
||||
for (const kept of ['// my setup', '// keep Chinese replies', '/* providers below */', '"qwen3-max"']) {
|
||||
expect(installed).toContain(kept);
|
||||
}
|
||||
expect(parseJsonc(installed).mcpServers.codegraph).toEqual(STDIO_ENTRY);
|
||||
expect(target.detect('global').alreadyConfigured).toBe(true);
|
||||
expect(fs.existsSync(`${file}.backup`)).toBe(false);
|
||||
|
||||
for (const f of target.install('global', { autoAllow: false }).files) expect(f.action, f.path).toBe('unchanged');
|
||||
expect(fs.readFileSync(file, 'utf-8')).toBe(installed);
|
||||
|
||||
target.uninstall('global');
|
||||
const after = fs.readFileSync(file, 'utf-8');
|
||||
expect(after).toContain('// my setup');
|
||||
expect(parseJsonc(after)).toEqual(parseJsonc(SETTINGS));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('Installer targets — Qwen Code auto-allow', () => {
|
||||
const dirs = useTmpDirs('qwen');
|
||||
const settings = () => path.join(dirs.home(), '.qwen', 'settings.json');
|
||||
const read = () => parseJsonc(fs.readFileSync(settings(), 'utf-8'));
|
||||
const seed = (content: string) => {
|
||||
fs.mkdirSync(path.dirname(settings()), { recursive: true });
|
||||
fs.writeFileSync(settings(), content);
|
||||
};
|
||||
|
||||
it('autoAllow adds the rule for the whole server, in the one write of the settings file', () => {
|
||||
const result = getTarget('qwen')!.install('global', { autoAllow: true });
|
||||
|
||||
expect(read().permissions).toEqual({ allow: ['mcp__codegraph'] });
|
||||
expect(result.files.filter((f) => f.path === settings())).toEqual([{ path: settings(), action: 'created' }]);
|
||||
});
|
||||
|
||||
it('without autoAllow no permissions are written', () => {
|
||||
getTarget('qwen')!.install('global', { autoAllow: false });
|
||||
expect(read().permissions).toBeUndefined();
|
||||
});
|
||||
|
||||
it('the rule joins the rules already there, with comments kept, and only once', () => {
|
||||
seed('{\n "permissions": {\n // team rules\n "allow": ["Bash(git *)"],\n "deny": ["Read(.env)"]\n }\n}\n');
|
||||
const qwen = getTarget('qwen')!;
|
||||
|
||||
qwen.install('global', { autoAllow: true });
|
||||
const installed = fs.readFileSync(settings(), 'utf-8');
|
||||
expect(read().permissions).toEqual({ allow: ['Bash(git *)', 'mcp__codegraph'], deny: ['Read(.env)'] });
|
||||
expect(installed).toContain('// team rules');
|
||||
|
||||
for (const f of qwen.install('global', { autoAllow: true }).files) expect(f.action, f.path).toBe('unchanged');
|
||||
expect(fs.readFileSync(settings(), 'utf-8')).toBe(installed);
|
||||
});
|
||||
|
||||
it('a permissions.allow that is not a list is left alone', () => {
|
||||
seed('{ "permissions": { "allow": "Bash" } }\n');
|
||||
getTarget('qwen')!.install('global', { autoAllow: true });
|
||||
expect(read().permissions).toEqual({ allow: 'Bash' });
|
||||
});
|
||||
|
||||
it('a refresh never adds the rule back', () => {
|
||||
const qwen = getTarget('qwen')!;
|
||||
qwen.install('global', { autoAllow: true });
|
||||
const trimmed = read();
|
||||
trimmed.permissions.allow = [];
|
||||
fs.writeFileSync(settings(), `${JSON.stringify(trimmed, null, 2)}\n`);
|
||||
|
||||
refreshTargets([qwen], 'global');
|
||||
|
||||
expect(read().permissions.allow).toEqual([]);
|
||||
});
|
||||
|
||||
it('uninstall takes out only the rule and the server', () => {
|
||||
seed(`${JSON.stringify({
|
||||
model: { name: 'qwen3-max' },
|
||||
mcpServers: { idea: { command: 'java' } },
|
||||
permissions: { allow: ['mcp__idea', 'Read'], deny: ['Read(.env)'] },
|
||||
}, null, 2)}\n`);
|
||||
const qwen = getTarget('qwen')!;
|
||||
|
||||
qwen.install('global', { autoAllow: true });
|
||||
qwen.uninstall('global');
|
||||
|
||||
expect(read()).toEqual({
|
||||
model: { name: 'qwen3-max' },
|
||||
mcpServers: { idea: { command: 'java' } },
|
||||
permissions: { allow: ['mcp__idea', 'Read'], deny: ['Read(.env)'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('uninstall removes a rule left without its server, and the wrappers that held only it', () => {
|
||||
seed(`${JSON.stringify({ theme: 'dark', permissions: { allow: ['mcp__codegraph'] } }, null, 2)}\n`);
|
||||
|
||||
const result = getTarget('qwen')!.uninstall('global');
|
||||
|
||||
expect(result.files[0]).toEqual({ path: settings(), action: 'removed' });
|
||||
expect(read()).toEqual({ theme: 'dark' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Installer targets — Grok Build', () => {
|
||||
const dirs = useTmpDirs('grok');
|
||||
const tomlPath = () => path.join(dirs.home(), '.grok', 'config.toml');
|
||||
const rulesPath = () => path.join(dirs.home(), '.grok', 'rules', 'codegraph.md');
|
||||
const seed = (content: string) => {
|
||||
fs.mkdirSync(path.dirname(tomlPath()), { recursive: true });
|
||||
fs.writeFileSync(tomlPath(), content);
|
||||
};
|
||||
const TABLE = '[mcp_servers.codegraph]\ncommand = "codegraph"\nargs = ["serve", "--mcp"]\n';
|
||||
|
||||
it('a global install writes the table and the rules file', () => {
|
||||
const grok = getTarget('grok')!;
|
||||
const result = grok.install('global', { autoAllow: true });
|
||||
|
||||
expect(fs.readFileSync(tomlPath(), 'utf-8')).toBe(TABLE);
|
||||
expect(fs.readFileSync(rulesPath(), 'utf-8')).toBe(`${CODEGRAPH_INSTRUCTIONS_BLOCK}\n`);
|
||||
expect(result.files.map((f) => f.path)).toEqual([tomlPath(), rulesPath()]);
|
||||
expect(result.notes?.join(' ')).toMatch(/\/mcps/);
|
||||
expect(result.notes?.join(' ')).not.toMatch(/trusted/);
|
||||
|
||||
for (const f of grok.install('global', { autoAllow: true }).files) expect(f.action, f.path).toBe('unchanged');
|
||||
});
|
||||
|
||||
it('a local install writes under ./.grok, says it needs trust, and leaves the global entry', () => {
|
||||
const grok = getTarget('grok')!;
|
||||
grok.install('global', { autoAllow: false });
|
||||
|
||||
const result = grok.install('local', { autoAllow: false });
|
||||
|
||||
expect(fs.readFileSync(path.join(dirs.cwd(), '.grok', 'config.toml'), 'utf-8')).toBe(TABLE);
|
||||
expect(fs.existsSync(path.join(dirs.cwd(), '.grok', 'rules', 'codegraph.md'))).toBe(true);
|
||||
expect(result.notes?.join(' ')).toMatch(/trusted/);
|
||||
|
||||
grok.uninstall('local');
|
||||
expect(grok.detect('local').alreadyConfigured).toBe(false);
|
||||
expect(grok.detect('global').alreadyConfigured).toBe(true);
|
||||
});
|
||||
|
||||
it('siblings survive, and uninstall gives the file back', () => {
|
||||
const content = '[models]\ndefault = "grok-4"\n\n[mcp_servers.other]\ncommand = "other"\n';
|
||||
seed(content);
|
||||
const grok = getTarget('grok')!;
|
||||
|
||||
grok.install('global', { autoAllow: false });
|
||||
expect(fs.readFileSync(tomlPath(), 'utf-8')).toBe(`${content}\n${TABLE}`);
|
||||
|
||||
grok.uninstall('global');
|
||||
expect(fs.readFileSync(tomlPath(), 'utf-8')).toBe(content);
|
||||
});
|
||||
|
||||
it('uninstall deletes a file holding only our table', () => {
|
||||
const grok = getTarget('grok')!;
|
||||
grok.install('global', { autoAllow: false });
|
||||
|
||||
grok.uninstall('global');
|
||||
|
||||
expect(fs.existsSync(tomlPath())).toBe(false);
|
||||
expect(fs.existsSync(rulesPath())).toBe(false);
|
||||
});
|
||||
|
||||
it('a table the user changed stays as it is through a re-install and a refresh', () => {
|
||||
const content = '[mcp_servers.codegraph]\ncommand = "/opt/bin/codegraph"\nargs = ["serve", "--mcp"]\nenabled = false\nstartup_timeout_sec = 60\n';
|
||||
seed(content);
|
||||
const grok = getTarget('grok')!;
|
||||
|
||||
expect(grok.install('global', { autoAllow: false }).files[0]).toEqual({ path: tomlPath(), action: 'unchanged' });
|
||||
refreshTargets([grok], 'global');
|
||||
|
||||
expect(fs.readFileSync(tomlPath(), 'utf-8')).toBe(content);
|
||||
});
|
||||
|
||||
it('a table written with an indent or quoted keys is found, and never defined twice', () => {
|
||||
const content = ' [ "mcp_servers" . codegraph ]\n command = "codegraph"\n args = ["serve", "--mcp"]\n';
|
||||
seed(content);
|
||||
const grok = getTarget('grok')!;
|
||||
|
||||
expect(grok.detect('global').alreadyConfigured).toBe(true);
|
||||
grok.install('global', { autoAllow: false });
|
||||
|
||||
expect(fs.readFileSync(tomlPath(), 'utf-8')).toBe(content);
|
||||
});
|
||||
|
||||
it('dotted keys or an inline table are left as written, with a note', () => {
|
||||
for (const content of [
|
||||
'mcp_servers.codegraph.command = "codegraph"\n',
|
||||
'[mcp_servers]\ncodegraph = { command = "codegraph", args = ["serve", "--mcp"] }\n',
|
||||
]) {
|
||||
seed(content);
|
||||
const grok = getTarget('grok')!;
|
||||
|
||||
const result = grok.install('global', { autoAllow: false });
|
||||
|
||||
expect(result.files[0]).toEqual({ path: tomlPath(), action: 'unchanged' });
|
||||
expect(result.notes?.join(' ')).toMatch(/Left the mcp_servers\.codegraph entry/);
|
||||
expect(fs.readFileSync(tomlPath(), 'utf-8')).toBe(content);
|
||||
}
|
||||
});
|
||||
|
||||
it('an inline mcp_servers stops the install with nothing changed', () => {
|
||||
const content = 'mcp_servers = { other = { command = "x" } }\n';
|
||||
seed(content);
|
||||
|
||||
expect(() => getTarget('grok')!.install('global', { autoAllow: false })).toThrow(/Nothing was changed/);
|
||||
expect(fs.readFileSync(tomlPath(), 'utf-8')).toBe(content);
|
||||
});
|
||||
|
||||
it('uninstall reports an entry inside an inline mcp_servers instead of touching it', () => {
|
||||
const content = 'mcp_servers = { codegraph = { command = "codegraph" } }\n';
|
||||
seed(content);
|
||||
const grok = getTarget('grok')!;
|
||||
|
||||
const result = grok.uninstall('global');
|
||||
|
||||
expect(result.files[0]).toEqual({ path: tomlPath(), action: 'not-found' });
|
||||
expect(result.notes?.join(' ')).toMatch(/Remove it from there yourself/);
|
||||
expect(fs.readFileSync(tomlPath(), 'utf-8')).toBe(content);
|
||||
});
|
||||
|
||||
it('the rules file keeps what the user wrote around the block', () => {
|
||||
fs.mkdirSync(path.dirname(rulesPath()), { recursive: true });
|
||||
fs.writeFileSync(rulesPath(), `# My notes\n\nBe terse.\n\n${LEGACY_BLOCK}\n`);
|
||||
const grok = getTarget('grok')!;
|
||||
|
||||
grok.install('global', { autoAllow: false });
|
||||
expect(fs.readFileSync(rulesPath(), 'utf-8')).toBe(`# My notes\n\nBe terse.\n\n${CODEGRAPH_INSTRUCTIONS_BLOCK}\n`);
|
||||
|
||||
grok.uninstall('global');
|
||||
expect(fs.readFileSync(rulesPath(), 'utf-8')).toBe('# My notes\n\nBe terse.\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Installer targets — DeepSeek Harness', () => {
|
||||
const dirs = useTmpDirs('dsh');
|
||||
const dsh = () => getTarget('dsh')!;
|
||||
const patchPath = () => path.join(dirs.home(), '.dsh', 'cordis.patch.yml');
|
||||
const agentsPath = () => path.join(dirs.home(), '.dsh', 'AGENTS.md');
|
||||
const seed = (content: string, file = patchPath()) => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, content);
|
||||
};
|
||||
const read = (file = patchPath()) => fs.readFileSync(file, 'utf-8');
|
||||
const ENTRY = [
|
||||
'- insert:',
|
||||
' - id: mcp-codegraph',
|
||||
" name: '@deepseek-ai/dsh-mcp-client'",
|
||||
' config:',
|
||||
' serverName: codegraph',
|
||||
' transport: stdio',
|
||||
' command: codegraph',
|
||||
' args:',
|
||||
' - serve',
|
||||
' - --mcp',
|
||||
'',
|
||||
].join('\n');
|
||||
const USER_ROW = [
|
||||
' - id: mcp-github',
|
||||
" name: '@deepseek-ai/dsh-mcp-client'",
|
||||
' config:',
|
||||
' serverName: github',
|
||||
' transport: stdio',
|
||||
' command: npx',
|
||||
" args: ['-y', '@modelcontextprotocol/server-github']",
|
||||
'',
|
||||
].join('\n');
|
||||
const profilePatch = (name: string) => path.join(dirs.home(), '.dsh', 'profiles', name, 'cordis.patch.yml');
|
||||
|
||||
it('a fresh install writes the patch and the AGENTS.md block; uninstall removes both', () => {
|
||||
const result = dsh().install('global', { autoAllow: true });
|
||||
|
||||
expect(read()).toBe(ENTRY);
|
||||
expect(read(agentsPath())).toBe(`${CODEGRAPH_INSTRUCTIONS_BLOCK}\n`);
|
||||
expect(result.files.map((f) => f.path)).toEqual([patchPath(), agentsPath()]);
|
||||
expect(dsh().detect('global').alreadyConfigured).toBe(true);
|
||||
for (const f of dsh().install('global', { autoAllow: true }).files) expect(f.action, f.path).toBe('unchanged');
|
||||
|
||||
dsh().uninstall('global');
|
||||
expect(fs.existsSync(patchPath())).toBe(false);
|
||||
expect(fs.existsSync(agentsPath())).toBe(false);
|
||||
});
|
||||
|
||||
it('the documented empty list is replaced, and comes back on uninstall', () => {
|
||||
const content = '# my patches\n[]\n# the end\n';
|
||||
seed(content);
|
||||
|
||||
dsh().install('global', { autoAllow: false });
|
||||
expect(read()).toBe(`# my patches\n${ENTRY}# the end\n`);
|
||||
|
||||
dsh().uninstall('global');
|
||||
expect(read()).toBe('# my patches\n# the end\n[]\n');
|
||||
});
|
||||
|
||||
it('a comments-only file, which stops dsh from starting, is repaired, and uninstall keeps it valid', () => {
|
||||
seed('# nothing here yet\n');
|
||||
|
||||
dsh().install('global', { autoAllow: false });
|
||||
expect(read()).toBe(`# nothing here yet\n${ENTRY}`);
|
||||
|
||||
dsh().uninstall('global');
|
||||
expect(read()).toBe('# nothing here yet\n[]\n');
|
||||
});
|
||||
|
||||
it('an empty file gets the patch, and goes away with it', () => {
|
||||
seed('');
|
||||
dsh().install('global', { autoAllow: false });
|
||||
expect(read()).toBe(ENTRY);
|
||||
dsh().uninstall('global');
|
||||
expect(fs.existsSync(patchPath())).toBe(false);
|
||||
});
|
||||
|
||||
it('the patch goes after the user\'s own, and uninstall gives the file back byte for byte', () => {
|
||||
const content = `# overrides\n- id: tool-bash\n disabled: true\n\n- insert:\n${USER_ROW}`;
|
||||
seed(content);
|
||||
|
||||
dsh().install('global', { autoAllow: false });
|
||||
expect(read()).toBe(`${content}\n${ENTRY}`);
|
||||
|
||||
dsh().uninstall('global');
|
||||
expect(read()).toBe(content);
|
||||
});
|
||||
|
||||
it('a row the user wrote stays as it is through a re-install and a refresh: a pinned cwd is not reset', () => {
|
||||
const row = [
|
||||
'- insert:',
|
||||
' - id: my-codegraph',
|
||||
' name: "@deepseek-ai/dsh-mcp-client"',
|
||||
' config:',
|
||||
' serverName: "codegraph"',
|
||||
' transport: stdio',
|
||||
' command: /opt/bin/codegraph',
|
||||
' args: [serve, --mcp, --path, /work]',
|
||||
' cwd: /work',
|
||||
'',
|
||||
].join('\n');
|
||||
seed(row);
|
||||
|
||||
expect(dsh().detect('global').alreadyConfigured).toBe(true);
|
||||
expect(dsh().install('global', { autoAllow: false }).files[0]).toEqual({ path: patchPath(), action: 'unchanged' });
|
||||
refreshTargets([dsh()], 'global');
|
||||
|
||||
expect(read()).toBe(row);
|
||||
});
|
||||
|
||||
it('a row merged into the user\'s insert list is found, and uninstall takes out only that row', () => {
|
||||
const ours = [
|
||||
' - id: mcp-codegraph',
|
||||
" name: '@deepseek-ai/dsh-mcp-client'",
|
||||
' config:',
|
||||
' serverName: codegraph',
|
||||
' transport: stdio',
|
||||
' command: codegraph',
|
||||
' args: [serve, --mcp]',
|
||||
'',
|
||||
].join('\n');
|
||||
seed(`- insert:\n${USER_ROW}${ours}${USER_ROW.replace(/github/g, 'gitlab')}`);
|
||||
|
||||
dsh().install('global', { autoAllow: false });
|
||||
expect(read().match(/serverName: codegraph/g)).toHaveLength(1);
|
||||
|
||||
dsh().uninstall('global');
|
||||
expect(read()).toBe(`- insert:\n${USER_ROW}${USER_ROW.replace(/github/g, 'gitlab')}`);
|
||||
});
|
||||
|
||||
it('a row written on one line is a row like any other', () => {
|
||||
const flow = " - { id: mcp-codegraph, name: '@deepseek-ai/dsh-mcp-client', config: { serverName: codegraph, transport: stdio, command: codegraph, args: [serve, --mcp] } }\n";
|
||||
seed(`- insert:\n${USER_ROW}${flow}`);
|
||||
|
||||
expect(dsh().detect('global').alreadyConfigured).toBe(true);
|
||||
dsh().install('global', { autoAllow: false });
|
||||
expect(read()).toBe(`- insert:\n${USER_ROW}${flow}`);
|
||||
|
||||
dsh().uninstall('global');
|
||||
expect(read()).toBe(`- insert:\n${USER_ROW}`);
|
||||
});
|
||||
|
||||
it('a comment that mentions the server is not an entry', () => {
|
||||
const content = "# serverName: codegraph\n- id: tool-bash\n disabled: true\n";
|
||||
seed(content);
|
||||
|
||||
expect(dsh().detect('global').alreadyConfigured).toBe(false);
|
||||
dsh().install('global', { autoAllow: false });
|
||||
expect(read()).toBe(`${content}\n${ENTRY}`);
|
||||
});
|
||||
|
||||
it('a file that is not a block list stops the install with nothing changed', () => {
|
||||
for (const content of ['[{ id: tool-bash, disabled: true }]\n', 'not: a list\n', ' - id: indented\n disabled: true\n']) {
|
||||
seed(content);
|
||||
expect(() => dsh().install('global', { autoAllow: false }), content).toThrow(/Nothing was changed/);
|
||||
expect(read()).toBe(content);
|
||||
}
|
||||
});
|
||||
|
||||
it('an entry in a form it can\'t edit is left alone, and uninstall says so', () => {
|
||||
const content = "[{ insert: [{ id: mcp-codegraph, name: '@deepseek-ai/dsh-mcp-client', config: { serverName: codegraph } }] }]\n";
|
||||
seed(content);
|
||||
|
||||
expect(dsh().detect('global').alreadyConfigured).toBe(true);
|
||||
expect(dsh().install('global', { autoAllow: false }).files[0]).toEqual({ path: patchPath(), action: 'unchanged' });
|
||||
|
||||
const result = dsh().uninstall('global');
|
||||
expect(result.files[0]).toEqual({ path: patchPath(), action: 'not-found' });
|
||||
expect(result.notes?.join(' ')).toMatch(/Remove it from there yourself/);
|
||||
expect(read()).toBe(content);
|
||||
});
|
||||
|
||||
it('a row nested in a group is not mistaken for the group, and the group stays', () => {
|
||||
const content = [
|
||||
'- insert:',
|
||||
' - id: my-group',
|
||||
' name: group',
|
||||
' config:',
|
||||
' - id: mcp-codegraph',
|
||||
" name: '@deepseek-ai/dsh-mcp-client'",
|
||||
' config: { serverName: codegraph }',
|
||||
'',
|
||||
].join('\n');
|
||||
seed(content);
|
||||
|
||||
const result = dsh().uninstall('global');
|
||||
|
||||
expect(result.notes?.join(' ')).toMatch(/Remove it from there yourself/);
|
||||
expect(read()).toBe(content);
|
||||
});
|
||||
|
||||
it('an insert item with keys of its own stays when its only row is ours', () => {
|
||||
const content = `${ENTRY} note: mine\n`;
|
||||
seed(content);
|
||||
|
||||
const result = dsh().uninstall('global');
|
||||
|
||||
expect(result.notes?.join(' ')).toMatch(/Remove it from there yourself/);
|
||||
expect(read()).toBe(content);
|
||||
});
|
||||
|
||||
it('a profile\'s own entry counts: nothing is added beside it, and uninstall removes it, keeping the file', () => {
|
||||
const profile = profilePatch('web');
|
||||
seed(`# my profile\n${ENTRY}`, profile);
|
||||
|
||||
expect(dsh().detect('global').alreadyConfigured).toBe(true);
|
||||
const result = dsh().install('global', { autoAllow: false });
|
||||
expect(result.files[0]).toEqual({ path: patchPath(), action: 'unchanged' });
|
||||
expect(result.notes?.join(' ')).toMatch(/Left the CodeGraph entry in .*web/);
|
||||
expect(fs.existsSync(patchPath())).toBe(false);
|
||||
|
||||
const removed = dsh().uninstall('global');
|
||||
expect(removed.files.map((f) => f.path)).toContain(profile);
|
||||
expect(read(profile)).toBe('# my profile\n[]\n');
|
||||
expect(dsh().detect('global').alreadyConfigured).toBe(false);
|
||||
});
|
||||
|
||||
it('a profile that was only emptied by uninstall keeps a valid file', () => {
|
||||
const profile = profilePatch('web');
|
||||
seed(ENTRY, profile);
|
||||
|
||||
dsh().uninstall('global');
|
||||
|
||||
expect(read(profile)).toBe('[]\n');
|
||||
});
|
||||
|
||||
it('CRLF is kept', () => {
|
||||
const content = '# mine\r\n- id: tool-bash\r\n disabled: true\r\n';
|
||||
seed(content);
|
||||
|
||||
dsh().install('global', { autoAllow: false });
|
||||
expect(read()).toBe(`${content}\r\n${ENTRY.replace(/\n/g, '\r\n')}`);
|
||||
|
||||
dsh().uninstall('global');
|
||||
expect(read()).toBe(content);
|
||||
});
|
||||
|
||||
it('global only', () => {
|
||||
expect(dsh().supportsLocation('local')).toBe(false);
|
||||
expect(dsh().install('local', { autoAllow: false }).files).toEqual([]);
|
||||
expect(dsh().uninstall('local').files).toEqual([]);
|
||||
expect(dsh().describePaths('local')).toEqual([]);
|
||||
});
|
||||
|
||||
it('printConfig prints the patch to paste', () => {
|
||||
expect(dsh().printConfig('global')).toBe(`# Add to ${patchPath()}\n\n${ENTRY}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Antigravity macOS command persistence (#1443)', () => {
|
||||
let tmpHome: string;
|
||||
let homeRestore: { restore: () => void };
|
||||
|
||||
@@ -54,7 +54,7 @@ if (process.platform === 'win32') {
|
||||
delete process.env.LOCALAPPDATA;
|
||||
}
|
||||
process.env.XDG_CONFIG_HOME = path.join(home, '.config');
|
||||
for (const override of ['CLAUDE_CONFIG_DIR', 'CODEX_HOME', 'COPILOT_HOME', 'HERMES_HOME']) {
|
||||
for (const override of ['CLAUDE_CONFIG_DIR', 'CODEX_HOME', 'COPILOT_HOME', 'HERMES_HOME', 'QWEN_HOME', 'DSH_HOME', 'GROK_HOME', 'KIMI_CODE_HOME', 'QODER_CONFIG_DIR', 'PI_CODING_AGENT_DIR']) {
|
||||
delete process.env[override];
|
||||
}
|
||||
|
||||
|
||||
@@ -3076,12 +3076,12 @@ program
|
||||
*/
|
||||
program
|
||||
.command('install')
|
||||
.description('Install codegraph MCP server into one or more agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, GitHub Copilot)')
|
||||
.description('Install codegraph MCP server into one or more agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, GitHub Copilot, IBM Bob, Factory Droid, Windsurf, Qwen Code, DeepSeek Harness, Grok Build, Kimi Code, Qoder CLI, Pi)')
|
||||
.option('-t, --target <ids>', 'Target agent(s): comma-separated ids, or "auto"|"all"|"none". Default: prompt')
|
||||
.option('-l, --location <where>', 'Install location: "global" or "local". Default: prompt')
|
||||
.option('-y, --yes', 'Non-interactive: defaults to --location=global --target=auto, auto-allow on')
|
||||
.option('-i, --init', 'After wiring agents, also run `codegraph init` in the current directory — builds this project’s index, so install + index is one command (combine with --yes for an unattended bootstrap)')
|
||||
.option('--no-permissions', 'Skip writing the auto-allow permissions list (Claude Code only)')
|
||||
.option('--no-permissions', 'Skip writing the auto-allow permissions list (Claude Code and Qwen Code only)')
|
||||
.option('--print-config <id>', 'Print MCP config snippet for the named agent and exit (no file writes)')
|
||||
.option('--refresh', 'Rewrite what previous installs configured, for already-configured agents only (never adds new ones). Run automatically by `codegraph upgrade`')
|
||||
.action(async (opts: {
|
||||
@@ -3198,7 +3198,7 @@ program
|
||||
*/
|
||||
program
|
||||
.command('uninstall')
|
||||
.description('Remove codegraph from your agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, GitHub Copilot)')
|
||||
.description('Remove codegraph from your agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, GitHub Copilot, IBM Bob, Factory Droid, Windsurf, Qwen Code, DeepSeek Harness, Grok Build, Kimi Code, Qoder CLI, Pi)')
|
||||
.option('-t, --target <ids>', 'Target agent(s): comma-separated ids, or "all". Default: all')
|
||||
.option('-l, --location <where>', 'Uninstall location: "global" or "local". Default: prompt')
|
||||
.option('-y, --yes', 'Non-interactive: defaults to --location=global --target=all')
|
||||
|
||||
+11
-8
@@ -3,8 +3,10 @@
|
||||
*
|
||||
* Multi-target: writes MCP server config + instructions for the
|
||||
* agents the user picks (Claude Code, Cursor, Codex CLI, opencode,
|
||||
* Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, and GitHub
|
||||
* Copilot in VS Code / the Copilot CLI / JetBrains IDEs).
|
||||
* Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, GitHub Copilot in
|
||||
* VS Code / the Copilot CLI / JetBrains IDEs, IBM Bob, Factory Droid,
|
||||
* Windsurf, Qwen Code, DeepSeek Harness, Grok Build, Kimi Code, the
|
||||
* Qoder CLI, and Pi).
|
||||
* Defaults to the Claude-only behavior for backwards compatibility
|
||||
* when no targets are explicitly chosen and nothing else is detected.
|
||||
*
|
||||
@@ -161,16 +163,17 @@ export async function runInstallerWithOptions(opts: RunInstallerOptions): Promis
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: auto-allow permissions (only meaningful for Claude;
|
||||
// skipped silently by other targets).
|
||||
// Step 4: auto-allow permissions (only meaningful for Claude Code and
|
||||
// Qwen Code; skipped silently by other targets).
|
||||
const autoAllowAgents = targets.filter((t) => t.id === 'claude' || t.id === 'qwen').map((t) => t.displayName);
|
||||
let autoAllow: boolean;
|
||||
if (opts.autoAllow !== undefined) {
|
||||
autoAllow = opts.autoAllow;
|
||||
} else if (useDefaults) {
|
||||
autoAllow = true;
|
||||
} else if (targets.some((t) => t.id === 'claude')) {
|
||||
} else if (autoAllowAgents.length > 0) {
|
||||
const ans = await clack.confirm({
|
||||
message: 'Auto-allow CodeGraph commands? (Skips permission prompts in Claude Code)',
|
||||
message: `Auto-allow CodeGraph commands? (Skips permission prompts in ${autoAllowAgents.join(' and ')})`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (clack.isCancel(ans)) {
|
||||
@@ -480,8 +483,8 @@ export async function runUninstaller(opts: RunUninstallerOptions): Promise<void>
|
||||
const sel = await clack.select({
|
||||
message: 'Remove CodeGraph from all your projects, or just this one?',
|
||||
options: [
|
||||
{ value: 'global' as const, label: 'All projects (global)', hint: '~/.claude, ~/.cursor, ~/.codex, ~/.config/opencode, ~/.hermes, ~/.gemini, ~/.kiro, ~/.copilot, ~/.config/github-copilot' },
|
||||
{ value: 'local' as const, label: 'Just this project (local)', hint: './.claude, ./.cursor, ./.vscode, ./opencode.jsonc, ./.gemini, ./.kiro' },
|
||||
{ value: 'global' as const, label: 'All projects (global)', hint: '~/.claude, ~/.cursor, ~/.codex, ~/.config/opencode, ~/.hermes, ~/.gemini, ~/.kiro, ~/.copilot, ~/.config/github-copilot, ~/.bob, ~/.factory, ~/.codeium/windsurf, ~/.qwen, ~/.dsh, ~/.grok, ~/.kimi-code, ~/.qoder, ~/.pi' },
|
||||
{ value: 'local' as const, label: 'Just this project (local)', hint: './.claude, ./.cursor, ./.vscode, ./opencode.jsonc, ./.gemini, ./.kiro, ./.bob, ./.factory, ./.qwen, ./.grok, ./.kimi-code, ./.qoder, ./.pi' },
|
||||
],
|
||||
initialValue: 'global' as const,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The marker-fenced agent-instructions block the installer writes into each
|
||||
* agent's instructions file (CLAUDE.md / AGENTS.md / GEMINI.md).
|
||||
* agent's instructions file (CLAUDE.md / AGENTS.md / GEMINI.md / QWEN.md).
|
||||
*
|
||||
* History: pre-#529 the installer wrote a full usage playbook here, which
|
||||
* duplicated the MCP `initialize` instructions for the main agent — so it
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* IBM Bob target (Bob IDE and Bob Shell, which read the same files). Writes
|
||||
* the MCP server entry only:
|
||||
*
|
||||
* - global: `~/.bob/settings/mcp.json`
|
||||
* - local: `./.bob/mcp.json`, which Bob means to be shared in the repo
|
||||
*
|
||||
* The entry is `{ command, args }` under `mcpServers`: Bob's stdio entry has
|
||||
* no `type` key. IBM's watsonx.data guide calls the global file
|
||||
* `mcp_settings.json`, so when only that file exists, it is the one used.
|
||||
*
|
||||
* No instructions file, and `autoAllow` is a no-op: Bob approves MCP tools one
|
||||
* by one, behind its own global "Use MCP servers" switch.
|
||||
*
|
||||
* Docs: https://bob.ibm.com/docs/ide/configuration/mcp/mcp-in-bob
|
||||
* https://bob.ibm.com/docs/shell/configuration/mcp/mcp-bobshell
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { AgentTarget, DetectionResult, InstallOptions, Location, WriteResult } from './types';
|
||||
import { hasCodegraphServer, removeCodegraphServer, upsertCodegraphServer } from './json-config';
|
||||
import { getStdioServerConfig } from './shared';
|
||||
|
||||
function configDir(loc: Location): string {
|
||||
return path.join(loc === 'global' ? os.homedir() : process.cwd(), '.bob');
|
||||
}
|
||||
|
||||
function mcpJsonPath(loc: Location): string {
|
||||
if (loc === 'local') return path.join(configDir('local'), 'mcp.json');
|
||||
const settings = path.join(configDir('global'), 'settings');
|
||||
const current = path.join(settings, 'mcp.json');
|
||||
const earlier = path.join(settings, 'mcp_settings.json');
|
||||
return !fs.existsSync(current) && fs.existsSync(earlier) ? earlier : current;
|
||||
}
|
||||
|
||||
class BobTarget implements AgentTarget {
|
||||
readonly id = 'bob' as const;
|
||||
readonly displayName = 'IBM Bob';
|
||||
readonly docsUrl = 'https://bob.ibm.com/docs/ide/configuration/mcp/mcp-in-bob';
|
||||
|
||||
supportsLocation(_loc: Location): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
detect(loc: Location): DetectionResult {
|
||||
const file = mcpJsonPath(loc);
|
||||
return {
|
||||
installed: fs.existsSync(configDir(loc)),
|
||||
alreadyConfigured: hasCodegraphServer(file),
|
||||
configPath: file,
|
||||
};
|
||||
}
|
||||
|
||||
install(loc: Location, _opts: InstallOptions): WriteResult {
|
||||
return { files: [upsertCodegraphServer(mcpJsonPath(loc), getStdioServerConfig())] };
|
||||
}
|
||||
|
||||
uninstall(loc: Location): WriteResult {
|
||||
return { files: [removeCodegraphServer(mcpJsonPath(loc))] };
|
||||
}
|
||||
|
||||
printConfig(loc: Location): string {
|
||||
const snippet = JSON.stringify({ mcpServers: { codegraph: getStdioServerConfig() } }, null, 2);
|
||||
return `# Add to ${mcpJsonPath(loc)}\n\n${snippet}\n`;
|
||||
}
|
||||
|
||||
describePaths(loc: Location): string[] {
|
||||
return [mcpJsonPath(loc)];
|
||||
}
|
||||
}
|
||||
|
||||
export const bobTarget: AgentTarget = new BobTarget();
|
||||
@@ -40,17 +40,11 @@ import {
|
||||
WriteResult,
|
||||
} from './types';
|
||||
import {
|
||||
atomicWriteFileSync,
|
||||
getMcpServerConfig,
|
||||
removeFileUnlessLinked,
|
||||
removeMarkedSection,
|
||||
removeInstructionsEntry,
|
||||
upsertInstructionsEntry,
|
||||
} from './shared';
|
||||
import {
|
||||
CODEGRAPH_SECTION_END,
|
||||
CODEGRAPH_SECTION_START,
|
||||
} from '../instructions-template';
|
||||
import { buildTomlTable, removeTomlTable, tomlDefinesTable, tomlLineBreak, upsertTomlTable } from './toml';
|
||||
import { buildTomlTable, deleteTomlTable, tomlDefinesTable, writeTomlTable } from './toml';
|
||||
|
||||
const TOML_HEADER = 'mcp_servers.codegraph';
|
||||
|
||||
@@ -119,7 +113,7 @@ class CodexTarget implements AgentTarget {
|
||||
const files: WriteResult['files'] = [];
|
||||
const notes: string[] = [];
|
||||
|
||||
const mcp = writeMcpEntry(loc);
|
||||
const mcp = writeTomlTable(tomlConfigPath(loc), TOML_HEADER, buildCodegraphBlock());
|
||||
files.push(mcp.file);
|
||||
if (mcp.note) notes.push(mcp.note);
|
||||
|
||||
@@ -136,29 +130,11 @@ class CodexTarget implements AgentTarget {
|
||||
const files: WriteResult['files'] = [];
|
||||
const notes: string[] = [];
|
||||
|
||||
const tomlPath = tomlConfigPath(loc);
|
||||
if (fs.existsSync(tomlPath)) {
|
||||
const content = fs.readFileSync(tomlPath, 'utf-8');
|
||||
const result = removeTomlTable(content, TOML_HEADER);
|
||||
if (result.action === 'removed') {
|
||||
if (result.content.trim() === '') {
|
||||
removeFileUnlessLinked(tomlPath, '');
|
||||
} else {
|
||||
atomicWriteFileSync(tomlPath, result.content.trimEnd() + tomlLineBreak(content));
|
||||
}
|
||||
files.push({ path: tomlPath, action: 'removed' });
|
||||
} else {
|
||||
files.push({ path: tomlPath, action: 'not-found' });
|
||||
if (result.action === 'conflict') {
|
||||
const { conflict } = result;
|
||||
notes.push(`${tomlPath} sets ${TOML_HEADER} inside ${conflict.key} (${conflict.form}, line ${conflict.line}), which CodeGraph can't edit safely. Remove it from there yourself.`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
files.push({ path: tomlPath, action: 'not-found' });
|
||||
}
|
||||
const mcp = deleteTomlTable(tomlConfigPath(loc), TOML_HEADER);
|
||||
files.push(mcp.file);
|
||||
if (mcp.note) notes.push(mcp.note);
|
||||
|
||||
files.push(removeInstructionsEntry(loc));
|
||||
files.push(removeInstructionsEntry(instructionsPath(loc)));
|
||||
|
||||
return notes.length > 0 ? { files, notes } : { files };
|
||||
}
|
||||
@@ -181,52 +157,4 @@ function buildCodegraphBlock(): string {
|
||||
});
|
||||
}
|
||||
|
||||
function writeMcpEntry(loc: Location): { file: WriteResult['files'][number]; note?: string } {
|
||||
const file = tomlConfigPath(loc);
|
||||
const dir = path.dirname(file);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const block = buildCodegraphBlock();
|
||||
// Single read — `existing === ''` derives both "is the file empty
|
||||
// or absent" and "what was its content," avoiding a TOCTOU window
|
||||
// between two `fs.existsSync` calls.
|
||||
const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
|
||||
const created = existing.length === 0;
|
||||
const result = upsertTomlTable(existing, TOML_HEADER, block);
|
||||
|
||||
if (result.action === 'conflict') {
|
||||
// Never append a second definition: a table defined twice makes the
|
||||
// whole file invalid TOML, and Codex then loads none of it (#2562).
|
||||
const { conflict } = result;
|
||||
if (conflict.kind === 'defined') {
|
||||
// The user's own entry, in a form CodeGraph doesn't rewrite: it
|
||||
// already configures Codex, so it is left exactly as written.
|
||||
return {
|
||||
file: { path: file, action: 'unchanged' },
|
||||
note: `Left the ${TOML_HEADER} entry in ${file} as you wrote it (${conflict.form}, line ${conflict.line}): CodeGraph only rewrites a [${TOML_HEADER}] table.`,
|
||||
};
|
||||
}
|
||||
throw new Error(
|
||||
`${file} sets ${conflict.key} as ${conflict.form} (line ${conflict.line}), so a [${TOML_HEADER}] table can't be added without breaking the file. ` +
|
||||
`Nothing was changed: add CodeGraph's server to ${conflict.key} yourself, or rewrite it as [${conflict.key}.<name>] tables, then run this again.`,
|
||||
);
|
||||
}
|
||||
if (result.action === 'unchanged') {
|
||||
return { file: { path: file, action: 'unchanged' } };
|
||||
}
|
||||
atomicWriteFileSync(file, result.content);
|
||||
return { file: { path: file, action: created ? 'created' : 'updated' } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the marker-delimited CodeGraph block from this location's
|
||||
* AGENTS.md if a prior install wrote one. Used by both install
|
||||
* (self-heal on upgrade) and uninstall — see issue #529.
|
||||
*/
|
||||
function removeInstructionsEntry(loc: Location): WriteResult['files'][number] {
|
||||
const file = instructionsPath(loc);
|
||||
const action = removeMarkedSection(file, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END);
|
||||
return { path: file, action };
|
||||
}
|
||||
|
||||
export const codexTarget: AgentTarget = new CodexTarget();
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Factory Droid target (the `droid` CLI).
|
||||
*
|
||||
* - MCP server entry under `mcpServers` in `~/.factory/mcp.json` (global) or
|
||||
* `./.factory/mcp.json` (local). The project file is meant to be committed,
|
||||
* so a local install says so; our stdio entry carries no secrets.
|
||||
* - Instructions to `~/.factory/AGENTS.md` (global) or `./AGENTS.md` (local).
|
||||
*
|
||||
* Droid reloads when an `mcp.json` changes, so there is no restart note, and
|
||||
* it remembers MCP tool approvals per server instead of keeping an allowlist in
|
||||
* a file, so `autoAllow` is a no-op.
|
||||
*
|
||||
* Docs: https://docs.factory.com/cli/configuration/mcp
|
||||
* https://docs.factory.com/cli/configuration/agents-md
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { AgentTarget, DetectionResult, InstallOptions, Location, WriteResult } from './types';
|
||||
import { hasCodegraphServer, removeCodegraphServer, upsertCodegraphServer } from './json-config';
|
||||
import { getMcpServerConfig, removeInstructionsEntry, upsertInstructionsEntry } from './shared';
|
||||
|
||||
function configDir(loc: Location): string {
|
||||
return path.join(loc === 'global' ? os.homedir() : process.cwd(), '.factory');
|
||||
}
|
||||
|
||||
function mcpJsonPath(loc: Location): string {
|
||||
return path.join(configDir(loc), 'mcp.json');
|
||||
}
|
||||
|
||||
function instructionsPath(loc: Location): string {
|
||||
return loc === 'global' ? path.join(configDir('global'), 'AGENTS.md') : path.join(process.cwd(), 'AGENTS.md');
|
||||
}
|
||||
|
||||
class DroidTarget implements AgentTarget {
|
||||
readonly id = 'droid' as const;
|
||||
readonly displayName = 'Factory Droid';
|
||||
readonly docsUrl = 'https://docs.factory.com/cli/configuration/mcp';
|
||||
|
||||
supportsLocation(_loc: Location): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
detect(loc: Location): DetectionResult {
|
||||
const file = mcpJsonPath(loc);
|
||||
return {
|
||||
installed: fs.existsSync(configDir(loc)),
|
||||
alreadyConfigured: hasCodegraphServer(file),
|
||||
configPath: file,
|
||||
};
|
||||
}
|
||||
|
||||
install(loc: Location, _opts: InstallOptions): WriteResult {
|
||||
const files = [
|
||||
upsertCodegraphServer(mcpJsonPath(loc), getMcpServerConfig()),
|
||||
upsertInstructionsEntry(instructionsPath(loc)),
|
||||
];
|
||||
if (loc === 'global') return { files };
|
||||
return { files, notes: [`Droid shares ${mcpJsonPath('local')} with your team: it is meant to be committed.`] };
|
||||
}
|
||||
|
||||
uninstall(loc: Location): WriteResult {
|
||||
return {
|
||||
files: [
|
||||
removeCodegraphServer(mcpJsonPath(loc)),
|
||||
removeInstructionsEntry(instructionsPath(loc)),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
printConfig(loc: Location): string {
|
||||
const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2);
|
||||
return `# Add to ${mcpJsonPath(loc)}\n\n${snippet}\n`;
|
||||
}
|
||||
|
||||
describePaths(loc: Location): string[] {
|
||||
return [mcpJsonPath(loc), instructionsPath(loc)];
|
||||
}
|
||||
}
|
||||
|
||||
export const droidTarget: AgentTarget = new DroidTarget();
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* DeepSeek Harness (`dsh`) target, global only.
|
||||
*
|
||||
* dsh has no `mcpServers` file: an MCP server is one row for the
|
||||
* `@deepseek-ai/dsh-mcp-client` plugin in a Cordis patch file. We add one to
|
||||
* `$DSH_HOME/cordis.patch.yml` (default `~/.dsh`), the home-level layer that
|
||||
* applies to every profile, as a `- insert:` patch of its own:
|
||||
*
|
||||
* - insert:
|
||||
* - id: mcp-codegraph
|
||||
* name: '@deepseek-ai/dsh-mcp-client'
|
||||
* config: { serverName: codegraph, transport: stdio, command, args }
|
||||
*
|
||||
* and the instructions block to `$DSH_HOME/AGENTS.md`, the start of dsh's
|
||||
* user-global instruction chain. `autoAllow` is a no-op.
|
||||
*
|
||||
* The patch file is the user's, so it is edited as text, line by line, and
|
||||
* comments and other patches survive. dsh parses it with js-yaml and refuses to
|
||||
* start unless it is a top-level array, so a comments-only file is as fatal as a
|
||||
* broken one: install repairs one by adding the patch, and an uninstall that
|
||||
* would leave one writes the documented empty list, `[]`.
|
||||
*
|
||||
* A server is identified by `serverName: codegraph`: two rows with one name in
|
||||
* the same scope make the later one fail to load. An entry already there, in
|
||||
* this file or a profile's own `cordis.patch.yml`, is the user's. It may pin a
|
||||
* `cwd` or a `command`, so install never rewrites it and never adds a second
|
||||
* one; uninstall takes it out wherever it can be edited.
|
||||
*
|
||||
* Docs: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/mcp/mcp-client/README.md
|
||||
* https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/guide/mcp-memory.md
|
||||
* https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/context/agent-instructions/README.md
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { AgentTarget, DetectionResult, InstallOptions, Location, WriteResult } from './types';
|
||||
import {
|
||||
atomicWriteFileSync,
|
||||
configDirFromEnv,
|
||||
getMcpServerConfig,
|
||||
joinText,
|
||||
removeFileUnlessLinked,
|
||||
removeInstructionsEntry,
|
||||
splitText,
|
||||
upsertInstructionsEntry,
|
||||
} from './shared';
|
||||
|
||||
type FileResult = WriteResult['files'][number];
|
||||
|
||||
/** A span of lines: `start` up to, not including, `end`. */
|
||||
interface Range {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
const PATCH_FILE = 'cordis.patch.yml';
|
||||
|
||||
/** `serverName: codegraph` in a block or a one-line `{ … }`, quoted or not. */
|
||||
const NAMES_SERVER = /(^|[\s{,])["']?serverName["']?\s*:\s*["']?codegraph["']?\s*([,}#]|$)/;
|
||||
const NAMES_CLIENT = /(^|[\s{,])["']?name["']?\s*:\s*["']?@deepseek-ai\/dsh-mcp-client["']?/;
|
||||
|
||||
function dshHome(): string {
|
||||
return configDirFromEnv('DSH_HOME', path.join(os.homedir(), '.dsh'));
|
||||
}
|
||||
|
||||
function homePatchPath(): string {
|
||||
return path.join(dshHome(), PATCH_FILE);
|
||||
}
|
||||
|
||||
function instructionsPath(): string {
|
||||
return path.join(dshHome(), 'AGENTS.md');
|
||||
}
|
||||
|
||||
function profilePatchPaths(): string[] {
|
||||
const profiles = path.join(dshHome(), 'profiles');
|
||||
let names: string[];
|
||||
try {
|
||||
names = fs.readdirSync(profiles);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return names.map((name) => path.join(profiles, name, PATCH_FILE)).filter((file) => fs.existsSync(file));
|
||||
}
|
||||
|
||||
function readLines(file: string): ReturnType<typeof splitText> {
|
||||
try {
|
||||
return splitText(fs.readFileSync(file, 'utf-8'));
|
||||
} catch {
|
||||
return splitText('');
|
||||
}
|
||||
}
|
||||
|
||||
function renderEntry(): string[] {
|
||||
const { command, args } = getMcpServerConfig();
|
||||
return [
|
||||
'- insert:',
|
||||
' - id: mcp-codegraph',
|
||||
" name: '@deepseek-ai/dsh-mcp-client'",
|
||||
' config:',
|
||||
' serverName: codegraph',
|
||||
' transport: stdio',
|
||||
` command: ${command}`,
|
||||
' args:',
|
||||
...args.map((arg) => ` - ${arg}`),
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Line-level YAML: a top-level block list and the rows of its `- insert:` items.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function indentOf(line: string): number {
|
||||
return /^ */.exec(line)![0].length;
|
||||
}
|
||||
|
||||
/** A line that holds no value: blank, a comment, or the `---` that opens a document. */
|
||||
function isTrivia(line: string): boolean {
|
||||
const t = line.trim();
|
||||
return t === '' || t === '---' || t.startsWith('#');
|
||||
}
|
||||
|
||||
function isTopLevelItem(line: string): boolean {
|
||||
return /^-(\s|$)/.test(line);
|
||||
}
|
||||
|
||||
/** Where a span ends, before the blank and comment lines leading up to what follows it. */
|
||||
function trimmedEnd(lines: string[], start: number, end: number): number {
|
||||
while (end > start + 1 && isTrivia(lines[end - 1]!)) end--;
|
||||
return end;
|
||||
}
|
||||
|
||||
function topLevelItems(lines: string[]): Range[] {
|
||||
const starts = lines.flatMap((line, i) => (isTopLevelItem(line) ? [i] : []));
|
||||
return starts.map((start, n) => ({ start, end: trimmedEnd(lines, start, starts[n + 1] ?? lines.length) }));
|
||||
}
|
||||
|
||||
/** The rows of a `- insert:` item: its list items, at the indent of the first. */
|
||||
function insertRows(lines: string[], item: Range): Range[] {
|
||||
if (!/^-\s+insert\s*:\s*(#.*)?$/.test(lines[item.start]!)) return [];
|
||||
const body = lines.slice(item.start + 1, item.end);
|
||||
const firstAt = body.findIndex((line) => !isTrivia(line));
|
||||
if (firstAt === -1 || !/^ +-(\s|$)/.test(body[firstAt]!)) return [];
|
||||
|
||||
const indent = indentOf(body[firstAt]!);
|
||||
const starts: number[] = [];
|
||||
let stop = item.end;
|
||||
for (let i = item.start + 1 + firstAt; i < item.end; i++) {
|
||||
const line = lines[i]!;
|
||||
if (isTrivia(line)) continue;
|
||||
const at = indentOf(line);
|
||||
if (at < indent) {
|
||||
stop = i;
|
||||
break;
|
||||
}
|
||||
if (at === indent) starts.push(i);
|
||||
}
|
||||
return starts.map((start, n) => ({ start, end: trimmedEnd(lines, start, starts[n + 1] ?? stop) }));
|
||||
}
|
||||
|
||||
/** Whether a row is CodeGraph's server, by its own `name:`, not one nested in a group's `config`. */
|
||||
function isCodeGraphRow(lines: string[], row: Range): boolean {
|
||||
const body = lines.slice(row.start, row.end).filter((line) => !isTrivia(line));
|
||||
if (!body.some((line) => NAMES_SERVER.test(line))) return false;
|
||||
const keyIndent = indentOf(body[0]!.replace('-', ' '));
|
||||
return body.some((line, i) => (i === 0 || indentOf(line) === keyIndent) && NAMES_CLIENT.test(line));
|
||||
}
|
||||
|
||||
function hasServer(lines: string[]): boolean {
|
||||
return lines.some((line) => !isTrivia(line) && NAMES_SERVER.test(line));
|
||||
}
|
||||
|
||||
/**
|
||||
* What the file holds, ignoring comments. `empty` has no patches yet, with
|
||||
* `emptyList` the line of a `[]` if there is one. Anything but a block list at
|
||||
* column 0 (a flow `[…]`, a mapping) is `other`: this editor doesn't change it.
|
||||
*/
|
||||
function layoutOf(lines: string[]): { kind: 'list' | 'other' } | { kind: 'empty'; emptyList: number } {
|
||||
const content = lines.flatMap((line, i) => (isTrivia(line) ? [] : [i]));
|
||||
const first = content[0];
|
||||
if (first === undefined) return { kind: 'empty', emptyList: -1 };
|
||||
if (content.length === 1 && /^\[\s*\]\s*(#.*)?$/.test(lines[first]!)) return { kind: 'empty', emptyList: first };
|
||||
const allItems = content.every((i) => /^\s/.test(lines[i]!) || isTopLevelItem(lines[i]!));
|
||||
return { kind: isTopLevelItem(lines[first]!) && allItems ? 'list' : 'other' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Take CodeGraph's rows out of `lines`. A `- insert:` holding nothing else goes
|
||||
* with them, and the blank line above it. True when anything was removed.
|
||||
*/
|
||||
function removeRows(lines: string[]): boolean {
|
||||
let removed = false;
|
||||
for (const item of topLevelItems(lines).reverse()) {
|
||||
const rows = insertRows(lines, item);
|
||||
const ours = rows.filter((row) => isCodeGraphRow(lines, row));
|
||||
if (ours.length === 0) continue;
|
||||
if (ours.length < rows.length) {
|
||||
for (const row of [...ours].reverse()) lines.splice(row.start, row.end - row.start);
|
||||
} else if (ours[ours.length - 1]!.end === item.end) {
|
||||
const start = item.start > 0 && lines[item.start - 1]!.trim() === '' ? item.start - 1 : item.start;
|
||||
lines.splice(start, item.end - start);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
removed = true;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The patch files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function writePatch(): { file: FileResult; note?: string } {
|
||||
const file = homePatchPath();
|
||||
const existed = fs.existsSync(file);
|
||||
const text = readLines(file);
|
||||
if (hasServer(text.lines)) return { file: { path: file, action: 'unchanged' } };
|
||||
|
||||
const profile = profilePatchPaths().find((p) => hasServer(readLines(p).lines));
|
||||
if (profile) {
|
||||
return {
|
||||
file: { path: file, action: 'unchanged' },
|
||||
note: `Left the CodeGraph entry in ${profile} as you wrote it: a second one in ${file} would fail to load. Move it there to use CodeGraph in every profile.`,
|
||||
};
|
||||
}
|
||||
|
||||
const layout = layoutOf(text.lines);
|
||||
if (layout.kind === 'other') {
|
||||
throw new Error(
|
||||
`${file} is not a block-style YAML list, so CodeGraph can't add its server. ` +
|
||||
'Nothing was changed: add the entry from `codegraph install --print-config dsh` yourself, then run this again.',
|
||||
);
|
||||
}
|
||||
if (layout.kind === 'empty' && layout.emptyList !== -1) {
|
||||
text.lines.splice(layout.emptyList, 1, ...renderEntry());
|
||||
} else {
|
||||
const separate = layout.kind === 'list';
|
||||
while (text.lines.length > 0 && text.lines[text.lines.length - 1]!.trim() === '') text.lines.pop();
|
||||
text.lines.push(...(separate ? [''] : []), ...renderEntry());
|
||||
}
|
||||
atomicWriteFileSync(file, joinText(text));
|
||||
return { file: { path: file, action: existed ? 'updated' : 'created' } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Take CodeGraph's server out of a patch file. One left with nothing in it is
|
||||
* deleted when `mayDelete` (dsh runs without a home-level file, and creates each
|
||||
* profile's own), or emptied through a symbolic link. With comments left, it
|
||||
* becomes the empty list, because a comments-only file stops dsh from starting.
|
||||
*/
|
||||
function removeFromPatch(file: string, mayDelete: boolean): { file: FileResult; note?: string } {
|
||||
const text = readLines(file);
|
||||
if (!hasServer(text.lines)) return { file: { path: file, action: 'not-found' } };
|
||||
|
||||
const removed = removeRows(text.lines);
|
||||
const note = hasServer(text.lines)
|
||||
? `${file} sets CodeGraph's server in a form CodeGraph can't edit safely. Remove it from there yourself.`
|
||||
: undefined;
|
||||
if (!removed) return { file: { path: file, action: 'not-found' }, note };
|
||||
|
||||
if (layoutOf(text.lines).kind === 'empty') {
|
||||
if (mayDelete && text.lines.every((line) => line.trim() === '')) {
|
||||
removeFileUnlessLinked(file, `[]${text.eol}`);
|
||||
return { file: { path: file, action: 'removed' }, note };
|
||||
}
|
||||
text.lines.push('[]');
|
||||
}
|
||||
atomicWriteFileSync(file, joinText(text));
|
||||
return { file: { path: file, action: 'removed' }, note };
|
||||
}
|
||||
|
||||
class DshTarget implements AgentTarget {
|
||||
readonly id = 'dsh' as const;
|
||||
readonly displayName = 'DeepSeek Harness';
|
||||
readonly docsUrl = 'https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/mcp/mcp-client/README.md';
|
||||
|
||||
supportsLocation(loc: Location): boolean {
|
||||
return loc === 'global';
|
||||
}
|
||||
|
||||
detect(loc: Location): DetectionResult {
|
||||
if (loc !== 'global') return { installed: false, alreadyConfigured: false };
|
||||
const file = homePatchPath();
|
||||
return {
|
||||
installed: fs.existsSync(dshHome()),
|
||||
alreadyConfigured: [file, ...profilePatchPaths()].some((p) => hasServer(readLines(p).lines)),
|
||||
configPath: file,
|
||||
};
|
||||
}
|
||||
|
||||
install(loc: Location, _opts: InstallOptions): WriteResult {
|
||||
if (loc !== 'global') {
|
||||
return { files: [], notes: ['DeepSeek Harness keeps its config in $DSH_HOME; re-run with --location=global.'] };
|
||||
}
|
||||
const patch = writePatch();
|
||||
const notes = ['Start a new dsh session to pick up the MCP server, after a restart if dsh-hmr is off.'];
|
||||
if (patch.note) notes.unshift(patch.note);
|
||||
return { files: [patch.file, upsertInstructionsEntry(instructionsPath())], notes };
|
||||
}
|
||||
|
||||
uninstall(loc: Location): WriteResult {
|
||||
if (loc !== 'global') return { files: [] };
|
||||
const profiles = profilePatchPaths().map((file) => removeFromPatch(file, false));
|
||||
const results = [removeFromPatch(homePatchPath(), true), ...profiles.filter((r) => r.file.action === 'removed' || r.note)];
|
||||
const notes = results.flatMap((r) => (r.note ? [r.note] : []));
|
||||
const files = [...results.map((r) => r.file), removeInstructionsEntry(instructionsPath())];
|
||||
return notes.length > 0 ? { files, notes } : { files };
|
||||
}
|
||||
|
||||
printConfig(loc: Location): string {
|
||||
if (loc !== 'global') return '# DeepSeek Harness keeps its config in $DSH_HOME; use --location=global.\n';
|
||||
return [`# Add to ${homePatchPath()}`, '', ...renderEntry(), ''].join('\n');
|
||||
}
|
||||
|
||||
describePaths(loc: Location): string[] {
|
||||
return loc === 'global' ? [homePatchPath(), instructionsPath()] : [];
|
||||
}
|
||||
}
|
||||
|
||||
export const dshTarget: AgentTarget = new DshTarget();
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Grok Build target (the `grok` CLI).
|
||||
*
|
||||
* - MCP server entry as the `[mcp_servers.codegraph]` table of `config.toml`:
|
||||
* `$GROK_HOME` (default `~/.grok`) when global, `./.grok` when local.
|
||||
* Written with the same helpers as Codex (`./toml.ts`), so a table in any
|
||||
* other form or a parent that rules it out is never defined twice (#2562).
|
||||
* - Instructions to `rules/codegraph.md` in the same folder. Grok loads every
|
||||
* `*.md` in `$GROK_HOME/rules/` for all projects, and every `*.md` in a
|
||||
* project's `.grok/rules/`.
|
||||
*
|
||||
* A table already there is the user's. `grok mcp disable` writes `enabled` into
|
||||
* it, and users add `env` or timeouts, so a refresh leaves it alone.
|
||||
*
|
||||
* A project's config applies only once the folder is trusted, so a local
|
||||
* install says so. `autoAllow` is a no-op: Grok's `[permission]` rules are the
|
||||
* user's own.
|
||||
*
|
||||
* Docs: https://docs.x.ai/build/features/mcp-servers
|
||||
* https://docs.x.ai/build/features/project-rules
|
||||
* https://docs.x.ai/build/features/hooks (folder trust)
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { AgentTarget, DetectionResult, InstallOptions, Location, WriteResult } from './types';
|
||||
import {
|
||||
configDirFromEnv,
|
||||
getMcpServerConfig,
|
||||
removeInstructionsEntry,
|
||||
upsertInstructionsEntry,
|
||||
} from './shared';
|
||||
import { buildTomlTable, deleteTomlTable, tomlDefinesTable, writeTomlTable } from './toml';
|
||||
|
||||
const TOML_HEADER = 'mcp_servers.codegraph';
|
||||
|
||||
function configDir(loc: Location): string {
|
||||
return loc === 'global'
|
||||
? configDirFromEnv('GROK_HOME', path.join(os.homedir(), '.grok'))
|
||||
: path.join(process.cwd(), '.grok');
|
||||
}
|
||||
|
||||
function tomlConfigPath(loc: Location): string {
|
||||
return path.join(configDir(loc), 'config.toml');
|
||||
}
|
||||
|
||||
function instructionsPath(loc: Location): string {
|
||||
return path.join(configDir(loc), 'rules', 'codegraph.md');
|
||||
}
|
||||
|
||||
function buildCodegraphBlock(): string {
|
||||
const { command, args } = getMcpServerConfig();
|
||||
return buildTomlTable(TOML_HEADER, { command, args });
|
||||
}
|
||||
|
||||
class GrokTarget implements AgentTarget {
|
||||
readonly id = 'grok' as const;
|
||||
readonly displayName = 'Grok Build';
|
||||
readonly docsUrl = 'https://docs.x.ai/build/features/mcp-servers';
|
||||
|
||||
supportsLocation(_loc: Location): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
detect(loc: Location): DetectionResult {
|
||||
const file = tomlConfigPath(loc);
|
||||
let alreadyConfigured = false;
|
||||
try {
|
||||
alreadyConfigured = tomlDefinesTable(fs.readFileSync(file, 'utf-8'), TOML_HEADER);
|
||||
} catch { /* no config yet */ }
|
||||
return { installed: fs.existsSync(configDir(loc)), alreadyConfigured, configPath: file };
|
||||
}
|
||||
|
||||
install(loc: Location, _opts: InstallOptions): WriteResult {
|
||||
const mcp = writeTomlTable(tomlConfigPath(loc), TOML_HEADER, buildCodegraphBlock(), { keepExisting: true });
|
||||
const notes = ['Start a new Grok session, or press r in /mcps, to pick up the MCP server.'];
|
||||
if (mcp.note) notes.unshift(mcp.note);
|
||||
if (loc === 'local') notes.push(`Grok applies ${tomlConfigPath('local')} only in a trusted folder: run /hooks-trust there, or start grok with --trust.`);
|
||||
return { files: [mcp.file, upsertInstructionsEntry(instructionsPath(loc))], notes };
|
||||
}
|
||||
|
||||
uninstall(loc: Location): WriteResult {
|
||||
const mcp = deleteTomlTable(tomlConfigPath(loc), TOML_HEADER);
|
||||
const files = [mcp.file, removeInstructionsEntry(instructionsPath(loc))];
|
||||
return mcp.note ? { files, notes: [mcp.note] } : { files };
|
||||
}
|
||||
|
||||
printConfig(loc: Location): string {
|
||||
return `# Add to ${tomlConfigPath(loc)}\n\n${buildCodegraphBlock()}\n`;
|
||||
}
|
||||
|
||||
describePaths(loc: Location): string[] {
|
||||
return [tomlConfigPath(loc), instructionsPath(loc)];
|
||||
}
|
||||
}
|
||||
|
||||
export const grokTarget: AgentTarget = new GrokTarget();
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
Location,
|
||||
WriteResult,
|
||||
} from './types';
|
||||
import { atomicWriteFileSync } from './shared';
|
||||
import { atomicWriteFileSync, joinText, splitText } from './shared';
|
||||
|
||||
class HermesTarget implements AgentTarget {
|
||||
readonly id = 'hermes' as const;
|
||||
@@ -168,25 +168,6 @@ function writeHermesConfig(): { file: WriteResult['files'][number]; notes: strin
|
||||
// Line-level YAML: just the shapes Hermes and its users write for these keys.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Text {
|
||||
lines: string[];
|
||||
eol: '\n' | '\r\n';
|
||||
}
|
||||
|
||||
function splitText(content: string): Text {
|
||||
const first = /\r\n|\r|\n/.exec(content);
|
||||
const lines = content.length === 0 ? [] : content.split(/\r\n|\r|\n/);
|
||||
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
||||
return { lines, eol: first?.[0] === '\r\n' ? '\r\n' : '\n' };
|
||||
}
|
||||
|
||||
/** The lines joined with the file's own line break, ending with one; no trailing blank lines. */
|
||||
function joinText(text: Text): string {
|
||||
const lines = [...text.lines];
|
||||
while (lines.length > 0 && (lines[lines.length - 1] ?? '').trim() === '') lines.pop();
|
||||
return lines.length === 0 ? '' : lines.join(text.eol) + text.eol;
|
||||
}
|
||||
|
||||
function indentOf(line: string): number {
|
||||
return /^ */.exec(line)![0].length;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* `mcpServers.codegraph` in an agent's JSON config, for the targets whose file
|
||||
* has that shape.
|
||||
*
|
||||
* The file is edited in place with `jsonc-parser`, not rewritten: it keeps its
|
||||
* indent and line breaks, and comments survive in the agents that allow them
|
||||
* (Qwen Code, Qoder). A file that can't be parsed is backed up before it is
|
||||
* replaced, as `writeJsonFile` does (#1870).
|
||||
*
|
||||
* An entry already in the file is the user's. Agents write into it themselves:
|
||||
* a disabled switch, an `alwaysAllow` list, Pi's `exposure`. Users pin a
|
||||
* `command` or a `cwd` there. So a re-install or the `install --refresh` that
|
||||
* `codegraph upgrade` runs only fills in a key that is missing, and never
|
||||
* changes one that is set.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import { applyEdits, modify, parse, type ParseError } from 'jsonc-parser';
|
||||
import type { WriteResult } from './types';
|
||||
import { atomicWriteFileSync, backupUnparseableJson, removeFileUnlessLinked } from './shared';
|
||||
|
||||
type FileResult = WriteResult['files'][number];
|
||||
type Edit = (text: string) => string;
|
||||
|
||||
const SERVER_PATH = ['mcpServers', 'codegraph'];
|
||||
const noEdit: Edit = (text) => text;
|
||||
|
||||
function isObject(value: unknown): value is Record<string, any> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseConfig(text: string): { config: Record<string, any>; valid: boolean } {
|
||||
const body = text.replace(/^\uFEFF/, '');
|
||||
if (body.trim() === '') return { config: {}, valid: true };
|
||||
const errors: ParseError[] = [];
|
||||
const value: unknown = parse(body, errors, { allowTrailingComma: true });
|
||||
return isObject(value) && errors.length === 0 ? { config: value, valid: true } : { config: {}, valid: false };
|
||||
}
|
||||
|
||||
/** The object in `text`, or `{}` when it holds none. */
|
||||
export function parseJsonConfig(text: string): Record<string, any> {
|
||||
return parseConfig(text).config;
|
||||
}
|
||||
|
||||
/** `text` with the value at `jsonPath` set, or removed when `value` is undefined. Everything else stays. */
|
||||
export function editJson(
|
||||
text: string,
|
||||
jsonPath: Array<string | number>,
|
||||
value: unknown,
|
||||
options: { isArrayInsertion?: boolean } = {},
|
||||
): string {
|
||||
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
||||
const indent = /^([ \t]+)\S/m.exec(text)?.[1] ?? ' ';
|
||||
const insertSpaces = !indent.startsWith('\t');
|
||||
return applyEdits(text, modify(text, jsonPath, value, {
|
||||
...options,
|
||||
formattingOptions: { tabSize: insertSpaces ? indent.length : 1, insertSpaces, eol },
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `file` defines CodeGraph's server. Read-only and quiet: `detect()`
|
||||
* runs this for every agent on every install (#1870).
|
||||
*/
|
||||
export function hasCodegraphServer(file: string): boolean {
|
||||
try {
|
||||
return !!parseConfig(fs.readFileSync(file, 'utf-8')).config.mcpServers?.codegraph;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add CodeGraph's server to `file`, or the keys of `entry` an existing one is
|
||||
* missing. `alsoEdit` runs on the same text, for a target with more to write in
|
||||
* this file, so the file is written once and reported once.
|
||||
*/
|
||||
export function upsertCodegraphServer(
|
||||
file: string,
|
||||
entry: Record<string, unknown>,
|
||||
alsoEdit: Edit = noEdit,
|
||||
): FileResult {
|
||||
const existed = fs.existsSync(file);
|
||||
const before = existed ? fs.readFileSync(file, 'utf-8') : '';
|
||||
const { config, valid } = parseConfig(before);
|
||||
const servers: unknown = config.mcpServers;
|
||||
if (servers !== undefined && !isObject(servers)) {
|
||||
throw new Error(
|
||||
`${file} sets mcpServers to something other than an object, so CodeGraph can't add its server. ` +
|
||||
'Nothing was changed: make mcpServers an object, then run this again.',
|
||||
);
|
||||
}
|
||||
if (!valid) backupUnparseableJson(file);
|
||||
|
||||
let text = valid && before.trim() !== '' ? before : '{\n}\n';
|
||||
const current: unknown = servers?.codegraph;
|
||||
if (isObject(current)) {
|
||||
for (const [key, value] of Object.entries(entry)) {
|
||||
if (!(key in current)) text = editJson(text, [...SERVER_PATH, key], value);
|
||||
}
|
||||
} else {
|
||||
text = editJson(text, SERVER_PATH, entry);
|
||||
}
|
||||
text = alsoEdit(text);
|
||||
|
||||
if (text === before) return { path: file, action: 'unchanged' };
|
||||
atomicWriteFileSync(file, text);
|
||||
return { path: file, action: existed ? 'updated' : 'created' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of `upsertCodegraphServer`: take out CodeGraph's server, its
|
||||
* `mcpServers` wrapper when that held nothing else, and whatever `alsoEdit`
|
||||
* takes out. A file with nothing left in it is deleted, so uninstall reverses a
|
||||
* from-scratch install. A symbolic link is the user's, so it is kept, with `{}`
|
||||
* written through it (#2721).
|
||||
*/
|
||||
export function removeCodegraphServer(file: string, alsoEdit: Edit = noEdit): FileResult {
|
||||
if (!fs.existsSync(file)) return { path: file, action: 'not-found' };
|
||||
const before = fs.readFileSync(file, 'utf-8');
|
||||
const { config, valid } = parseConfig(before);
|
||||
if (!valid) return { path: file, action: 'not-found' };
|
||||
|
||||
let text = before;
|
||||
const servers: unknown = config.mcpServers;
|
||||
if (isObject(servers) && 'codegraph' in servers) {
|
||||
text = editJson(text, SERVER_PATH, undefined);
|
||||
if (Object.keys(servers).length === 1) text = editJson(text, ['mcpServers'], undefined);
|
||||
}
|
||||
text = alsoEdit(text);
|
||||
|
||||
if (text === before) return { path: file, action: 'not-found' };
|
||||
if (/^\s*\{\s*\}\s*$/.test(text)) removeFileUnlessLinked(file, '{}\n');
|
||||
else atomicWriteFileSync(file, text);
|
||||
return { path: file, action: 'removed' };
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Kimi Code target.
|
||||
*
|
||||
* - MCP server entry under `mcpServers` in `mcp.json`: `$KIMI_CODE_HOME`
|
||||
* (default `~/.kimi-code`) when global, `./.kimi-code` when local. Kimi
|
||||
* reads an entry with `command` as a stdio server and documents no `type`
|
||||
* key. A project-level server runs only once the folder is trusted, so a
|
||||
* local install says so.
|
||||
* - Instructions to `AGENTS.md`: in `$KIMI_CODE_HOME` when global, at the
|
||||
* project root when local.
|
||||
*
|
||||
* `autoAllow` is a no-op: Kimi keeps its approval rules in its own
|
||||
* `config.toml`, as `[[permission.rules]]`.
|
||||
*
|
||||
* Docs: https://moonshotai.github.io/kimi-code/en/customization/mcp.html
|
||||
* https://moonshotai.github.io/kimi-code/en/configuration/data-locations.html
|
||||
* https://moonshotai.github.io/kimi-code/en/customization/agents.html
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { AgentTarget, DetectionResult, InstallOptions, Location, WriteResult } from './types';
|
||||
import { hasCodegraphServer, removeCodegraphServer, upsertCodegraphServer } from './json-config';
|
||||
import {
|
||||
configDirFromEnv,
|
||||
getStdioServerConfig,
|
||||
removeInstructionsEntry,
|
||||
upsertInstructionsEntry,
|
||||
} from './shared';
|
||||
|
||||
function configDir(loc: Location): string {
|
||||
return loc === 'global'
|
||||
? configDirFromEnv('KIMI_CODE_HOME', path.join(os.homedir(), '.kimi-code'))
|
||||
: path.join(process.cwd(), '.kimi-code');
|
||||
}
|
||||
|
||||
function mcpJsonPath(loc: Location): string {
|
||||
return path.join(configDir(loc), 'mcp.json');
|
||||
}
|
||||
|
||||
function instructionsPath(loc: Location): string {
|
||||
return loc === 'global' ? path.join(configDir('global'), 'AGENTS.md') : path.join(process.cwd(), 'AGENTS.md');
|
||||
}
|
||||
|
||||
class KimiTarget implements AgentTarget {
|
||||
readonly id = 'kimi' as const;
|
||||
readonly displayName = 'Kimi Code';
|
||||
readonly docsUrl = 'https://moonshotai.github.io/kimi-code/en/customization/mcp.html';
|
||||
|
||||
supportsLocation(_loc: Location): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
detect(loc: Location): DetectionResult {
|
||||
const file = mcpJsonPath(loc);
|
||||
return {
|
||||
installed: fs.existsSync(configDir(loc)),
|
||||
alreadyConfigured: hasCodegraphServer(file),
|
||||
configPath: file,
|
||||
};
|
||||
}
|
||||
|
||||
install(loc: Location, _opts: InstallOptions): WriteResult {
|
||||
const notes = ['Start a new Kimi Code session: a server added mid-session only joins later ones.'];
|
||||
if (loc === 'local') notes.push(`Kimi Code runs ${mcpJsonPath('local')} only in a trusted folder: trust this folder when Kimi Code asks.`);
|
||||
return {
|
||||
files: [
|
||||
upsertCodegraphServer(mcpJsonPath(loc), getStdioServerConfig()),
|
||||
upsertInstructionsEntry(instructionsPath(loc)),
|
||||
],
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
uninstall(loc: Location): WriteResult {
|
||||
return {
|
||||
files: [
|
||||
removeCodegraphServer(mcpJsonPath(loc)),
|
||||
removeInstructionsEntry(instructionsPath(loc)),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
printConfig(loc: Location): string {
|
||||
const snippet = JSON.stringify({ mcpServers: { codegraph: getStdioServerConfig() } }, null, 2);
|
||||
return `# Add to ${mcpJsonPath(loc)}\n\n${snippet}\n`;
|
||||
}
|
||||
|
||||
describePaths(loc: Location): string[] {
|
||||
return [mcpJsonPath(loc), instructionsPath(loc)];
|
||||
}
|
||||
}
|
||||
|
||||
export const kimiTarget: AgentTarget = new KimiTarget();
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Pi (pi-coding-agent) target.
|
||||
*
|
||||
* - MCP server entry under `mcpServers` in `mcp.json`: the agent directory
|
||||
* when global (`$PI_CODING_AGENT_DIR`, default `~/.pi/agent`), `./.pi` when
|
||||
* local. Pi reads a project's file only after the project is trusted, so a
|
||||
* local install says so.
|
||||
* - Instructions to `AGENTS.md`: in the agent directory when global, at the
|
||||
* project root when local. Context files need no project trust.
|
||||
*
|
||||
* The entry carries `exposure: "direct"`. Pi's default, `codemode`, makes a
|
||||
* server's tools callable from scripts but never declares them to the model, so
|
||||
* `codegraph_explore` would be a name the model can't call as a tool. CodeGraph
|
||||
* lists one tool by default, which is what `direct` is for.
|
||||
*
|
||||
* `autoAllow` is a no-op: Pi has no allowlist file, and permission gates are
|
||||
* extensions.
|
||||
*
|
||||
* Docs: https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/mcp.md
|
||||
* https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/configuration.md
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { AgentTarget, DetectionResult, InstallOptions, Location, WriteResult } from './types';
|
||||
import { hasCodegraphServer, removeCodegraphServer, upsertCodegraphServer } from './json-config';
|
||||
import {
|
||||
configDirFromEnv,
|
||||
getStdioServerConfig,
|
||||
removeInstructionsEntry,
|
||||
upsertInstructionsEntry,
|
||||
} from './shared';
|
||||
|
||||
function configDir(loc: Location): string {
|
||||
return loc === 'global'
|
||||
? configDirFromEnv('PI_CODING_AGENT_DIR', path.join(os.homedir(), '.pi', 'agent'))
|
||||
: path.join(process.cwd(), '.pi');
|
||||
}
|
||||
|
||||
function mcpJsonPath(loc: Location): string {
|
||||
return path.join(configDir(loc), 'mcp.json');
|
||||
}
|
||||
|
||||
function instructionsPath(loc: Location): string {
|
||||
return loc === 'global' ? path.join(configDir('global'), 'AGENTS.md') : path.join(process.cwd(), 'AGENTS.md');
|
||||
}
|
||||
|
||||
function buildPiEntry(): { command: string; args: string[]; exposure: string } {
|
||||
return { ...getStdioServerConfig(), exposure: 'direct' };
|
||||
}
|
||||
|
||||
class PiTarget implements AgentTarget {
|
||||
readonly id = 'pi' as const;
|
||||
readonly displayName = 'Pi';
|
||||
readonly docsUrl = 'https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/mcp.md';
|
||||
|
||||
supportsLocation(_loc: Location): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
detect(loc: Location): DetectionResult {
|
||||
const file = mcpJsonPath(loc);
|
||||
return {
|
||||
installed: fs.existsSync(configDir(loc)),
|
||||
alreadyConfigured: hasCodegraphServer(file),
|
||||
configPath: file,
|
||||
};
|
||||
}
|
||||
|
||||
install(loc: Location, _opts: InstallOptions): WriteResult {
|
||||
const notes = ['Run /reload in Pi, or restart it, to pick up the MCP server.'];
|
||||
if (loc === 'local') notes.push(`Pi reads ${mcpJsonPath('local')} only after you trust this project.`);
|
||||
return {
|
||||
files: [
|
||||
upsertCodegraphServer(mcpJsonPath(loc), buildPiEntry()),
|
||||
upsertInstructionsEntry(instructionsPath(loc)),
|
||||
],
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
uninstall(loc: Location): WriteResult {
|
||||
return {
|
||||
files: [
|
||||
removeCodegraphServer(mcpJsonPath(loc)),
|
||||
removeInstructionsEntry(instructionsPath(loc)),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
printConfig(loc: Location): string {
|
||||
const snippet = JSON.stringify({ mcpServers: { codegraph: buildPiEntry() } }, null, 2);
|
||||
return `# Add to ${mcpJsonPath(loc)}\n\n${snippet}\n`;
|
||||
}
|
||||
|
||||
describePaths(loc: Location): string[] {
|
||||
return [mcpJsonPath(loc), instructionsPath(loc)];
|
||||
}
|
||||
}
|
||||
|
||||
export const piTarget: AgentTarget = new PiTarget();
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Qoder CLI target. Writes the MCP server entry only, under `mcpServers` in:
|
||||
*
|
||||
* - global: `settings.json` in `$QODER_CONFIG_DIR` (default `~/.qoder`)
|
||||
* - local: `./.qoder/settings.local.json`, the scope `qoder mcp add` writes
|
||||
* by default. It stays on this machine, and Qoder loads it only once the
|
||||
* folder is trusted.
|
||||
*
|
||||
* Qoder also reads a project `.mcp.json`, but that file is Claude Code's local
|
||||
* config too, so an uninstall here would take the server away from Claude.
|
||||
* Settings files may hold comments, and the edits keep them (`./json-config.ts`).
|
||||
* The entry is `{ command, args }`; a server with no `type` is a stdio server.
|
||||
*
|
||||
* The Qoder IDE is not covered: its docs show an in-app editor and name no file
|
||||
* on disk. No instructions file, and `autoAllow` is a no-op.
|
||||
*
|
||||
* Docs: https://docs.qoder.com/cli/mcp-reference
|
||||
* https://docs.qoder.com/cli/mcp-servers
|
||||
* https://docs.qoder.com/cli/settings
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { AgentTarget, DetectionResult, InstallOptions, Location, WriteResult } from './types';
|
||||
import { hasCodegraphServer, removeCodegraphServer, upsertCodegraphServer } from './json-config';
|
||||
import { configDirFromEnv, getStdioServerConfig } from './shared';
|
||||
|
||||
function configDir(loc: Location): string {
|
||||
return loc === 'global'
|
||||
? configDirFromEnv('QODER_CONFIG_DIR', path.join(os.homedir(), '.qoder'))
|
||||
: path.join(process.cwd(), '.qoder');
|
||||
}
|
||||
|
||||
function settingsPath(loc: Location): string {
|
||||
return path.join(configDir(loc), loc === 'global' ? 'settings.json' : 'settings.local.json');
|
||||
}
|
||||
|
||||
class QoderTarget implements AgentTarget {
|
||||
readonly id = 'qoder' as const;
|
||||
readonly displayName = 'Qoder CLI';
|
||||
readonly docsUrl = 'https://docs.qoder.com/cli/mcp-reference';
|
||||
|
||||
supportsLocation(_loc: Location): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
detect(loc: Location): DetectionResult {
|
||||
const file = settingsPath(loc);
|
||||
return {
|
||||
installed: fs.existsSync(configDir(loc)),
|
||||
alreadyConfigured: hasCodegraphServer(file),
|
||||
configPath: file,
|
||||
};
|
||||
}
|
||||
|
||||
install(loc: Location, _opts: InstallOptions): WriteResult {
|
||||
const notes = ['This sets up the Qoder CLI. In the Qoder IDE, add the server under Settings > MCP.'];
|
||||
if (loc === 'local') notes.push(`Qoder loads ${settingsPath('local')} only in a trusted folder.`);
|
||||
return { files: [upsertCodegraphServer(settingsPath(loc), getStdioServerConfig())], notes };
|
||||
}
|
||||
|
||||
uninstall(loc: Location): WriteResult {
|
||||
return { files: [removeCodegraphServer(settingsPath(loc))] };
|
||||
}
|
||||
|
||||
printConfig(loc: Location): string {
|
||||
const snippet = JSON.stringify({ mcpServers: { codegraph: getStdioServerConfig() } }, null, 2);
|
||||
return `# Add to ${settingsPath(loc)}\n\n${snippet}\n`;
|
||||
}
|
||||
|
||||
describePaths(loc: Location): string[] {
|
||||
return [settingsPath(loc)];
|
||||
}
|
||||
}
|
||||
|
||||
export const qoderTarget: AgentTarget = new QoderTarget();
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Qwen Code target.
|
||||
*
|
||||
* - MCP server entry under `mcpServers` in `settings.json`: `$QWEN_HOME`
|
||||
* (default `~/.qwen`) when global, `./.qwen` when local. Qwen documents a
|
||||
* stdio entry as `{ command, args }`.
|
||||
* - Instructions to `QWEN.md`: in `$QWEN_HOME` when global, at the project
|
||||
* root when local.
|
||||
* - With `autoAllow`, `permissions.allow` gains `mcp__codegraph`, Qwen's rule
|
||||
* for every tool of one server. It is not Claude's `mcp__codegraph__*`.
|
||||
*
|
||||
* Qwen reads `settings.json` with comments allowed and keeps them when it saves
|
||||
* the file, so this target's edits keep them too (see `./json-config.ts`).
|
||||
*
|
||||
* Docs: https://qwenlm.github.io/qwen-code-docs/en/users/features/mcp/
|
||||
* https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/
|
||||
* https://qwenlm.github.io/qwen-code-docs/en/users/features/memory/
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { AgentTarget, DetectionResult, InstallOptions, Location, WriteResult } from './types';
|
||||
import {
|
||||
editJson,
|
||||
hasCodegraphServer,
|
||||
parseJsonConfig,
|
||||
removeCodegraphServer,
|
||||
upsertCodegraphServer,
|
||||
} from './json-config';
|
||||
import {
|
||||
configDirFromEnv,
|
||||
getStdioServerConfig,
|
||||
removeInstructionsEntry,
|
||||
upsertInstructionsEntry,
|
||||
} from './shared';
|
||||
|
||||
const ALLOW_RULE = 'mcp__codegraph';
|
||||
|
||||
function configDir(loc: Location): string {
|
||||
return loc === 'global'
|
||||
? configDirFromEnv('QWEN_HOME', path.join(os.homedir(), '.qwen'))
|
||||
: path.join(process.cwd(), '.qwen');
|
||||
}
|
||||
|
||||
function settingsJsonPath(loc: Location): string {
|
||||
return path.join(configDir(loc), 'settings.json');
|
||||
}
|
||||
|
||||
function instructionsPath(loc: Location): string {
|
||||
return loc === 'global' ? path.join(configDir('global'), 'QWEN.md') : path.join(process.cwd(), 'QWEN.md');
|
||||
}
|
||||
|
||||
/** Adds the rule, unless `permissions` or its `allow` is something this can't add to. */
|
||||
function addAllowRule(text: string): string {
|
||||
const { permissions } = parseJsonConfig(text);
|
||||
if (permissions === undefined) return editJson(text, ['permissions', 'allow'], [ALLOW_RULE]);
|
||||
if (permissions === null || typeof permissions !== 'object' || Array.isArray(permissions)) return text;
|
||||
if (permissions.allow === undefined) return editJson(text, ['permissions', 'allow'], [ALLOW_RULE]);
|
||||
if (!Array.isArray(permissions.allow) || permissions.allow.includes(ALLOW_RULE)) return text;
|
||||
return editJson(text, ['permissions', 'allow', -1], ALLOW_RULE, { isArrayInsertion: true });
|
||||
}
|
||||
|
||||
/** Takes the rule out, with an `allow` or `permissions` left empty by that. */
|
||||
function removeAllowRule(text: string): string {
|
||||
const { permissions } = parseJsonConfig(text);
|
||||
const at = Array.isArray(permissions?.allow) ? permissions.allow.indexOf(ALLOW_RULE) : -1;
|
||||
if (at === -1) return text;
|
||||
if (permissions.allow.length > 1) return editJson(text, ['permissions', 'allow', at], undefined);
|
||||
const rest = editJson(text, ['permissions', 'allow'], undefined);
|
||||
return Object.keys(permissions).length === 1 ? editJson(rest, ['permissions'], undefined) : rest;
|
||||
}
|
||||
|
||||
class QwenTarget implements AgentTarget {
|
||||
readonly id = 'qwen' as const;
|
||||
readonly displayName = 'Qwen Code';
|
||||
readonly docsUrl = 'https://qwenlm.github.io/qwen-code-docs/en/users/features/mcp/';
|
||||
|
||||
supportsLocation(_loc: Location): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
detect(loc: Location): DetectionResult {
|
||||
const file = settingsJsonPath(loc);
|
||||
return {
|
||||
installed: fs.existsSync(configDir(loc)),
|
||||
alreadyConfigured: hasCodegraphServer(file),
|
||||
configPath: file,
|
||||
};
|
||||
}
|
||||
|
||||
install(loc: Location, opts: InstallOptions): WriteResult {
|
||||
const settings = upsertCodegraphServer(
|
||||
settingsJsonPath(loc),
|
||||
getStdioServerConfig(),
|
||||
opts.autoAllow ? addAllowRule : undefined,
|
||||
);
|
||||
return {
|
||||
files: [settings, upsertInstructionsEntry(instructionsPath(loc))],
|
||||
notes: ['Restart any running Qwen Code session to pick up the MCP server.'],
|
||||
};
|
||||
}
|
||||
|
||||
uninstall(loc: Location): WriteResult {
|
||||
return {
|
||||
files: [
|
||||
removeCodegraphServer(settingsJsonPath(loc), removeAllowRule),
|
||||
removeInstructionsEntry(instructionsPath(loc)),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
printConfig(loc: Location): string {
|
||||
const snippet = JSON.stringify({ mcpServers: { codegraph: getStdioServerConfig() } }, null, 2);
|
||||
return `# Add to ${settingsJsonPath(loc)}\n\n${snippet}\n`;
|
||||
}
|
||||
|
||||
describePaths(loc: Location): string[] {
|
||||
return [settingsJsonPath(loc), instructionsPath(loc)];
|
||||
}
|
||||
}
|
||||
|
||||
export const qwenTarget: AgentTarget = new QwenTarget();
|
||||
@@ -19,6 +19,15 @@ import { kiroTarget } from './kiro';
|
||||
import { copilotVscodeTarget } from './copilot-vscode';
|
||||
import { copilotCliTarget } from './copilot-cli';
|
||||
import { copilotJetbrainsTarget } from './copilot-jetbrains';
|
||||
import { bobTarget } from './bob';
|
||||
import { droidTarget } from './droid';
|
||||
import { windsurfTarget } from './windsurf';
|
||||
import { qwenTarget } from './qwen';
|
||||
import { dshTarget } from './dsh';
|
||||
import { grokTarget } from './grok';
|
||||
import { kimiTarget } from './kimi';
|
||||
import { qoderTarget } from './qoder';
|
||||
import { piTarget } from './pi';
|
||||
|
||||
export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([
|
||||
claudeTarget,
|
||||
@@ -32,6 +41,15 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([
|
||||
copilotVscodeTarget,
|
||||
copilotCliTarget,
|
||||
copilotJetbrainsTarget,
|
||||
bobTarget,
|
||||
droidTarget,
|
||||
windsurfTarget,
|
||||
qwenTarget,
|
||||
dshTarget,
|
||||
grokTarget,
|
||||
kimiTarget,
|
||||
qoderTarget,
|
||||
piTarget,
|
||||
]);
|
||||
|
||||
export function getTarget(id: string): AgentTarget | undefined {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
CODEGRAPH_INSTRUCTIONS_BLOCK,
|
||||
@@ -29,6 +30,29 @@ export function getMcpServerConfig(): { type: string; command: string; args: str
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The same server as a bare `{ command, args }`, for agents whose documented
|
||||
* stdio entry has no `type` key.
|
||||
*/
|
||||
export function getStdioServerConfig(): { command: string; args: string[] } {
|
||||
const { command, args } = getMcpServerConfig();
|
||||
return { command, args };
|
||||
}
|
||||
|
||||
/**
|
||||
* The folder an agent's `*_HOME`-style variable names, or `fallback` when it
|
||||
* is unset or blank. A leading `~` is the user's home and a relative path is
|
||||
* taken from the working directory, so the installer writes where the agent
|
||||
* reads. Installing to the fallback while the agent reads the override is a
|
||||
* silent no-op: the files are written, and nothing ever looks at them (#1627).
|
||||
*/
|
||||
export function configDirFromEnv(name: string, fallback: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) return fallback;
|
||||
const inHome = value === '~' || value.startsWith('~/') || value.startsWith('~\\');
|
||||
return path.resolve(inHome ? path.join(os.homedir(), value.slice(1)) : value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissions list for Claude `settings.json`. Other targets that
|
||||
* have a permissions concept can compose this list directly.
|
||||
@@ -184,7 +208,7 @@ export function removeFileUnlessLinked(filePath: string, emptied: string): void
|
||||
* write is a replacement, not an edit, and without this the user's
|
||||
* config would be gone. No-op when the file is absent or parses fine.
|
||||
*/
|
||||
function backupUnparseableJson(filePath: string): void {
|
||||
export function backupUnparseableJson(filePath: string): void {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
try {
|
||||
JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
@@ -238,6 +262,26 @@ export function jsonDeepEqual(a: unknown, b: unknown): boolean {
|
||||
return ak.every((k) => jsonDeepEqual(ao[k], bo[k]));
|
||||
}
|
||||
|
||||
/** A text file as lines, without their line breaks, and the line break it uses. */
|
||||
export interface TextLines {
|
||||
lines: string[];
|
||||
eol: '\n' | '\r\n';
|
||||
}
|
||||
|
||||
export function splitText(content: string): TextLines {
|
||||
const first = /\r\n|\r|\n/.exec(content);
|
||||
const lines = content.length === 0 ? [] : content.split(/\r\n|\r|\n/);
|
||||
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
||||
return { lines, eol: first?.[0] === '\r\n' ? '\r\n' : '\n' };
|
||||
}
|
||||
|
||||
/** The lines joined with the file's own line break, ending with one; no trailing blank lines. */
|
||||
export function joinText(text: TextLines): string {
|
||||
const lines = [...text.lines];
|
||||
while (lines.length > 0 && (lines[lines.length - 1] ?? '').trim() === '') lines.pop();
|
||||
return lines.length === 0 ? '' : lines.join(text.eol) + text.eol;
|
||||
}
|
||||
|
||||
/** One marker-delimited block: `start` is the start marker's offset, `end` is just past the end marker. */
|
||||
interface MarkedSection {
|
||||
start: number;
|
||||
@@ -413,6 +457,11 @@ export function upsertInstructionsEntry(file: string): { path: string; action: '
|
||||
return { path: file, action: action === 'appended' ? 'updated' : action };
|
||||
}
|
||||
|
||||
/** Inverse of `upsertInstructionsEntry`: strip the CodeGraph block from `file`, if it has one. */
|
||||
export function removeInstructionsEntry(file: string): { path: string; action: 'removed' | 'not-found' | 'kept' } {
|
||||
return { path: file, action: removeMarkedSection(file, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END) };
|
||||
}
|
||||
|
||||
/**
|
||||
* `content` with every well-formed marker block removed, or `null` when it
|
||||
* has none. A stray start or end marker is left alone (see
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
* inline table): those are reported as a conflict instead (#2562).
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import { atomicWriteFileSync, removeFileUnlessLinked } from './shared';
|
||||
import type { WriteResult } from './types';
|
||||
|
||||
/**
|
||||
* Serialize a record into the body lines of a TOML table. Values
|
||||
* supported: string, string[]. Other types throw — the codex MCP
|
||||
@@ -172,6 +176,82 @@ export function tomlDefinesTable(fileContent: string, header: string): boolean {
|
||||
return layout.tables.length > 0 || layout.subTables.length > 0 || layout.defined !== null;
|
||||
}
|
||||
|
||||
/** What a write or removal did to the file, and what the user should know about it. */
|
||||
interface TomlFileResult {
|
||||
file: WriteResult['files'][number];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the `[header]` table `block` into `file`, creating the file if needed.
|
||||
* With `keepExisting`, a `[header]` table already there is left as it is: it's
|
||||
* the user's, however it differs from `block`.
|
||||
*
|
||||
* Never appends a second definition: a table defined twice makes the whole
|
||||
* file invalid TOML, and the agent then loads none of it (#2562). A definition
|
||||
* in another form is left as written, with a note. A parent that rules the
|
||||
* table out throws, with nothing changed.
|
||||
*/
|
||||
export function writeTomlTable(
|
||||
file: string,
|
||||
header: string,
|
||||
block: string,
|
||||
options: { keepExisting?: boolean } = {},
|
||||
): TomlFileResult {
|
||||
// Single read — `existing === ''` derives both "is the file empty or absent"
|
||||
// and "what was its content," avoiding a TOCTOU window between two
|
||||
// `fs.existsSync` calls.
|
||||
const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
|
||||
const created = existing.length === 0;
|
||||
const result = upsertTomlTable(existing, header, block);
|
||||
|
||||
if (result.action === 'conflict') {
|
||||
const { conflict } = result;
|
||||
if (conflict.kind === 'defined') {
|
||||
// The user's own entry, in a form CodeGraph doesn't rewrite: it already
|
||||
// configures the agent, so it is left exactly as written.
|
||||
return {
|
||||
file: { path: file, action: 'unchanged' },
|
||||
note: `Left the ${header} entry in ${file} as you wrote it (${conflict.form}, line ${conflict.line}): CodeGraph only rewrites a [${header}] table.`,
|
||||
};
|
||||
}
|
||||
throw new Error(
|
||||
`${file} sets ${conflict.key} as ${conflict.form} (line ${conflict.line}), so a [${header}] table can't be added without breaking the file. ` +
|
||||
`Nothing was changed: add CodeGraph's server to ${conflict.key} yourself, or rewrite it as [${conflict.key}.<name>] tables, then run this again.`,
|
||||
);
|
||||
}
|
||||
if (result.action === 'unchanged' || (result.action === 'replaced' && options.keepExisting)) {
|
||||
return { file: { path: file, action: 'unchanged' } };
|
||||
}
|
||||
atomicWriteFileSync(file, result.content);
|
||||
return { file: { path: file, action: created ? 'created' : 'updated' } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Take every definition of the table out of `file`. A file left with nothing
|
||||
* in it is deleted, or emptied through a symbolic link (#2721). A definition
|
||||
* that can't be edited stays, with a note saying so.
|
||||
*/
|
||||
export function deleteTomlTable(file: string, header: string): TomlFileResult {
|
||||
if (!fs.existsSync(file)) return { file: { path: file, action: 'not-found' } };
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
const result = removeTomlTable(content, header);
|
||||
if (result.action === 'conflict') {
|
||||
const { conflict } = result;
|
||||
return {
|
||||
file: { path: file, action: 'not-found' },
|
||||
note: `${file} sets ${header} inside ${conflict.key} (${conflict.form}, line ${conflict.line}), which CodeGraph can't edit safely. Remove it from there yourself.`,
|
||||
};
|
||||
}
|
||||
if (result.action === 'not-found') return { file: { path: file, action: 'not-found' } };
|
||||
if (result.content.trim() === '') {
|
||||
removeFileUnlessLinked(file, '');
|
||||
} else {
|
||||
atomicWriteFileSync(file, result.content.trimEnd() + tomlLineBreak(content));
|
||||
}
|
||||
return { file: { path: file, action: 'removed' } };
|
||||
}
|
||||
|
||||
/** The line break `content` uses (its first one); `\n` when it has none. */
|
||||
export function tomlLineBreak(content: string): '\n' | '\r\n' {
|
||||
const nl = content.indexOf('\n');
|
||||
|
||||
@@ -19,7 +19,7 @@ export type Location = 'global' | 'local';
|
||||
* lookup. New targets add a value here when they're added to the
|
||||
* registry. Keep these short and lowercase.
|
||||
*/
|
||||
export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'copilot-vscode' | 'copilot-cli' | 'copilot-jetbrains';
|
||||
export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'copilot-vscode' | 'copilot-cli' | 'copilot-jetbrains' | 'bob' | 'droid' | 'windsurf' | 'qwen' | 'dsh' | 'grok' | 'kimi' | 'qoder' | 'pi';
|
||||
|
||||
/**
|
||||
* Result of `target.detect(location)`.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Windsurf target, global only: MCP server entry under `mcpServers` in
|
||||
* `~/.codeium/windsurf/mcp_config.json`.
|
||||
*
|
||||
* Windsurf became Devin Desktop in June 2026. That file is the stable channel's
|
||||
* MCP config, and Devin imports it: `read_config_from.windsurf` is on unless the
|
||||
* user turns it off. Devin's own `~/.config/devin/mcp_config.json` belongs to
|
||||
* a Devin target, not this one. Windsurf documents no project-local MCP file, so
|
||||
* `supportsLocation('local')` is false.
|
||||
*
|
||||
* The entry is `{ command, args }`, with no `type` key, as in Windsurf's docs.
|
||||
* No instructions file: the only per-user rules file it documented,
|
||||
* `global_rules.md`, belonged to Cascade, which Devin Desktop removed in
|
||||
* September 2026. `autoAllow` is a no-op: tools are approved in the app.
|
||||
*
|
||||
* Docs: https://docs.devin.ai/cli/reference/configuration/read-config-from
|
||||
* https://docs.devin.ai/windsurf/plugins/cascade/mcp
|
||||
* https://docs.devin.ai/desktop/devin-desktop-faq
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { AgentTarget, DetectionResult, InstallOptions, Location, WriteResult } from './types';
|
||||
import { hasCodegraphServer, removeCodegraphServer, upsertCodegraphServer } from './json-config';
|
||||
import { getStdioServerConfig } from './shared';
|
||||
|
||||
function configDir(): string {
|
||||
return path.join(os.homedir(), '.codeium', 'windsurf');
|
||||
}
|
||||
|
||||
function mcpConfigPath(): string {
|
||||
return path.join(configDir(), 'mcp_config.json');
|
||||
}
|
||||
|
||||
class WindsurfTarget implements AgentTarget {
|
||||
readonly id = 'windsurf' as const;
|
||||
readonly displayName = 'Windsurf';
|
||||
readonly docsUrl = 'https://docs.windsurf.com/windsurf/cascade/mcp';
|
||||
|
||||
supportsLocation(loc: Location): boolean {
|
||||
return loc === 'global';
|
||||
}
|
||||
|
||||
detect(loc: Location): DetectionResult {
|
||||
if (loc !== 'global') return { installed: false, alreadyConfigured: false };
|
||||
const file = mcpConfigPath();
|
||||
return {
|
||||
installed: fs.existsSync(configDir()),
|
||||
alreadyConfigured: hasCodegraphServer(file),
|
||||
configPath: file,
|
||||
};
|
||||
}
|
||||
|
||||
install(loc: Location, _opts: InstallOptions): WriteResult {
|
||||
if (loc !== 'global') {
|
||||
return { files: [], notes: ['Windsurf has no project-local MCP config — re-run with --location=global.'] };
|
||||
}
|
||||
return {
|
||||
files: [upsertCodegraphServer(mcpConfigPath(), getStdioServerConfig())],
|
||||
notes: ['Restart Windsurf, or refresh its MCP list, to load CodeGraph.'],
|
||||
};
|
||||
}
|
||||
|
||||
uninstall(loc: Location): WriteResult {
|
||||
if (loc !== 'global') return { files: [] };
|
||||
return { files: [removeCodegraphServer(mcpConfigPath())] };
|
||||
}
|
||||
|
||||
printConfig(loc: Location): string {
|
||||
if (loc !== 'global') return '# Windsurf has no project-local MCP config — use --location=global.\n';
|
||||
const snippet = JSON.stringify({ mcpServers: { codegraph: getStdioServerConfig() } }, null, 2);
|
||||
return `# Add to ${mcpConfigPath()}\n\n${snippet}\n`;
|
||||
}
|
||||
|
||||
describePaths(loc: Location): string[] {
|
||||
return loc === 'global' ? [mcpConfigPath()] : [];
|
||||
}
|
||||
}
|
||||
|
||||
export const windsurfTarget: AgentTarget = new WindsurfTarget();
|
||||
Reference in New Issue
Block a user