From 3d801d0c4b797f93bc00e5853d3879c54ccdb343 Mon Sep 17 00:00:00 2001 From: RealZST Date: Sat, 26 Sep 2026 00:45:42 +0800 Subject: [PATCH 1/3] refactor(adapter): share the Claude-shaped hooks parser Move ClaudeAdapter's settings.json hooks reader into a crate-level read_claude_like_hooks() so other agents with the same format can reuse it. Behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/hk-core/src/adapter/claude.rs | 47 +--------------------------- crates/hk-core/src/adapter/mod.rs | 47 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 46 deletions(-) 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..5ca3bc30 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)] From 46492bdae37125a987ae0527e4e2c2cc2730e4d5 Mon Sep 17 00:00:00 2001 From: RealZST Date: Sat, 26 Sep 2026 00:45:57 +0800 Subject: [PATCH 2/3] feat(qoder-cn): read and install hooks Qoder CN keeps hooks under the `hooks` key of settings.json (user) and .qoder/settings.json (project), in the same shape and with the same event names as Claude Code. Reuse the Claude parser and event table. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/hk-core/src/adapter/mod.rs | 2 +- crates/hk-core/src/adapter/qoder_cn.rs | 51 ++++++++++++++++++++------ 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/crates/hk-core/src/adapter/mod.rs b/crates/hk-core/src/adapter/mod.rs index 5ca3bc30..1afce551 100644 --- a/crates/hk-core/src/adapter/mod.rs +++ b/crates/hk-core/src/adapter/mod.rs @@ -1016,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); } } From b1345b2f53b273e682b7aee0c069c27f73e74064 Mon Sep 17 00:00:00 2001 From: RealZST Date: Sat, 26 Sep 2026 00:45:57 +0800 Subject: [PATCH 3/3] docs: mark Qoder CN hooks as supported and trim the matrix footnote Tick the Hooks column for Qoder CN and drop the Devin Desktop sentence from the support matrix footnote in both READMEs. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 4 ++-- README.zh-CN.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 的这类扩展。