Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** | ✓ | ✓ | ✓ | ✓ | ✓ |

<small><i>* "—" means HarnessKit does not manage this extension type for the agent yet. Devin Desktop support keeps legacy Windsurf paths compatible.</i></small>
<small><i>* "—" means HarnessKit does not manage this extension type for the agent yet.</i></small>

</div>

Expand Down
4 changes: 2 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ HarnessKit 通过统一界面管理 **全部五种扩展类型** —— **Skill*
| **Oh My Pi** | ✓ | ✓ | ✓ | — | ✓ |
| **DeepSeek Harness** | ✓ | ✓ | ✓ | — | ✓ |
| **Grok Build** | ✓ | ✓ | ✓ | ✓ | ✓ |
| **Qoder CN** | ✓ | ✓ | ✓ | — | ✓ |
| **Qoder CN** | ✓ | ✓ | ✓ | ✓ | ✓ |

<small><i>* "—" 表示 HarnessKit 目前还不管理该 Agent 的这类扩展。Devin Desktop 支持会继续兼容旧 Windsurf 路径。</i></small>
<small><i>* "—" 表示 HarnessKit 目前还不管理该 Agent 的这类扩展。</i></small>

</div>

Expand Down
47 changes: 1 addition & 46 deletions crates/hk-core/src/adapter/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,52 +150,7 @@ impl AgentAdapter for ClaudeAdapter {
}

fn read_hooks_from(&self, path: &Path) -> Vec<HookEntry> {
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<PathBuf> {
Expand Down
49 changes: 48 additions & 1 deletion crates/hk-core/src/adapter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,53 @@ pub struct HookEntry {
pub enabled: bool,
}

/// Read the `hooks` object of a Claude-shaped settings file:
/// `{"hooks": {"Event": [{"matcher": "...", "hooks": [<hook>]}]}}`, 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<HookEntry> {
let Some(settings) = std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&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)]
Expand Down Expand Up @@ -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();
Expand Down
51 changes: 40 additions & 11 deletions crates/hk-core/src/adapter/qoder_cn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<String> {
Some(".qoder/settings.json".into())
}
fn translate_hook_event(&self, event: &str) -> Option<String> {
super::hook_events::to_claude(event)
}
fn plugin_dirs(&self) -> Vec<PathBuf> {
// Plugins are manifest rows, not HK-owned directories: the app owns
Expand Down Expand Up @@ -168,7 +172,11 @@ impl AgentAdapter for QoderCnAdapter {
}

fn read_hooks(&self) -> Vec<HookEntry> {
vec![]
self.read_hooks_from(&self.hook_config_path())
}

fn read_hooks_from(&self, path: &Path) -> Vec<HookEntry> {
super::read_claude_like_hooks(path)
}

fn read_plugins(&self) -> Vec<PluginEntry> {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
}
Loading