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);
}
}