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.
[](#supported-agents)
[](#supported-agents)
[](#supported-agents)
+[](#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)`.