diff --git a/README.md b/README.md
index 28f7e38..113a916 100644
--- a/README.md
+++ b/README.md
@@ -69,6 +69,7 @@ HarnessKit manages **all five extension types** from a unified interface — **S
| **Hermes** | ✓ | ✓ | ✓ | ✓ | ✓ |
| **Kiro** | ✓ | ✓ | — | ✓ | ✓ |
| **Oh My Pi** | ✓ | ✓ | ✓ | — | ✓ |
+| **DeepSeek Harness** | ✓ | ✓ | — | — | ✓ |
* "—" indicates the agent currently does not support this extension type. Devin Desktop support keeps legacy Windsurf paths compatible.
@@ -88,7 +89,7 @@ HarnessKit manages **all five extension types** from a unified interface — **S
### 🤖 Agent Configs, Memory & Rules
-HarnessKit manages every agent's **Configs**, **Memory**, **Rules**, **Subagents**, and **Ignore** files from one place. Currently supporting **11 agents**: **Claude Code**, **Codex**, **Gemini CLI**, **Cursor**, **Antigravity**, **Copilot**, **Devin Desktop**, **OpenCode**, **Hermes**, **Kiro**, and **Oh My Pi**.
+HarnessKit manages every agent's **Configs**, **Memory**, **Rules**, **Subagents**, and **Ignore** files from one place. Currently supporting **12 agents**: **Claude Code**, **Codex**, **Gemini CLI**, **Cursor**, **Antigravity**, **Copilot**, **Devin Desktop**, **OpenCode**, **Hermes**, **Kiro**, **Oh My Pi**, and **DeepSeek Harness**.
- **Config file tracking** — Automatically discovers every agent's config files — both global and per-project. Add your project directories or custom paths and HarnessKit picks them up alongside the global ones.
- **Per-agent dashboard** — Each agent gets its own page with all files organized by category, showing scope, path, file size, and a summary of installed extensions. Expand any file to preview its content right in the app.
@@ -176,7 +177,7 @@ HarnessKit ships a standalone command-line interface (`hk`) for terminal-first w
```shell
$ hk status
- Agents 11 detected (claude · codex · gemini · cursor · antigravity · copilot · windsurf · opencode · hermes · kiro · omp)
+ Agents 12 detected (claude · codex · gemini · cursor · antigravity · copilot · windsurf · opencode · hermes · kiro · omp · dsh)
Extensions 136 total (124 skills · 2 mcp · 8 plugins · 1 hooks · 1 clis)
$ hk list --kind skill --agent claude # filter by type and agent
diff --git a/README.zh-CN.md b/README.zh-CN.md
index cf43846..97f0fc3 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -69,6 +69,7 @@ HarnessKit 通过统一界面管理 **全部五种扩展类型** —— **Skill*
| **Hermes** | ✓ | ✓ | ✓ | ✓ | ✓ |
| **Kiro** | ✓ | ✓ | — | ✓ | ✓ |
| **Oh My Pi** | ✓ | ✓ | ✓ | — | ✓ |
+| **DeepSeek Harness** | ✓ | ✓ | — | — | ✓ |
* "—" 表示该 Agent 目前不支持此扩展类型。Devin Desktop 支持会继续兼容旧 Windsurf 路径。
@@ -88,7 +89,7 @@ HarnessKit 通过统一界面管理 **全部五种扩展类型** —— **Skill*
### 🤖 Agent 配置、记忆与规则
-HarnessKit 统一管理每个 Agent 的 **配置**、**记忆**、**规则**、**子 Agent** 与 **忽略**(Ignore)文件。目前支持 **11 个 Agent**:**Claude Code**、**Codex**、**Gemini CLI**、**Cursor**、**Antigravity**、**Copilot**、**Devin Desktop**、**OpenCode**、**Hermes**、**Kiro** 与 **Oh My Pi**。
+HarnessKit 统一管理每个 Agent 的 **配置**、**记忆**、**规则**、**子 Agent** 与 **忽略**(Ignore)文件。目前支持 **12 个 Agent**:**Claude Code**、**Codex**、**Gemini CLI**、**Cursor**、**Antigravity**、**Copilot**、**Devin Desktop**、**OpenCode**、**Hermes**、**Kiro**、**Oh My Pi** 与 **DeepSeek Harness**。
- **配置文件跟踪** —— 自动发现每个 Agent 的全局与项目级配置文件。添加项目目录或自定义路径后,HarnessKit 会将它们与全局配置一同纳入管理。
- **Agent 专属面板** —— 每个 Agent 拥有独立页面,文件按类别组织,列出范围、路径、文件大小以及已安装扩展的概览。展开任意文件即可在应用内预览。
@@ -176,7 +177,7 @@ HarnessKit 提供独立命令行工具(`hk`),面向偏好终端的工作
```shell
$ hk status
- Agents 11 detected (claude · codex · gemini · cursor · antigravity · copilot · windsurf · opencode · hermes · kiro · omp)
+ Agents 12 detected (claude · codex · gemini · cursor · antigravity · copilot · windsurf · opencode · hermes · kiro · omp · dsh)
Extensions 136 total (124 skills · 2 mcp · 8 plugins · 1 hooks · 1 clis)
$ hk list --kind skill --agent claude # 按类型与 Agent 筛选
diff --git a/crates/hk-core/src/adapter/dsh.rs b/crates/hk-core/src/adapter/dsh.rs
new file mode 100644
index 0000000..76f9a96
--- /dev/null
+++ b/crates/hk-core/src/adapter/dsh.rs
@@ -0,0 +1,659 @@
+// DeepSeek Harness (dsh) config references — verified against
+// github.com/deepseek-ai/deepseek-harness @ v0.1.0-rc (2026-08-14):
+// - Home resolution: packages/util/home-paths (`$DSH_HOME` else `~/.dsh`;
+// the harness keeps all user data under one root).
+// - Skills: docs/subsystems/skills.md — roots (rank order) `/.dsh/skills`,
+// `/.agents/skills`, `/skills` (skips `.system` child),
+// `/skills` where agentsHome = `$DSH_AGENTS_HOME` else `~/.agents`
+// (packages/skill/skill-filesystem/src/index.ts). Bundle `/SKILL.md` or
+// flat `.md`, one level deep — matches scan_skill_dir exactly, and the
+// `.system` dir is naturally invisible to a one-level scan (its skills nest
+// one level deeper).
+// - MCP: packages/mcp/mcp-client — servers are `@deepseek-ai/dsh-mcp-client`
+// plugin rows in cordis patch files. dsh runs one profile at a time
+// (profile patch, then home patch); HK reads the HOME layer only —
+// `/cordis.patch.yml`, the one user layer every profile applies.
+// No project-level MCP config exists.
+// - Hooks: packages/hooks — dsh has no own hook format; bridge plugins replay
+// Claude Code / Codex hooks.json. HookFormat::None.
+// - Rules: packages/context/agent-instructions — `$DSH_HOME/AGENTS.md` global,
+// project chain reads AGENTS.md / CLAUDE.md + AGENTS.local.md / CLAUDE.local.md.
+
+use super::{
+ AgentAdapter, HookEntry, HookFormat, McpFormat, McpServerEntry, McpTransport, ProjectMarker,
+};
+use std::path::{Path, PathBuf};
+
+pub struct DshAdapter {
+ /// `$DSH_HOME` else `~/.dsh`.
+ dsh_home: PathBuf,
+ /// `$DSH_AGENTS_HOME` else `~/.agents` (cross-vendor shared skills root).
+ agents_home: PathBuf,
+}
+
+impl Default for DshAdapter {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+/// Resolve `(dsh_home, agents_home)` from the `DSH_HOME` / `DSH_AGENTS_HOME`
+/// overrides, falling back to `/.dsh` / `/.agents`. Pure so it can
+/// be tested with explicit inputs (mutating process env in tests is racy
+/// under parallel execution).
+fn resolve_homes(
+ dsh_home: Option,
+ agents_home: Option,
+ home: &Path,
+) -> (PathBuf, PathBuf) {
+ (
+ dsh_home
+ .map(PathBuf::from)
+ .unwrap_or_else(|| home.join(".dsh")),
+ agents_home
+ .map(PathBuf::from)
+ .unwrap_or_else(|| home.join(".agents")),
+ )
+}
+
+impl DshAdapter {
+ pub fn new() -> Self {
+ let home = dirs::home_dir().unwrap_or_default();
+ let (dsh_home, agents_home) = resolve_homes(
+ std::env::var_os("DSH_HOME"),
+ std::env::var_os("DSH_AGENTS_HOME"),
+ &home,
+ );
+ Self {
+ dsh_home,
+ agents_home,
+ }
+ }
+
+ /// Test/deployer constructor rooting both homes under `home`; production
+ /// uses `new()`.
+ pub fn with_home(home: PathBuf) -> Self {
+ Self {
+ dsh_home: home.join(".dsh"),
+ agents_home: home.join(".agents"),
+ }
+ }
+
+ /// Existing per-profile patch files (settings listing only — MCP reading
+ /// is home-layer-only by design; see module header).
+ fn profile_patch_files(&self) -> Vec {
+ let profiles = self.dsh_home.join("profiles");
+ let mut dirs: Vec = std::fs::read_dir(&profiles)
+ .ok()
+ .into_iter()
+ .flatten()
+ .flatten()
+ .map(|e| e.path())
+ .filter(|p| p.is_dir())
+ .collect();
+ dirs.sort();
+ dirs.into_iter()
+ .map(|d| d.join("cordis.patch.yml"))
+ .filter(|p| p.is_file())
+ .collect()
+ }
+}
+
+/// dsh's `@deepseek-ai/dsh-mcp-client` plugin name — the marker for MCP rows.
+const MCP_CLIENT_PLUGIN: &str = "@deepseek-ai/dsh-mcp-client";
+
+/// One entry parsed from a patch file. `from_insert` distinguishes row
+/// DEFINITIONS (inside `insert:`) from id-targeted overrides — upstream, an
+/// override can never create a row.
+struct CordisRow {
+ id: Option,
+ name: Option,
+ /// Literal booleans, plus `null` ≡ `false` (static upstream rule,
+ /// vendor/loader/src/config/entry.ts:104-107). Only absent keys and
+ /// `!!js` expressions (which arrive as tag-stripped strings) read as
+ /// None — upstream evaluates js `disabled` at runtime; HK can't, so it
+ /// shows the base state.
+ disabled: Option,
+ config: serde_yaml::Value,
+ from_insert: bool,
+}
+
+/// Parse one patch file into ordered entries. `{id, insert}` (group append)
+/// is out of scope and skipped. A parse failure returns empty WITH a stderr
+/// diagnostic — silence here would read as "dsh has no MCP".
+fn parse_patch_rows(text: &str, origin: &Path) -> Vec {
+ let doc: serde_yaml::Value = match serde_yaml::from_str(text) {
+ Ok(doc) => doc,
+ Err(err) => {
+ eprintln!("[hk] warning: cannot parse {}: {err}", origin.display());
+ return vec![];
+ }
+ };
+ let Some(items) = doc.as_sequence() else {
+ eprintln!(
+ "[hk] warning: {} is not a YAML list (cordis patch files are top-level arrays)",
+ origin.display()
+ );
+ return vec![];
+ };
+ let mut rows = Vec::new();
+ for item in items {
+ let Some(map) = item.as_mapping() else { continue };
+ let has_id = map.get("id").is_some();
+ let insert = map.get("insert").and_then(|v| v.as_sequence());
+ match (has_id, insert) {
+ (false, Some(inserted)) => {
+ for row in inserted {
+ let Some(rm) = row.as_mapping() else { continue };
+ rows.push(CordisRow {
+ id: yaml_str(rm, "id"),
+ name: yaml_str(rm, "name"),
+ disabled: yaml_disabled(rm.get("disabled")),
+ config: rm.get("config").cloned().unwrap_or(serde_yaml::Value::Null),
+ from_insert: true,
+ });
+ }
+ }
+ (true, None) => rows.push(CordisRow {
+ id: yaml_str(map, "id"),
+ name: yaml_str(map, "name"),
+ disabled: yaml_disabled(map.get("disabled")),
+ config: map.get("config").cloned().unwrap_or(serde_yaml::Value::Null),
+ from_insert: false,
+ }),
+ // {id, insert} = group append; bare junk = neither. Both skipped.
+ _ => {}
+ }
+ }
+ rows
+}
+
+fn yaml_str(map: &serde_yaml::Mapping, key: &str) -> Option {
+ map.get(key).and_then(|v| v.as_str()).map(String::from)
+}
+
+fn yaml_disabled(v: Option<&serde_yaml::Value>) -> Option {
+ match v {
+ Some(serde_yaml::Value::Null) => Some(false), // upstream: null ≡ false
+ Some(serde_yaml::Value::Bool(b)) => Some(*b),
+ _ => None, // absent, or !!js expression (arrives as String — HK can't evaluate)
+ }
+}
+
+fn yaml_config_str(config: &serde_yaml::Value, key: &str) -> Option {
+ config.get(key).and_then(|v| v.as_str()).map(String::from)
+}
+
+/// Folded final state of one MCP row within one patch file (single ordered
+/// apply, later entries win — mirrors upstream applyEntryPatches).
+struct McpRowState {
+ id: Option,
+ disabled: bool,
+ config: serde_yaml::Value,
+}
+
+impl DshAdapter {
+ fn fold_mcp_rows_in_text(text: &str, origin: &Path) -> Vec {
+ let mut order: Vec = Vec::new();
+ let mut by_id: std::collections::HashMap =
+ std::collections::HashMap::new();
+ let mut anon: Vec = Vec::new();
+
+ for row in parse_patch_rows(text, origin) {
+ let CordisRow { id, name, disabled, config, from_insert } = row;
+ let is_mcp_def = from_insert && name.as_deref() == Some(MCP_CLIENT_PLUGIN);
+ match id {
+ Some(id) if is_mcp_def => {
+ order.push(id.clone());
+ by_id.insert(
+ id.clone(),
+ McpRowState { id: Some(id), disabled: disabled.unwrap_or(false), config },
+ );
+ }
+ Some(id) if !from_insert => {
+ // Override: only mutates an existing row (upstream:
+ // unknown id is warn+skip, never a definition).
+ if let Some(existing) = by_id.get_mut(&id) {
+ if let Some(d) = disabled {
+ existing.disabled = d;
+ }
+ if !config.is_null() {
+ existing.config = config;
+ }
+ }
+ }
+ // From-insert definition of some other plugin — never an
+ // override; skip (even on a malformed id collision).
+ Some(_) => {}
+ None if is_mcp_def => {
+ anon.push(McpRowState { id: None, disabled: disabled.unwrap_or(false), config })
+ }
+ None => {}
+ }
+ }
+ let mut out: Vec =
+ order.into_iter().filter_map(|id| by_id.remove(&id)).collect();
+ out.extend(anon);
+ out
+ }
+
+ fn mcp_entries_in_text(text: &str, origin: &Path) -> Vec {
+ Self::fold_mcp_rows_in_text(text, origin)
+ .into_iter()
+ .filter_map(|row| {
+ let config = &row.config;
+ let server_name = yaml_config_str(config, "serverName")?;
+ // Remote MCP: {url, headers?} — stdio MCP: {command, args, env}.
+ // `url` decides remote-vs-stdio FIRST (as in hermes.rs): dsh
+ // ships only stdio and streamable-http, so a url-bearing row is
+ // Streamable HTTP even when `transport` is omitted or carries
+ // some other value. Deciding on the transport string instead
+ // would emit a contradictory stdio entry with an empty command.
+ let url = yaml_config_str(config, "url");
+ let (transport, command) = match &url {
+ Some(_) => (McpTransport::Http, String::new()),
+ // Command may be absent on a malformed row; keep the entry
+ // visible (empty command) rather than hiding it.
+ None => (
+ McpTransport::Stdio,
+ yaml_config_str(config, "command").unwrap_or_default(),
+ ),
+ };
+ Some(McpServerEntry {
+ name: server_name,
+ command,
+ args: config
+ .get("args")
+ .and_then(|v| v.as_sequence())
+ .map(|seq| {
+ seq.iter()
+ .filter_map(|v| v.as_str().map(String::from))
+ .collect()
+ })
+ .unwrap_or_default(),
+ env: super::yaml_string_map(config, "env"),
+ transport,
+ url,
+ headers: super::yaml_string_map(config, "headers"),
+ enabled: !row.disabled,
+ })
+ })
+ .collect()
+ }
+
+ /// Row id for a serverName (unique across live instances upstream).
+ /// Text-based so the deployer can evaluate the file it already read.
+ pub fn mcp_row_id_in_text(text: &str, server_name: &str) -> Option {
+ Self::fold_mcp_rows_in_text(text, Path::new("cordis.patch.yml"))
+ .into_iter()
+ .find(|r| yaml_config_str(&r.config, "serverName").as_deref() == Some(server_name))
+ .and_then(|r| r.id)
+ }
+
+ /// serverName → enabled for the given home-layer text (deployer uses this
+ /// to compute base state with HK's managed block stripped).
+ pub fn mcp_enabled_in_text(text: &str) -> std::collections::HashMap {
+ Self::fold_mcp_rows_in_text(text, Path::new("cordis.patch.yml"))
+ .into_iter()
+ .filter_map(|r| Some((yaml_config_str(&r.config, "serverName")?, !r.disabled)))
+ .collect()
+ }
+}
+
+impl AgentAdapter for DshAdapter {
+ fn name(&self) -> &str {
+ "dsh"
+ }
+
+ fn base_dir(&self) -> PathBuf {
+ self.dsh_home.clone()
+ }
+
+ fn detect(&self) -> bool {
+ self.dsh_home.exists()
+ }
+
+ fn skill_dirs(&self) -> Vec {
+ vec![
+ self.dsh_home.join("skills"),
+ self.agents_home.join("skills"),
+ ]
+ }
+
+ /// Home-level user patch — the highest always-applied user layer and the
+ /// canonical write target for HK's managed toggle block. NEVER point this
+ /// at `/cordis.yml`: dsh overwrites that file on every boot.
+ fn mcp_config_path(&self) -> PathBuf {
+ self.dsh_home.join("cordis.patch.yml")
+ }
+
+ fn hook_config_path(&self) -> PathBuf {
+ // dsh has no own hook config; return the settings doc so the default
+ // plugin_config_path() has a sane anchor. Never read for hooks
+ // (hook_format is None).
+ self.dsh_home.join("settings.yaml")
+ }
+
+ fn plugin_dirs(&self) -> Vec {
+ vec![]
+ }
+
+ fn hook_format(&self) -> HookFormat {
+ HookFormat::None
+ }
+
+ fn mcp_format(&self) -> McpFormat {
+ McpFormat::DshCordis
+ }
+
+ fn supports_native_mcp_toggle(&self) -> bool {
+ // Toggle appends id-targeted patch rows via a managed block (deployer::set_dsh_mcp_enabled); never rewrites user YAML.
+ true
+ }
+
+ fn read_mcp_servers(&self) -> Vec {
+ self.read_mcp_servers_from(&self.mcp_config_path())
+ }
+
+ fn read_mcp_servers_from(&self, path: &Path) -> Vec {
+ let Ok(text) = std::fs::read_to_string(path) else {
+ return vec![];
+ };
+ Self::mcp_entries_in_text(&text, path)
+ }
+
+ fn read_hooks(&self) -> Vec {
+ vec![]
+ }
+
+ fn global_rules_files(&self) -> Vec {
+ vec![self.dsh_home.join("AGENTS.md")]
+ }
+
+ fn global_settings_files(&self) -> Vec {
+ let mut files = vec![
+ self.dsh_home.join("settings.yaml"),
+ self.dsh_home.join("cordis.patch.yml"),
+ ];
+ files.extend(self.profile_patch_files());
+ files
+ }
+
+ fn project_rules_patterns(&self) -> Vec {
+ vec![
+ "AGENTS.md".into(),
+ "CLAUDE.md".into(),
+ "AGENTS.local.md".into(),
+ "CLAUDE.local.md".into(),
+ ]
+ }
+
+ fn project_markers(&self) -> Vec {
+ vec![ProjectMarker::Dir(".dsh")]
+ }
+
+ fn project_skill_dirs(&self) -> Vec {
+ vec![".dsh/skills".into()]
+ }
+
+ fn project_skill_read_dirs(&self) -> Vec {
+ vec![".agents/skills".into()]
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::super::AgentAdapter;
+ use super::*;
+
+ #[test]
+ fn resolve_homes_env_overrides_and_fallbacks() {
+ let home = Path::new("/home/u");
+
+ // Both env vars set → both override.
+ let (dsh, agents) = resolve_homes(
+ Some("/custom/dsh".into()),
+ Some("/custom/agents".into()),
+ home,
+ );
+ assert_eq!(dsh, PathBuf::from("/custom/dsh"));
+ assert_eq!(agents, PathBuf::from("/custom/agents"));
+
+ // Both unset → ~/.dsh and ~/.agents fallbacks.
+ let (dsh, agents) = resolve_homes(None, None, home);
+ assert_eq!(dsh, home.join(".dsh"));
+ assert_eq!(agents, home.join(".agents"));
+
+ // DSH_HOME set, DSH_AGENTS_HOME unset → mixed.
+ let (dsh, agents) = resolve_homes(Some("/custom/dsh".into()), None, home);
+ assert_eq!(dsh, PathBuf::from("/custom/dsh"));
+ assert_eq!(agents, home.join(".agents"));
+ }
+
+ #[test]
+ fn detect_requires_dsh_home() {
+ let tmp = tempfile::tempdir().unwrap();
+ let adapter = DshAdapter::with_home(tmp.path().to_path_buf());
+ assert!(!adapter.detect());
+ std::fs::create_dir_all(tmp.path().join(".dsh")).unwrap();
+ assert!(adapter.detect());
+ }
+
+ #[test]
+ fn skill_dirs_cover_dsh_and_agents_homes() {
+ let tmp = tempfile::tempdir().unwrap();
+ let adapter = DshAdapter::with_home(tmp.path().to_path_buf());
+ assert_eq!(
+ adapter.skill_dirs(),
+ vec![
+ tmp.path().join(".dsh/skills"),
+ tmp.path().join(".agents/skills"),
+ ]
+ );
+ // Canonical install target is the dsh-owned dir (skill_dir_for uses first).
+ assert_eq!(adapter.project_skill_dirs(), vec![".dsh/skills".to_string()]);
+ assert_eq!(
+ adapter.project_skill_read_dirs(),
+ vec![".agents/skills".to_string()]
+ );
+ }
+
+ #[test]
+ fn config_discovery_paths() {
+ let tmp = tempfile::tempdir().unwrap();
+ let dsh_home = tmp.path().join(".dsh");
+ // settings files include existing per-profile patch files
+ std::fs::create_dir_all(dsh_home.join("profiles/web")).unwrap();
+ std::fs::write(dsh_home.join("profiles/web/cordis.patch.yml"), "[]\n").unwrap();
+ let adapter = DshAdapter::with_home(tmp.path().to_path_buf());
+
+ assert_eq!(adapter.global_rules_files(), vec![dsh_home.join("AGENTS.md")]);
+ let settings = adapter.global_settings_files();
+ assert!(settings.contains(&dsh_home.join("settings.yaml")));
+ assert!(settings.contains(&dsh_home.join("cordis.patch.yml")));
+ assert!(settings.contains(&dsh_home.join("profiles/web/cordis.patch.yml")));
+ assert_eq!(
+ adapter.project_rules_patterns(),
+ vec!["AGENTS.md", "CLAUDE.md", "AGENTS.local.md", "CLAUDE.local.md"]
+ );
+ // HK-side project-discovery marker (dsh's only project-level dir).
+ // dsh itself finds project roots by walking to the nearest `.git`.
+ assert_eq!(adapter.project_markers(), vec![super::super::ProjectMarker::Dir(".dsh")]);
+ // No project-level MCP/hook config exists upstream.
+ assert_eq!(adapter.project_mcp_config_relpath(), None);
+ assert_eq!(adapter.project_hook_config_relpath(), None);
+ }
+
+ /// Pins the load-bearing dependency behavior: serde_yaml parses `!!js`
+ /// scalars (plain / quoted / block forms) WITHOUT error and silently
+ /// strips the tag, yielding the expression as a plain String. Every
+ /// reader below builds on this. If this test ever fails, serde_yaml's
+ /// tag handling changed — re-verify the readers before touching them.
+ #[test]
+ fn serde_yaml_strips_double_bang_tags_to_plain_strings() {
+ for (text, expected) in [
+ ("k: !!js process.cwd()", "process.cwd()"),
+ ("k: !!js '`Bearer ${process.env.T}`'", "`Bearer ${process.env.T}`"),
+ (
+ "k: !!js >-\n process.env.X?.trim() ||\n fallback()",
+ "process.env.X?.trim() || fallback()",
+ ),
+ ] {
+ let v: serde_yaml::Value = serde_yaml::from_str(text).unwrap();
+ assert_eq!(v.get("k").and_then(|v| v.as_str()), Some(expected), "input: {text}");
+ }
+ }
+
+ /// Home-layer fixture. The `env` block-scalar entry is copied from dsh's
+ /// own examples/mcp-memory/mcp-reference-memory.cordis.yml — its README
+ /// tells users to merge exactly this into the patch files HK reads.
+ const HOME_PATCH: &str = r#"# user layer — keep this comment
+- insert:
+ - id: mcp-github
+ name: '@deepseek-ai/dsh-mcp-client'
+ config:
+ serverName: github
+ transport: stdio
+ command: npx
+ args: ['-y', '@modelcontextprotocol/server-github']
+ cwd: !!js process.cwd()
+ env:
+ GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN
+ MEMORY_FILE_PATH: !!js >-
+ process.env.MEMORY_FILE_PATH?.trim() ||
+ process.getBuiltinModule('node:path').join(process.cwd(), 'memory.json')
+ - id: mcp-web
+ name: '@deepseek-ai/dsh-mcp-client'
+ config:
+ serverName: web
+ transport: streamable-http
+ url: http://localhost:3000/mcp
+ headers:
+ Authorization: !!js '`Bearer ${process.env.MCP_TOKEN}`'
+- id: mcp-github
+ disabled: true
+"#;
+
+ fn write_home_patch(home: &Path, text: &str) {
+ std::fs::create_dir_all(home.join(".dsh")).unwrap();
+ std::fs::write(home.join(".dsh/cordis.patch.yml"), text).unwrap();
+ }
+
+ #[test]
+ fn read_mcp_servers_parses_home_layer_with_js_tags() {
+ let tmp = tempfile::tempdir().unwrap();
+ write_home_patch(tmp.path(), HOME_PATCH);
+ let adapter = DshAdapter::with_home(tmp.path().to_path_buf());
+ let servers = adapter.read_mcp_servers();
+ assert_eq!(servers.len(), 2);
+
+ let gh = servers.iter().find(|s| s.name == "github").unwrap();
+ assert!(!gh.enabled, "later same-file override disabled mcp-github");
+ assert_eq!(gh.command, "npx");
+ assert_eq!(gh.args, vec!["-y", "@modelcontextprotocol/server-github"]);
+ // !!js values arrive tag-stripped as bare expression text — shown
+ // as-is, never evaluated (see the probe test above).
+ assert_eq!(gh.env["GITHUB_TOKEN"], "process.env.GITHUB_TOKEN");
+ assert!(gh.env["MEMORY_FILE_PATH"].starts_with("process.env.MEMORY_FILE_PATH?.trim()"));
+
+ let web = servers.iter().find(|s| s.name == "web").unwrap();
+ assert_eq!(web.transport, McpTransport::Http);
+ assert_eq!(web.url.as_deref(), Some("http://localhost:3000/mcp"));
+ assert_eq!(web.headers["Authorization"], "`Bearer ${process.env.MCP_TOKEN}`");
+ assert!(web.enabled);
+ }
+
+ #[test]
+ fn disabled_false_later_in_file_reenables() {
+ let tmp = tempfile::tempdir().unwrap();
+ let text = format!("{HOME_PATCH}- id: mcp-github\n disabled: false\n");
+ write_home_patch(tmp.path(), &text);
+ let adapter = DshAdapter::with_home(tmp.path().to_path_buf());
+ let gh = adapter
+ .read_mcp_servers()
+ .into_iter()
+ .find(|s| s.name == "github")
+ .unwrap();
+ assert!(gh.enabled, "later entry wins (single ordered apply upstream)");
+ }
+
+ #[test]
+ fn bare_override_never_creates_a_row() {
+ // Upstream: a patch targeting an unknown id is warn+skip, never a
+ // definition (vendor/include/src/index.ts:107-112).
+ let tmp = tempfile::tempdir().unwrap();
+ write_home_patch(
+ tmp.path(),
+ "- id: ghost\n name: '@deepseek-ai/dsh-mcp-client'\n config:\n serverName: ghost\n",
+ );
+ let adapter = DshAdapter::with_home(tmp.path().to_path_buf());
+ assert!(adapter.read_mcp_servers().is_empty());
+ }
+
+ #[test]
+ fn mcp_row_id_lookup_by_server_name() {
+ assert_eq!(
+ DshAdapter::mcp_row_id_in_text(HOME_PATCH, "github").as_deref(),
+ Some("mcp-github")
+ );
+ assert_eq!(DshAdapter::mcp_row_id_in_text(HOME_PATCH, "nope"), None);
+ }
+
+ #[test]
+ fn mcp_enabled_map_reflects_folded_state() {
+ let map = DshAdapter::mcp_enabled_in_text(HOME_PATCH);
+ assert_eq!(map.len(), 2);
+ assert_eq!(map["github"], false);
+ assert_eq!(map["web"], true);
+
+ // `disabled: null` ≡ false upstream (static rule, entry.ts:104-107) —
+ // a null override re-enables a previously disabled row.
+ let text = format!("{HOME_PATCH}- id: mcp-github\n disabled: null\n");
+ let map = DshAdapter::mcp_enabled_in_text(&text);
+ assert_eq!(map["github"], true, "disabled: null re-enables");
+ }
+
+ #[test]
+ fn url_decides_remote_even_without_a_transport_key() {
+ // A url-bearing row with `transport` omitted (or set to anything other
+ // than streamable-http) is still remote — never a stdio entry with an
+ // empty command.
+ let tmp = tempfile::tempdir().unwrap();
+ write_home_patch(
+ tmp.path(),
+ r#"- insert:
+ - id: mcp-a
+ name: '@deepseek-ai/dsh-mcp-client'
+ config:
+ serverName: a
+ url: https://a.example/mcp
+ - id: mcp-b
+ name: '@deepseek-ai/dsh-mcp-client'
+ config:
+ serverName: b
+ transport: sse
+ url: https://b.example/mcp
+"#,
+ );
+ let adapter = DshAdapter::with_home(tmp.path().to_path_buf());
+ let servers = adapter.read_mcp_servers();
+ assert_eq!(servers.len(), 2);
+ for s in &servers {
+ assert_eq!(s.transport, McpTransport::Http, "{} should be remote", s.name);
+ assert!(s.command.is_empty(), "{} should carry no command", s.name);
+ assert!(s.url.is_some(), "{} should keep its url", s.name);
+ }
+ }
+
+ #[test]
+ fn read_mcp_servers_from_reads_the_given_file() {
+ // The service delete path locates entries via read_mcp_servers_from;
+ // returning them (instead of the trait's empty default) turns dsh MCP
+ // deletion into a loud DshCordis error instead of a silent no-op.
+ let tmp = tempfile::tempdir().unwrap();
+ write_home_patch(tmp.path(), HOME_PATCH);
+ let adapter = DshAdapter::with_home(tmp.path().to_path_buf());
+ let servers = adapter.read_mcp_servers_from(&tmp.path().join(".dsh/cordis.patch.yml"));
+ assert_eq!(servers.len(), 2);
+ }
+}
diff --git a/crates/hk-core/src/adapter/hermes.rs b/crates/hk-core/src/adapter/hermes.rs
index 9c89dba..ddb168d 100644
--- a/crates/hk-core/src/adapter/hermes.rs
+++ b/crates/hk-core/src/adapter/hermes.rs
@@ -14,22 +14,6 @@ use super::{
};
use std::path::{Path, PathBuf};
-/// Parse a YAML `key: {A: B, ...}` sub-mapping into a string map, dropping
-/// non-string values. Used for `env` and `headers` blocks.
-fn yaml_string_map(
- val: &serde_yaml::Value,
- key: &str,
-) -> std::collections::HashMap {
- val.get(key)
- .and_then(|v| v.as_mapping())
- .map(|m| {
- m.iter()
- .filter_map(|(k, v)| Some((k.as_str()?.to_string(), v.as_str()?.to_string())))
- .collect()
- })
- .unwrap_or_default()
-}
-
pub struct HermesAdapter {
home: PathBuf,
}
@@ -268,10 +252,10 @@ impl AgentAdapter for HermesAdapter {
name,
command,
args,
- env: yaml_string_map(val, "env"),
+ env: super::yaml_string_map(val, "env"),
transport,
url,
- headers: yaml_string_map(val, "headers"),
+ headers: super::yaml_string_map(val, "headers"),
enabled,
})
})
diff --git a/crates/hk-core/src/adapter/mod.rs b/crates/hk-core/src/adapter/mod.rs
index be05b77..0aeaba7 100644
--- a/crates/hk-core/src/adapter/mod.rs
+++ b/crates/hk-core/src/adapter/mod.rs
@@ -3,6 +3,7 @@ pub mod claude;
pub mod codex;
pub mod copilot;
pub mod cursor;
+pub mod dsh;
pub mod gemini;
pub mod hermes;
pub mod hook_events;
@@ -168,6 +169,22 @@ pub(crate) fn json_string_map(
.unwrap_or_default()
}
+/// Parse a YAML `key: {A: B, ...}` sub-mapping into a string map, dropping
+/// non-string values. Used for `env` and `headers` blocks.
+pub(crate) fn yaml_string_map(
+ val: &serde_yaml::Value,
+ key: &str,
+) -> std::collections::HashMap {
+ val.get(key)
+ .and_then(|v| v.as_mapping())
+ .map(|m| {
+ m.iter()
+ .filter_map(|(k, v)| Some((k.as_str()?.to_string(), v.as_str()?.to_string())))
+ .collect()
+ })
+ .unwrap_or_default()
+}
+
/// Transport + url for `{type: "http"|"sse", url}`-style entries (Claude,
/// Copilot, omp — the `RemoteMcpSchema::TypeAndUrl` agents).
/// `streamable-http` is the MCP spec's name for the HTTP transport and an
@@ -303,6 +320,12 @@ pub enum McpFormat {
/// Each entry is URL-based ({url, headers?, transport: sse?}) or
/// command-based ({command, args?, env?}).
HermesYaml,
+ /// DeepSeek Harness: MCP servers are `@deepseek-ai/dsh-mcp-client` plugin
+ /// rows in cordis patch files (YAML top-level array with `!!js` tags), not
+ /// a server map. Generic JSON/TOML writers must never touch these files —
+ /// every deployer arm for this variant errors; toggling goes through the
+ /// native in-place path (`set_dsh_mcp_enabled`).
+ DshCordis,
}
/// How an agent's config spells a remote (HTTP/SSE) MCP entry.
@@ -680,6 +703,7 @@ pub fn all_adapters() -> Vec> {
Box::new(hermes::HermesAdapter::new()),
Box::new(kiro::KiroAdapter::new()),
Box::new(omp::OmpAdapter::new()),
+ Box::new(dsh::DshAdapter::new()),
]
}
@@ -720,9 +744,11 @@ mod tests {
#[test]
fn mcp_remote_capability_derivation() {
- // Codex (TOML) is the only HTTP-only agent; every other adapter's
- // remote schema supports both transports. Pinned so a future agent
- // with partial support must consciously extend the derivation.
+ // Codex (TOML) is HTTP-only and dsh supports neither (remote entries
+ // are never deployed into cordis rows; see arm below); every other
+ // adapter's remote schema supports both transports. Pinned so a
+ // future agent with partial support must consciously extend the
+ // derivation.
for a in all_adapters() {
let caps = crate::models::AgentCapabilities::from_adapter(a.as_ref());
match a.name() {
@@ -730,6 +756,13 @@ mod tests {
assert!(caps.mcp_remote.http);
assert!(!caps.mcp_remote.sse);
}
+ "dsh" => {
+ // Remote schema Unsupported: HK never deploys remote
+ // entries into cordis patch rows (home-layer read is
+ // display-only for remote transports).
+ assert!(!caps.mcp_remote.http);
+ assert!(!caps.mcp_remote.sse);
+ }
_ => {
assert!(caps.mcp_remote.http, "{} should accept http", a.name());
assert!(caps.mcp_remote.sse, "{} should accept sse", a.name());
@@ -739,9 +772,9 @@ mod tests {
}
#[test]
- fn test_all_adapters_returns_eleven() {
+ fn test_all_adapters_returns_twelve() {
let adapters = all_adapters();
- assert_eq!(adapters.len(), 11);
+ assert_eq!(adapters.len(), 12);
let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
assert!(names.contains(&"claude"));
assert!(names.contains(&"cursor"));
@@ -754,6 +787,7 @@ mod tests {
assert!(names.contains(&"hermes"));
assert!(names.contains(&"kiro"));
assert!(names.contains(&"omp"));
+ assert!(names.contains(&"dsh"));
}
#[test]
@@ -775,6 +809,7 @@ mod tests {
// hurting cross-machine portability.
for name in [
"claude", "codex", "gemini", "cursor", "copilot", "opencode", "hermes", "kiro", "omp",
+ "dsh",
] {
assert!(
!by_name[name].needs_path_injection(),
@@ -785,9 +820,11 @@ mod tests {
#[test]
fn test_supports_native_mcp_toggle_only_native_agents() {
+ // Adding a name here also requires a dispatch branch in
+ // manager.rs::toggle_mcp — the trailing else there errors out.
let adapters = all_adapters();
for a in &adapters {
- let expected = matches!(a.name(), "hermes" | "kiro" | "omp");
+ let expected = matches!(a.name(), "hermes" | "kiro" | "omp" | "dsh");
assert_eq!(
a.supports_native_mcp_toggle(),
expected,
@@ -837,6 +874,7 @@ mod tests {
("kiro", true, true, true, true, false), // kirodotdev/Kiro#5440
("omp", true, true, false, false, true), // hooks are JS/TS modules
("hermes", false, false, false, true, true), // global-only (hermes-agent#4667)
+ ("dsh", true, false, false, false, true), // MCP is cordis-layer only; no own hook format
];
let adapters = all_adapters();
@@ -968,6 +1006,7 @@ mod tests {
("opencode", ".opencode/skills"),
("kiro", ".kiro/skills"),
("omp", ".omp/skills"),
+ ("dsh", ".dsh/skills"),
// hermes is global-only — no project skill dir (hermes-agent#4667).
]
.into_iter()
diff --git a/crates/hk-core/src/auditor/mod.rs b/crates/hk-core/src/auditor/mod.rs
index c844340..6492b33 100644
--- a/crates/hk-core/src/auditor/mod.rs
+++ b/crates/hk-core/src/auditor/mod.rs
@@ -173,7 +173,7 @@ mod tests {
#[test]
fn test_auditor_runs_all_enabled_rules() {
let auditor = Auditor::new();
- assert_eq!(auditor.rules.len(), 18);
+ assert_eq!(auditor.rules.len(), 19);
}
#[test]
diff --git a/crates/hk-core/src/auditor/rules.rs b/crates/hk-core/src/auditor/rules.rs
index 04f215b..46e34cc 100644
--- a/crates/hk-core/src/auditor/rules.rs
+++ b/crates/hk-core/src/auditor/rules.rs
@@ -14,7 +14,7 @@ pub use cli::{
};
pub use content::{
CredentialTheft, DangerousCommands, PlaintextSecrets, PromptInjection, RemoteCodeExecution,
- SafetyBypass,
+ SafetyBypass, SkillInvocationKeyCase,
};
pub use mcp::McpCommandInjection;
pub use permissions::{
@@ -30,6 +30,7 @@ pub fn all_rules() -> Vec> {
Box::new(PlaintextSecrets),
Box::new(SafetyBypass),
Box::new(DangerousCommands),
+ Box::new(SkillInvocationKeyCase),
Box::new(BroadPermissions),
Box::new(SupplyChainRisk),
Box::new(UnknownSource),
diff --git a/crates/hk-core/src/auditor/rules/content.rs b/crates/hk-core/src/auditor/rules/content.rs
index 63120d5..c0b58a1 100644
--- a/crates/hk-core/src/auditor/rules/content.rs
+++ b/crates/hk-core/src/auditor/rules/content.rs
@@ -386,6 +386,62 @@ impl AuditRule for DangerousCommands {
}
}
+/// dsh (DeepSeek Harness) rejects the camelCase invocation-key aliases
+/// `disableModelInvocation` / `modelInvocable` / `userInvocable` by dropping
+/// the WHOLE skill from discovery with only a log warning
+/// (deepseek-harness packages/skill/skill-filesystem: rejectLegacyInvocationKey).
+/// None of these spellings is valid in any supported agent — the canonical
+/// keys are kebab-case (`disable-model-invocation`, `user-invocable`).
+pub struct SkillInvocationKeyCase;
+
+const CAMELCASE_INVOCATION_KEYS: [(&str, &str); 3] = [
+ ("disableModelInvocation", "disable-model-invocation"),
+ ("modelInvocable", "disable-model-invocation (note: inverted meaning)"),
+ ("userInvocable", "user-invocable"),
+];
+
+impl AuditRule for SkillInvocationKeyCase {
+ fn id(&self) -> &str {
+ "skill-invocation-key-case"
+ }
+
+ fn severity(&self) -> Severity {
+ Severity::Medium
+ }
+
+ fn check(&self, input: &AuditInput) -> Vec {
+ if input.kind != ExtensionKind::Skill {
+ return vec![];
+ }
+ // Only inspect the frontmatter block: first line `---` … next `---`.
+ let mut lines = input.content.lines().enumerate();
+ if lines.next().map(|(_, line)| line.trim()) != Some("---") {
+ return vec![];
+ }
+ let mut findings = Vec::new();
+ for (i, line) in lines {
+ if line.trim() == "---" {
+ break;
+ }
+ for (key, suggestion) in CAMELCASE_INVOCATION_KEYS {
+ if line.trim_start().starts_with(&format!("{key}:")) {
+ findings.push(AuditFinding {
+ rule_id: self.id().into(),
+ severity: self.severity(),
+ message: format!(
+ "Frontmatter key '{key}' is a rejected camelCase alias — \
+ DeepSeek Harness silently drops the entire skill; \
+ use '{suggestion}' instead"
+ ),
+ location: format!("{}:{}", input.file_path, i + 1),
+ });
+ }
+ }
+ }
+ findings
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -574,6 +630,26 @@ mod tests {
);
}
+ #[test]
+ fn test_skill_invocation_key_case_flags_camelcase_aliases() {
+ let rule = SkillInvocationKeyCase;
+ for (key, suggestion) in CAMELCASE_INVOCATION_KEYS {
+ let bad = format!("---\nname: my-skill\ndescription: d\n{key}: false\n---\nbody");
+ let findings = rule.check(&skill_input(&bad));
+ assert_eq!(findings.len(), 1, "expected exactly 1 finding for {key}");
+ assert!(findings[0].message.contains(key));
+ assert!(findings[0].message.contains(suggestion));
+ }
+
+ let good = "---\nname: my-skill\ndescription: d\nuser-invocable: false\n---\nbody";
+ assert!(rule.check(&skill_input(good)).is_empty());
+
+ // Key names in the body (outside frontmatter) must not fire.
+ let body_mention =
+ "---\nname: my-skill\ndescription: d\n---\nSet userInvocable: false in dsh? No.";
+ assert!(rule.check(&skill_input(body_mention)).is_empty());
+ }
+
#[test]
fn test_skill_with_cli_parent_still_audited() {
let rule = PromptInjection;
diff --git a/crates/hk-core/src/deployer.rs b/crates/hk-core/src/deployer.rs
index 8711d88..ffb1b3c 100644
--- a/crates/hk-core/src/deployer.rs
+++ b/crates/hk-core/src/deployer.rs
@@ -157,6 +157,12 @@ fn json_top_key(format: McpFormat) -> &'static str {
McpFormat::HermesYaml => {
unreachable!("HermesYaml format routes through dedicated YAML helpers")
}
+ McpFormat::DshCordis => {
+ unreachable!(
+ "DshCordis never reaches the JSON writers — install/remove are refused, \
+ toggling uses the native patch-layer path (set_dsh_mcp_enabled)"
+ )
+ }
}
}
@@ -184,6 +190,12 @@ pub fn deploy_mcp_server(
McpFormat::Toml => deploy_mcp_server_toml(config_path, entry),
McpFormat::Opencode => deploy_mcp_server_opencode(config_path, entry),
McpFormat::HermesYaml => deploy_mcp_server_hermes_yaml(config_path, entry),
+ McpFormat::DshCordis => Err(HkError::Validation(
+ "dsh MCP servers are composition rows in cordis.patch.yml; \
+ installing or removing them for dsh is not supported yet — \
+ use enable/disable (native patch-layer path) or edit the file"
+ .into(),
+ )),
}
}
@@ -648,6 +660,183 @@ pub fn set_omp_mcp_enabled(
})
}
+const DSH_BLOCK_BEGIN: &str = "# >>> managed by HarnessKit — do not edit this block >>>";
+const DSH_BLOCK_END: &str = "# <<< managed by HarnessKit <<<";
+
+/// Flip a dsh MCP server via the official patch-layer mechanism: an
+/// id-targeted `disabled:` override inside an HK-owned marked block at the
+/// END of the home-level `cordis.patch.yml` (the last always-applied user
+/// layer — later entries win in dsh's single ordered apply).
+///
+/// Hard rules (upstream-verified):
+/// - Only ever writes `home_patch` — NEVER `/cordis.yml` (dsh
+/// overwrites that on boot) and never any profile's patch file.
+/// - User bytes outside the markers are preserved; the sole structural edits
+/// involve the `[]` empty-list placeholder (see render_dsh_patch).
+/// - The edited text must re-parse as a YAML sequence, else nothing is
+/// written (a broken file would make dsh keep last-good config and
+/// silently ignore all future edits).
+pub fn set_dsh_mcp_enabled(
+ home_patch: &Path,
+ server_name: &str,
+ enabled: bool,
+) -> Result<(), HkError> {
+ use crate::adapter::dsh::DshAdapter;
+
+ let original = match std::fs::read_to_string(home_patch) {
+ Ok(text) => text,
+ // Absent file = dsh not yet seeded its template; start from the
+ // valid empty form. Any other IO error must surface, not be
+ // mistaken for an empty file.
+ // Intentional error normalization: this text never reaches the write
+ // path — an empty patch has no rows, so the row-id lookup below fails
+ // first and the user sees "server not found" rather than a raw IO error.
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => "[]\n".to_string(),
+ Err(e) => return Err(e.into()),
+ };
+ let (user_text, mut managed) = split_dsh_managed_block(&original)?;
+
+ let row_id = DshAdapter::mcp_row_id_in_text(&user_text, server_name).ok_or_else(|| {
+ HkError::NotFound(format!(
+ "MCP server '{server_name}' not found in {}",
+ home_patch.display()
+ ))
+ })?;
+
+ // Base state = the file WITHOUT our block.
+ let base_enabled = DshAdapter::mcp_enabled_in_text(&user_text)
+ .get(server_name)
+ .copied()
+ .unwrap_or(true);
+
+ if base_enabled == enabled {
+ managed.remove(&row_id);
+ } else {
+ managed.insert(row_id, !enabled); // value = disabled flag
+ }
+
+ let new_text = render_dsh_patch(&user_text, &managed);
+ let parsed: Result = serde_yaml::from_str(&new_text);
+ if !matches!(parsed, Ok(serde_yaml::Value::Sequence(_))) {
+ return Err(HkError::ConfigCorrupted(format!(
+ "refusing to write {}: edited content is not a YAML list",
+ home_patch.display()
+ )));
+ }
+ atomic_write(home_patch, &new_text)
+}
+
+/// Split file text into (user text without the managed block, managed
+/// entries id → disabled). Unrecognized lines inside the block are dropped —
+/// the block is HK-owned by contract. `split_inclusive` keeps user lines
+/// byte-exact (including CRLF endings).
+///
+/// Unbalanced markers are a hard `ConfigCorrupted` error: a BEGIN without a
+/// matching END would otherwise swallow every user line to EOF (and the
+/// rewritten file could still parse as a valid YAML sequence, so the
+/// caller's post-edit guard would not catch the loss).
+fn split_dsh_managed_block(
+ text: &str,
+) -> Result<(String, std::collections::BTreeMap), HkError> {
+ let mut user = String::new();
+ let mut managed = std::collections::BTreeMap::new();
+ let mut in_block = false;
+ let mut current_id: Option = None;
+ for raw in text.split_inclusive('\n') {
+ let line = raw.trim();
+ if line == DSH_BLOCK_BEGIN {
+ if in_block {
+ return Err(HkError::ConfigCorrupted(
+ "unbalanced HarnessKit managed-block markers: \
+ nested BEGIN marker inside the managed block"
+ .into(),
+ ));
+ }
+ in_block = true;
+ continue;
+ }
+ if line == DSH_BLOCK_END {
+ if !in_block {
+ return Err(HkError::ConfigCorrupted(
+ "unbalanced HarnessKit managed-block markers: \
+ END marker without a preceding BEGIN"
+ .into(),
+ ));
+ }
+ in_block = false;
+ current_id = None;
+ continue;
+ }
+ if in_block {
+ if let Some(id) = line.strip_prefix("- id: ") {
+ current_id = Some(id.trim().to_string());
+ } else if let Some(flag) = line.strip_prefix("disabled: ") {
+ if let (Some(id), Ok(b)) = (current_id.take(), flag.trim().parse::()) {
+ managed.insert(id, b);
+ }
+ }
+ } else {
+ user.push_str(raw);
+ }
+ }
+ if in_block {
+ return Err(HkError::ConfigCorrupted(
+ "unbalanced HarnessKit managed-block markers: \
+ BEGIN marker without a matching END"
+ .into(),
+ ));
+ }
+ Ok((user, managed))
+}
+
+/// Reassemble user text + managed block. Structural rules:
+/// - Block present → any lone `[]` placeholder line is dropped (it can't
+/// coexist with block-style entries in one document).
+/// - Block absent → if the remaining text has no non-comment content, append
+/// `[]` (an empty/comment-only patch file is a dsh boot error).
+fn render_dsh_patch(
+ user_text: &str,
+ managed: &std::collections::BTreeMap,
+) -> String {
+ if managed.is_empty() {
+ let has_content = user_text
+ .lines()
+ .any(|l| !l.trim().is_empty() && !l.trim().starts_with('#') && l.trim() != "[]");
+ let has_placeholder = user_text.lines().any(|l| l.trim() == "[]");
+ if has_content || has_placeholder {
+ return user_text.to_string();
+ }
+ let mut out = user_text.to_string();
+ if !out.is_empty() && !out.ends_with('\n') {
+ out.push('\n');
+ }
+ out.push_str("[]\n");
+ return out;
+ }
+
+ let mut block = String::new();
+ block.push_str(DSH_BLOCK_BEGIN);
+ block.push('\n');
+ for (id, disabled) in managed {
+ block.push_str(&format!("- id: {id}\n disabled: {disabled}\n"));
+ }
+ block.push_str(DSH_BLOCK_END);
+ block.push('\n');
+
+ // Drop `[]` placeholder lines byte-preservingly (keep every other raw line).
+ let mut out = String::new();
+ for raw in user_text.split_inclusive('\n') {
+ if raw.trim() != "[]" {
+ out.push_str(raw);
+ }
+ }
+ if !out.is_empty() && !out.ends_with('\n') {
+ out.push('\n');
+ }
+ out.push_str(&block);
+ out
+}
+
/// Flip a Kiro IDE hook's native `enabled` flag in place, keeping the entry
/// in the file — mirrors Kiro's own panel toggle ("skip without deleting").
pub fn set_kiro_hook_enabled(
@@ -1067,6 +1256,12 @@ pub fn remove_mcp_server(
}
Ok(())
}),
+ McpFormat::DshCordis => Err(HkError::Validation(
+ "dsh MCP servers are composition rows in cordis.patch.yml; \
+ installing or removing them for dsh is not supported yet — \
+ use enable/disable (native patch-layer path) or edit the file"
+ .into(),
+ )),
_ => locked_modify_json(config_path, |config| {
let key = json_top_key(format);
if let Some(servers) = config.get_mut(key).and_then(|v| v.as_object_mut()) {
@@ -1236,6 +1431,10 @@ pub fn restore_mcp_server(
"Hermes MCP uses native in-place enable/disable (set_hermes_mcp_enabled); \
the remove+snapshot+restore path is never reached for Hermes"
),
+ McpFormat::DshCordis => unreachable!(
+ "dsh MCP uses native in-place enable/disable (set_dsh_mcp_enabled); \
+ the remove+snapshot+restore path is never reached for dsh"
+ ),
_ => {
let key = json_top_key(format);
locked_modify_json(config_path, |config| {
@@ -1718,6 +1917,10 @@ pub fn read_mcp_server_config(
"Hermes MCP uses native in-place enable/disable (set_hermes_mcp_enabled); \
the read-config-for-snapshot path is never reached for Hermes"
),
+ McpFormat::DshCordis => unreachable!(
+ "dsh MCP uses native in-place enable/disable (set_dsh_mcp_enabled); \
+ the read-config-for-snapshot path is never reached for dsh"
+ ),
_ => {
let config = read_or_create_json(config_path)?;
let key = json_top_key(format);
@@ -2024,6 +2227,7 @@ mod tests {
McpFormat::Toml => Box::new(codex::CodexAdapter::with_home(home)),
McpFormat::Opencode => Box::new(opencode::OpencodeAdapter::with_home(home)),
McpFormat::HermesYaml => Box::new(hermes::HermesAdapter::with_home(home)),
+ McpFormat::DshCordis => Box::new(dsh::DshAdapter::with_home(home)),
}
}
@@ -3951,3 +4155,148 @@ mod tests {
assert_eq!(entries[0].0, "file:///plugin-b");
}
}
+
+#[cfg(test)]
+mod dsh_toggle_tests {
+ use super::*;
+
+ const HOME_WITH_GH: &str = r#"# precious comment
+- insert:
+ - id: mcp-github
+ name: '@deepseek-ai/dsh-mcp-client'
+ config:
+ serverName: github
+ transport: stdio
+ command: npx
+ env:
+ GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN
+"#;
+
+ fn patch_file(text: &str) -> (tempfile::TempDir, std::path::PathBuf) {
+ let tmp = tempfile::tempdir().unwrap();
+ let path = tmp.path().join("cordis.patch.yml");
+ std::fs::write(&path, text).unwrap();
+ (tmp, path)
+ }
+
+ #[test]
+ fn disable_appends_managed_block_and_enable_removes_it() {
+ let (_tmp, path) = patch_file(HOME_WITH_GH);
+
+ set_dsh_mcp_enabled(&path, "github", false).unwrap();
+ let text = std::fs::read_to_string(&path).unwrap();
+ assert!(text.starts_with(HOME_WITH_GH), "user bytes preserved verbatim");
+ assert!(text.contains("- id: mcp-github\n disabled: true"));
+ assert!(text.contains("managed by HarnessKit"));
+
+ // Enable: base state (the insert) is already enabled → block entry
+ // removed entirely; user content restored byte-for-byte.
+ set_dsh_mcp_enabled(&path, "github", true).unwrap();
+ assert_eq!(std::fs::read_to_string(&path).unwrap(), HOME_WITH_GH);
+ }
+
+ #[test]
+ fn enable_overrides_user_disable_with_disabled_false() {
+ // User disabled it themselves → HK writes an explicit disabled: false
+ // override (upstream e2e-covered semantics).
+ let text = format!("{HOME_WITH_GH}- id: mcp-github\n disabled: true\n");
+ let (_tmp, path) = patch_file(&text);
+ set_dsh_mcp_enabled(&path, "github", true).unwrap();
+ let out = std::fs::read_to_string(&path).unwrap();
+ assert!(out.starts_with(&text), "user bytes preserved");
+ assert!(out.contains("- id: mcp-github\n disabled: false"));
+ }
+
+ #[test]
+ fn template_file_toggle_errors_not_found() {
+ // dsh's seeded patch template: comment header + literal []. No row
+ // exists in it → toggling must error, and the file must be untouched.
+ let template = "# header comment\n[]\n";
+ let (_tmp, path) = patch_file(template);
+ let err = set_dsh_mcp_enabled(&path, "github", false).unwrap_err();
+ assert!(matches!(err, HkError::NotFound(_)));
+ assert_eq!(std::fs::read_to_string(&path).unwrap(), template);
+ }
+
+ #[test]
+ fn roundtrip_always_leaves_valid_yaml_list() {
+ // After any disable→enable cycle the file must re-parse as a YAML
+ // list — an empty/comment-only patch file is a dsh boot error.
+ let (_tmp, path) = patch_file(HOME_WITH_GH);
+ set_dsh_mcp_enabled(&path, "github", false).unwrap();
+ set_dsh_mcp_enabled(&path, "github", true).unwrap();
+ let out = std::fs::read_to_string(&path).unwrap();
+ let parsed: serde_yaml::Value = serde_yaml::from_str(&out).unwrap();
+ assert!(parsed.is_sequence(), "file must stay a valid YAML list");
+ }
+
+ #[test]
+ fn unknown_server_errors() {
+ let (_tmp, path) = patch_file(HOME_WITH_GH);
+ let err = set_dsh_mcp_enabled(&path, "nope", false).unwrap_err();
+ assert!(matches!(err, HkError::NotFound(_)));
+ }
+
+ #[test]
+ fn toggle_is_idempotent() {
+ let (_tmp, path) = patch_file(HOME_WITH_GH);
+ set_dsh_mcp_enabled(&path, "github", false).unwrap();
+ let once = std::fs::read_to_string(&path).unwrap();
+ set_dsh_mcp_enabled(&path, "github", false).unwrap();
+ assert_eq!(std::fs::read_to_string(&path).unwrap(), once);
+ }
+
+ #[test]
+ fn unbalanced_markers_error_and_leave_file_untouched() {
+ let text = format!(
+ "{HOME_WITH_GH}{}\n- id: mcp-github\n disabled: true\n",
+ DSH_BLOCK_BEGIN
+ );
+ let (_tmp, path) = patch_file(&text); // BEGIN without END
+ let err = set_dsh_mcp_enabled(&path, "github", false).unwrap_err();
+ assert!(matches!(err, HkError::ConfigCorrupted(_)));
+ assert_eq!(std::fs::read_to_string(&path).unwrap(), text);
+ }
+
+ #[test]
+ fn user_content_after_block_survives_roundtrip() {
+ // Documented out-vote mechanism: user lines AFTER the managed block
+ // must never be lost. (They may legitimately be reordered before the
+ // re-appended block on the next toggle — base-state semantics.)
+ let (_tmp, path) = patch_file(HOME_WITH_GH);
+ set_dsh_mcp_enabled(&path, "github", false).unwrap();
+ let mut text = std::fs::read_to_string(&path).unwrap();
+ text.push_str("# user note after block\n");
+ std::fs::write(&path, &text).unwrap();
+ set_dsh_mcp_enabled(&path, "github", true).unwrap();
+ let out = std::fs::read_to_string(&path).unwrap();
+ assert!(out.contains("# user note after block"));
+ assert!(!out.contains("managed by HarnessKit"), "override no longer needed");
+ }
+
+ #[test]
+ fn two_servers_share_one_managed_block() {
+ let text = format!(
+ "{HOME_WITH_GH} - id: mcp-web\n name: '@deepseek-ai/dsh-mcp-client'\n config:\n serverName: web\n transport: streamable-http\n url: http://localhost:3000/mcp\n"
+ );
+ let (_tmp, path) = patch_file(&text);
+ set_dsh_mcp_enabled(&path, "github", false).unwrap();
+ set_dsh_mcp_enabled(&path, "web", false).unwrap();
+ let out = std::fs::read_to_string(&path).unwrap();
+ assert_eq!(out.matches(DSH_BLOCK_BEGIN).count(), 1, "exactly one block");
+ assert!(out.contains("- id: mcp-github\n disabled: true"));
+ assert!(out.contains("- id: mcp-web\n disabled: true"));
+ let parsed: serde_yaml::Value = serde_yaml::from_str(&out).unwrap();
+ assert!(parsed.is_sequence());
+ }
+
+ #[test]
+ fn remove_mcp_server_refuses_dsh_cordis() {
+ // Pin: the generic remove path must never touch the dsh patch file —
+ // create the file so the early-return-on-missing-file path isn't taken.
+ let (_tmp, path) = patch_file(HOME_WITH_GH);
+ let err = remove_mcp_server(&path, "github", McpFormat::DshCordis).unwrap_err();
+ assert!(matches!(&err, HkError::Validation(m) if m.contains("cordis.patch.yml")));
+ assert_eq!(std::fs::read_to_string(&path).unwrap(), HOME_WITH_GH);
+ }
+}
diff --git a/crates/hk-core/src/kits/install_plan.rs b/crates/hk-core/src/kits/install_plan.rs
index 03739fa..ca33df0 100644
--- a/crates/hk-core/src/kits/install_plan.rs
+++ b/crates/hk-core/src/kits/install_plan.rs
@@ -62,6 +62,9 @@ fn mcp_entry_exists(config_path: &Path, name: &str, format: McpFormat) -> bool {
.and_then(|v| v.get(name))
.is_some()
}
+ // dsh MCP can't be Kit-installed (cordis patch files are never a Kit
+ // install target), so no conflict is ever detectable.
+ McpFormat::DshCordis => false,
}
}
diff --git a/crates/hk-core/src/manager.rs b/crates/hk-core/src/manager.rs
index aff6160..76e9506 100644
--- a/crates/hk-core/src/manager.rs
+++ b/crates/hk-core/src/manager.rs
@@ -201,8 +201,23 @@ fn toggle_mcp(
&ext.name,
enabled,
)?;
- } else {
+ } else if a.name() == "dsh" {
+ // Official mechanism: id-targeted `disabled` override in the
+ // home-level cordis.patch.yml (HK-managed block). Hot-reloaded
+ // by dsh; state is read back from the patch layers on rescan.
+ deployer::set_dsh_mcp_enabled(&config_path, &ext.name, enabled)?;
+ } else if a.name() == "hermes" {
+ // Per-server `enabled` field flipped in place in config.yaml.
deployer::set_hermes_mcp_enabled(&config_path, &ext.name, enabled)?;
+ } else {
+ // Every native-toggle agent needs its own writer; a missing
+ // branch must fail loudly instead of falling through to some
+ // other agent's format and corrupting its config. Pinned by
+ // adapter::tests::test_supports_native_mcp_toggle_only_native_agents.
+ return Err(HkError::Internal(format!(
+ "agent '{}' claims supports_native_mcp_toggle but has no MCP toggle dispatch branch",
+ a.name()
+ )));
}
// Clear any legacy redacted snapshot so the store.rs upsert CASE uses
// the on-disk enabled state (disabled_config IS NULL).
diff --git a/crates/hk-core/tests/toggle_integration.rs b/crates/hk-core/tests/toggle_integration.rs
index 6e3b51d..676744e 100644
--- a/crates/hk-core/tests/toggle_integration.rs
+++ b/crates/hk-core/tests/toggle_integration.rs
@@ -398,3 +398,50 @@ fn test_single_agent_extension_toggle_state() {
assert_eq!(all.len(), 1);
assert!(all[0].enabled, "Single extension should show enabled");
}
+
+#[test]
+fn test_dsh_mcp_native_toggle_roundtrip() {
+ use hk_core::adapter::dsh::DshAdapter;
+ use hk_core::adapter::AgentAdapter;
+
+ let dir = TempDir::new().unwrap();
+ let store = Store::open(&dir.path().join("test.db")).unwrap();
+ std::fs::create_dir_all(dir.path().join(".dsh")).unwrap();
+ std::fs::write(
+ dir.path().join(".dsh/cordis.patch.yml"),
+ r#"- insert:
+ - id: mcp-github
+ name: '@deepseek-ai/dsh-mcp-client'
+ config:
+ serverName: github
+ transport: stdio
+ command: npx
+"#,
+ )
+ .unwrap();
+
+ let adapter = DshAdapter::with_home(dir.path().to_path_buf());
+ let servers = adapter.read_mcp_servers();
+ assert_eq!(servers.len(), 1);
+ assert!(servers[0].enabled);
+
+ // Store the extension the way the scanner would, then toggle through the
+ // manager with only the dsh adapter mounted.
+ let exts = hk_core::scanner::scan_mcp_servers(&adapter);
+ assert_eq!(exts.len(), 1);
+ store.sync_extensions(&exts).unwrap();
+ let ext_id = store.list_extensions(None, None).unwrap()[0].id.clone();
+
+ let adapters: Vec> =
+ vec![Box::new(DshAdapter::with_home(dir.path().to_path_buf()))];
+ hk_core::manager::toggle_extension_with_adapters(&store, &adapters, &ext_id, false).unwrap();
+
+ // On-disk state flipped via the managed block; no DB snapshot taken.
+ let servers = DshAdapter::with_home(dir.path().to_path_buf()).read_mcp_servers();
+ assert!(!servers[0].enabled);
+ assert!(store.get_disabled_config(&ext_id).unwrap().is_none());
+
+ hk_core::manager::toggle_extension_with_adapters(&store, &adapters, &ext_id, true).unwrap();
+ let servers = DshAdapter::with_home(dir.path().to_path_buf()).read_mcp_servers();
+ assert!(servers[0].enabled);
+}
diff --git a/src/components/extensions/extension-detail.tsx b/src/components/extensions/extension-detail.tsx
index d1376a6..b6b62f0 100644
--- a/src/components/extensions/extension-detail.tsx
+++ b/src/components/extensions/extension-detail.tsx
@@ -43,6 +43,7 @@ import {
sortAgents,
} from "@/lib/types";
import { useAgentStore } from "@/stores/agent-store";
+import { useAuditStore } from "@/stores/audit-store";
import {
agentsInScope,
findCliChildren,
@@ -85,6 +86,10 @@ export function ExtensionDetail() {
);
const [loadingContent, setLoadingContent] = useState(false);
const agents = useAgentStore((s) => s.agents);
+ // Live audit results (cached at App start, refreshed by Run Audit). Used
+ // to surface functional-breakage findings inline; purely derived, so the
+ // warning disappears as soon as a re-run no longer reports the finding.
+ const auditResults = useAuditStore((s) => s.results);
const agentOrder = useAgentStore((s) => s.agentOrder);
const scope = useScopeStore((s) => s.current);
// Install to Agent targets the active scope. In All-scopes mode the user
@@ -325,6 +330,36 @@ export function ExtensionDetail() {
)}
+ {/* dsh naming bridge — DeepSeek Harness has no MCP-servers page; its
+ * Settings→Plugins list shows every MCP server as an "mcp-client"
+ * plugin card (@deepseek-ai/dsh-mcp-client) rather than by server
+ * name. Surface that mapping so users can locate this server there. */}
+ {group.kind === "mcp" && group.agents.includes("dsh") && (
+
+
+ {t("detail.dshMcpPluginNote")}
+
+ )}
+
+ {/* Silent skill-drop warning — the `skill-invocation-key-case` audit
+ * rule means DeepSeek Harness discards this ENTIRE skill (functional
+ * breakage, not just a score deduction), so it warrants an inline
+ * warning here, not only an Audit-page row. Derived from live audit
+ * results (extension_id === instance id), so it vanishes on the next
+ * audit run once the frontmatter key is fixed. */}
+ {group.kind === "skill" &&
+ auditResults.some(
+ (r) =>
+ r.findings.some(
+ (f) => f.rule_id === "skill-invocation-key-case",
+ ) && group.instances.some((i) => i.id === r.extension_id),
+ ) && (
+