diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b2d07fb3..fe2f18569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - A new `deprioritize` setting in `codegraph.json` keeps the paths you name from outranking your product code in search and `codegraph_explore` answers, without removing anything from the index. It takes gitignore-style patterns just like `exclude`, but is ranking-only: helper-script trees, generated output, or optional add-on directories whose generic symbol names (`usage`, `run`, `status`) would otherwise crowd out the code that actually answers a query stay fully indexed and findable — and a query that genuinely targets such a tree still returns it. Thanks @maxmilian. (#982) +- The DeepSeek Harness (`dsh`) is now a supported agent: `codegraph install` can configure it (`dsh`), it is auto-detected like every other agent, and `codegraph uninstall` reverses the setup cleanly. One configuration covers every dsh profile at once, and a running dsh session picks the change up without a restart. Existing entries in the patch file are preserved, and an entry added by hand to a single profile is migrated to the shared location so it doesn't conflict with itself. + ### Fixes - Indexing no longer hangs on a Swift Vapor project containing a call with a long argument list. A single `.get(...)`-style call with many labeled arguments and no `use:` handler — the shape generated request builders produce — could stall `codegraph index`, `codegraph sync`, and the MCP server indefinitely. Route detection now handles such files in milliseconds, and every previously-recognized route shape still parses exactly as before. Thanks @maxmilian. (#1544) (Swift) diff --git a/README.md b/README.md index 4f89a40e6..7f086eea0 100644 --- a/README.md +++ b/README.md @@ -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 DeepSeek Harness with Semantic Code Intelligence **The fastest complete code graph · surgical context · built for how agents actually work · 100% local** @@ -36,6 +36,7 @@ Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates. [![Antigravity](https://img.shields.io/badge/Antigravity-supported-blueviolet.svg)](#supported-agents) [![Kiro](https://img.shields.io/badge/Kiro-supported-blueviolet.svg)](#supported-agents) [![GitHub Copilot](https://img.shields.io/badge/GitHub_Copilot-supported-blueviolet.svg)](#supported-agents) +[![DeepSeek Harness](https://img.shields.io/badge/DeepSeek_Harness-supported-blueviolet.svg)](#supported-agents)
@@ -105,7 +106,7 @@ In a **new terminal**, run the installer to connect CodeGraph to the agents you codegraph install ``` -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.) +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), and DeepSeek Harness — 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.) ### 3. Initialize each project @@ -376,7 +377,7 @@ 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), **DeepSeek Harness** (`dsh`) - 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`. @@ -391,8 +392,10 @@ codegraph install --yes # auto-detect agents, insta codegraph install --target=cursor,claude --yes # explicit target list codegraph install --target=auto --location=local # detected agents, project-local codegraph install --target=copilot-vscode,copilot-cli,copilot-jetbrains --yes # GitHub Copilot everywhere +codegraph install --target=dsh --yes # DeepSeek Harness codegraph install --print-config codex # print snippet, no file writes codegraph install --print-config copilot-vscode # same, for Copilot in VS Code +codegraph install --print-config dsh # same, for DeepSeek Harness ``` | Flag | Values | Default | @@ -405,7 +408,7 @@ codegraph install --print-config copilot-vscode # same, for Copilot in VS C ### 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) for the MCP server to load. A running DeepSeek Harness session picks the change up live — no restart needed. ### 3. Initialize Projects @@ -784,6 +787,7 @@ is written): - **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`) +- **DeepSeek Harness** (`dsh`) — writes the MCP server into `~/.dsh/cordis.patch.yml`, which applies to every dsh profile at once. dsh doesn't pass a workspace root to MCP servers, so point CodeGraph at your project with `projectPath` (the server's own guidance covers this), or pin `cwd` / `--path` on the entry for a single-project setup. `codegraph init` still needs to run once in each project ## Supported Languages @@ -882,7 +886,7 @@ MIT
-**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, and DeepSeek Harness** [Report Bug](https://github.com/colbymchenry/codegraph/issues) · [Request Feature](https://github.com/colbymchenry/codegraph/issues) diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index 4ec3e5903..f9e4a1819 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -40,6 +40,7 @@ function setHome(dir: string): { restore: () => void } { XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, HERMES_HOME: process.env.HERMES_HOME, COPILOT_HOME: process.env.COPILOT_HOME, + DSH_HOME: process.env.DSH_HOME, }; process.env.HOME = dir; process.env.USERPROFILE = dir; @@ -47,6 +48,7 @@ function setHome(dir: string): { restore: () => void } { process.env.XDG_CONFIG_HOME = path.join(dir, '.config'); delete process.env.HERMES_HOME; delete process.env.COPILOT_HOME; + delete process.env.DSH_HOME; return { restore() { if (prev.HOME === undefined) delete process.env.HOME; else process.env.HOME = prev.HOME; @@ -55,6 +57,7 @@ function setHome(dir: string): { restore: () => void } { if (prev.XDG_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = prev.XDG_CONFIG_HOME; if (prev.HERMES_HOME === undefined) delete process.env.HERMES_HOME; else process.env.HERMES_HOME = prev.HERMES_HOME; if (prev.COPILOT_HOME === undefined) delete process.env.COPILOT_HOME; else process.env.COPILOT_HOME = prev.COPILOT_HOME; + if (prev.DSH_HOME === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = prev.DSH_HOME; }, }; } @@ -1305,6 +1308,466 @@ describe('Installer targets — partial-state idempotency', () => { const stopCmds = (s.hooks?.Stop ?? []).flatMap((g: any) => (g.hooks ?? []).map((h: any) => h.command)); expect(stopCmds).toContain('codegraph sync-if-dirty'); }); + + // ---- DeepSeek Harness (dsh) ----------------------------------------- + // + // dsh configures MCP servers as plugin rows in layered YAML patch + // files; the home-level $DSH_HOME/cordis.patch.yml applies to every + // profile. The file must stay a valid top-level YAML array — dsh + // fails its boot on a comments-only or mixed-indent file. + + const DSH_ENTRY_4 = [ + ' - id: mcp-codegraph', + " name: '@deepseek-ai/dsh-mcp-client'", + ' config:', + ' serverName: codegraph', + ' transport: stdio', + ' command: codegraph', + ' args:', + ' - serve', + ' - --mcp', + ' failOnStartupError: false', + ].join('\n'); + const DSH_BLOCK_4 = ['- insert:', DSH_ENTRY_4].join('\n'); + + it('dsh: install writes the home-level MCP entry, detects it, and is idempotent', () => { + const dsh = getTarget('dsh')!; + // DeepSeek Harness keeps all config under $DSH_HOME — no project-local. + expect(dsh.supportsLocation('local')).toBe(false); + expect(dsh.describePaths('local')).toEqual([]); + const file = path.join(tmpHome, '.dsh', 'cordis.patch.yml'); + expect(dsh.detect('global').alreadyConfigured).toBe(false); + + const first = dsh.install('global', { autoAllow: true }); + const mcp = first.files.find((f) => f.path === file)!; + expect(mcp.action).toBe('created'); + const body = fs.readFileSync(file, 'utf-8'); + expect(body).toContain(DSH_BLOCK_4); + expect(dsh.detect('global').alreadyConfigured).toBe(true); + expect(dsh.detect('global').configPath).toBe(file); + + const second = dsh.install('global', { autoAllow: true }); + expect(second.files.find((f) => f.path === file)?.action).toBe('unchanged'); + expect(fs.readFileSync(file, 'utf-8')).toBe(body); + }); + + it('dsh: install replaces a lone [] line, preserving the user\'s comments; uninstall restores them + []', () => { + const dsh = getTarget('dsh')!; + const file = path.join(tmpHome, '.dsh', 'cordis.patch.yml'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, '# my patch layer\n[]\n'); + + const result = dsh.install('global', { autoAllow: true }); + expect(result.files.find((f) => f.path === file)?.action).toBe('updated'); + expect(fs.readFileSync(file, 'utf-8')).toBe(`# my patch layer\n${DSH_BLOCK_4}\n`); + + dsh.uninstall('global'); + // The user created this file — never delete it; restore their + // comments plus the documented empty state. + expect(fs.existsSync(file)).toBe(true); + expect(fs.readFileSync(file, 'utf-8')).toBe('# my patch layer\n[]\n'); + expect(dsh.detect('global').alreadyConfigured).toBe(false); + }); + + it('dsh: user []-only file round-trips back to []', () => { + const dsh = getTarget('dsh')!; + const file = path.join(tmpHome, '.dsh', 'cordis.patch.yml'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, '[]\n'); + + dsh.install('global', { autoAllow: true }); + expect(fs.readFileSync(file, 'utf-8')).toBe(`${DSH_BLOCK_4}\n`); + + dsh.uninstall('global'); + expect(fs.readFileSync(file, 'utf-8')).toBe('[]\n'); + }); + + it('dsh: install repairs a comments-only file (which dsh cannot boot)', () => { + const dsh = getTarget('dsh')!; + const file = path.join(tmpHome, '.dsh', 'cordis.patch.yml'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, '# only comments, no array\n'); + + const result = dsh.install('global', { autoAllow: true }); + expect(result.files.find((f) => f.path === file)?.action).toBe('updated'); + expect(fs.readFileSync(file, 'utf-8')).toBe(`# only comments, no array\n${DSH_BLOCK_4}\n`); + }); + + it('dsh: install appends into a 2-space-style insert block AT THAT INDENT (mixed-indent regression, cf. hermes #456)', () => { + const dsh = getTarget('dsh')!; + const file = path.join(tmpHome, '.dsh', 'cordis.patch.yml'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, [ + '# my patch layer', + '- insert:', + ' - id: my-plugin', + ' name: "./plugins/my-plugin.mjs"', + ' config:', + ' enabled: true', + '', + ].join('\n')); + + dsh.install('global', { autoAllow: true }); + const body = fs.readFileSync(file, 'utf-8'); + + // Our entry landed INSIDE the block, at the block's own 2-space + // item indent — never at our canonical 4-space (js-yaml rejects + // mixed item indents in one sequence, and a parse failure aborts + // the dsh boot). + expect(body).toContain(' enabled: true\n - id: mcp-codegraph'); + expect(body).not.toContain('\n - id: mcp-codegraph'); + expect(body).toContain(' - id: my-plugin'); + + // Idempotent: the byte-equal 2-space entry is left alone. + const second = dsh.install('global', { autoAllow: true }); + expect(second.files.find((f) => f.path === file)?.action).toBe('unchanged'); + expect(fs.readFileSync(file, 'utf-8')).toBe(body); + + // Uninstall strips only our entry, in the user's style file. + dsh.uninstall('global'); + const after = fs.readFileSync(file, 'utf-8'); + expect(after).not.toContain('mcp-codegraph'); + expect(after).toContain(' - id: my-plugin'); + expect(after).toContain(' enabled: true'); + expect(after).toContain('# my patch layer'); + }); + + it('dsh: install appends a fresh block after sibling id-patches when no insert block exists', () => { + const dsh = getTarget('dsh')!; + const file = path.join(tmpHome, '.dsh', 'cordis.patch.yml'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, [ + '# my patch layer', + '- id: telemetry-otel', + ' disabled: true', + '', + ].join('\n')); + + const result = dsh.install('global', { autoAllow: true }); + expect(result.files.find((f) => f.path === file)?.action).toBe('updated'); + const body = fs.readFileSync(file, 'utf-8'); + expect(body).toBe(`# my patch layer\n- id: telemetry-otel\n disabled: true\n\n${DSH_BLOCK_4}\n`); + + dsh.uninstall('global'); + expect(fs.readFileSync(file, 'utf-8')).toBe('# my patch layer\n- id: telemetry-otel\n disabled: true\n'); + }); + + it('dsh: install rewrites a user-edited entry in place at its own indent, keeping siblings', () => { + const dsh = getTarget('dsh')!; + const file = path.join(tmpHome, '.dsh', 'cordis.patch.yml'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, [ + '- insert:', + ' - id: mcp-codegraph', + " name: '@deepseek-ai/dsh-mcp-client'", + ' config:', + ' serverName: codegraph', + ' transport: stdio', + ' command: /custom/path/codegraph', + ' args:', + ' - serve', + ' - --mcp', + ' failOnStartupError: false', + ' - id: other', + " name: './plugins/other.mjs'", + '', + ].join('\n')); + + const result = dsh.install('global', { autoAllow: true }); + expect(result.files.find((f) => f.path === file)?.action).toBe('updated'); + const body = fs.readFileSync(file, 'utf-8'); + // Canonical command restored, sibling preserved, still 2-space style. + expect(body).toContain(' - id: other'); + expect(body).toContain(' command: codegraph'); + expect(body).not.toContain('/custom/path/'); + expect(body).not.toContain('\n - id: mcp-codegraph'); + // Idempotent afterwards. + const second = dsh.install('global', { autoAllow: true }); + expect(second.files.find((f) => f.path === file)?.action).toBe('unchanged'); + }); + + it('dsh: an id-targeted override patch is never confused with our entry', () => { + const dsh = getTarget('dsh')!; + const file = path.join(tmpHome, '.dsh', 'cordis.patch.yml'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + // A user patch that targets our (or any) mcp-codegraph row — a + // bare id row, no plugin name. Detection requires BOTH markers. + const override = [ + '# my patch layer', + '- insert:', + ' - id: other', + " name: './plugins/other.mjs'", + '- id: mcp-codegraph', + ' disabled: true', + '', + ].join('\n'); + fs.writeFileSync(file, override); + + dsh.install('global', { autoAllow: true }); + const body = fs.readFileSync(file, 'utf-8'); + // The override row survives byte-identical… + expect(body).toContain('- id: mcp-codegraph\n disabled: true'); + // …our insert entry was appended into the block at its indent. + expect(body).toContain(' - id: mcp-codegraph'); + expect(body).toContain(" name: '@deepseek-ai/dsh-mcp-client'"); + + // Uninstall removes only the inserted entry — the override row is + // user content and stays. + dsh.uninstall('global'); + const after = fs.readFileSync(file, 'utf-8'); + expect(after).toContain('- id: mcp-codegraph\n disabled: true'); + expect(after).not.toContain('@deepseek-ai/dsh-mcp-client'); + expect(after).toContain(' - id: other'); + }); + + it('dsh: uninstall removes only our entry, preserving siblings and comments', () => { + const dsh = getTarget('dsh')!; + const file = path.join(tmpHome, '.dsh', 'cordis.patch.yml'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, [ + '# my patch layer', + '- insert:', + ...DSH_ENTRY_4.split('\n'), + ' - id: graphify', + " name: './plugins/graphify.mjs'", + ' config:', + ' enabled: true', + '', + ].join('\n')); + + const result = dsh.uninstall('global'); + expect(result.files.find((f) => f.path === file)?.action).toBe('removed'); + const body = fs.readFileSync(file, 'utf-8'); + expect(body).not.toContain('mcp-codegraph'); + expect(body).not.toContain('serverName: codegraph'); + expect(body).toContain('# my patch layer'); + expect(body).toContain(' - id: graphify'); + expect(dsh.detect('global').alreadyConfigured).toBe(false); + }); + + it('dsh: uninstall deletes the patch file when the installer created it', () => { + const dsh = getTarget('dsh')!; + const file = path.join(tmpHome, '.dsh', 'cordis.patch.yml'); + dsh.install('global', { autoAllow: true }); + expect(fs.existsSync(file)).toBe(true); + + dsh.uninstall('global'); + expect(fs.existsSync(file)).toBe(false); + expect(dsh.detect('global').alreadyConfigured).toBe(false); + }); + + it('dsh: install migrates a per-profile codegraph entry up to the home layer (duplicate serverName self-heal)', () => { + const dsh = getTarget('dsh')!; + const profileDir = path.join(tmpHome, '.dsh', 'profiles', 'web'); + fs.mkdirSync(profileDir, { recursive: true }); + const profileFile = path.join(profileDir, 'cordis.patch.yml'); + fs.writeFileSync(profileFile, [ + '- insert:', + ' - id: mcp-codegraph', + " name: '@deepseek-ai/dsh-mcp-client'", + ' config:', + ' serverName: codegraph', + ' transport: stdio', + ' command: /manual/path/codegraph', + ' args:', + ' - serve', + ' - --mcp', + ' failOnStartupError: false', + ' - id: graphify', + " name: './plugins/graphify.mjs'", + ' config:', + ' enabled: true', + '', + ].join('\n')); + + // A per-profile entry alone counts as configured… + expect(dsh.detect('global').alreadyConfigured).toBe(true); + + const result = dsh.install('global', { autoAllow: true }); + // …and install migrates it: home layer created, profile entry swept, + // sibling profile plugin kept (never deleted — we didn't create it). + const homeFile = path.join(tmpHome, '.dsh', 'cordis.patch.yml'); + expect(result.files.find((f) => f.path === homeFile)?.action).toBe('created'); + expect(result.files.find((f) => f.path === profileFile)?.action).toBe('removed'); + expect(fs.readFileSync(homeFile, 'utf-8')).toContain(DSH_BLOCK_4); + const profileBody = fs.readFileSync(profileFile, 'utf-8'); + expect(profileBody).not.toContain('mcp-codegraph'); + expect(profileBody).not.toContain('/manual/path/'); + expect(profileBody).toContain(' - id: graphify'); + expect(fs.existsSync(profileFile)).toBe(true); + }); + + it('dsh: uninstall fully removes a profile-only install (detect/uninstall symmetry)', () => { + const dsh = getTarget('dsh')!; + const profileDir = path.join(tmpHome, '.dsh', 'profiles', 'headless'); + fs.mkdirSync(profileDir, { recursive: true }); + const profileFile = path.join(profileDir, 'cordis.patch.yml'); + fs.writeFileSync(profileFile, `${DSH_BLOCK_4}\n`); + + // A profile-only entry is "configured"… + expect(dsh.detect('global').alreadyConfigured).toBe(true); + + const result = dsh.uninstall('global'); + // …and uninstall removes it (home file absent → not-found, profile + // swept back to a valid empty patch list — never deleted). + expect(result.files.find((f) => f.path === path.join(tmpHome, '.dsh', 'cordis.patch.yml'))?.action).toBe('not-found'); + expect(result.files.find((f) => f.path === profileFile)?.action).toBe('removed'); + expect(fs.existsSync(profileFile)).toBe(true); + expect(fs.readFileSync(profileFile, 'utf-8')).toBe('[]\n'); + expect(dsh.detect('global').alreadyConfigured).toBe(false); + }); + + it('dsh: an unclassifiable flow-style file is left untouched with a note', () => { + const dsh = getTarget('dsh')!; + const file = path.join(tmpHome, '.dsh', 'cordis.patch.yml'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const flow = '[{id: my-thing, disabled: true}]\n'; + fs.writeFileSync(file, flow); + + const result = dsh.install('global', { autoAllow: true }); + expect(result.files.find((f) => f.path === file)?.action).toBe('kept'); + expect(result.notes?.[0]).toContain('print-config'); + expect(fs.readFileSync(file, 'utf-8')).toBe(flow); + expect(dsh.detect('global').alreadyConfigured).toBe(false); + + // Uninstall is equally conservative. + const un = dsh.uninstall('global'); + expect(un.files.find((f) => f.path === file)?.action).toBe('not-found'); + expect(fs.readFileSync(file, 'utf-8')).toBe(flow); + }); + + // A hand-written FLOW-STYLE codegraph entry nested in a block: detected + // as configured, but the line editor cannot rewrite flow style, so + // install must NEVER append a block-style duplicate (a second + // serverName:codegraph row makes the later dsh-mcp-client instance + // fail at load) and uninstall must never claim it removed one. + const FLOW_OURS = [ + '- insert:', + " - { id: mcp-codegraph, name: '@deepseek-ai/dsh-mcp-client', config: { serverName: codegraph, transport: stdio, command: codegraph, args: [serve, --mcp], failOnStartupError: false } }", + '', + ].join('\n'); + + it('dsh: a flow-style codegraph entry in a block is detected as configured but never rewritten (install keeps it)', () => { + const dsh = getTarget('dsh')!; + const file = path.join(tmpHome, '.dsh', 'cordis.patch.yml'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, FLOW_OURS); + + // Detection recognizes the flow-style entry as ours. + expect(dsh.detect('global').alreadyConfigured).toBe(true); + + // Kept + note, file byte-identical, no duplicate appended. + const result = dsh.install('global', { autoAllow: true }); + expect(result.files.find((f) => f.path === file)?.action).toBe('kept'); + expect(result.notes?.[0]).toContain('print-config'); + expect(fs.readFileSync(file, 'utf-8')).toBe(FLOW_OURS); + + // Re-running behaves identically — nothing accumulates. + const again = dsh.install('global', { autoAllow: true }); + expect(again.files.find((f) => f.path === file)?.action).toBe('kept'); + expect(fs.readFileSync(file, 'utf-8')).toBe(FLOW_OURS); + }); + + it('dsh: uninstall on a flow-style codegraph entry reports kept (detected but not strippable), file byte-identical', () => { + const dsh = getTarget('dsh')!; + const file = path.join(tmpHome, '.dsh', 'cordis.patch.yml'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, FLOW_OURS); + expect(dsh.detect('global').alreadyConfigured).toBe(true); + + const result = dsh.uninstall('global'); + expect(result.files.find((f) => f.path === file)?.action).toBe('kept'); + expect(result.notes?.length).toBe(1); + expect(result.notes?.[0]).toContain('flow style'); + expect(fs.readFileSync(file, 'utf-8')).toBe(FLOW_OURS); + // We refused to strip what we cannot classify — still configured. + expect(dsh.detect('global').alreadyConfigured).toBe(true); + }); + + it('dsh: a flow-style entry in a per-profile patch blocks the install (kept + note, nothing written anywhere)', () => { + const dsh = getTarget('dsh')!; + const profileDir = path.join(tmpHome, '.dsh', 'profiles', 'web'); + fs.mkdirSync(profileDir, { recursive: true }); + const profileFile = path.join(profileDir, 'cordis.patch.yml'); + fs.writeFileSync(profileFile, FLOW_OURS); + + // Profile-level flow entry counts as configured… + expect(dsh.detect('global').alreadyConfigured).toBe(true); + + // …and install refuses wholesale: writing the home-level entry would + // double-mount serverName:codegraph across the two layers. + const homeFile = path.join(tmpHome, '.dsh', 'cordis.patch.yml'); + const result = dsh.install('global', { autoAllow: true }); + expect(result.files.find((f) => f.path === homeFile)?.action).toBe('kept'); + expect(result.notes?.[0]).toContain('flow style'); + expect(result.notes?.[0]).toContain('print-config'); + expect(fs.existsSync(homeFile)).toBe(false); + expect(fs.readFileSync(profileFile, 'utf-8')).toBe(FLOW_OURS); + }); + + it('dsh: uninstall sweeps a profile-only flow-style entry as kept, never mangling it', () => { + const dsh = getTarget('dsh')!; + const profileDir = path.join(tmpHome, '.dsh', 'profiles', 'headless'); + fs.mkdirSync(profileDir, { recursive: true }); + const profileFile = path.join(profileDir, 'cordis.patch.yml'); + fs.writeFileSync(profileFile, FLOW_OURS); + + const result = dsh.uninstall('global'); + expect(result.files.find((f) => f.path === path.join(tmpHome, '.dsh', 'cordis.patch.yml'))?.action).toBe('not-found'); + expect(result.files.find((f) => f.path === profileFile)?.action).toBe('kept'); + expect(result.notes?.[0]).toContain('flow style'); + expect(fs.readFileSync(profileFile, 'utf-8')).toBe(FLOW_OURS); + }); + + it('dsh: local install is rejected with a note; printConfig writes nothing', () => { + const dsh = getTarget('dsh')!; + const r = dsh.install('local', { autoAllow: true }); + expect(r.files.length).toBe(0); + expect(r.notes?.length).toBe(1); + + const before = listAllFiles(tmpHome).concat(listAllFiles(tmpCwd)); + const out = dsh.printConfig('global'); + expect(out).toContain('- insert:'); + expect(out).toContain('- id: mcp-codegraph'); + expect(out).toContain('serverName: codegraph'); + expect(out).toContain('failOnStartupError: false'); + const after = listAllFiles(tmpHome).concat(listAllFiles(tmpCwd)); + expect(after.sort()).toEqual(before.sort()); + }); + + it('dsh: honors DSH_HOME over ~/.dsh (non-empty env only)', () => { + const dsh = getTarget('dsh')!; + const custom = path.join(tmpHome, 'custom-dsh'); + process.env.DSH_HOME = custom; + try { + const result = dsh.install('global', { autoAllow: true }); + const file = path.join(custom, 'cordis.patch.yml'); + expect(result.files.some((f) => f.path === file)).toBe(true); + expect(fs.existsSync(file)).toBe(true); + expect(dsh.detect('global').configPath).toBe(file); + expect(fs.existsSync(path.join(tmpHome, '.dsh'))).toBe(false); + } finally { + delete process.env.DSH_HOME; + } + + // An empty-string DSH_HOME falls back to ~/.dsh. + process.env.DSH_HOME = ''; + try { + expect(dsh.detect('global').configPath).toBe(path.join(tmpHome, '.dsh', 'cordis.patch.yml')); + } finally { + delete process.env.DSH_HOME; + } + }); + + it('dsh: CRLF patch files are handled (normalized to LF on write)', () => { + const dsh = getTarget('dsh')!; + const file = path.join(tmpHome, '.dsh', 'cordis.patch.yml'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, '# my patch layer\r\n[]\r\n'); + + dsh.install('global', { autoAllow: true }); + expect(fs.readFileSync(file, 'utf-8')).toBe(`# my patch layer\n${DSH_BLOCK_4}\n`); + }); }); describe('Installer targets — registry', () => { @@ -1320,6 +1783,7 @@ 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'); + expect(getTarget('dsh')?.id).toBe('dsh'); expect(getTarget('not-a-real-target')).toBeUndefined(); }); @@ -1335,6 +1799,7 @@ describe('Installer targets — registry', () => { expect(ids).toContain('copilot-vscode'); expect(ids).toContain('copilot-cli'); expect(ids).toContain('copilot-jetbrains'); + expect(ids).toContain('dsh'); }); it('resolveTargetFlag resolves the Copilot ids from a csv list', () => { diff --git a/site/src/content/docs/getting-started/installation.md b/site/src/content/docs/getting-started/installation.md index 4f9b90986..60ec7cbcc 100644 --- a/site/src/content/docs/getting-started/installation.md +++ b/site/src/content/docs/getting-started/installation.md @@ -11,7 +11,7 @@ npx @colbymchenry/codegraph The installer will: -- Ask which agent(s) to configure — auto-detecting installed ones from **Claude Code**, **Cursor**, **Codex CLI**, **opencode**, **Hermes Agent**, **Gemini CLI**, **Antigravity IDE**, and **Kiro**. +- Ask which agent(s) to configure — auto-detecting installed ones from **Claude Code**, **Cursor**, **Codex CLI**, **opencode**, **Hermes Agent**, **Gemini CLI**, **Antigravity IDE**, **Kiro**, and **DeepSeek Harness**. - 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`). Cursor and Kiro get the MCP config only. Removed cleanly by `codegraph uninstall`. @@ -38,7 +38,7 @@ codegraph install --print-config codex # print snippet, no file wr ## 2. Restart your agent -Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent / Gemini CLI / Antigravity IDE / Kiro) for the MCP server to load. +Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent / Gemini CLI / Antigravity IDE / Kiro / DeepSeek Harness) for the MCP server to load. A running DeepSeek Harness session picks the change up live — no restart needed. ## 3. Initialize projects diff --git a/site/src/content/docs/getting-started/introduction.md b/site/src/content/docs/getting-started/introduction.md index 44d6546c1..180436725 100644 --- a/site/src/content/docs/getting-started/introduction.md +++ b/site/src/content/docs/getting-started/introduction.md @@ -5,7 +5,7 @@ description: What CodeGraph is, and why it makes AI coding agents faster and mor CodeGraph is a **local-first code-intelligence tool**. It parses your codebase with [tree-sitter](https://tree-sitter.github.io/), stores every symbol, edge, and file in a local SQLite database, and exposes the result as a queryable **knowledge graph** — over the [Model Context Protocol (MCP)](/codegraph/reference/mcp-server/), a CLI, and a TypeScript library. -It exists to make AI coding agents — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, and Kiro — **answer structural questions without scanning files**. Instead of fanning out across `grep`, `glob`, and `Read` to reconstruct how code fits together, an agent queries a pre-built index and gets the answer in a handful of calls. +It exists to make AI coding agents — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, and DeepSeek Harness — **answer structural questions without scanning files**. Instead of fanning out across `grep`, `glob`, and `Read` to reconstruct how code fits together, an agent queries a pre-built index and gets the answer in a handful of calls. ## Why it matters diff --git a/site/src/content/docs/getting-started/quickstart.md b/site/src/content/docs/getting-started/quickstart.md index e1ea543f0..121f6c828 100644 --- a/site/src/content/docs/getting-started/quickstart.md +++ b/site/src/content/docs/getting-started/quickstart.md @@ -25,7 +25,7 @@ Already have Node? `npm i -g @colbymchenry/codegraph` works on any version. Code codegraph install ``` -Auto-detects and configures Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, and Kiro — wiring the CodeGraph MCP server into each. This step connects your agents only; it does **not** index any code. (Shortcut: `npx @colbymchenry/codegraph` downloads and runs the installer in one go.) +Auto-detects and configures Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, and DeepSeek Harness — wiring the CodeGraph MCP server into each. This step connects your agents only; it does **not** index any code. (Shortcut: `npx @colbymchenry/codegraph` downloads and runs the installer in one go.) ## 3. Initialize each project diff --git a/site/src/content/docs/guides/indexing.md b/site/src/content/docs/guides/indexing.md index 1518c3d0a..a492c2b00 100644 --- a/site/src/content/docs/guides/indexing.md +++ b/site/src/content/docs/guides/indexing.md @@ -24,7 +24,7 @@ codegraph sync # incremental — only changed files ## Stay fresh automatically -**You don't need to run `codegraph sync` by hand during an agent session.** When your agent (Claude Code, Cursor, Codex, opencode, Hermes, Gemini, Antigravity, Kiro) launches `codegraph serve --mcp`, three layers cooperate to keep the index in step with your code — and to never give the agent a quiet wrong answer in the small window between an edit and the next sync. +**You don't need to run `codegraph sync` by hand during an agent session.** When your agent (Claude Code, Cursor, Codex, opencode, Hermes, Gemini, Antigravity, Kiro, DeepSeek Harness) launches `codegraph serve --mcp`, three layers cooperate to keep the index in step with your code — and to never give the agent a quiet wrong answer in the small window between an edit and the next sync. ### 1. File watcher with debounced auto-sync (always on) diff --git a/site/src/content/docs/reference/integrations.md b/site/src/content/docs/reference/integrations.md index 1b4b87c89..a0db32461 100644 --- a/site/src/content/docs/reference/integrations.md +++ b/site/src/content/docs/reference/integrations.md @@ -15,6 +15,7 @@ The interactive installer auto-detects and configures each supported agent — w - **Gemini CLI** - **Antigravity IDE** - **Kiro** +- **DeepSeek Harness** (`dsh`) — writes the MCP server into `~/.dsh/cordis.patch.yml`, which applies to every dsh profile at once Run `npx @colbymchenry/codegraph` and pick your agent(s); see [Installation](/codegraph/getting-started/installation/) for the non-interactive flags. @@ -57,3 +58,23 @@ One wildcard auto-approves every CodeGraph tool. The server lists a single tool :::tip Cursor launches MCP subprocesses with the wrong working directory. The installer handles this for you by injecting a `--path` argument; if you wire Cursor up by hand, pass the project path explicitly. ::: + +### DeepSeek Harness (dsh) + +dsh configures MCP servers as plugin rows in layered YAML patch files, not `mcpServers` JSON. Add the entry to `~/.dsh/cordis.patch.yml` (`$DSH_HOME/cordis.patch.yml` if you've moved the home) — the home-level layer, which applies to every dsh profile at once: + +```yaml +- insert: + - id: mcp-codegraph + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: codegraph + transport: stdio + command: codegraph + args: + - serve + - --mcp + failOnStartupError: false +``` + +The file must stay a valid top-level YAML array — `[]` is the empty state, and a comments-only file fails to load. A running dsh session picks the change up without a restart. dsh doesn't pass a workspace root to MCP servers, so point CodeGraph at your project with `projectPath`. diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index a469c00b4..aa9418b27 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -94,7 +94,7 @@ const install = 'npx @colbymchenry/codegraph';

MCP server

Expose the graph to Claude Code, Cursor, Codex, opencode, Hermes, - Gemini, Antigravity, and Kiro over MCP — agents answer in a handful of calls. + Gemini, Antigravity, Kiro, and DeepSeek Harness over MCP — agents answer in a handful of calls.

diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index e4038200d..5ba81ab0f 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -2264,7 +2264,7 @@ 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, DeepSeek Harness)') .option('-t, --target ', 'Target agent(s): comma-separated ids, or "auto"|"all"|"none". Default: prompt') .option('-l, --location ', 'Install location: "global" or "local". Default: prompt') .option('-y, --yes', 'Non-interactive: defaults to --location=global --target=auto, auto-allow on') @@ -2364,7 +2364,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, DeepSeek Harness)') .option('-t, --target ', 'Target agent(s): comma-separated ids, or "all". Default: all') .option('-l, --location ', 'Uninstall location: "global" or "local". Default: prompt') .option('-y, --yes', 'Non-interactive: defaults to --location=global --target=all') diff --git a/src/installer/index.ts b/src/installer/index.ts index 9a99ffe34..c772ca618 100644 --- a/src/installer/index.ts +++ b/src/installer/index.ts @@ -3,8 +3,9 @@ * * 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, and the + * DeepSeek Harness). * Defaults to the Claude-only behavior for backwards compatibility * when no targets are explicitly chosen and nothing else is detected. * @@ -250,7 +251,8 @@ export async function runInstallerWithOptions(opts: RunInstallerOptions): Promis ? 'Unchanged' : file.action === 'created' ? 'Created' : file.action === 'removed' ? 'Removed' - : 'Updated'; + : file.action === 'kept' ? 'Kept' + : 'Updated'; clack.log.success(`${target.displayName}: ${verb} ${tildify(file.path)}`); } for (const note of result.notes ?? []) { @@ -468,7 +470,7 @@ export async function runUninstaller(opts: RunUninstallerOptions): Promise 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: 'global' as const, label: 'All projects (global)', hint: '~/.claude, ~/.cursor, ~/.codex, ~/.config/opencode, ~/.hermes, ~/.gemini, ~/.kiro, ~/.copilot, ~/.config/github-copilot, ~/.dsh' }, { value: 'local' as const, label: 'Just this project (local)', hint: './.claude, ./.cursor, ./.vscode, ./opencode.jsonc, ./.gemini, ./.kiro' }, ], initialValue: 'global' as const, diff --git a/src/installer/targets/dsh.ts b/src/installer/targets/dsh.ts new file mode 100644 index 000000000..dcccce04a --- /dev/null +++ b/src/installer/targets/dsh.ts @@ -0,0 +1,744 @@ +/** + * DeepSeek Harness (dsh) target. + * + * DSH keeps all of its state under `$DSH_HOME` (default `~/.dsh`, + * overridable via a non-empty `DSH_HOME` env var) — there is no + * project-local config, so this target is global-only like Codex and + * Hermes. MCP servers are NOT `mcpServers` JSON: they are Cordis plugin + * rows using `@deepseek-ai/dsh-mcp-client`, declared in the harness's + * layered patch files. The user-owned layer that applies to EVERY dsh + * profile (web, headless, custom) is the home-level patch file: + * + * $DSH_HOME/cordis.patch.yml + * + * One write there configures codegraph for every profile at once, and a + * running dsh watches the file and recomposes live (HMR), so the change + * is picked up without restarting the session. + * + * We append one root insert patch (an insert without `id` appends its + * entries to the composed root entry list — see + * `@deepseek-ai/cordis-plugin-include`'s `applyEntryPatches`): + * + * - insert: + * - id: mcp-codegraph + * name: '@deepseek-ai/dsh-mcp-client' + * config: + * serverName: codegraph + * transport: stdio + * command: codegraph + * args: + * - serve + * - --mcp + * failOnStartupError: false + * + * The model sees the server's tools as `mcp__codegraph__*` — the same + * server-qualified naming Claude Code and Codex use, so the existing + * `mcp__codegraph__*` permission convention applies unchanged. + * `failOnStartupError: false` pins the dsh-mcp-client default so a + * missing/unlaunchable codegraph binary never blocks the harness from + * booting. + * + * Project root: dsh never sends a workspace root to MCP servers + * (dsh-mcp-client connects with no roots capability), so the agent + * reaches the graph by passing `projectPath` — the server's + * no-root-index guidance points at it. A user who wants a + * single-project setup can instead pin `cwd` (a dsh-mcp-client field) + * or `--path` on the entry by hand; the installer writes neither. + * + * dsh requires the file to be a valid top-level YAML array: a missing + * file is fine, but a comments-only file fails to boot (it parses to + * nothing, not to a list) and `[]` is the documented empty state. This + * editor keeps the file structurally valid through every operation — + * and when appending into an existing `- insert:` block it MATCHES the + * indent of that block's items (js-yaml, which dsh uses to parse the + * file, rejects block sequences with mixed item indents, and a parse + * failure aborts the dsh boot): + * + * - install replaces a lone `[]` line with our entry (comments around + * it preserved), appends below the comments of a comments-only file + * — repairing a currently-unbootable file — appends into the last + * `- insert:` block at the matched item indent, or appends a fresh + * block after sibling patches; + * - a pre-existing codegraph entry that already matches the canonical + * rendering at its own indent is left byte-identical (`unchanged`); + * one that differs is rewritten in place — at its own indent when + * nested in a user's block — so a valid 2-space style file is never + * rewritten into a mixed-indent one; + * - uninstall strips only our entry when siblings remain, restores + * `[]` when only user comments remain, and deletes the file when we + * created it and nothing else is left; + * - flow-style YAML is handled conservatively: a hand-written + * flow-style codegraph entry (a `- { id: mcp-codegraph, ... }` list + * item inside a block) IS recognized as configured, but since the + * line editor cannot rewrite flow style it is left byte-identical — + * install and uninstall report `kept` with a note (appending a + * block-style duplicate would double-mount the serverName), and + * such an entry in a per-profile patch blocks the install entirely + * (migrating it is impossible, and writing the home-level entry + * anyway would mount the same serverName twice across layers); + * - other files this line editor cannot classify (flow-style arrays, + * block mappings, document markers) are left untouched with a note + * pointing at `codegraph install --print-config dsh`. + * + * Self-heal sweep: dsh composes the home-level layer AFTER each + * profile's own layer, and a duplicate `serverName` across live + * dsh-mcp-client instances fails the later instance at load. A + * pre-installer codegraph entry may therefore sit in a per-profile + * `$DSH_HOME/profiles//cordis.patch.yml` — install migrates it up + * to the shared home layer (removing it from the profile), and uninstall + * removes codegraph from dsh entirely (home + every profile), keeping + * detect() and uninstall() symmetric even for a profile-only install. + * + * No instructions file is written (issue #529): the MCP `initialize` + * instructions are the single source of truth for agent-facing tool + * guidance, and dsh's user-global `$DSH_HOME/AGENTS.md` is the user's + * personal file, left untouched. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { atomicWriteFileSync } from './shared'; + +/** Stable entry id the installer owns. */ +const ENTRY_ID = 'mcp-codegraph'; +/** Cordis plugin that bridges external MCP servers into dsh. */ +const MCP_CLIENT_PLUGIN = '@deepseek-ai/dsh-mcp-client'; +/** Patch filename, identical at the home level and inside each profile. */ +const PATCH_FILENAME = 'cordis.patch.yml'; +/** A root `- insert:` patch header line. */ +const INSERT_HEADER_RE = /^- insert:\s*(?:#.*)?$/; + +/** Matches `id: mcp-codegraph` at any indent (trimmed) — including the + * `- id:` list-item spelling our insert entry uses. */ +const ID_LINE = /^(?:- )?id:\s*mcp-codegraph\s*$/; +/** Matches the plugin name single- or double-quoted (YAML requires the + * quoting — `@` is a reserved indicator at the start of a plain scalar). */ +const NAME_LINE = /^name:\s*['"]@deepseek-ai\/dsh-mcp-client['"]\s*$/; + +/** + * A single non-comment line carrying our entry in YAML FLOW style — + * `{ id: mcp-codegraph, name: '@deepseek-ai/dsh-mcp-client', ... }`, + * typically a `- { ... }` list item a user hand-wrote inside a block. + * This predicate is DETECTION-ONLY: it makes detect() report the entry + * as configured and makes install/uninstall refuse with `kept` + a + * note (the line editor cannot rewrite flow style, and appending or + * stripping would duplicate or orphan the serverName mount) — but the + * entry is never treated as strippable by the block-style logic. + */ +function isFlowStyleOurLine(rawLine: string): boolean { + const line = (rawLine ?? '').trim(); + if (line.startsWith('#')) return false; + if (!line.includes(MCP_CLIENT_PLUGIN)) return false; + return /[{,]\s*id:\s*['"]?mcp-codegraph['"]?\s*[,}]/.test(line); +} + +/** + * Comment lines stamped into a patch file this installer creates. + * Uninstall recognizes "we created this file" by an exact match against + * them; any other comment belongs to the user and survives. + */ +function renderHeaderComment(): string[] { + return [ + '# dsh home-level plugin patch layer — applies to every dsh profile.', + '# CodeGraph MCP server entry below, added by `codegraph install`.', + ]; +} + +/** + * The `mcp-codegraph` insert entry rendered at the given item indent: + * the `- id:` line at `indent`, keys at +2, config keys at +4, list + * items at +6 — internally consistent at every indent, which is what + * keeps js-yaml happy next to user entries at the same item indent. + */ +function renderCodeGraphEntry(indent: string): string[] { + const key = indent + ' '; + const cfg = key + ' '; + const arg = cfg + ' '; + return [ + `${indent}- id: ${ENTRY_ID}`, + `${key}name: '${MCP_CLIENT_PLUGIN}'`, + `${key}config:`, + `${cfg}serverName: codegraph`, + `${cfg}transport: stdio`, + `${cfg}command: codegraph`, + `${cfg}args:`, + `${arg}- serve`, + `${arg}- --mcp`, + `${cfg}failOnStartupError: false`, + ]; +} + +/** + * A fresh standalone root insert block, in the style dsh itself renders + * (item at 4 spaces, keys at 6, config at 8, list items at 10). + */ +function renderCodeGraphBlock(): string[] { + return ['- insert:', ...renderCodeGraphEntry(' ')]; +} + +type LineRange = { start: number; end: number }; + +class DshTarget implements AgentTarget { + readonly id = 'dsh' as const; + readonly displayName = 'DeepSeek Harness (dsh)'; + readonly docsUrl = 'https://github.com/deepseek-ai/deepseek-harness'; + + supportsLocation(loc: Location): boolean { + return loc === 'global'; + } + + detect(loc: Location): DetectionResult { + if (loc !== 'global') { + return { installed: false, alreadyConfigured: false }; + } + const home = homePatchPath(); + let alreadyConfigured = hasCodeGraphEntry(readText(home)); + // A per-profile entry also counts as configured — install migrates + // it up to the home-level layer (duplicate serverName collision). + if (!alreadyConfigured) { + alreadyConfigured = profilePatchPaths().some((p) => + hasCodeGraphEntry(readText(p)), + ); + } + return { + installed: fs.existsSync(dshHome()) || fs.existsSync(home), + alreadyConfigured, + configPath: home, + }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + if (loc !== 'global') { + return { + files: [], + notes: ['DeepSeek Harness config lives under $DSH_HOME; re-run with --location=global.'], + }; + } + const files: WriteResult['files'] = []; + + // A flow-style codegraph entry in any per-profile patch blocks the + // install wholesale: migrating it up to the home layer is impossible + // (the line editor cannot rewrite flow style), and writing the + // home-level entry anyway would mount serverName `codegraph` twice + // across the two layers — the later instance fails at load. + const flowProfile = profilePatchPaths().find((p) => + fs.existsSync(p) && splitLines(readText(p)).some(isFlowStyleOurLine)); + if (flowProfile !== undefined) { + return { + files: [{ path: homePatchPath(), action: 'kept' }], + notes: [ + `${flowProfile} carries a CodeGraph entry in YAML flow style, which this installer cannot edit — nothing was written. Rewrite or remove that entry by hand (see \`codegraph install --print-config dsh\` for the block-style form) and re-run.`, + ], + }; + } + + const write = writeDshPatch(); + files.push({ path: write.file, action: write.action }); + if (write.note) { + return { files, notes: [write.note] }; + } + + // Self-heal: migrate any per-profile codegraph entry up to the + // home-level layer so the same `serverName` never mounts twice. + files.push(...sweepProfileEntries()); + + return { + files, + notes: ['DSH applies patch-file changes live (HMR); start a new session if the tools don\'t appear.'], + }; + } + + uninstall(loc: Location): WriteResult { + if (loc !== 'global') return { files: [] }; + const files: WriteResult['files'] = []; + files.push(removeDshPatch()); + // Full reversal: sweep any per-profile codegraph entry too, so a + // profile-only install (which detect() reports as configured) is + // removed as well. + files.push(...sweepProfileEntries()); + // A flow-style codegraph entry was detected but refused: say so, + // rather than silently claiming codegraph was removed. + if (files.some((f) => f.action === 'kept')) { + return { + files, + notes: [ + 'A CodeGraph entry written in YAML flow style was left untouched — this installer edits block-style entries only. Remove it by hand if needed (see `codegraph install --print-config dsh` for the block-style form).', + ], + }; + } + return { files }; + } + + printConfig(loc: Location): string { + if (loc !== 'global') { + return '# DeepSeek Harness config lives under $DSH_HOME; use --location=global.\n'; + } + return [ + `# Add to ${homePatchPath()} — the dsh home-level patch layer, applied to every dsh profile.`, + '# A running dsh reloads this file live.', + '', + ...renderCodeGraphBlock(), + '', + ].join('\n'); + } + + describePaths(loc: Location): string[] { + return loc === 'global' ? [homePatchPath()] : []; + } +} + +/** + * The DSH home. Precedence matches `@deepseek-ai/dsh-home-paths`: + * a non-empty `$DSH_HOME`, else `~/.dsh`. + */ +function dshHome(): string { + const env = process.env.DSH_HOME; + return env && env.trim().length > 0 + ? path.resolve(env) + : path.join(os.homedir(), '.dsh'); +} + +/** The home-level patch layer, applied over every profile. */ +function homePatchPath(): string { + return path.join(dshHome(), PATCH_FILENAME); +} + +/** Every profile's own patch file, for the duplicate-serverName sweep. */ +function profilePatchPaths(): string[] { + const profilesDir = path.join(dshHome(), 'profiles'); + if (!fs.existsSync(profilesDir)) return []; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(profilesDir, { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter((e) => e.isDirectory()) + .map((e) => path.join(profilesDir, e.name, PATCH_FILENAME)); +} + +function readText(file: string): string { + try { + return fs.readFileSync(file, 'utf-8'); + } catch { + return ''; + } +} + +function splitLines(content: string): string[] { + return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); +} + +/** Join lines with a single trailing newline, dropping trailing blanks. */ +function joinLines(lines: string[]): string { + const out = [...lines]; + while (out.length > 0 && out[out.length - 1] === '') out.pop(); + return out.length === 0 ? '' : out.join('\n') + '\n'; +} + +function stripTrailingBlanks(lines: string[]): string[] { + const out = [...lines]; + while (out.length > 0) { + const last = out[out.length - 1]; + if (last === undefined || last.trim() !== '') break; + out.pop(); + } + return out; +} + +function linesEqual(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((value, idx) => value === b[idx]); +} + +function isBlankOrComment(line: string | undefined): boolean { + const trimmed = (line ?? '').trim(); + return trimmed === '' || trimmed.startsWith('#'); +} + +/** + * Top-level YAML list items: lines starting with `- ` (or a bare `-`) + * at column 0. Each span runs to the next top-level item or EOF, minus + * trailing blank / comment lines — those sit *between* items and must + * survive an item's removal or replacement. + */ +function topLevelItems(lines: string[]): LineRange[] { + const spans: LineRange[] = []; + let start = -1; + for (let i = 0; i <= lines.length; i++) { + const isItemStart = i < lines.length && /^-(?: |$)/.test(lines[i] ?? ''); + if (start !== -1 && (isItemStart || i === lines.length)) { + let end = i; + while (end > start + 1 && isBlankOrComment(lines[end - 1])) end--; + spans.push({ start, end }); + start = -1; + } + if (isItemStart) start = i; + } + return spans; +} + +/** + * Nested list entries inside a top-level item's span: lines matching + * `^(\s+)- ` (at least one leading space, so the top-level `- insert:` + * header itself is not one). Each entry runs to the next line starting + * a list item at the same or a shallower indent, to a non-comment line + * dedented below the entry's indent, or to the span end — trailing + * blank/comment lines excluded so they survive a removal. + */ +function nestedEntrySpans(lines: string[], span: LineRange): Array { + const spans: Array = []; + let start = -1; + let indent = ''; + const closeAt = (i: number): void => { + let end = i; + while (end > start + 1 && isBlankOrComment(lines[end - 1])) end--; + spans.push({ start, end, indent }); + start = -1; + }; + for (let i = span.start + 1; i < span.end; i++) { + const line = lines[i] ?? ''; + const m = line.match(/^(\s+)- /); + if (m && m[1] !== undefined) { + if (start !== -1 && m[1].length <= indent.length) closeAt(i); + if (start === -1) { + start = i; + indent = m[1]; + } + continue; + } + if (start !== -1 && !isBlankOrComment(line)) { + const lineIndent = line.match(/^( *)/)?.[1] ?? ''; + if (lineIndent.length < indent.length) closeAt(i); + } + } + if (start !== -1) { + let end = span.end; + while (end > start + 1 && isBlankOrComment(lines[end - 1])) end--; + spans.push({ start, end, indent }); + } + return spans; +} + +/** + * Whether a line range is OUR insert entry: it carries both the + * `id: mcp-codegraph` and the `name: '@deepseek-ai/dsh-mcp-client'` + * lines at some indent inside the range. Requiring both keeps a user's + * id-targeted override patch (`- id: mcp-codegraph` with e.g. + * `disabled: true`) recognized as a sibling, not as ours. + */ +function isOurEntry(lines: string[], span: LineRange): boolean { + let hasId = false; + let hasName = false; + for (let i = span.start; i < span.end; i++) { + const trimmed = (lines[i] ?? '').trim(); + if (ID_LINE.test(trimmed)) hasId = true; + if (NAME_LINE.test(trimmed)) hasName = true; + } + return hasId && hasName; +} + +function hasCodeGraphEntry(content: string): boolean { + const lines = splitLines(content); + // Flow-style entries count for detection even though the editor + // refuses to rewrite them (see isFlowStyleOurLine). + if (lines.some(isFlowStyleOurLine)) return true; + return topLevelItems(lines).some((span) => isOurEntry(lines, span)); +} + +/** Indices of non-blank, non-comment lines. */ +function nonCommentLines(lines: string[]): number[] { + const idx: number[] = []; + for (let i = 0; i < lines.length; i++) { + if (!isBlankOrComment(lines[i])) idx.push(i); + } + return idx; +} + +function findEmptyArrayLine(lines: string[]): number { + return lines.findIndex((line) => (line ?? '').trim() === '[]'); +} + +/** + * A top-level line that is neither a list item, nor blank/comment, nor + * the documented empty state `[]` — a scalar, mapping, flow collection, + * or document marker. A file carrying one of those is not a shape this + * editor can classify; touching it risks corrupting an already-unusual + * (or already-invalid) file. (`[]` mixed with real items is rejected + * separately, inside `placeCanonicalEntry`.) + */ +function hasForeignTopLevelLine(lines: string[]): boolean { + return lines.some((line) => { + const raw = line ?? ''; + if (/^\s/.test(raw)) return false; // nested content, not top-level + const trimmed = raw.trim(); + if (trimmed === '' || trimmed.startsWith('#')) return false; + if (trimmed === '[]') return false; + return !/^-(?: |$)/.test(raw); + }); +} + +type PatchWrite = + | { file: string; action: 'created' | 'updated' | 'unchanged' | 'kept'; note?: string }; + +/** + * Upsert our insert entry into the home-level patch file. See the module + * doc comment for the full state matrix. + */ +function writeDshPatch(): PatchWrite { + const file = homePatchPath(); + const existed = fs.existsSync(file); + const before = readText(file); + + if (!existed) { + atomicWriteFileSync(file, joinLines([...renderHeaderComment(), '', ...renderCodeGraphBlock()])); + return { file, action: 'created' }; + } + + const lines = splitLines(before); + + // A flow-style codegraph entry: recognized as configured, but the + // line editor cannot rewrite flow style — appending a duplicate + // block-style entry would double-mount serverName `codegraph`. Refuse + // with the unsupported note. + if (lines.some(isFlowStyleOurLine)) { + return unsupported(file); + } + + const items = topLevelItems(lines); + const ours = items.filter((span) => isOurEntry(lines, span)); + + // Fast path: exactly one entry of ours whose bytes already match the + // canonical rendering at its own indent (standalone block or nested + // among user entries) — a byte-identical no-op. + if (ours.length === 1) { + const first = ours[0]!; + const nested = nestedEntrySpans(lines, first); + const nestedOurs = nested.filter((s) => isOurEntry(lines, s)); + if (nestedOurs.length === 1) { + const target = nestedOurs[0]!; + if (nested.length === 1) { + const standalone = ['- insert:', ...renderCodeGraphEntry(target.indent)]; + if (linesEqual(lines.slice(first.start, first.end), standalone)) { + return { file, action: 'unchanged' }; + } + } else if (linesEqual(lines.slice(target.start, target.end), renderCodeGraphEntry(target.indent))) { + return { file, action: 'unchanged' }; + } + } + } + + // A shape we cannot classify: leave the file alone rather than risk + // corrupting an already-unusual (or already-invalid) patch file. + if (hasForeignTopLevelLine(lines)) { + return unsupported(file); + } + + // Everything else — absent, hand-modified, duplicated, or misplaced — + // normalizes through strip-then-place: remove every entry of ours, + // then place the canonical entry at the file's own style. + const placed = placeCanonicalEntry(stripOurEntries(lines)); + if (placed === null) return unsupported(file); + if (placed === before) return { file, action: 'unchanged' }; + atomicWriteFileSync(file, placed); + return { file, action: 'updated' }; +} + +/** + * Place the canonical entry into a patch file that holds none of ours. + * Returns the new file content, or null when the file's shape is not + * classifiable. Never mutates the input array. + */ +function placeCanonicalEntry(lines: string[]): string | null { + const contentIdx = nonCommentLines(lines); + const items = topLevelItems(lines); + const emptyArray = findEmptyArrayLine(lines); + + // Comments-only: dsh fails to boot on this (parses to nothing, not a + // list). Appending our entry below the comments repairs the file. + if (contentIdx.length === 0) { + return joinLines([...stripTrailingBlanks(lines), ...renderCodeGraphBlock()]); + } + + // The documented empty state: comments around a lone `[]`. Replace + // the `[]` token with our block so the comments stay where the user + // put them. Anything mixing `[]` with other content is not a shape we + // can classify. + if (emptyArray !== -1) { + if (items.length > 0 || contentIdx.length !== 1) return null; + const next = [...lines]; + next.splice(emptyArray, 1, ...renderCodeGraphBlock()); + return joinLines(next); + } + + // Append into the LAST root `- insert:` block that carries entries, + // at that block's item indent — matching the user's style instead of + // forcing ours (js-yaml rejects mixed item indents in one sequence). + for (let i = items.length - 1; i >= 0; i--) { + const span = items[i]!; + if (!INSERT_HEADER_RE.test(lines[span.start] ?? '')) continue; + const nested = nestedEntrySpans(lines, span); + if (nested.length === 0) continue; + const last = nested[nested.length - 1]!; + const next = [...lines]; + next.splice(last.end, 0, ...renderCodeGraphEntry(last.indent)); + return joinLines(next); + } + + // Sibling patch entries, no insert block: append ours as a new + // top-level block, separated by one blank line. + if (items.length > 0) { + const next = stripTrailingBlanks(lines); + next.push('', ...renderCodeGraphBlock()); + return joinLines(next); + } + + // Flow-style arrays, block mappings, document markers, … + return null; +} + +/** + * Remove every codegraph entry (standalone block, nested entry, or + * misplaced top-level row) from a patch file's lines. Returns the + * remaining lines; never mutates the input. + */ +function stripOurEntries(lines: string[]): string[] { + let current = [...lines]; + // Loop until stable: removing a top-level item shifts the spans of + // everything after it, so recompute each pass. + for (;;) { + const items = topLevelItems(current); + let removed = false; + + for (const span of items) { + if (!isOurEntry(current, span)) continue; + const nested = nestedEntrySpans(current, span); + const nestedOurs = nested.filter((s) => isOurEntry(current, s)); + + if (INSERT_HEADER_RE.test(current[span.start] ?? '') && nested.length === nestedOurs.length) { + // Standalone block (or an insert header holding only our + // entries): remove the whole top-level item and one blank + // separator above it. + removeTopLevelItem(current, span); + removed = true; + break; + } + + if (nestedOurs.length > 0) { + // Nested among user entries: remove only our entries, latest + // first so earlier indices stay valid. + for (const ours of nestedOurs.reverse()) { + current.splice(ours.start, ours.end - ours.start); + } + removed = true; + break; + } + + // Misplaced top-level row carrying our id+name. + removeTopLevelItem(current, span); + removed = true; + break; + } + + if (!removed) return current; + } +} + +/** + * Remove one top-level item's span (and, when the line above it is a + * blank separator, that single blank) from `lines`, in place. + */ +function removeTopLevelItem(lines: string[], span: LineRange): void { + const start = span.start > 0 && (lines[span.start - 1] ?? '').trim() === '' + ? span.start - 1 + : span.start; + lines.splice(start, span.end - start); +} + +function unsupported(file: string): PatchWrite { + return { + file, + action: 'kept', + note: `${file} uses a style this installer cannot edit safely — add the entry from \`codegraph install --print-config dsh\` by hand.`, + }; +} + +/** + * Remove our insert entry from a patch file. Sibling entries always + * survive; when nothing but comments remains the file is restored to + * the documented empty state `[]` (a comments-only file aborts the dsh + * boot). `allowDelete` lets the home-level uninstall remove a file this + * installer created; per-profile sweep files are never deleted. + */ +function removeCodeGraphFromFile( + file: string, + allowDelete: boolean, +): WriteResult['files'][number] { + if (!fs.existsSync(file)) { + return { path: file, action: 'not-found' }; + } + + const content = readText(file); + if (!hasCodeGraphEntry(content)) { + return { path: file, action: 'not-found' }; + } + + // A flow-style codegraph entry is detected as configured but cannot + // be stripped safely — never claim removed, never mangle the file. + if (splitLines(content).some(isFlowStyleOurLine)) { + return { path: file, action: 'kept' }; + } + + const remaining = stripOurEntries(splitLines(content)); + + // Sibling patch entries or other content survive — strip only ours. + if (topLevelItems(remaining).length > 0 || nonCommentLines(remaining).length > 0) { + atomicWriteFileSync(file, joinLines(remaining)); + return { path: file, action: 'removed' }; + } + + // Only comments remain (or nothing at all). If they are exactly — and + // only — the header we stamp, we created this file: delete it for a + // clean round-trip. Otherwise the comments are the user's: keep them + // and restore `[]` so the file stays a valid (empty) patch list. + const nonBlank = remaining.filter((line) => line.trim() !== ''); + const header = renderHeaderComment(); + if (allowDelete && nonBlank.length === header.length && linesEqual(nonBlank, header)) { + try { fs.unlinkSync(file); } catch { /* already gone */ } + return { path: file, action: 'removed' }; + } + atomicWriteFileSync(file, joinLines([...stripTrailingBlanks(remaining), '[]'])); + return { path: file, action: 'removed' }; +} + +function removeDshPatch(): WriteResult['files'][number] { + return removeCodeGraphFromFile(homePatchPath(), true); +} + +/** + * Strip a codegraph entry from every per-profile `cordis.patch.yml` + * (the home-level layer composes after each profile's own layer, so a + * leftover same-`serverName` entry there would collide). Profile files + * are never deleted — the installer does not create them. Returns only + * files actually changed, keeping install output quiet when there is + * nothing to heal. + */ +function sweepProfileEntries(): WriteResult['files'] { + const out: WriteResult['files'] = []; + for (const file of profilePatchPaths()) { + if (!fs.existsSync(file)) continue; + if (!hasCodeGraphEntry(readText(file))) continue; + out.push(removeCodeGraphFromFile(file, false)); + } + return out; +} + +export const dshTarget: AgentTarget = new DshTarget(); diff --git a/src/installer/targets/registry.ts b/src/installer/targets/registry.ts index 3798b39ad..a58930cda 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -19,6 +19,7 @@ import { kiroTarget } from './kiro'; import { copilotVscodeTarget } from './copilot-vscode'; import { copilotCliTarget } from './copilot-cli'; import { copilotJetbrainsTarget } from './copilot-jetbrains'; +import { dshTarget } from './dsh'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ claudeTarget, @@ -32,6 +33,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ copilotVscodeTarget, copilotCliTarget, copilotJetbrainsTarget, + dshTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index d93680573..c77e2b9b5 100644 --- a/src/installer/targets/types.ts +++ b/src/installer/targets/types.ts @@ -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' | 'dsh'; /** * Result of `target.detect(location)`.