diff --git a/README.md b/README.md index 7eb77fba..6f7fa2dd 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,9 @@ HarnessKit manages **all five extension types** from a unified interface — **S | **Oh My Pi** | ✓ | ✓ | ✓ | — | ✓ | | **DeepSeek Harness** | ✓ | ✓ | ✓ | — | ✓ | | **Grok Build** | ✓ | ✓ | ✓ | ✓ | ✓ | -| **Qoder CN** | ✓ | ✓ | ✓ | — | ✓ | +| **Qoder CN** | ✓ | ✓ | ✓ | ✓ | ✓ | -* "—" means HarnessKit does not manage this extension type for the agent yet. Devin Desktop support keeps legacy Windsurf paths compatible. +* "—" means HarnessKit does not manage this extension type for the agent yet. diff --git a/README.zh-CN.md b/README.zh-CN.md index e431f191..8974207e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -71,9 +71,9 @@ HarnessKit 通过统一界面管理 **全部五种扩展类型** —— **Skill* | **Oh My Pi** | ✓ | ✓ | ✓ | — | ✓ | | **DeepSeek Harness** | ✓ | ✓ | ✓ | — | ✓ | | **Grok Build** | ✓ | ✓ | ✓ | ✓ | ✓ | -| **Qoder CN** | ✓ | ✓ | ✓ | — | ✓ | +| **Qoder CN** | ✓ | ✓ | ✓ | ✓ | ✓ | -* "—" 表示 HarnessKit 目前还不管理该 Agent 的这类扩展。Devin Desktop 支持会继续兼容旧 Windsurf 路径。 +* "—" 表示 HarnessKit 目前还不管理该 Agent 的这类扩展。 diff --git a/crates/hk-core/src/adapter/claude.rs b/crates/hk-core/src/adapter/claude.rs index 5866750e..5ecd55ef 100644 --- a/crates/hk-core/src/adapter/claude.rs +++ b/crates/hk-core/src/adapter/claude.rs @@ -150,52 +150,7 @@ impl AgentAdapter for ClaudeAdapter { } fn read_hooks_from(&self, path: &Path) -> Vec { - let Some(settings) = Self::parse_json(path) else { - return vec![]; - }; - let Some(hooks) = settings.get("hooks").and_then(|v| v.as_object()) else { - return vec![]; - }; - - let mut entries = Vec::new(); - for (event, hook_list) in hooks { - let Some(arr) = hook_list.as_array() else { - continue; - }; - for hook in arr { - let matcher = hook - .get("matcher") - .and_then(|v| v.as_str()) - .map(String::from); - if let Some(cmds) = hook.get("hooks").and_then(|v| v.as_array()) { - for cmd in cmds { - // String format: "echo test" - let cmd_str = if let Some(s) = cmd.as_str() { - Some(s.to_string()) - } - // Object format: {"type": "command", "command": "echo test"} - else if let Some(s) = cmd.get("command").and_then(|v| v.as_str()) { - Some(s.to_string()) - } - // Prompt/agent hook: {"type": "prompt", "prompt": "..."} - else { - cmd.get("prompt") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - }; - if let Some(command) = cmd_str { - entries.push(HookEntry { - event: event.clone(), - matcher: matcher.clone(), - command, - enabled: true, - }); - } - } - } - } - } - entries + super::read_claude_like_hooks(path) } fn global_rules_files(&self) -> Vec { diff --git a/crates/hk-core/src/adapter/mod.rs b/crates/hk-core/src/adapter/mod.rs index 53c86198..1afce551 100644 --- a/crates/hk-core/src/adapter/mod.rs +++ b/crates/hk-core/src/adapter/mod.rs @@ -246,6 +246,53 @@ pub struct HookEntry { pub enabled: bool, } +/// Read the `hooks` object of a Claude-shaped settings file: +/// `{"hooks": {"Event": [{"matcher": "...", "hooks": []}]}}`, where a +/// hook is a bare command string, `{"command": ...}`, or a prompt/agent hook +/// `{"prompt": ...}`. Shared by Claude Code and Qoder CN. +pub(crate) fn read_claude_like_hooks(path: &Path) -> Vec { + let Some(settings) = std::fs::read_to_string(path) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + else { + return vec![]; + }; + let Some(hooks) = settings.get("hooks").and_then(|v| v.as_object()) else { + return vec![]; + }; + + let mut entries = Vec::new(); + for (event, hook_list) in hooks { + let Some(arr) = hook_list.as_array() else { + continue; + }; + for hook in arr { + let matcher = hook + .get("matcher") + .and_then(|v| v.as_str()) + .map(String::from); + let Some(cmds) = hook.get("hooks").and_then(|v| v.as_array()) else { + continue; + }; + for cmd in cmds { + let command = cmd + .as_str() + .or_else(|| cmd.get("command").and_then(|v| v.as_str())) + .or_else(|| cmd.get("prompt").and_then(|v| v.as_str())); + if let Some(command) = command { + entries.push(HookEntry { + event: event.clone(), + matcher: matcher.clone(), + command: command.to_string(), + enabled: true, + }); + } + } + } + } + entries +} + /// How a plugin has to be removed, which is a property of the agent's install /// model rather than of HarnessKit. #[derive(Debug, Clone, PartialEq, Eq)] @@ -969,7 +1016,7 @@ mod tests { ("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 ("grok", true, true, true, true, true), - ("qoder-cn", true, false, false, false, true), // project MCP merge + hooks next PR + ("qoder-cn", true, false, true, true, true), // project MCP merge pending ]; let adapters = all_adapters(); diff --git a/crates/hk-core/src/adapter/qoder_cn.rs b/crates/hk-core/src/adapter/qoder_cn.rs index a570bb78..cd1f9cad 100644 --- a/crates/hk-core/src/adapter/qoder_cn.rs +++ b/crates/hk-core/src/adapter/qoder_cn.rs @@ -12,11 +12,15 @@ // (per-entry `enabled`, installPath, precise timestamps). The sibling // `installed_plugins-v2.json` (dash) and `installed_plugins.json` are older // shapes without `enabled`; reading the underscore file covers both. +// +// Hooks: the `hooks` key of `settings.json` (user) and `.qoder/settings.json` +// (project), same shape and event names as Claude Code +// (https://docs.qoder.cn/cli/hooks-reference). No per-hook enabled flag, so +// toggling uses the default remove + DB snapshot path. Project hooks are +// skipped in untrusted folders only if the user turns on +// `security.folderTrust.enabled`, which is off unless set. -use super::{ - AgentAdapter, HookEntry, HookFormat, McpServerEntry, PluginEntry, ProjectMarker, - RemoteMcpSchema, -}; +use super::{AgentAdapter, HookEntry, McpServerEntry, PluginEntry, ProjectMarker, RemoteMcpSchema}; use std::path::{Path, PathBuf}; pub struct QoderCnAdapter { @@ -115,13 +119,13 @@ impl AgentAdapter for QoderCnAdapter { self.base_dir().join("settings.json") } fn hook_config_path(&self) -> PathBuf { - // Hooks live under the `hooks` key of settings.json (Claude-shaped: - // https://docs.qoder.cn/cli/hooks-reference). Reading them is deferred - // to a follow-up PR, so `hook_format()` stays `None` for now. self.base_dir().join("settings.json") } - fn hook_format(&self) -> HookFormat { - HookFormat::None + fn project_hook_config_relpath(&self) -> Option { + Some(".qoder/settings.json".into()) + } + fn translate_hook_event(&self, event: &str) -> Option { + super::hook_events::to_claude(event) } fn plugin_dirs(&self) -> Vec { // Plugins are manifest rows, not HK-owned directories: the app owns @@ -168,7 +172,11 @@ impl AgentAdapter for QoderCnAdapter { } fn read_hooks(&self) -> Vec { - vec![] + self.read_hooks_from(&self.hook_config_path()) + } + + fn read_hooks_from(&self, path: &Path) -> Vec { + super::read_claude_like_hooks(path) } fn read_plugins(&self) -> Vec { @@ -283,6 +291,28 @@ mod tests { assert_eq!(remote.headers["Authorization"], "Bearer t"); } + #[test] + fn read_hooks_parses_claude_shaped_hooks_next_to_mcp_servers() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join(".qoder-cn"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("settings.json"), + r#"{"mcpServers":{"srv":{"command":"npx"}},"hooks":{ + "PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo pre"}]}], + "Stop":[{"hooks":[{"type":"prompt","prompt":"check the work"}]}] + }}"#, + ) + .unwrap(); + let hooks = adapter_in(tmp.path()).read_hooks(); + assert_eq!(hooks.len(), 2); + let pre = hooks.iter().find(|h| h.event == "PreToolUse").unwrap(); + assert_eq!(pre.matcher.as_deref(), Some("Bash")); + assert_eq!(pre.command, "echo pre"); + let stop = hooks.iter().find(|h| h.event == "Stop").unwrap(); + assert_eq!(stop.command, "check the work"); + } + #[test] fn read_plugins_parses_enabled_and_timestamps() { let tmp = tempfile::tempdir().unwrap(); @@ -344,6 +374,5 @@ mod tests { a.mcp_config_path(), tmp.path().join(".qoder-cn").join("settings.json") ); - assert_eq!(a.read_hooks().len(), 0); } }