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), + ) && ( +
+ + {t("detail.skillInvocationKeyWarning")} +
+ )} + {/* 1. Status + Source row */}