diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8d8ab6f79..de56c298c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- GitHub Copilot is now a supported agent: `codegraph install` can configure Copilot Chat in VS Code (`copilot-vscode`), the GitHub Copilot CLI (`copilot-cli`), and the Copilot plugin in JetBrains IDEs (`copilot-jetbrains`). Installed Copilot surfaces are auto-detected like every other agent, existing MCP server entries in their config files are preserved, and `codegraph uninstall` reverses the setup cleanly. Restart VS Code or your JetBrains IDE after installing so Copilot picks up the server.
+- The DeepSeek Harness (`dsh`) is now a supported agent: `codegraph install` wires the CodeGraph MCP server into `~/.dsh/cordis.patch.yml`, which applies to every dsh profile — the web GUI, headless one-shot runs, and custom profiles — and the harness hot-swaps the change in live via HMR. If an earlier install had put a codegraph entry into a single profile's patch file, it is migrated up to the shared layer automatically so the same server never mounts twice, and `codegraph uninstall` reverses the setup cleanly.
+
### Fixes
- C, C++, Objective-C and Rust unions are now indexed as first-class `union` nodes. A `union` declaration previously produced no symbol at all, so it never appeared in search or `codegraph_explore`, and anything attached to it disappeared with it — in Rust, every `impl SomeTrait for MyUnion` lost its edge, the methods from that impl were left pointing at a type the graph did not contain, and asking which types implement a trait quietly skipped the union ones. A union-shaped dispatch table in C now resolves its function pointers like a struct-shaped one. A `typedef union { … } Name;` in C keeps the typedef's name and is no longer mistaken for a plain type alias. Thanks @ctype-lab. Re-index after upgrading to pick up unions in existing projects. (#1515)
diff --git a/README.md b/README.md
index f8bb60bbc..75a260c10 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 (dsh) — 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,6 +392,7 @@ 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 (all dsh profiles)
codegraph install --print-config codex # print snippet, no file writes
codegraph install --print-config copilot-vscode # same, for Copilot in VS Code
```
@@ -405,7 +407,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 / DeepSeek Harness) for the MCP server to load.
### 3. Initialize Projects
@@ -764,6 +766,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. DSH doesn't send the workspace root to the server, so query a project by passing `projectPath` to `codegraph_explore` (or pin `cwd`/`--path` in the entry to a specific project).
## Supported Languages
@@ -862,7 +865,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 0d185a9d8..5253853f7 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;
},
};
}
@@ -1269,6 +1272,238 @@ 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');
});
+
+ 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);
+ 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');
+ expect(fs.existsSync(file)).toBe(true);
+ const body = fs.readFileSync(file, 'utf-8');
+ expect(body).toContain('- insert:');
+ expect(body).toContain('- id: mcp-codegraph');
+ expect(body).toContain("name: '@deepseek-ai/dsh-mcp-client'");
+ expect(body).toContain('serverName: codegraph');
+ expect(body).toContain('transport: stdio');
+ expect(body).toContain('command: codegraph');
+ expect(body).toContain('- serve');
+ expect(body).toContain('- --mcp');
+ expect(body).toContain('failOnStartupError: false');
+ expect(dsh.detect('global').alreadyConfigured).toBe(true);
+
+ const second = dsh.install('global', { autoAllow: true });
+ const mcp2 = second.files.find((f) => f.path === file)!;
+ expect(mcp2.action).toBe('unchanged');
+ expect(fs.readFileSync(file, 'utf-8')).toBe(body);
+ });
+
+ it('dsh: install preserves sibling entries in an existing home patch', () => {
+ const dsh = getTarget('dsh')!;
+ const dir = path.join(tmpHome, '.dsh');
+ fs.mkdirSync(dir, { recursive: true });
+ const file = path.join(dir, 'cordis.patch.yml');
+ fs.writeFileSync(file, [
+ '# my patch layer',
+ '- insert:',
+ ' - id: my-plugin',
+ ' name: "./plugins/my-plugin.mjs"',
+ ' config:',
+ ' enabled: 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).toContain('# my patch layer');
+ expect(body).toContain('- id: my-plugin');
+ expect(body).toContain('- id: mcp-codegraph');
+
+ const second = dsh.install('global', { autoAllow: true });
+ expect(second.files.find((f) => f.path === file)?.action).toBe('unchanged');
+ });
+
+ it('dsh: install rewrites a user-edited home entry to the canonical form, keeping the rest', () => {
+ const dsh = getTarget('dsh')!;
+ const dir = path.join(tmpHome, '.dsh');
+ fs.mkdirSync(dir, { recursive: true });
+ const file = path.join(dir, 'cordis.patch.yml');
+ fs.writeFileSync(file, [
+ '- insert:',
+ ' - id: mcp-codegraph',
+ " name: '@deepseek-ai/dsh-mcp-client'",
+ ' config:',
+ ' serverName: codegraph',
+ ' transport: stdio',
+ " command: 'C:\\pinned\\node.exe'",
+ ' args:',
+ " - 'C:\\pinned\\codegraph'",
+ ' - serve',
+ ' - --mcp',
+ " cwd: 'D:\\proj'",
+ ' 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, pinned cwd dropped, sibling preserved.
+ expect(body).toContain(' command: codegraph');
+ expect(body).not.toContain('pinned');
+ expect(body).not.toContain('cwd:');
+ expect(body).toContain('- id: other');
+ });
+
+ it('dsh: install self-heals a per-profile codegraph entry into the home layer', () => {
+ 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: 'C:\\manual\\node.exe'",
+ ' args:',
+ " - 'C:\\manual\\codegraph'",
+ ' - 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.
+ expect(result.files.some((f) => f.path === path.join(tmpHome, '.dsh', 'cordis.patch.yml'))).toBe(true);
+ expect(result.files.find((f) => f.path === profileFile)?.action).toBe('removed');
+ const profileBody = fs.readFileSync(profileFile, 'utf-8');
+ expect(profileBody).not.toContain('mcp-codegraph');
+ expect(profileBody).not.toContain('manual');
+ expect(profileBody).toContain('graphify');
+ });
+
+ it('dsh: uninstall removes only the codegraph entry, preserving siblings', () => {
+ const dsh = getTarget('dsh')!;
+ const dir = path.join(tmpHome, '.dsh');
+ fs.mkdirSync(dir, { recursive: true });
+ const file = path.join(dir, 'cordis.patch.yml');
+ fs.writeFileSync(file, [
+ '# my patch layer',
+ '- insert:',
+ ' - id: mcp-codegraph',
+ " name: '@deepseek-ai/dsh-mcp-client'",
+ ' config:',
+ ' serverName: codegraph',
+ ' transport: stdio',
+ ' command: codegraph',
+ ' args:',
+ ' - serve',
+ ' - --mcp',
+ ' failOnStartupError: false',
+ ' - 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('graphify');
+ expect(dsh.detect('global').alreadyConfigured).toBe(false);
+ });
+
+ it('dsh: uninstall deletes the patch file when it only held codegraph', () => {
+ 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: uninstall fully removes a profile-only install (detect/uninstall symmetric)', () => {
+ 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, [
+ '- insert:',
+ ' - id: mcp-codegraph',
+ " name: '@deepseek-ai/dsh-mcp-client'",
+ ' config:',
+ ' serverName: codegraph',
+ ' transport: stdio',
+ ' command: codegraph',
+ ' args:',
+ ' - serve',
+ ' - --mcp',
+ ' failOnStartupError: false',
+ '',
+ ].join('\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).
+ 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(false);
+ expect(dsh.detect('global').alreadyConfigured).toBe(false);
+ });
+
+ it('dsh: honors DSH_HOME over ~/.dsh', () => {
+ 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);
+ } finally {
+ delete process.env.DSH_HOME;
+ }
+ });
+
+ it('dsh: printConfig prints a parseable YAML insert block without writing anything', () => {
+ const dsh = getTarget('dsh')!;
+ const before = listAllFiles(tmpHome).concat(listAllFiles(tmpCwd));
+ const out = dsh.printConfig('global');
+ expect(out.length).toBeGreaterThan(0);
+ expect(out).toContain('- insert:');
+ expect(out).toContain('- id: mcp-codegraph');
+ expect(out).toContain('serverName: codegraph');
+ const after = listAllFiles(tmpHome).concat(listAllFiles(tmpCwd));
+ expect(after.sort()).toEqual(before.sort());
+ });
});
describe('Installer targets — registry', () => {
@@ -1284,6 +1519,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();
});
diff --git a/site/src/content/docs/getting-started/installation.md b/site/src/content/docs/getting-started/installation.md
index 4f9b90986..6338011a7 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** (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`). 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.
## 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..1d2774976 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 (dsh) — 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..5851a7ed8 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
Run `npx @colbymchenry/codegraph` and pick your agent(s); see [Installation](/codegraph/getting-started/installation/) for the non-interactive flags.
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 067cdd3e0..71747725e 100644
--- a/src/bin/codegraph.ts
+++ b/src/bin/codegraph.ts
@@ -2246,7 +2246,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')
@@ -2346,7 +2346,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 edeb4ac94..50b8ef20c 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 DeepSeek
+ * Harness).
* Defaults to the Claude-only behavior for backwards compatibility
* when no targets are explicitly chosen and nothing else is detected.
*
@@ -468,7 +469,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..f1bc5547c
--- /dev/null
+++ b/src/installer/targets/dsh.ts
@@ -0,0 +1,399 @@
+/**
+ * DeepSeek Harness (dsh) target.
+ *
+ * The DeepSeek Harness keeps ALL of its state under `$DSH_HOME` (default
+ * `~/.dsh`, overridable via the `DSH_HOME` env var) — there is no
+ * project-local config, so this target is global-only like Codex /
+ * Hermes. Within the home there are two user-editable patch layers:
+ *
+ * - `$DSH_HOME/cordis.patch.yml` — the HOME-level layer, applied
+ * over EVERY profile's own layer (web, headless, custom, ...). This is
+ * the single, machine-wide place to put an MCP server.
+ * - `$DSH_HOME/profiles//cordis.patch.yml` — one layer PER profile.
+ *
+ * We write to the home-level file: one write configures codegraph for every
+ * DSH profile, and the boot watches that file live (`watchUserPatches`), so
+ * the dsh-mcp-client plugin hot-swaps the server via HMR without a restart.
+ *
+ * The MCP server is a Cordis plugin entry inserted through the patch
+ * file's `- insert:` root form:
+ *
+ * - insert:
+ * - id: mcp-codegraph
+ * name: '@deepseek-ai/dsh-mcp-client'
+ * config:
+ * serverName: codegraph
+ * transport: stdio
+ * command: codegraph
+ * args:
+ * - serve
+ * - --mcp
+ * failOnStartupError: false
+ *
+ * (dsh-mcp-client schema: `serverName` namespaces the tools as
+ * `mcp__codegraph__*`; `failOnStartupError: false` keeps the UI booting
+ * even if the server can't start.)
+ *
+ * Self-heal: a pre-installer user may have put a codegraph entry in a
+ * per-profile `cordis.patch.yml` (as was done by hand before this target
+ * existed). Because the home-level layer composes AFTER a profile's layer,
+ * a same-`serverName` entry left in a profile would collide with the
+ * home-level one — dsh-mcp-client fails the later duplicate at load. So
+ * install AND uninstall both sweep codegraph entries out of every profile
+ * patch, exactly like opencode's legacy-%APPDATA% cleanup: install migrates
+ * the entry up to the shared layer, and uninstall removes codegraph from the
+ * agent entirely (home + any profile), keeping detect() and uninstall()
+ * symmetric even for a profile-only install.
+ *
+ * Project root: DSH never sends a workspace root (dsh-mcp-client connects
+ * with no roots capability) and never chdir()s to the workspace, so a
+ * global `command: codegraph` entry resolves the project from the spawned
+ * process's inherited cwd — the harness launch dir, not an indexed project.
+ * The agent reaches the graph by passing `projectPath` (which the server's
+ * no-root-index guidance points at), or the user can pin `cwd` / `--path`
+ * on the entry for a specific project.
+ *
+ * No instructions file is written. The MCP server's `initialize`
+ * instructions are the single source of truth for agent-facing tool
+ * guidance (issue #529), so the installer deliberately writes no duplicate
+ * block — DSH's own dsh-agent-instructions does read a user-global
+ * `$DSH_HOME/AGENTS.md`, but that is the user's personal file and is left
+ * untouched (matching the no-duplicate-block policy every target follows).
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import {
+ AgentTarget,
+ DetectionResult,
+ InstallOptions,
+ Location,
+ WriteResult,
+} from './types';
+import {
+ arrayEqual,
+ atomicWriteFileSync,
+ escapeRegExp,
+ joinLines,
+ readTextFile,
+ splitLines,
+} from './shared';
+
+/** Stable entry id the installer owns, also used as the duplicate marker. */
+const ENTRY_ID = 'mcp-codegraph';
+/** Cordis plugin that connects to external MCP servers. */
+const MCP_CLIENT_PLUGIN = '@deepseek-ai/dsh-mcp-client';
+/** Per-profile patch filename, same at home level and inside each profile. */
+const PATCH_FILENAME = 'cordis.patch.yml';
+/** A root `- insert:` patch header (inserts entries into the tree root). */
+const INSERT_HEADER_RE = /^- insert:\s*(?:#.*)?$/;
+
+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, if any. */
+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));
+}
+
+/**
+ * The canonical `mcp-codegraph` entry, indented for a root `- insert:`
+ * list (entry at 4 spaces, keys at 6, config keys at 8, args at 10 —
+ * matching what DSH itself renders).
+ */
+function renderCodeGraphEntry(): string[] {
+ return [
+ ' - id: mcp-codegraph',
+ ` name: '${MCP_CLIENT_PLUGIN}'`,
+ ' config:',
+ ' serverName: codegraph',
+ ' transport: stdio',
+ ' command: codegraph',
+ ' args:',
+ ' - serve',
+ ' - --mcp',
+ ' failOnStartupError: false',
+ ];
+}
+
+/** Index of the first line that is a `- id: ` list item (any indent). */
+function findEntryLine(lines: string[], id: string): number {
+ const pattern = new RegExp(`^\\s+- id: ${escapeRegExp(id)}\\s*$`);
+ for (let i = 0; i < lines.length; i++) {
+ if (pattern.test(lines[i] ?? '')) return i;
+ }
+ return -1;
+}
+
+/**
+ * `[start, end)` of a list-item entry whose first line is `lines[start]`
+ * (a `- id: ...` line at some indent). Runs until the next line that starts
+ * a list item at the SAME or a shallower indent (a sibling or a top-level
+ * patch), or EOF. Blank lines and comments don't end the entry.
+ */
+function entryRange(lines: string[], start: number): { start: number; end: number } {
+ const indent = (lines[start] ?? '').match(/^( *)- /)?.[1]?.length ?? 0;
+ let lastContent = start + 1;
+ for (let i = start + 1; i < lines.length; i++) {
+ const line = lines[i] ?? '';
+ if (line.trim() === '') continue;
+ const m = line.match(/^( *)- /);
+ if (m && (m[1]?.length ?? 0) <= indent) {
+ // A sibling entry (or top-level patch) at the same/shallower indent
+ // ends this one — at the boundary line, trailing blanks excluded.
+ return { start, end: i };
+ }
+ lastContent = i + 1;
+ }
+ // EOF — end at the last content line so a trailing blank line left by
+ // the file's final newline is not counted as part of the entry.
+ return { start, end: lastContent };
+}
+
+/**
+ * Ensure exactly one canonical codegraph entry, inside a root `- insert:`
+ * block, preserving every other line of the file. Returns the new content
+ * and whether it changed (byte-compare used by the caller for idempotency).
+ */
+function upsertCodeGraphEntry(content: string): { content: string; changed: boolean } {
+ const lines = splitLines(content);
+ const canonical = renderCodeGraphEntry();
+ const existing = findEntryLine(lines, ENTRY_ID);
+
+ if (existing !== -1) {
+ const range = entryRange(lines, existing);
+ const current = lines.slice(range.start, range.end);
+ if (arrayEqual(current, canonical)) return { content, changed: false };
+ lines.splice(range.start, range.end - range.start, ...canonical);
+ return { content: joinLines(lines), changed: true };
+ }
+
+ // No entry yet. Prefer appending into the LAST root `- insert:` block
+ // (so a home patch already carrying other plugins gets one more entry);
+ // otherwise start a new top-level `- insert:` block.
+ let insertHeader = -1;
+ for (let i = 0; i < lines.length; i++) {
+ if (INSERT_HEADER_RE.test(lines[i] ?? '')) insertHeader = i;
+ }
+
+ if (insertHeader !== -1) {
+ // Insert as the first entry of that block (right after the header) so
+ // existing sibling entries and any trailing comments stay untouched.
+ lines.splice(insertHeader + 1, 0, ...canonical);
+ return { content: joinLines(lines), changed: true };
+ }
+
+ // No root insert block — start a new one. A brand-new file gets the block
+ // directly; an existing patch file gets it after a blank-line separator.
+ const fresh = lines.length === 1 && lines[0] === '';
+ if (fresh) {
+ return { content: '- insert:\n' + canonical.join('\n') + '\n', changed: true };
+ }
+ const text = joinLines(lines);
+ return {
+ content: text + '\n' + '- insert:\n' + canonical.join('\n') + '\n',
+ changed: true,
+ };
+}
+
+/**
+ * Remove every codegraph entry from a patch file. Drops a root `- insert:`
+ * block that becomes empty, and the whole file if only comments remain.
+ */
+function removeCodeGraphEntry(content: string): {
+ content: string;
+ removed: boolean;
+ empty: boolean;
+} {
+ const lines = splitLines(content);
+ let removed = false;
+
+ // Remove every codegraph entry (not just the first) via the same
+ // ENTRY_ID marker the rest of the file keys off.
+ for (let idx = findEntryLine(lines, ENTRY_ID); idx !== -1; idx = findEntryLine(lines, ENTRY_ID)) {
+ const range = entryRange(lines, idx);
+ lines.splice(range.start, range.end - range.start);
+ removed = true;
+ }
+
+ // Drop root `- insert:` blocks that now hold no entries.
+ const out: string[] = [];
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i] ?? '';
+ if (INSERT_HEADER_RE.test(line)) {
+ // Consume this block's blank lines and nested list items only.
+ let j = i + 1;
+ let hasNested = false;
+ while (j < lines.length) {
+ const l = lines[j] ?? '';
+ if (l.trim() === '') {
+ j++;
+ continue;
+ }
+ if (/^ {2,}\S/.test(l)) {
+ hasNested = true;
+ j++;
+ continue;
+ }
+ break;
+ }
+ if (!hasNested) {
+ i = j; // block emptied — drop it and its trailing blanks
+ continue;
+ }
+ }
+ out.push(line);
+ }
+
+ if (!removed) return { content, removed: false, empty: false };
+ const joined = joinLines(out);
+ const empty = joined.trim() === '' || out.every((l) => l.trim() === '' || l.trim().startsWith('#'));
+ return { content: joined, removed: true, empty };
+}
+
+function hasCodeGraphEntry(content: string): boolean {
+ return findEntryLine(splitLines(content), ENTRY_ID) !== -1;
+}
+
+class DshTarget implements AgentTarget {
+ readonly id = 'dsh' as const;
+ readonly displayName = 'DeepSeek Harness';
+ 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();
+ const installed = fs.existsSync(dshHome()) || fs.existsSync(home);
+ let alreadyConfigured = hasCodeGraphEntry(readTextFile(home));
+ // A per-profile entry also counts as configured (install will migrate
+ // it up to the home-level layer to avoid a same-serverName collision).
+ if (!alreadyConfigured) {
+ alreadyConfigured = profilePatchPaths().some((p) => hasCodeGraphEntry(readTextFile(p)));
+ }
+ return { installed, 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'] = [];
+
+ const file = homePatchPath();
+ const existed = fs.existsSync(file);
+ const before = readTextFile(file);
+ const { content, changed } = upsertCodeGraphEntry(before);
+ if (changed) {
+ atomicWriteFileSync(file, content);
+ files.push({ path: file, action: existed ? 'updated' : 'created' });
+ } else {
+ files.push({ path: file, action: 'unchanged' });
+ }
+
+ // 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 MCP changes live via HMR; start a new session if the tools don\'t appear.'],
+ };
+ }
+
+ uninstall(loc: Location): WriteResult {
+ if (loc !== 'global') return { files: [] };
+ const files: WriteResult['files'] = [];
+ const file = homePatchPath();
+
+ if (fs.existsSync(file)) {
+ const before = readTextFile(file);
+ const { content, removed, empty } = removeCodeGraphEntry(before);
+ if (removed) {
+ if (empty) {
+ try { fs.unlinkSync(file); } catch { /* ignore */ }
+ } else {
+ atomicWriteFileSync(file, content);
+ }
+ files.push({ path: file, action: 'removed' });
+ } else {
+ files.push({ path: file, action: 'not-found' });
+ }
+ } else {
+ files.push({ path: file, action: 'not-found' });
+ }
+
+ // Full reversal: sweep any per-profile codegraph entry too, so a
+ // profile-only install (which detect() reports as configured) is removed
+ // as well — keeping detect() and uninstall() symmetric.
+ files.push(...sweepProfileEntries());
+
+ return { files };
+ }
+
+ printConfig(loc: Location): string {
+ if (loc !== 'global') {
+ return '# DeepSeek Harness config lives under $DSH_HOME; use --location=global.\n';
+ }
+ const snippet = ['- insert:', ...renderCodeGraphEntry()].join('\n');
+ return `# Add to ${homePatchPath()}\n\n${snippet}\n`;
+ }
+
+ describePaths(loc: Location): string[] {
+ return loc === 'global' ? [homePatchPath()] : [];
+ }
+}
+
+/**
+ * Strip a codegraph entry from every per-profile `cordis.patch.yml`
+ * (home-level composes after the profile layer, so a leftover same-name
+ * entry there would collide). 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;
+ const before = readTextFile(file);
+ if (!hasCodeGraphEntry(before)) continue;
+ const { content, removed, empty } = removeCodeGraphEntry(before);
+ if (!removed) continue;
+ if (empty) {
+ try { fs.unlinkSync(file); } catch { /* ignore */ }
+ } else {
+ atomicWriteFileSync(file, content);
+ }
+ out.push({ path: file, action: 'removed' });
+ }
+ return out;
+}
+
+export const dshTarget: AgentTarget = new DshTarget();
\ No newline at end of file
diff --git a/src/installer/targets/hermes.ts b/src/installer/targets/hermes.ts
index eacab6d3d..7084c0dbb 100644
--- a/src/installer/targets/hermes.ts
+++ b/src/installer/targets/hermes.ts
@@ -24,7 +24,14 @@ import {
Location,
WriteResult,
} from './types';
-import { atomicWriteFileSync } from './shared';
+import {
+ arrayEqual,
+ atomicWriteFileSync,
+ escapeRegExp,
+ joinLines,
+ readTextFile,
+ splitLines,
+} from './shared';
type LineRange = { start: number; end: number };
@@ -42,7 +49,7 @@ class HermesTarget implements AgentTarget {
return { installed: false, alreadyConfigured: false };
}
const file = configPath();
- const content = readText(file);
+ const content = readTextFile(file);
const installed = fs.existsSync(hermesHome()) || fs.existsSync(file);
return {
installed,
@@ -71,7 +78,7 @@ class HermesTarget implements AgentTarget {
return { files: [{ path: file, action: 'not-found' }] };
}
- const before = readText(file);
+ const before = readTextFile(file);
const after = removeCodeGraphToolset(removeCodeGraphMcpServer(before));
if (after === before) {
return { files: [{ path: file, action: 'not-found' }] };
@@ -112,18 +119,10 @@ function configPath(): string {
return path.join(hermesHome(), 'config.yaml');
}
-function readText(file: string): string {
- try {
- return fs.readFileSync(file, 'utf-8');
- } catch {
- return '';
- }
-}
-
function writeHermesConfig(): WriteResult['files'][number] {
const file = configPath();
const existed = fs.existsSync(file);
- const before = readText(file);
+ const before = readTextFile(file);
const afterMcp = upsertCodeGraphMcpServer(before);
const after = upsertCodeGraphToolset(afterMcp);
@@ -138,15 +137,6 @@ function ensureTrailingNewline(text: string): string {
return text.endsWith('\n') ? text : text + '\n';
}
-function splitLines(content: string): string[] {
- return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
-}
-
-function joinLines(lines: string[]): string {
- while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
- return lines.join('\n') + '\n';
-}
-
function topLevelRange(lines: string[], key: string): LineRange | null {
const start = lines.findIndex((line) => line.trim() === `${key}:`);
if (start === -1) return null;
@@ -245,10 +235,6 @@ function listChildBlock(
return { start, end, itemIndent };
}
-function escapeRegExp(value: string): string {
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-}
-
function renderCodeGraphMcpChild(): string[] {
return [
' codegraph:',
@@ -349,8 +335,4 @@ function removeCodeGraphToolset(content: string): string {
return joinLines(next);
}
-function arrayEqual(a: string[], b: string[]): boolean {
- return a.length === b.length && a.every((value, idx) => value === b[idx]);
-}
-
export const hermesTarget: AgentTarget = new HermesTarget();
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/shared.ts b/src/installer/targets/shared.ts
index 364f40427..50a31c53c 100644
--- a/src/installer/targets/shared.ts
+++ b/src/installer/targets/shared.ts
@@ -127,6 +127,40 @@ export function jsonDeepEqual(a: unknown, b: unknown): boolean {
return ak.every((k) => jsonDeepEqual(ao[k], bo[k]));
}
+/**
+ * Read a text file, returning `''` when missing or unreadable. Shared by the
+ * line-based YAML targets (Hermes, DeepSeek Harness) which treat an absent
+ * config file the same as an empty one.
+ */
+export function readTextFile(filePath: string): string {
+ try {
+ return fs.readFileSync(filePath, 'utf-8');
+ } catch {
+ return '';
+ }
+}
+
+/** Split file content into lines, normalizing CRLF / CR to LF. */
+export function splitLines(content: string): string[] {
+ return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
+}
+
+/** Re-join lines, dropping a single trailing blank line and ending with `\n`. */
+export function joinLines(lines: string[]): string {
+ while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
+ return lines.join('\n') + '\n';
+}
+
+/** Escape a string so it is literal inside a RegExp. */
+export function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+/** Order-insensitive array equality by value (element-wise `===`). */
+export function arrayEqual(a: string[], b: string[]): boolean {
+ return a.length === b.length && a.every((value, idx) => value === b[idx]);
+}
+
/**
* Replace or append a marker-delimited section in a markdown-ish file.
*
diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts
index 022ab28e8..29e290e5f 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)`.