diff --git a/README.md b/README.md
index 4ddc066..8a5829c 100644
--- a/README.md
+++ b/README.md
@@ -67,6 +67,7 @@ HarnessKit manages **all five extension types** from a unified interface — **S
| **Windsurf** | ✓ | ✓ | — | ✓ | ✓ |
| **OpenCode** | ✓ | ✓ | ✓ | — | ✓ |
| **Hermes** | ✓ | ✓ | ✓ | ✓ | ✓ |
+| **Kiro** | ✓ | ✓ | — | ✓ | ✓ |
* "—" indicates the agent currently does not support this extension type.
@@ -86,7 +87,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 **9 agents**: **Claude Code**, **Codex**, **Gemini CLI**, **Cursor**, **Antigravity**, **Copilot**, **Windsurf**, **OpenCode**, and **Hermes**.
+HarnessKit manages every agent's **Configs**, **Memory**, **Rules**, **Subagents**, and **Ignore** files from one place. Currently supporting **10 agents**: **Claude Code**, **Codex**, **Gemini CLI**, **Cursor**, **Antigravity**, **Copilot**, **Windsurf**, **OpenCode**, **Hermes**, and **Kiro**.
- **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.
@@ -174,7 +175,7 @@ HarnessKit ships a standalone command-line interface (`hk`) for terminal-first w
```shell
$ hk status
- Agents 9 detected (claude · codex · gemini · cursor · antigravity · copilot · windsurf · opencode · hermes)
+ Agents 10 detected (claude · codex · gemini · cursor · antigravity · copilot · windsurf · opencode · hermes · kiro)
Extensions 136 total (124 skills · 2 mcp · 8 plugins · 1 hooks · 1 clis)
$ hk list --kind skill --agent claude # filter by type and agent
@@ -366,7 +367,7 @@ See [CLI Support](#%EF%B8%8F-cli-support) above for the full list of commands.
## Roadmap
-- 🤖 **More Agents** — Hermes-agent, OpenClaw, and more
+- 🤖 **More Agents** — OpenClaw and more
- ⌨️ **CLI Enhancements** — More commands and richer functionality for `hk`
---
diff --git a/README.zh-CN.md b/README.zh-CN.md
index a630802..6562705 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -67,6 +67,7 @@ HarnessKit 通过统一界面管理 **全部五种扩展类型** —— **Skill*
| **Windsurf** | ✓ | ✓ | — | ✓ | ✓ |
| **OpenCode** | ✓ | ✓ | ✓ | — | ✓ |
| **Hermes** | ✓ | ✓ | ✓ | ✓ | ✓ |
+| **Kiro** | ✓ | ✓ | — | ✓ | ✓ |
* "—" 表示该 Agent 目前不支持此扩展类型。
@@ -86,7 +87,7 @@ HarnessKit 通过统一界面管理 **全部五种扩展类型** —— **Skill*
### 🤖 Agent 配置、记忆与规则
-HarnessKit 统一管理每个 Agent 的 **配置**、**记忆**、**规则**、**子 Agent** 与 **忽略**(Ignore)文件。目前支持 **9 个 Agent**:**Claude Code**、**Codex**、**Gemini CLI**、**Cursor**、**Antigravity**、**Copilot**、**Windsurf**、**OpenCode** 与 **Hermes**。
+HarnessKit 统一管理每个 Agent 的 **配置**、**记忆**、**规则**、**子 Agent** 与 **忽略**(Ignore)文件。目前支持 **10 个 Agent**:**Claude Code**、**Codex**、**Gemini CLI**、**Cursor**、**Antigravity**、**Copilot**、**Windsurf**、**OpenCode**、**Hermes** 与 **Kiro**。
- **配置文件跟踪** —— 自动发现每个 Agent 的全局与项目级配置文件。添加项目目录或自定义路径后,HarnessKit 会将它们与全局配置一同纳入管理。
- **Agent 专属面板** —— 每个 Agent 拥有独立页面,文件按类别组织,列出范围、路径、文件大小以及已安装扩展的概览。展开任意文件即可在应用内预览。
@@ -174,7 +175,7 @@ HarnessKit 提供独立命令行工具(`hk`),面向偏好终端的工作
```shell
$ hk status
- Agents 9 detected (claude · codex · gemini · cursor · antigravity · copilot · windsurf · opencode · hermes)
+ Agents 10 detected (claude · codex · gemini · cursor · antigravity · copilot · windsurf · opencode · hermes · kiro)
Extensions 136 total (124 skills · 2 mcp · 8 plugins · 1 hooks · 1 clis)
$ hk list --kind skill --agent claude # 按类型与 Agent 筛选
@@ -366,7 +367,7 @@ HarnessKit Web UI [my-host] running at http://127.0.0.1:7070/?token=a1b2c3…
## 未来计划
-- 🤖 **更多 Agent** —— Hermes-agent、OpenClaw 等
+- 🤖 **更多 Agent** —— OpenClaw 等
- ⌨️ **CLI 增强** —— 为 `hk` 添加更多命令与更丰富的功能
---
diff --git a/crates/hk-core/src/adapter/claude.rs b/crates/hk-core/src/adapter/claude.rs
index bfee325..daff441 100644
--- a/crates/hk-core/src/adapter/claude.rs
+++ b/crates/hk-core/src/adapter/claude.rs
@@ -193,6 +193,7 @@ impl AgentAdapter for ClaudeAdapter {
event: event.clone(),
matcher: matcher.clone(),
command,
+ enabled: true,
});
}
}
diff --git a/crates/hk-core/src/adapter/codex.rs b/crates/hk-core/src/adapter/codex.rs
index ca37b28..2bb1936 100644
--- a/crates/hk-core/src/adapter/codex.rs
+++ b/crates/hk-core/src/adapter/codex.rs
@@ -307,6 +307,7 @@ impl AgentAdapter for CodexAdapter {
event: event.clone(),
matcher: matcher.clone(),
command,
+ enabled: true,
});
}
}
diff --git a/crates/hk-core/src/adapter/copilot.rs b/crates/hk-core/src/adapter/copilot.rs
index 0ec2fa6..ec89b83 100644
--- a/crates/hk-core/src/adapter/copilot.rs
+++ b/crates/hk-core/src/adapter/copilot.rs
@@ -410,6 +410,7 @@ impl AgentAdapter for CopilotAdapter {
event: event.clone(),
matcher: None,
command: cmd_str.to_string(),
+ enabled: true,
});
}
}
diff --git a/crates/hk-core/src/adapter/cursor.rs b/crates/hk-core/src/adapter/cursor.rs
index e78ee5b..e77ea4f 100644
--- a/crates/hk-core/src/adapter/cursor.rs
+++ b/crates/hk-core/src/adapter/cursor.rs
@@ -148,6 +148,7 @@ impl AgentAdapter for CursorAdapter {
event: event.clone(),
matcher: None,
command: cmd.to_string(),
+ enabled: true,
});
}
}
diff --git a/crates/hk-core/src/adapter/gemini.rs b/crates/hk-core/src/adapter/gemini.rs
index 9fb1876..e2542eb 100644
--- a/crates/hk-core/src/adapter/gemini.rs
+++ b/crates/hk-core/src/adapter/gemini.rs
@@ -292,6 +292,7 @@ impl AgentAdapter for GeminiAdapter {
event: event.clone(),
matcher: matcher.clone(),
command,
+ enabled: true,
});
}
}
diff --git a/crates/hk-core/src/adapter/hermes.rs b/crates/hk-core/src/adapter/hermes.rs
index c4d5c99..c9aa3fb 100644
--- a/crates/hk-core/src/adapter/hermes.rs
+++ b/crates/hk-core/src/adapter/hermes.rs
@@ -308,6 +308,7 @@ impl AgentAdapter for HermesAdapter {
event: event.to_string(),
matcher,
command: command.to_string(),
+ enabled: true,
});
}
}
diff --git a/crates/hk-core/src/adapter/hook_events.rs b/crates/hk-core/src/adapter/hook_events.rs
index 7041f82..5cea1bc 100644
--- a/crates/hk-core/src/adapter/hook_events.rs
+++ b/crates/hk-core/src/adapter/hook_events.rs
@@ -19,6 +19,7 @@
//! - Copilot: https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/use-hooks
//! - Windsurf: https://docs.windsurf.com/windsurf/cascade/hooks
//! - Antigravity: no hook support — use rules instead: https://antigravity.google/docs/rules-workflows
+//! - Kiro IDE: https://kiro.dev/docs/hooks/
// Canonical event names (Claude's convention) used as the internal lingua franca.
const CANONICAL_EVENTS: &[&str] = &[
@@ -337,6 +338,52 @@ const HERMES_EVENTS: &[EventMapping] = &[
},
];
+/// Kiro IDE Agent Hook mappings.
+/// Reference: https://kiro.dev/docs/hooks/
+const KIRO_EVENTS: &[EventMapping] = &[
+ EventMapping {
+ canonical: "SessionStart",
+ agent: "SessionStart",
+ },
+ EventMapping {
+ canonical: "Stop",
+ agent: "Stop",
+ },
+ EventMapping {
+ canonical: "PreToolUse",
+ agent: "PreToolUse",
+ },
+ EventMapping {
+ canonical: "PostToolUse",
+ agent: "PostToolUse",
+ },
+ EventMapping {
+ canonical: "UserPromptSubmit",
+ agent: "UserPromptSubmit",
+ },
+ // Kiro-specific triggers.
+ EventMapping {
+ canonical: "PreTaskExec",
+ agent: "PreTaskExec",
+ },
+ EventMapping {
+ canonical: "PostTaskExec",
+ agent: "PostTaskExec",
+ },
+ EventMapping {
+ canonical: "PostFileCreate",
+ agent: "PostFileCreate",
+ },
+ EventMapping {
+ canonical: "PostFileSave",
+ agent: "PostFileSave",
+ },
+ EventMapping {
+ canonical: "PostFileDelete",
+ agent: "PostFileDelete",
+ },
+];
+
/// Translate an event name from any agent's convention to the target agent's convention.
/// Returns None if the event has no equivalent in the target agent.
fn translate(
@@ -371,6 +418,7 @@ pub fn to_claude(event: &str) -> Option {
.or_else(|| translate(event, COPILOT_EVENTS, CLAUDE_EVENTS))
.or_else(|| translate(event, WINDSURF_EVENTS, CLAUDE_EVENTS))
.or_else(|| translate(event, HERMES_EVENTS, CLAUDE_EVENTS))
+ .or_else(|| translate(event, KIRO_EVENTS, CLAUDE_EVENTS))
}
/// Translate an event name to Gemini convention.
@@ -381,6 +429,7 @@ pub fn to_gemini(event: &str) -> Option {
.or_else(|| translate(event, COPILOT_EVENTS, GEMINI_EVENTS))
.or_else(|| translate(event, WINDSURF_EVENTS, GEMINI_EVENTS))
.or_else(|| translate(event, HERMES_EVENTS, GEMINI_EVENTS))
+ .or_else(|| translate(event, KIRO_EVENTS, GEMINI_EVENTS))
}
/// Translate an event name to Cursor convention.
@@ -391,6 +440,7 @@ pub fn to_cursor(event: &str) -> Option {
.or_else(|| translate(event, COPILOT_EVENTS, CURSOR_EVENTS))
.or_else(|| translate(event, WINDSURF_EVENTS, CURSOR_EVENTS))
.or_else(|| translate(event, HERMES_EVENTS, CURSOR_EVENTS))
+ .or_else(|| translate(event, KIRO_EVENTS, CURSOR_EVENTS))
}
/// Translate an event name to Copilot convention.
@@ -401,6 +451,7 @@ pub fn to_copilot(event: &str) -> Option {
.or_else(|| translate(event, CURSOR_EVENTS, COPILOT_EVENTS))
.or_else(|| translate(event, WINDSURF_EVENTS, COPILOT_EVENTS))
.or_else(|| translate(event, HERMES_EVENTS, COPILOT_EVENTS))
+ .or_else(|| translate(event, KIRO_EVENTS, COPILOT_EVENTS))
}
/// Translate an event name to Windsurf convention.
@@ -411,6 +462,7 @@ pub fn to_windsurf(event: &str) -> Option {
.or_else(|| translate(event, CURSOR_EVENTS, WINDSURF_EVENTS))
.or_else(|| translate(event, COPILOT_EVENTS, WINDSURF_EVENTS))
.or_else(|| translate(event, HERMES_EVENTS, WINDSURF_EVENTS))
+ .or_else(|| translate(event, KIRO_EVENTS, WINDSURF_EVENTS))
}
/// Translate an event name to Hermes convention.
@@ -421,6 +473,18 @@ pub fn to_hermes(event: &str) -> Option {
.or_else(|| translate(event, CURSOR_EVENTS, HERMES_EVENTS))
.or_else(|| translate(event, COPILOT_EVENTS, HERMES_EVENTS))
.or_else(|| translate(event, WINDSURF_EVENTS, HERMES_EVENTS))
+ .or_else(|| translate(event, KIRO_EVENTS, HERMES_EVENTS))
+}
+
+/// Translate an event name to Kiro IDE convention.
+pub fn to_kiro(event: &str) -> Option {
+ translate(event, KIRO_EVENTS, KIRO_EVENTS)
+ .or_else(|| translate(event, CLAUDE_EVENTS, KIRO_EVENTS))
+ .or_else(|| translate(event, GEMINI_EVENTS, KIRO_EVENTS))
+ .or_else(|| translate(event, CURSOR_EVENTS, KIRO_EVENTS))
+ .or_else(|| translate(event, COPILOT_EVENTS, KIRO_EVENTS))
+ .or_else(|| translate(event, WINDSURF_EVENTS, KIRO_EVENTS))
+ .or_else(|| translate(event, HERMES_EVENTS, KIRO_EVENTS))
}
#[cfg(test)]
@@ -485,6 +549,15 @@ mod tests {
assert_eq!(to_windsurf("PreToolUse"), None);
}
+ #[test]
+ fn kiro_events_translate_and_passthrough() {
+ assert_eq!(to_kiro("PostFileSave"), Some("PostFileSave".into()));
+ assert_eq!(to_kiro("PreTaskExec"), Some("PreTaskExec".into()));
+ assert_eq!(to_kiro("BeforeTool"), Some("PreToolUse".into()));
+ assert_eq!(to_claude("PostFileSave"), None);
+ assert_eq!(to_kiro("pre_run_command"), None);
+ }
+
/// The 10 Windsurf-specific events have no canonical equivalent;
/// they must passthrough when the target agent is Windsurf itself.
#[test]
@@ -532,12 +605,7 @@ mod tests {
assert_eq!(to_claude(event), None, "to_claude leaked for '{}'", event);
assert_eq!(to_gemini(event), None, "to_gemini leaked for '{}'", event);
assert_eq!(to_cursor(event), None, "to_cursor leaked for '{}'", event);
- assert_eq!(
- to_copilot(event),
- None,
- "to_copilot leaked for '{}'",
- event
- );
+ assert_eq!(to_copilot(event), None, "to_copilot leaked for '{}'", event);
}
}
@@ -565,7 +633,10 @@ mod tests {
// canonical → hermes
assert_eq!(to_hermes("PreToolUse").as_deref(), Some("pre_tool_call"));
assert_eq!(to_hermes("PostToolUse").as_deref(), Some("post_tool_call"));
- assert_eq!(to_hermes("SessionStart").as_deref(), Some("on_session_start"));
+ assert_eq!(
+ to_hermes("SessionStart").as_deref(),
+ Some("on_session_start")
+ );
assert_eq!(to_hermes("SubagentStop").as_deref(), Some("subagent_stop"));
// hermes native passthrough
assert_eq!(to_hermes("pre_tool_call").as_deref(), Some("pre_tool_call"));
diff --git a/crates/hk-core/src/adapter/kiro.rs b/crates/hk-core/src/adapter/kiro.rs
new file mode 100644
index 0000000..b1c6080
--- /dev/null
+++ b/crates/hk-core/src/adapter/kiro.rs
@@ -0,0 +1,344 @@
+// Kiro IDE / CLI config references:
+// - Hooks: https://kiro.dev/docs/hooks/
+// - MCP: https://kiro.dev/docs/mcp/configuration/
+// - Steering https://kiro.dev/docs/steering/
+// - Skills: https://kiro.dev/docs/cli/skills/
+//
+// Hooks are IDE Agent Hooks in `.kiro/hooks/*.json`, not CLI custom-agent
+// hooks embedded in `.kiro/agents/*.json`.
+
+use super::{AgentAdapter, HookEntry, HookFormat, McpServerEntry, ProjectMarker};
+use crate::models::ConfigScope;
+use std::path::{Path, PathBuf};
+
+pub struct KiroAdapter {
+ home: PathBuf,
+}
+
+impl Default for KiroAdapter {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl KiroAdapter {
+ pub fn new() -> Self {
+ Self {
+ home: dirs::home_dir().unwrap_or_default(),
+ }
+ }
+
+ #[cfg(test)]
+ pub fn with_home(home: PathBuf) -> Self {
+ Self { home }
+ }
+
+ fn parse_json(path: &Path) -> Option {
+ let content = std::fs::read_to_string(path).ok()?;
+ serde_json::from_str(&content).ok()
+ }
+
+ fn json_files(dir: &Path) -> Vec {
+ super::files_with_ext(dir, "json")
+ .filter(|p| p.is_file())
+ .collect()
+ }
+
+ fn parse_hook(value: &serde_json::Value) -> Option {
+ let action = value.get("action")?.as_object()?;
+ if action.get("type").and_then(|v| v.as_str()) != Some("command") {
+ return None;
+ }
+ let command = action.get("command")?.as_str()?;
+ Some(HookEntry {
+ event: value.get("trigger")?.as_str()?.to_string(),
+ matcher: value
+ .get("matcher")
+ .and_then(|v| v.as_str())
+ .map(String::from),
+ command: command.to_string(),
+ // Kiro's native per-hook flag: default true, false = skipped
+ // without deleting (https://kiro.dev/docs/hooks/).
+ enabled: value
+ .get("enabled")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(true),
+ })
+ }
+}
+
+impl AgentAdapter for KiroAdapter {
+ fn name(&self) -> &str {
+ "kiro"
+ }
+
+ fn base_dir(&self) -> PathBuf {
+ self.home.join(".kiro")
+ }
+
+ fn detect(&self) -> bool {
+ self.base_dir().exists()
+ }
+
+ fn skill_dirs(&self) -> Vec {
+ vec![self.base_dir().join("skills")]
+ }
+
+ fn mcp_config_path(&self) -> PathBuf {
+ self.base_dir().join("settings").join("mcp.json")
+ }
+
+ fn hook_config_path(&self) -> PathBuf {
+ self.base_dir().join("hooks").join("harnesskit.json")
+ }
+
+ fn plugin_dirs(&self) -> Vec {
+ vec![]
+ }
+
+ fn hook_format(&self) -> HookFormat {
+ HookFormat::KiroIde
+ }
+
+ fn supports_native_mcp_toggle(&self) -> bool {
+ true
+ }
+
+ /// Kiro's docs describe `~/.kiro/hooks/` (user-level), but no released
+ /// version loads it — verified on-device with 1.0.89 (macOS): the IDE
+ /// only counts `.kiro/hooks/` workspace hooks. Upstream:
+ /// kirodotdev/Kiro#5440 (open feature request, Feb 2026) and
+ /// kirodotdev/Kiro#9857 (open bug, "not loaded in 1.0.52"). Flip this
+ /// back to `true` once upstream ships user-level loading.
+ fn supports_global_hook_install(&self) -> bool {
+ false
+ }
+
+ fn hook_config_paths_for(&self, scope: &ConfigScope) -> Vec {
+ match scope {
+ ConfigScope::Global => Self::json_files(&self.base_dir().join("hooks")),
+ ConfigScope::Project { path, .. } => {
+ Self::json_files(&Path::new(path).join(".kiro").join("hooks"))
+ }
+ }
+ }
+
+ 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 Some(config) = Self::parse_json(path) else {
+ return vec![];
+ };
+ let Some(servers) = config.get("mcpServers").and_then(|v| v.as_object()) else {
+ return vec![];
+ };
+ servers
+ .iter()
+ .map(|(name, val)| McpServerEntry {
+ name: name.clone(),
+ command: val
+ .get("command")
+ .and_then(|v| v.as_str())
+ .or_else(|| val.get("url").and_then(|v| v.as_str()))
+ .unwrap_or("")
+ .into(),
+ args: val
+ .get("args")
+ .and_then(|v| v.as_array())
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|v| v.as_str().map(String::from))
+ .collect()
+ })
+ .unwrap_or_default(),
+ env: val
+ .get("env")
+ .and_then(|v| v.as_object())
+ .map(|obj| {
+ obj.iter()
+ .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
+ .collect()
+ })
+ .unwrap_or_default(),
+ enabled: !val
+ .get("disabled")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false),
+ })
+ .collect()
+ }
+
+ fn read_hooks(&self) -> Vec {
+ self.hook_config_paths_for(&ConfigScope::Global)
+ .into_iter()
+ .flat_map(|path| self.read_hooks_from(&path))
+ .collect()
+ }
+
+ fn read_hooks_from(&self, path: &Path) -> Vec {
+ let Some(config) = Self::parse_json(path) else {
+ return vec![];
+ };
+ let Some(hooks) = config.get("hooks").and_then(|v| v.as_array()) else {
+ return vec![];
+ };
+ hooks.iter().filter_map(Self::parse_hook).collect()
+ }
+
+ fn translate_hook_event(&self, event: &str) -> Option {
+ super::hook_events::to_kiro(event)
+ }
+
+ fn global_rules_files(&self) -> Vec {
+ super::files_with_ext(&self.base_dir().join("steering"), "md").collect()
+ }
+
+ fn global_settings_files(&self) -> Vec {
+ let mut files = vec![self.mcp_config_path()];
+ files.extend(Self::json_files(&self.base_dir().join("hooks")));
+ files
+ }
+
+ fn global_subagent_files(&self) -> Vec {
+ // Two coexisting formats in the same dir: Kiro CLI agents are *.json,
+ // IDE custom subagents are *.md with YAML front matter
+ // (https://kiro.dev/docs/chat/subagents/).
+ let agents_dir = self.base_dir().join("agents");
+ let mut files = Self::json_files(&agents_dir);
+ files.extend(super::files_with_ext(&agents_dir, "md"));
+ files
+ }
+
+ fn project_markers(&self) -> Vec {
+ vec![ProjectMarker::Dir(".kiro")]
+ }
+
+ fn project_rules_patterns(&self) -> Vec {
+ vec![".kiro/steering/*.md".into()]
+ }
+
+ fn project_settings_patterns(&self) -> Vec {
+ vec![
+ ".kiro/settings/mcp.json".into(),
+ ".kiro/hooks/*.json".into(),
+ ]
+ }
+
+ fn project_subagent_patterns(&self) -> Vec {
+ vec![".kiro/agents/*.json".into(), ".kiro/agents/*.md".into()]
+ }
+
+ fn project_skill_dirs(&self) -> Vec {
+ vec![".kiro/skills".into()]
+ }
+
+ fn project_mcp_config_relpath(&self) -> Option {
+ Some(".kiro/settings/mcp.json".into())
+ }
+
+ fn project_hook_config_relpath(&self) -> Option {
+ Some(".kiro/hooks/harnesskit.json".into())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::super::AgentAdapter;
+ use super::*;
+
+ #[test]
+ fn detect_requires_kiro_dir() {
+ let tmp = tempfile::tempdir().unwrap();
+ let adapter = KiroAdapter::with_home(tmp.path().to_path_buf());
+ assert!(!adapter.detect());
+
+ std::fs::create_dir_all(tmp.path().join(".kiro")).unwrap();
+ assert!(adapter.detect());
+ }
+
+ #[test]
+ fn read_mcp_servers_reads_disabled_flag() {
+ let tmp = tempfile::tempdir().unwrap();
+ let adapter = KiroAdapter::with_home(tmp.path().to_path_buf());
+ let cfg = tmp.path().join(".kiro/settings/mcp.json");
+ std::fs::create_dir_all(cfg.parent().unwrap()).unwrap();
+ std::fs::write(
+ &cfg,
+ r#"{"mcpServers":{"lint":{"command":"npm","args":["run","lint"],"disabled":true}}}"#,
+ )
+ .unwrap();
+
+ let servers = adapter.read_mcp_servers();
+ assert_eq!(servers.len(), 1);
+ assert_eq!(servers[0].name, "lint");
+ assert!(!servers[0].enabled);
+ }
+
+ #[test]
+ fn read_hooks_reads_command_actions_only() {
+ let tmp = tempfile::tempdir().unwrap();
+ let adapter = KiroAdapter::with_home(tmp.path().to_path_buf());
+ let hooks_dir = tmp.path().join(".kiro/hooks");
+ std::fs::create_dir_all(&hooks_dir).unwrap();
+ std::fs::write(
+ hooks_dir.join("lint.json"),
+ r#"{
+ "version": "v1",
+ "hooks": [
+ {
+ "name": "lint-on-save",
+ "trigger": "PostFileSave",
+ "matcher": "\\.ts$",
+ "action": { "type": "command", "command": "npm run lint" },
+ "enabled": false
+ },
+ {
+ "name": "ask",
+ "trigger": "Stop",
+ "action": { "type": "agent", "prompt": "summarize" }
+ }
+ ]
+ }"#,
+ )
+ .unwrap();
+
+ let hooks = adapter.read_hooks();
+ assert_eq!(hooks.len(), 1);
+ assert_eq!(hooks[0].event, "PostFileSave");
+ assert_eq!(hooks[0].matcher.as_deref(), Some("\\.ts$"));
+ assert_eq!(hooks[0].command, "npm run lint");
+ assert!(
+ !hooks[0].enabled,
+ "natively disabled hook must be listed as disabled, not dropped"
+ );
+ }
+
+ #[test]
+ fn subagents_cover_cli_json_and_ide_markdown() {
+ let tmp = tempfile::tempdir().unwrap();
+ let adapter = KiroAdapter::with_home(tmp.path().to_path_buf());
+ let agents_dir = tmp.path().join(".kiro/agents");
+ std::fs::create_dir_all(&agents_dir).unwrap();
+ std::fs::write(agents_dir.join("cli-agent.json"), r#"{"name":"cli"}"#).unwrap();
+ std::fs::write(
+ agents_dir.join("reviewer.md"),
+ "---\nname: reviewer\n---\nYou are a code reviewer.\n",
+ )
+ .unwrap();
+
+ let files = adapter.global_subagent_files();
+ let names: Vec = files
+ .iter()
+ .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
+ .collect();
+ assert!(names.contains(&"cli-agent.json".to_string()), "{names:?}");
+ assert!(names.contains(&"reviewer.md".to_string()), "{names:?}");
+
+ assert_eq!(
+ adapter.project_subagent_patterns(),
+ vec![".kiro/agents/*.json", ".kiro/agents/*.md"]
+ );
+ }
+}
diff --git a/crates/hk-core/src/adapter/mod.rs b/crates/hk-core/src/adapter/mod.rs
index 0ccff4e..08854b1 100644
--- a/crates/hk-core/src/adapter/mod.rs
+++ b/crates/hk-core/src/adapter/mod.rs
@@ -6,6 +6,7 @@ pub mod cursor;
pub mod gemini;
pub mod hermes;
pub mod hook_events;
+pub mod kiro;
pub mod opencode;
pub mod windsurf;
@@ -64,6 +65,9 @@ pub struct HookEntry {
pub event: String,
pub matcher: Option,
pub command: String,
+ /// On-disk enable state. Only agents with a native per-hook flag (Kiro's
+ /// `enabled`) ever report `false`; formats without one are always `true`.
+ pub enabled: bool,
}
/// Represents a plugin entry parsed from an agent's config
@@ -102,6 +106,10 @@ pub enum HookFormat {
/// is a list of `{matcher?, command, timeout?}`. Routed through dedicated
/// YAML helpers in deployer.rs (NOT locked_modify_json, which is JSON-only).
HermesYaml,
+ /// Kiro IDE hook files (`~/.kiro/hooks/*.json`, `.kiro/hooks/*.json`).
+ /// Each file has `{version, hooks: [{name, trigger, matcher?, action, timeout?}]}`.
+ /// Only command actions map to HarnessKit's shell-command HookEntry model.
+ KiroIde,
/// Agent does not support hooks
None,
}
@@ -203,6 +211,15 @@ pub trait AgentAdapter: Send + Sync {
false
}
+ /// Whether the agent actually LOADS hooks installed at the user-level
+ /// (global) location. `install_to_agent` refuses to deploy a global hook
+ /// when this is `false`, so HK never writes a config the agent silently
+ /// ignores. Scanning/toggling of files already on disk is unaffected.
+ /// Default `true`; override only with verified evidence.
+ fn supports_global_hook_install(&self) -> bool {
+ true
+ }
+
/// Translate a hook event name from any agent's convention to this agent's convention.
/// Returns None if the event has no equivalent in this agent.
/// Mappings are centralized in `hook_events.rs`.
@@ -379,6 +396,13 @@ pub trait AgentAdapter: Send + Sync {
}
}
+ /// Resolve every hook config file for a scope. Most agents have exactly
+ /// one hook config file, so the default wraps `hook_config_path_for`.
+ /// Agents such as Kiro can override this to scan a directory of hook files.
+ fn hook_config_paths_for(&self, scope: &ConfigScope) -> Vec {
+ self.hook_config_path_for(scope).into_iter().collect()
+ }
+
/// Resolve the skill directory for a given scope.
/// - `Global` → first entry of `skill_dirs()` (today's behavior).
/// - `Project` → `/`, or `None` if
@@ -431,6 +455,7 @@ pub fn all_adapters() -> Vec> {
Box::new(windsurf::WindsurfAdapter::new()),
Box::new(opencode::OpencodeAdapter::new()),
Box::new(hermes::HermesAdapter::new()),
+ Box::new(kiro::KiroAdapter::new()),
]
}
@@ -439,9 +464,9 @@ mod tests {
use super::*;
#[test]
- fn test_all_adapters_returns_nine() {
+ fn test_all_adapters_returns_ten() {
let adapters = all_adapters();
- assert_eq!(adapters.len(), 9);
+ assert_eq!(adapters.len(), 10);
let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
assert!(names.contains(&"claude"));
assert!(names.contains(&"cursor"));
@@ -452,6 +477,7 @@ mod tests {
assert!(names.contains(&"windsurf"));
assert!(names.contains(&"opencode"));
assert!(names.contains(&"hermes"));
+ assert!(names.contains(&"kiro"));
}
#[test]
@@ -471,7 +497,9 @@ mod tests {
// setups). Adding an agent here without a confirmed PATH bug would
// unnecessarily rewrite users' mcp_config.json with absolute paths,
// hurting cross-machine portability.
- for name in ["claude", "codex", "gemini", "cursor", "copilot", "opencode", "hermes"] {
+ for name in [
+ "claude", "codex", "gemini", "cursor", "copilot", "opencode", "hermes", "kiro",
+ ] {
assert!(
!by_name[name].needs_path_injection(),
"{name} should not need path injection"
@@ -480,10 +508,10 @@ mod tests {
}
#[test]
- fn test_supports_native_mcp_toggle_only_hermes() {
+ fn test_supports_native_mcp_toggle_only_native_agents() {
let adapters = all_adapters();
for a in &adapters {
- let expected = a.name() == "hermes";
+ let expected = a.name() == "hermes" || a.name() == "kiro";
assert_eq!(
a.supports_native_mcp_toggle(),
expected,
@@ -493,6 +521,23 @@ mod tests {
}
}
+ #[test]
+ fn test_supports_global_hook_install_false_only_for_kiro() {
+ // Kiro's docs claim `~/.kiro/hooks/` (user-level) but no released
+ // version loads it (kirodotdev/Kiro#5440, #9857; verified on 1.0.89).
+ // Everyone else keeps the default: global hook deploy allowed.
+ let adapters = all_adapters();
+ for a in &adapters {
+ let expected = a.name() != "kiro";
+ assert_eq!(
+ a.supports_global_hook_install(),
+ expected,
+ "{} supports_global_hook_install should be {expected}",
+ a.name()
+ );
+ }
+ }
+
#[test]
fn test_skill_dir_for_category_default_is_none_except_hermes() {
// The install handlers rely on this contract: only category-aware
@@ -603,6 +648,7 @@ mod tests {
("antigravity", ".agents/skills"), // 1.18.4+ canonical; .agent/ kept as backward-compat alias
("copilot", ".github/skills"),
("opencode", ".opencode/skills"),
+ ("kiro", ".kiro/skills"),
// hermes is global-only — no project skill dir (hermes-agent#4667).
]
.into_iter()
diff --git a/crates/hk-core/src/adapter/windsurf.rs b/crates/hk-core/src/adapter/windsurf.rs
index 157dd7b..1c09b7d 100644
--- a/crates/hk-core/src/adapter/windsurf.rs
+++ b/crates/hk-core/src/adapter/windsurf.rs
@@ -162,6 +162,7 @@ impl AgentAdapter for WindsurfAdapter {
event: event.clone(),
matcher: None,
command: command.to_string(),
+ enabled: true,
});
}
}
diff --git a/crates/hk-core/src/deployer.rs b/crates/hk-core/src/deployer.rs
index 312ce67..ed971ab 100644
--- a/crates/hk-core/src/deployer.rs
+++ b/crates/hk-core/src/deployer.rs
@@ -311,8 +311,9 @@ fn modify_hermes_yaml(
let mut doc: serde_yaml::Value = if existing.trim().is_empty() {
serde_yaml::Value::Mapping(serde_yaml::Mapping::new())
} else {
- serde_yaml::from_str(&existing)
- .map_err(|e| HkError::ConfigCorrupted(format!("Failed to parse Hermes config.yaml: {e}")))?
+ serde_yaml::from_str(&existing).map_err(|e| {
+ HkError::ConfigCorrupted(format!("Failed to parse Hermes config.yaml: {e}"))
+ })?
};
let root = doc
.as_mapping_mut()
@@ -345,8 +346,12 @@ fn deploy_mcp_server_hermes_yaml(
} else {
server.insert("command".into(), entry.command.clone().into());
if !entry.args.is_empty() {
- let args: Vec =
- entry.args.iter().cloned().map(serde_yaml::Value::String).collect();
+ let args: Vec = entry
+ .args
+ .iter()
+ .cloned()
+ .map(serde_yaml::Value::String)
+ .collect();
server.insert("args".into(), serde_yaml::Value::Sequence(args));
}
if !entry.env.is_empty() {
@@ -358,7 +363,10 @@ fn deploy_mcp_server_hermes_yaml(
}
}
server.insert("enabled".into(), serde_yaml::Value::Bool(true));
- servers.insert(entry.name.clone().into(), serde_yaml::Value::Mapping(server));
+ servers.insert(
+ entry.name.clone().into(),
+ serde_yaml::Value::Mapping(server),
+ );
Ok(())
})
}
@@ -421,6 +429,90 @@ pub fn set_hermes_mcp_enabled(
})
}
+/// Flip a Kiro MCP server's native `disabled` flag in place.
+pub fn set_kiro_mcp_enabled(
+ config_path: &Path,
+ server_name: &str,
+ enabled: bool,
+) -> Result<(), HkError> {
+ locked_modify_json(config_path, |config| {
+ let servers = config
+ .get_mut("mcpServers")
+ .and_then(|v| v.as_object_mut())
+ .ok_or_else(|| HkError::NotFound("No mcpServers block found".into()))?;
+ let server = servers
+ .get_mut(server_name)
+ .and_then(|v| v.as_object_mut())
+ .ok_or_else(|| HkError::NotFound(format!("MCP server '{server_name}' not found")))?;
+ if enabled {
+ server.remove("disabled");
+ } else {
+ server.insert("disabled".into(), serde_json::Value::Bool(true));
+ }
+ Ok(())
+ })
+}
+
+/// 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(
+ config_path: &Path,
+ event: &str,
+ matcher: Option<&str>,
+ command: &str,
+ enabled: bool,
+) -> Result<(), HkError> {
+ locked_modify_json(config_path, |config| {
+ let hooks = config
+ .get_mut("hooks")
+ .and_then(|v| v.as_array_mut())
+ .ok_or_else(|| HkError::NotFound("No hooks array found".into()))?;
+ let hook = hooks
+ .iter_mut()
+ .find(|h| kiro_hook_matches(h, event, matcher, command))
+ .ok_or_else(|| HkError::NotFound(format!("Hook for '{event}' not found in config")))?;
+ let obj = hook
+ .as_object_mut()
+ .ok_or_else(|| HkError::ConfigCorrupted("hook is not an object".into()))?;
+ obj.insert("enabled".into(), serde_json::Value::Bool(enabled));
+ Ok(())
+ })
+}
+
+fn kiro_hook_matches(
+ hook: &serde_json::Value,
+ event: &str,
+ matcher: Option<&str>,
+ command: &str,
+) -> bool {
+ hook.get("trigger").and_then(|v| v.as_str()) == Some(event)
+ && hook.get("matcher").and_then(|v| v.as_str()) == matcher
+ && hook
+ .get("action")
+ .and_then(|v| v.get("type"))
+ .and_then(|v| v.as_str())
+ == Some("command")
+ && hook
+ .get("action")
+ .and_then(|v| v.get("command"))
+ .and_then(|v| v.as_str())
+ == Some(command)
+}
+
+fn kiro_hook_value(entry: &HookEntry) -> serde_json::Value {
+ let mut hook = serde_json::json!({
+ "name": format!("{} {}", entry.event, entry.command),
+ "trigger": entry.event,
+ "action": { "type": "command", "command": entry.command },
+ });
+ if let Some(matcher) = &entry.matcher
+ && let Some(obj) = hook.as_object_mut()
+ {
+ obj.insert("matcher".into(), serde_json::Value::String(matcher.clone()));
+ }
+ hook
+}
+
/// True if a hooks-list item matches (matcher, command).
fn hermes_hook_item_matches(
item: &serde_yaml::Value,
@@ -526,8 +618,9 @@ fn read_hook_config_hermes_yaml(
return Ok(None);
}
let content = std::fs::read_to_string(config_path)?;
- let doc: serde_yaml::Value = serde_yaml::from_str(&content)
- .map_err(|e| HkError::ConfigCorrupted(format!("Failed to parse Hermes config.yaml: {e}")))?;
+ let doc: serde_yaml::Value = serde_yaml::from_str(&content).map_err(|e| {
+ HkError::ConfigCorrupted(format!("Failed to parse Hermes config.yaml: {e}"))
+ })?;
let Some(item) = doc
.get("hooks")
.and_then(|h| h.get(event))
@@ -540,8 +633,7 @@ fn read_hook_config_hermes_yaml(
return Ok(None);
};
let json_str = serde_json::to_string(item).map_err(|e| HkError::Internal(e.to_string()))?;
- let json_val =
- serde_json::from_str(&json_str).map_err(|e| HkError::Internal(e.to_string()))?;
+ let json_val = serde_json::from_str(&json_str).map_err(|e| HkError::Internal(e.to_string()))?;
Ok(Some(json_val))
}
@@ -700,6 +792,26 @@ pub fn deploy_hook(
// Handled by the early return above; YAML is not JSON.
unreachable!("HermesYaml handled before locked_modify_json")
}
+ HookFormat::KiroIde => {
+ config
+ .as_object_mut()
+ .ok_or_else(|| HkError::ConfigCorrupted("Config is not an object".into()))?
+ .entry("version")
+ .or_insert(serde_json::json!("v1"));
+ let hooks = config
+ .as_object_mut()
+ .unwrap()
+ .entry("hooks")
+ .or_insert_with(|| serde_json::json!([]));
+ let arr = hooks
+ .as_array_mut()
+ .ok_or_else(|| HkError::ConfigCorrupted("hooks is not an array".into()))?;
+ if !arr.iter().any(|h| {
+ kiro_hook_matches(h, &entry.event, entry.matcher.as_deref(), &entry.command)
+ }) {
+ arr.push(kiro_hook_value(entry));
+ }
+ }
HookFormat::None => {
return Err(HkError::Internal("Agent does not support hooks".into()));
}
@@ -859,6 +971,11 @@ pub fn remove_hook(
// Handled by the early return above; YAML is not JSON.
unreachable!("HermesYaml handled before locked_modify_json")
}
+ HookFormat::KiroIde => {
+ if let Some(hooks) = config.get_mut("hooks").and_then(|v| v.as_array_mut()) {
+ hooks.retain(|h| !kiro_hook_matches(h, event, matcher, command));
+ }
+ }
HookFormat::None => {
return Err(HkError::Internal("Agent does not support hooks".into()));
}
@@ -1039,6 +1156,35 @@ pub fn restore_hook(
// Handled by the early return above; YAML is not JSON.
unreachable!("HermesYaml handled before locked_modify_json")
}
+ HookFormat::KiroIde => {
+ config
+ .as_object_mut()
+ .ok_or_else(|| HkError::ConfigCorrupted("Config is not an object".into()))?
+ .entry("version")
+ .or_insert(serde_json::json!("v1"));
+ let hooks = config
+ .as_object_mut()
+ .unwrap()
+ .entry("hooks")
+ .or_insert_with(|| serde_json::json!([]));
+ let arr = hooks
+ .as_array_mut()
+ .ok_or_else(|| HkError::ConfigCorrupted("hooks is not an array".into()))?;
+ // Same (event, matcher, command) identity as deploy_hook, so a
+ // double-restore doesn't duplicate the entry.
+ let matcher = entry.get("matcher").and_then(|v| v.as_str());
+ let command = entry
+ .get("action")
+ .and_then(|a| a.get("command"))
+ .and_then(|v| v.as_str())
+ .unwrap_or_default();
+ if !arr
+ .iter()
+ .any(|h| kiro_hook_matches(h, event, matcher, command))
+ {
+ arr.push(entry.clone());
+ }
+ }
HookFormat::None => {
return Err(HkError::Internal("Agent does not support hooks".into()));
}
@@ -1473,6 +1619,15 @@ pub fn read_hook_config(
return Ok(None);
}
let config = read_or_create_json(config_path)?;
+ if format == HookFormat::KiroIde {
+ let Some(hooks) = config.get("hooks").and_then(|v| v.as_array()) else {
+ return Ok(None);
+ };
+ return Ok(hooks
+ .iter()
+ .find(|entry| kiro_hook_matches(entry, event, matcher, command))
+ .cloned());
+ }
let hooks = config.get("hooks").and_then(|v| v.as_object());
let Some(hooks) = hooks else {
return Ok(None);
@@ -1526,6 +1681,7 @@ pub fn read_hook_config(
}
Ok(None)
}
+ HookFormat::KiroIde => Ok(None),
// Handled by the early return above; YAML is not JSON.
HookFormat::HermesYaml => Ok(None),
HookFormat::None => Ok(None),
@@ -2421,6 +2577,7 @@ mod tests {
event: "PreToolUse".into(),
matcher: Some("Bash".into()),
command: "echo test".into(),
+ enabled: true,
};
deploy_hook(&config, &entry, HookFormat::ClaudeLike).unwrap();
@@ -2448,6 +2605,7 @@ mod tests {
event: "PreToolUse".into(),
matcher: Some("Bash".into()),
command: "echo second".into(),
+ enabled: true,
};
deploy_hook(&config, &entry, HookFormat::ClaudeLike).unwrap();
@@ -2472,6 +2630,7 @@ mod tests {
event: "PreToolUse".into(),
matcher: Some("Bash".into()),
command: "echo test".into(),
+ enabled: true,
};
deploy_hook(&config, &entry, HookFormat::ClaudeLike).unwrap();
@@ -2644,6 +2803,7 @@ mod tests {
event: "stop".into(),
matcher: None,
command: "echo done".into(),
+ enabled: true,
};
deploy_hook(&config, &entry, HookFormat::Cursor).unwrap();
@@ -2664,6 +2824,7 @@ mod tests {
event: "PreToolUse".into(),
matcher: None,
command: "./check.sh".into(),
+ enabled: true,
};
deploy_hook(&config, &entry, HookFormat::Copilot).unwrap();
@@ -2682,6 +2843,7 @@ mod tests {
event: "pre_user_prompt".into(),
matcher: None,
command: "echo hi".into(),
+ enabled: true,
};
deploy_hook(&config, &entry, HookFormat::Windsurf).unwrap();
@@ -2691,6 +2853,141 @@ mod tests {
assert_eq!(content["hooks"]["pre_user_prompt"][0]["command"], "echo hi");
}
+ #[test]
+ fn test_kiro_ide_hook_roundtrip() {
+ let dir = TempDir::new().unwrap();
+ let config = dir.path().join("lint.json");
+ let entry = HookEntry {
+ event: "PostFileSave".into(),
+ matcher: Some("\\.ts$".into()),
+ command: "npm run lint".into(),
+ enabled: true,
+ };
+ deploy_hook(&config, &entry, HookFormat::KiroIde).unwrap();
+ let deployed: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap();
+ // Kiro only documents "v1" (https://kiro.dev/docs/hooks/); other values
+ // may make Kiro skip the file entirely.
+ assert_eq!(deployed["version"], "v1");
+ let saved = read_hook_config(
+ &config,
+ "PostFileSave",
+ Some("\\.ts$"),
+ "npm run lint",
+ HookFormat::KiroIde,
+ )
+ .unwrap()
+ .expect("Kiro hook should be readable");
+ assert_eq!(saved["action"]["type"], "command");
+ assert_eq!(saved["action"]["command"], "npm run lint");
+
+ remove_hook(
+ &config,
+ "PostFileSave",
+ Some("\\.ts$"),
+ "npm run lint",
+ HookFormat::KiroIde,
+ )
+ .unwrap();
+ let removed: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap();
+ assert_eq!(removed["hooks"].as_array().unwrap().len(), 0);
+
+ restore_hook(&config, "PostFileSave", &saved, HookFormat::KiroIde).unwrap();
+ let restored = read_hook_config(
+ &config,
+ "PostFileSave",
+ Some("\\.ts$"),
+ "npm run lint",
+ HookFormat::KiroIde,
+ )
+ .unwrap();
+ assert!(restored.is_some());
+ }
+
+ #[test]
+ fn test_kiro_ide_restore_hook_is_idempotent() {
+ let dir = TempDir::new().unwrap();
+ let config = dir.path().join("lint.json");
+ let entry = serde_json::json!({
+ "name": "lint-on-save",
+ "trigger": "PostFileSave",
+ "matcher": "\\.ts$",
+ "action": { "type": "command", "command": "npm run lint" },
+ });
+ restore_hook(&config, "PostFileSave", &entry, HookFormat::KiroIde).unwrap();
+ restore_hook(&config, "PostFileSave", &entry, HookFormat::KiroIde).unwrap();
+ let content: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap();
+ assert_eq!(
+ content["hooks"].as_array().unwrap().len(),
+ 1,
+ "double restore must not duplicate the hook"
+ );
+ }
+
+ #[test]
+ fn test_set_kiro_mcp_enabled_flips_disabled() {
+ let dir = TempDir::new().unwrap();
+ let config = dir.path().join("mcp.json");
+ std::fs::write(
+ &config,
+ r#"{"mcpServers":{"github":{"command":"npx","args":["server"]}}}"#,
+ )
+ .unwrap();
+ set_kiro_mcp_enabled(&config, "github", false).unwrap();
+ let disabled: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap();
+ assert_eq!(disabled["mcpServers"]["github"]["disabled"], true);
+
+ set_kiro_mcp_enabled(&config, "github", true).unwrap();
+ let enabled: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap();
+ assert!(enabled["mcpServers"]["github"].get("disabled").is_none());
+ }
+
+ #[test]
+ fn test_set_kiro_hook_enabled_flips_in_place() {
+ let dir = TempDir::new().unwrap();
+ let config = dir.path().join("lint.json");
+ let entry = HookEntry {
+ event: "PostFileSave".into(),
+ matcher: Some("\\.ts$".into()),
+ command: "npm run lint".into(),
+ enabled: true,
+ };
+ deploy_hook(&config, &entry, HookFormat::KiroIde).unwrap();
+
+ set_kiro_hook_enabled(
+ &config,
+ "PostFileSave",
+ Some("\\.ts$"),
+ "npm run lint",
+ false,
+ )
+ .unwrap();
+ let doc: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap();
+ assert_eq!(doc["hooks"].as_array().unwrap().len(), 1, "entry kept");
+ assert_eq!(doc["hooks"][0]["enabled"], false);
+
+ set_kiro_hook_enabled(
+ &config,
+ "PostFileSave",
+ Some("\\.ts$"),
+ "npm run lint",
+ true,
+ )
+ .unwrap();
+ let doc2: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap();
+ assert_eq!(doc2["hooks"][0]["enabled"], true);
+
+ // Unknown hook → NotFound, so callers can fall through to other files.
+ let err = set_kiro_hook_enabled(&config, "Stop", None, "missing", false).unwrap_err();
+ assert!(matches!(err, HkError::NotFound(_)));
+ }
+
#[test]
fn test_remove_hook_cursor_format() {
let dir = TempDir::new().unwrap();
@@ -2769,6 +3066,7 @@ mod tests {
event: "pre_tool_call".into(),
matcher: Some("terminal".into()),
command: "~/.hermes/agent-hooks/block.sh".into(),
+ enabled: true,
};
deploy_hook(&cfg, &entry, HookFormat::HermesYaml).unwrap();
let doc: serde_yaml::Value =
@@ -2804,7 +3102,13 @@ mod tests {
.and_then(|h| h.get("pre_tool_call"))
.is_none()
);
- restore_hook(&cfg, "pre_tool_call", &saved.unwrap(), HookFormat::HermesYaml).unwrap();
+ restore_hook(
+ &cfg,
+ "pre_tool_call",
+ &saved.unwrap(),
+ HookFormat::HermesYaml,
+ )
+ .unwrap();
let restored = read_hook_config(
&cfg,
"pre_tool_call",
@@ -2825,6 +3129,7 @@ mod tests {
event: "pre_tool_call".into(),
matcher: Some("terminal".into()),
command: "~/.hermes/agent-hooks/block.sh".into(),
+ enabled: true,
};
// Deploying the identical hook twice must not duplicate the list item.
deploy_hook(&cfg, &entry, HookFormat::HermesYaml).unwrap();
@@ -2848,6 +3153,7 @@ mod tests {
event: "on_session_start".into(),
matcher: None,
command: "~/.hermes/agent-hooks/log.sh".into(),
+ enabled: true,
};
deploy_hook(&cfg, &entry, HookFormat::HermesYaml).unwrap();
@@ -2887,14 +3193,24 @@ mod tests {
let cfg = tmp.path().join("config.yaml");
std::fs::write(&cfg, "plugins:\n enabled:\n - calculator\n").unwrap();
set_hermes_plugin_enabled(&cfg, "weather", true).unwrap();
- let doc: serde_yaml::Value = serde_yaml::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap();
- let list: Vec<&str> = doc["plugins"]["enabled"].as_sequence().unwrap()
- .iter().filter_map(|v| v.as_str()).collect();
+ let doc: serde_yaml::Value =
+ serde_yaml::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap();
+ let list: Vec<&str> = doc["plugins"]["enabled"]
+ .as_sequence()
+ .unwrap()
+ .iter()
+ .filter_map(|v| v.as_str())
+ .collect();
assert!(list.contains(&"calculator") && list.contains(&"weather"));
set_hermes_plugin_enabled(&cfg, "calculator", false).unwrap();
- let doc2: serde_yaml::Value = serde_yaml::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap();
- let list2: Vec<&str> = doc2["plugins"]["enabled"].as_sequence().unwrap()
- .iter().filter_map(|v| v.as_str()).collect();
+ let doc2: serde_yaml::Value =
+ serde_yaml::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap();
+ let list2: Vec<&str> = doc2["plugins"]["enabled"]
+ .as_sequence()
+ .unwrap()
+ .iter()
+ .filter_map(|v| v.as_str())
+ .collect();
assert!(!list2.contains(&"calculator") && list2.contains(&"weather"));
}
@@ -2926,7 +3242,11 @@ mod tests {
.iter()
.filter_map(|v| v.as_str())
.collect();
- assert_eq!(list2, vec!["calculator"], "disabling absent plugin is a no-op");
+ assert_eq!(
+ list2,
+ vec!["calculator"],
+ "disabling absent plugin is a no-op"
+ );
}
#[test]
@@ -2942,23 +3262,41 @@ mod tests {
set_hermes_mcp_enabled(&cfg, "github", false).unwrap();
let doc: serde_yaml::Value =
serde_yaml::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap();
- let gh = doc.get("mcp_servers").and_then(|m| m.get("github")).unwrap();
+ let gh = doc
+ .get("mcp_servers")
+ .and_then(|m| m.get("github"))
+ .unwrap();
assert_eq!(gh.get("enabled").and_then(|v| v.as_bool()), Some(false));
- assert_eq!(gh.get("env").and_then(|e| e.get("TOKEN")).and_then(|v| v.as_str()), Some("secret123"));
- let include: Vec<&str> = gh["tools"]["include"].as_sequence().unwrap()
- .iter().filter_map(|v| v.as_str()).collect();
+ assert_eq!(
+ gh.get("env")
+ .and_then(|e| e.get("TOKEN"))
+ .and_then(|v| v.as_str()),
+ Some("secret123")
+ );
+ let include: Vec<&str> = gh["tools"]["include"]
+ .as_sequence()
+ .unwrap()
+ .iter()
+ .filter_map(|v| v.as_str())
+ .collect();
assert_eq!(include, vec!["a", "b"]);
assert!(doc.get("mcp_servers").and_then(|m| m.get("time")).is_some());
// re-enable
set_hermes_mcp_enabled(&cfg, "github", true).unwrap();
let doc2: serde_yaml::Value =
serde_yaml::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap();
- assert_eq!(doc2["mcp_servers"]["github"]["enabled"].as_bool(), Some(true));
+ assert_eq!(
+ doc2["mcp_servers"]["github"]["enabled"].as_bool(),
+ Some(true)
+ );
// `time` has no `enabled` key on disk; disabling must INSERT enabled:false.
set_hermes_mcp_enabled(&cfg, "time", false).unwrap();
let doc3: serde_yaml::Value =
serde_yaml::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap();
- assert_eq!(doc3["mcp_servers"]["time"]["enabled"].as_bool(), Some(false));
+ assert_eq!(
+ doc3["mcp_servers"]["time"]["enabled"].as_bool(),
+ Some(false)
+ );
// and `time` keeps its command (entry not rebuilt)
assert_eq!(doc3["mcp_servers"]["time"]["command"].as_str(), Some("uvx"));
}
diff --git a/crates/hk-core/src/manager.rs b/crates/hk-core/src/manager.rs
index a1d95f7..ab9a282 100644
--- a/crates/hk-core/src/manager.rs
+++ b/crates/hk-core/src/manager.rs
@@ -165,7 +165,12 @@ fn toggle_skill(
Ok(())
}
-fn toggle_mcp(ext: &Extension, enabled: bool, store: &Store, adapters: &[Box]) -> Result<(), HkError> {
+fn toggle_mcp(
+ ext: &Extension,
+ enabled: bool,
+ store: &Store,
+ adapters: &[Box],
+) -> Result<(), HkError> {
for a in adapters {
if !ext.agents.contains(&a.name().to_string()) {
continue;
@@ -174,14 +179,20 @@ fn toggle_mcp(ext: &Extension, enabled: bool, store: &Store, adapters: &[Box/; global entries use the
// adapter's user-scope path. None means this adapter has no project-
// level MCP support, so skip it for project-scoped extensions.
- let Some(config_path) = a.mcp_config_path_for(&ext.scope) else { continue };
+ let Some(config_path) = a.mcp_config_path_for(&ext.scope) else {
+ continue;
+ };
// Agents with a native per-server `enabled` field (Hermes) disable IN
// PLACE: flip `enabled` in the config, keeping the entry, secrets, and
// advanced keys, and take NO DB snapshot — the on-disk `enabled` is read
// back by read_mcp_servers on rescan. Mirrors `hermes mcp` enable/disable.
// Docs: https://hermes-agent.nousresearch.com/docs/reference/mcp-config-reference
if a.supports_native_mcp_toggle() {
- deployer::set_hermes_mcp_enabled(&config_path, &ext.name, enabled)?;
+ if a.name() == "kiro" {
+ deployer::set_kiro_mcp_enabled(&config_path, &ext.name, enabled)?;
+ } else {
+ deployer::set_hermes_mcp_enabled(&config_path, &ext.name, enabled)?;
+ }
// Clear any legacy redacted snapshot so the store.rs upsert CASE uses
// the on-disk enabled state (disabled_config IS NULL).
store.set_disabled_config(&ext.id, None)?;
@@ -285,7 +296,12 @@ fn redact_mcp_env(entry: &serde_json::Value) -> serde_json::Value {
redacted
}
-fn toggle_hook(ext: &Extension, enabled: bool, store: &Store, adapters: &[Box]) -> Result<(), HkError> {
+fn toggle_hook(
+ ext: &Extension,
+ enabled: bool,
+ store: &Store,
+ adapters: &[Box],
+) -> Result<(), HkError> {
let parts: Vec<&str> = ext.name.splitn(3, ':').collect();
if parts.len() < 3 {
return Err(HkError::Validation(format!(
@@ -303,20 +319,74 @@ fn toggle_hook(ext: &Extension, enabled: bool, store: &Store, adapters: &[Box = ext
+ .source_path
+ .as_ref()
+ .map(|p| vec![PathBuf::from(p)])
+ .unwrap_or_else(|| a.hook_config_paths_for(&ext.scope));
+ // Kiro hooks have a native per-hook `enabled` flag ("skip without
+ // deleting" — https://kiro.dev/docs/hooks/). Flip it IN PLACE, keeping
+ // the entry, and take NO DB snapshot: the on-disk state is read back by
+ // read_hooks on rescan (same pattern as the native MCP toggle above).
+ if a.hook_format() == adapter::HookFormat::KiroIde {
+ let mut flipped = false;
+ for config_path in &config_paths {
+ if !config_path.is_file() {
+ continue;
+ }
+ match deployer::set_kiro_hook_enabled(config_path, event, matcher, command, enabled)
+ {
+ Ok(()) => {
+ flipped = true;
+ break;
+ }
+ Err(HkError::NotFound(_)) => continue,
+ Err(e) => return Err(e),
+ }
+ }
+ if !flipped {
+ return Err(HkError::NotFound(format!(
+ "Hook '{}' not found in config",
+ ext.name
+ )));
+ }
+ // Clear any stale snapshot so the store upsert CASE trusts the
+ // scanner's on-disk enabled state (disabled_config IS NULL).
+ store.set_disabled_config(&ext.id, None)?;
+ continue;
+ }
if enabled {
let saved = store.get_disabled_config(&ext.id)?.ok_or_else(|| {
HkError::NotFound(format!("No saved config for hook '{}'", ext.name))
})?;
let entry: serde_json::Value = serde_json::from_str(&saved)?;
- deployer::restore_hook(&config_path, event, &entry, a.hook_format())?;
+ let config_path = config_paths.first().ok_or_else(|| {
+ HkError::NotFound(format!("No hook config path for '{}'", ext.name))
+ })?;
+ deployer::restore_hook(config_path, event, &entry, a.hook_format())?;
store.set_disabled_config(&ext.id, None)?;
} else {
- let entry =
- deployer::read_hook_config(&config_path, event, matcher, command, a.hook_format())?
- .ok_or_else(|| {
- HkError::NotFound(format!("Hook '{}' not found in config", ext.name))
- })?;
+ let mut found = None;
+ let mut found_path = None;
+ for config_path in &config_paths {
+ if let Some(entry) = deployer::read_hook_config(
+ config_path,
+ event,
+ matcher,
+ command,
+ a.hook_format(),
+ )? {
+ found = Some(entry);
+ found_path = Some(config_path.clone());
+ break;
+ }
+ }
+ let entry = found.ok_or_else(|| {
+ HkError::NotFound(format!("Hook '{}' not found in config", ext.name))
+ })?;
+ let config_path = found_path.ok_or_else(|| {
+ HkError::NotFound(format!("Hook '{}' not found in config", ext.name))
+ })?;
store.set_disabled_config(&ext.id, Some(&entry.to_string()))?;
deployer::remove_hook(&config_path, event, matcher, command, a.hook_format())?;
}
@@ -368,7 +438,12 @@ fn disabled_plugin_name(path: &Path) -> String {
.unwrap_or_else(|| base.to_string())
}
-fn toggle_plugin(ext: &Extension, enabled: bool, store: &Store, adapters: &[Box]) -> Result<(), HkError> {
+fn toggle_plugin(
+ ext: &Extension,
+ enabled: bool,
+ store: &Store,
+ adapters: &[Box],
+) -> Result<(), HkError> {
for a in adapters {
if !ext.agents.contains(&a.name().to_string()) {
continue;
@@ -397,7 +472,8 @@ fn toggle_plugin(ext: &Extension, enabled: bool, store: &Store, adapters: &[Box<
// If so, toggle via state.vscdb. Otherwise fall through to manifest rename.
// Cache read_plugins result to avoid scanning twice for CLI plugins.
let plugins = a.read_plugins();
- let plugin_uri = plugins.iter()
+ let plugin_uri = plugins
+ .iter()
.find(|p| {
let id_name = format!("{}:{}", p.name, p.source);
scanner::stable_id_for(&id_name, "plugin", a.name()) == ext.id
@@ -474,7 +550,8 @@ fn toggle_plugin_manifest(
&& let Some(manifest) = plugin_toggle_target(path)
{
let disabled_manifest = disabled_plugin_target(&manifest);
- let saved = serde_json::json!({ "manifest_path": disabled_manifest.to_string_lossy() });
+ let saved =
+ serde_json::json!({ "manifest_path": disabled_manifest.to_string_lossy() });
store.set_disabled_config(&ext.id, Some(&saved.to_string()))?;
std::fs::rename(&manifest, &disabled_manifest)?;
found = true;
@@ -592,14 +669,13 @@ pub fn check_update_with_cache(
};
// Validate DB-sourced URL before passing to git
if let Err(e) = sanitize::validate_git_url(url) {
- return UpdateStatus::Error { message: e.to_string() };
+ return UpdateStatus::Error {
+ message: e.to_string(),
+ };
}
let remote_result = cache
.entry(url.to_string())
- .or_insert_with(|| {
- get_remote_head(url)
- .map_err(|e| e.to_string())
- });
+ .or_insert_with(|| get_remote_head(url).map_err(|e| e.to_string()));
match remote_result {
Ok(remote_hash) => {
let remote_hash = remote_hash.clone();
@@ -623,7 +699,9 @@ pub fn check_update_with_cache(
remote_hash: meta.revision.clone().unwrap_or_default(),
}
} else {
- UpdateStatus::Error { message: msg.clone() }
+ UpdateStatus::Error {
+ message: msg.clone(),
+ }
}
}
}
@@ -676,7 +754,14 @@ pub fn install_from_git_with_id(
let clone_dir = temp.path().join("repo");
let output = Command::new("git")
- .args(["clone", "--depth", "1", "--", url, &clone_dir.to_string_lossy()])
+ .args([
+ "clone",
+ "--depth",
+ "1",
+ "--",
+ url,
+ &clone_dir.to_string_lossy(),
+ ])
.output()
.map_err(|e| HkError::CommandFailed(format!("Failed to run git clone: {e}")))?;
@@ -1815,8 +1900,7 @@ mod tests {
});
let redacted = super::redact_mcp_env(&entry);
assert_eq!(
- redacted["env"]["PATH"],
- "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",
+ redacted["env"]["PATH"], "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",
"PATH must be preserved verbatim, not redacted"
);
assert_eq!(redacted["env"]["API_KEY"], "");
@@ -1838,8 +1922,7 @@ mod tests {
});
let redacted = super::redact_mcp_env(&entry);
assert_eq!(
- redacted["environment"]["PATH"],
- "/opt/homebrew/bin:/usr/local/bin",
+ redacted["environment"]["PATH"], "/opt/homebrew/bin:/usr/local/bin",
"PATH must round-trip through redaction even under OpenCode's 'environment' key"
);
assert_eq!(redacted["environment"]["GITHUB_TOKEN"], "");
@@ -1860,11 +1943,8 @@ mod tests {
// Create a symlink to an outside file
let outside = TempDir::new().unwrap();
std::fs::write(outside.path().join("secret"), "TOP SECRET").unwrap();
- std::os::unix::fs::symlink(
- outside.path().join("secret"),
- src.path().join("stolen"),
- )
- .unwrap();
+ std::os::unix::fs::symlink(outside.path().join("secret"), src.path().join("stolen"))
+ .unwrap();
let dst = TempDir::new().unwrap();
let dst_dir = dst.path().join("result");
@@ -1880,7 +1960,9 @@ mod tests {
// Issue #16: plugin toggle end-to-end scenarios
// -----------------------------------------------------------------------
- fn claude_env(dir: &std::path::Path) -> (Vec>, std::path::PathBuf) {
+ fn claude_env(
+ dir: &std::path::Path,
+ ) -> (Vec>, std::path::PathBuf) {
let claude_dir = dir.join(".claude");
std::fs::create_dir_all(claude_dir.join("plugins")).unwrap();
let settings = claude_dir.join("settings.json");
@@ -1930,7 +2012,8 @@ mod tests {
std::fs::write(
&settings,
r#"{"enabledPlugins":{"test-plugin@marketplace":true}}"#,
- ).unwrap();
+ )
+ .unwrap();
std::fs::write(
dir.path().join(".claude/plugins/installed_plugins.json"),
r#"{"plugins":{"test-plugin@marketplace":[{"installPath":"/tmp/p/1.0","installedAt":"2026-01-01T00:00:00Z"}]}}"#,
@@ -1939,7 +2022,10 @@ mod tests {
// Use scanner to get the real extension with correct stable ID
let scanned = scanner::scan_plugins(&*adapters[0]);
assert_eq!(scanned.len(), 1);
- assert!(scanned[0].enabled, "Plugin should be enabled (in enabledPlugins)");
+ assert!(
+ scanned[0].enabled,
+ "Plugin should be enabled (in enabledPlugins)"
+ );
store.sync_extensions(&scanned).unwrap();
let ext_id = scanned[0].id.clone();
@@ -1949,7 +2035,8 @@ mod tests {
assert!(!store.get_extension(&ext_id).unwrap().unwrap().enabled);
// Native toggle: no disabled_config needed, settings.json has false
- let s: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
+ let s: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
assert_eq!(s["enabledPlugins"]["test-plugin@marketplace"], false);
// Re-enable via HK — native toggle sets enabledPlugins to true
@@ -1957,7 +2044,8 @@ mod tests {
assert!(r.is_ok(), "re-enable failed: {:?}", r.err());
assert!(store.get_extension(&ext_id).unwrap().unwrap().enabled);
- let s: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
+ let s: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
assert_eq!(s["enabledPlugins"]["test-plugin@marketplace"], true);
}
@@ -1970,7 +2058,11 @@ mod tests {
let (adapters, settings) = claude_env(dir.path());
// Plugin in installed_plugins but enabledPlugins has it as false
- std::fs::write(&settings, r#"{"enabledPlugins":{"test-plugin@marketplace":false}}"#).unwrap();
+ std::fs::write(
+ &settings,
+ r#"{"enabledPlugins":{"test-plugin@marketplace":false}}"#,
+ )
+ .unwrap();
std::fs::write(
dir.path().join(".claude/plugins/installed_plugins.json"),
r#"{"plugins":{"test-plugin@marketplace":[{"installPath":"/tmp/p/1.0","installedAt":"2026-01-01T00:00:00Z"}]}}"#,
@@ -1986,7 +2078,8 @@ mod tests {
assert!(r.is_ok(), "enable should succeed: {:?}", r.err());
// Verify settings.json was updated
- let s: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
+ let s: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
assert_eq!(s["enabledPlugins"]["test-plugin@marketplace"], true);
}
@@ -1997,7 +2090,11 @@ mod tests {
let store = crate::store::Store::open(&dir.path().join("test.db")).unwrap();
let (adapters, settings) = claude_env(dir.path());
- std::fs::write(&settings, r#"{"enabledPlugins":{"test-plugin@marketplace":true}}"#).unwrap();
+ std::fs::write(
+ &settings,
+ r#"{"enabledPlugins":{"test-plugin@marketplace":true}}"#,
+ )
+ .unwrap();
std::fs::write(
dir.path().join(".claude/plugins/installed_plugins.json"),
r#"{"plugins":{"test-plugin@marketplace":[{"installPath":"/tmp/p/1.0","installedAt":"2026-01-01T00:00:00Z"}]}}"#,
@@ -2010,13 +2107,15 @@ mod tests {
// Disable
let r = toggle_extension_with_adapters(&store, &adapters, &ext_id, false);
assert!(r.is_ok(), "disable failed: {:?}", r.err());
- let s: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
+ let s: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
assert_eq!(s["enabledPlugins"]["test-plugin@marketplace"], false);
// Re-enable
let r = toggle_extension_with_adapters(&store, &adapters, &ext_id, true);
assert!(r.is_ok(), "re-enable failed: {:?}", r.err());
- let s: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
+ let s: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
assert_eq!(s["enabledPlugins"]["test-plugin@marketplace"], true);
}
@@ -2039,12 +2138,14 @@ mod tests {
let r = toggle_extension_with_adapters(&store, &adapters, &ext_id, false);
assert!(r.is_ok(), "disable no-source plugin failed: {:?}", r.err());
- let s: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
+ let s: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
assert_eq!(s["enabledPlugins"]["local-plugin"], false);
let r = toggle_extension_with_adapters(&store, &adapters, &ext_id, true);
assert!(r.is_ok(), "enable no-source plugin failed: {:?}", r.err());
- let s: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
+ let s: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
assert_eq!(s["enabledPlugins"]["local-plugin"], true);
}
@@ -2053,11 +2154,13 @@ mod tests {
let dir = TempDir::new().unwrap();
let store = crate::store::Store::open(&dir.path().join("test.db")).unwrap();
let codex_dir = dir.path().join(".codex");
- std::fs::create_dir_all(codex_dir.join("plugins/cache/mp/my-plugin/1.0.0/.codex-plugin")).unwrap();
+ std::fs::create_dir_all(codex_dir.join("plugins/cache/mp/my-plugin/1.0.0/.codex-plugin"))
+ .unwrap();
std::fs::write(
codex_dir.join("plugins/cache/mp/my-plugin/1.0.0/.codex-plugin/plugin.json"),
r#"{"name":"my-plugin"}"#,
- ).unwrap();
+ )
+ .unwrap();
std::fs::write(codex_dir.join("config.toml"), "").unwrap();
let codex_adapter = adapter::codex::CodexAdapter::with_home(dir.path().to_path_buf());
@@ -2073,8 +2176,12 @@ mod tests {
assert!(r.is_ok(), "codex disable failed: {:?}", r.err());
let config: toml::Table = std::fs::read_to_string(codex_dir.join("config.toml"))
- .unwrap().parse().unwrap();
- let plugin_enabled = config["plugins"]["my-plugin@mp"]["enabled"].as_bool().unwrap();
+ .unwrap()
+ .parse()
+ .unwrap();
+ let plugin_enabled = config["plugins"]["my-plugin@mp"]["enabled"]
+ .as_bool()
+ .unwrap();
assert!(!plugin_enabled, "config.toml should show enabled=false");
// Re-enable
@@ -2082,8 +2189,12 @@ mod tests {
assert!(r.is_ok(), "codex re-enable failed: {:?}", r.err());
let config: toml::Table = std::fs::read_to_string(codex_dir.join("config.toml"))
- .unwrap().parse().unwrap();
- let plugin_enabled = config["plugins"]["my-plugin@mp"]["enabled"].as_bool().unwrap();
+ .unwrap()
+ .parse()
+ .unwrap();
+ let plugin_enabled = config["plugins"]["my-plugin@mp"]["enabled"]
+ .as_bool()
+ .unwrap();
assert!(plugin_enabled, "config.toml should show enabled=true");
}
@@ -2129,7 +2240,11 @@ mod tests {
serde_yaml::from_str(&std::fs::read_to_string(hermes.join("config.yaml")).unwrap())
.unwrap();
let gh = &doc["mcp_servers"]["github"];
- assert_eq!(gh["enabled"].as_bool(), Some(false), "server disabled in place");
+ assert_eq!(
+ gh["enabled"].as_bool(),
+ Some(false),
+ "server disabled in place"
+ );
assert_eq!(
gh["env"]["TOKEN"].as_str(),
Some("secret123"),
@@ -2168,6 +2283,82 @@ mod tests {
);
}
+ #[test]
+ fn test_kiro_hook_native_disable_enable_in_place() {
+ let dir = TempDir::new().unwrap();
+ let home = dir.path();
+ let hooks_dir = home.join(".kiro/hooks");
+ std::fs::create_dir_all(&hooks_dir).unwrap();
+ std::fs::write(
+ hooks_dir.join("lint.json"),
+ r#"{
+ "version": "v1",
+ "hooks": [
+ {
+ "name": "lint-on-save",
+ "trigger": "PostFileSave",
+ "matcher": "\\.ts$",
+ "action": { "type": "command", "command": "npm run lint" }
+ },
+ {
+ "name": "test-on-stop",
+ "trigger": "Stop",
+ "action": { "type": "command", "command": "npm test" }
+ }
+ ]
+ }"#,
+ )
+ .unwrap();
+
+ let store = crate::store::Store::open(&dir.path().join("test.db")).unwrap();
+ let adapters: Vec> = vec![Box::new(
+ adapter::kiro::KiroAdapter::with_home(home.to_path_buf()),
+ )];
+
+ let scanned = scanner::scan_hooks(&*adapters[0]);
+ store.sync_extensions(&scanned).unwrap();
+ let ext = store
+ .list_extensions(Some(ExtensionKind::Hook), None)
+ .unwrap()
+ .into_iter()
+ .find(|e| e.name == "PostFileSave:\\.ts$:npm run lint")
+ .expect("kiro hook scanned");
+ assert!(ext.enabled);
+
+ // DISABLE — native in-place flip: entry stays, sibling untouched.
+ toggle_extension_with_adapters(&store, &adapters, &ext.id, false).unwrap();
+ let doc: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(hooks_dir.join("lint.json")).unwrap())
+ .unwrap();
+ let hooks = doc["hooks"].as_array().unwrap();
+ assert_eq!(hooks.len(), 2, "no entry removed");
+ assert_eq!(hooks[0]["enabled"], false, "target hook disabled in place");
+ assert!(hooks[1].get("enabled").is_none(), "sibling hook untouched");
+ assert!(!store.get_extension(&ext.id).unwrap().unwrap().enabled);
+ assert!(
+ store.get_disabled_config(&ext.id).unwrap().is_none(),
+ "native disable must take no DB snapshot"
+ );
+
+ // RESCAN — scanner reads enabled:false back; disabled state survives.
+ let rescanned = scanner::scan_hooks(&*adapters[0]);
+ let row = rescanned
+ .iter()
+ .find(|e| e.id == ext.id)
+ .expect("hook still scanned while disabled");
+ assert!(!row.enabled, "scanner reflects on-disk enabled:false");
+ store.sync_extensions(&rescanned).unwrap();
+ assert!(!store.get_extension(&ext.id).unwrap().unwrap().enabled);
+
+ // ENABLE — must NOT hit the generic get_disabled_config(NotFound) path.
+ toggle_extension_with_adapters(&store, &adapters, &ext.id, true).unwrap();
+ let doc2: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(hooks_dir.join("lint.json")).unwrap())
+ .unwrap();
+ assert_eq!(doc2["hooks"][0]["enabled"], true);
+ assert!(store.get_extension(&ext.id).unwrap().unwrap().enabled);
+ }
+
// -----------------------------------------------------------------------
// Task 3: manifest candidate list + silent failure
// -----------------------------------------------------------------------
@@ -2178,7 +2369,9 @@ mod tests {
let store = crate::store::Store::open(&dir.path().join("test.db")).unwrap();
// Set up a Copilot VS Code agent plugin with .github/plugin/plugin.json
- let plugin_dir = dir.path().join(".vscode/agent-plugins/github.com/org/repo/plugins/my-plugin");
+ let plugin_dir = dir
+ .path()
+ .join(".vscode/agent-plugins/github.com/org/repo/plugins/my-plugin");
let manifest_dir = plugin_dir.join(".github/plugin");
std::fs::create_dir_all(&manifest_dir).unwrap();
std::fs::write(manifest_dir.join("plugin.json"), r#"{"name":"my-plugin"}"#).unwrap();
@@ -2193,8 +2386,10 @@ mod tests {
"marketplace": "github.com",
"pluginUri": &plugin_uri
}]
- }).to_string(),
- ).unwrap();
+ })
+ .to_string(),
+ )
+ .unwrap();
// Create VS Code state.vscdb with the enablement table
#[cfg(target_os = "macos")]
@@ -2208,10 +2403,18 @@ mod tests {
let state_db = state_db_dir.join("state.vscdb");
{
let conn = rusqlite::Connection::open(&state_db).unwrap();
- conn.execute("CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE, value TEXT)", []).unwrap();
+ conn.execute(
+ "CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE, value TEXT)",
+ [],
+ )
+ .unwrap();
// Plugin starts enabled
let val = serde_json::json!([[&plugin_uri, true]]).to_string();
- conn.execute("INSERT INTO ItemTable (key, value) VALUES ('agentPlugins.enablement', ?1)", [&val]).unwrap();
+ conn.execute(
+ "INSERT INTO ItemTable (key, value) VALUES ('agentPlugins.enablement', ?1)",
+ [&val],
+ )
+ .unwrap();
}
let adapter = adapter::copilot::CopilotAdapter::with_home(dir.path().to_path_buf());
@@ -2235,9 +2438,13 @@ mod tests {
// state.vscdb should show disabled
{
let conn = rusqlite::Connection::open(&state_db).unwrap();
- let val: String = conn.query_row(
- "SELECT value FROM ItemTable WHERE key = 'agentPlugins.enablement'", [], |r| r.get(0)
- ).unwrap();
+ let val: String = conn
+ .query_row(
+ "SELECT value FROM ItemTable WHERE key = 'agentPlugins.enablement'",
+ [],
+ |r| r.get(0),
+ )
+ .unwrap();
let entries: Vec<(String, bool)> = serde_json::from_str(&val).unwrap();
assert!(!entries[0].1, "state.vscdb should show plugin disabled");
}
@@ -2247,9 +2454,13 @@ mod tests {
assert!(r.is_ok(), "re-enable failed: {:?}", r.err());
{
let conn = rusqlite::Connection::open(&state_db).unwrap();
- let val: String = conn.query_row(
- "SELECT value FROM ItemTable WHERE key = 'agentPlugins.enablement'", [], |r| r.get(0)
- ).unwrap();
+ let val: String = conn
+ .query_row(
+ "SELECT value FROM ItemTable WHERE key = 'agentPlugins.enablement'",
+ [],
+ |r| r.get(0),
+ )
+ .unwrap();
let entries: Vec<(String, bool)> = serde_json::from_str(&val).unwrap();
assert!(entries[0].1, "state.vscdb should show plugin enabled");
}
@@ -2260,14 +2471,25 @@ mod tests {
let dir = TempDir::new().unwrap();
// Set up VS Code plugin
- let plugin_dir = dir.path().join(".vscode/agent-plugins/github.com/org/repo/plugins/my-plugin");
+ let plugin_dir = dir
+ .path()
+ .join(".vscode/agent-plugins/github.com/org/repo/plugins/my-plugin");
std::fs::create_dir_all(plugin_dir.join(".github/plugin")).unwrap();
- std::fs::write(plugin_dir.join(".github/plugin/plugin.json"), r#"{"name":"my-plugin"}"#).unwrap();
+ std::fs::write(
+ plugin_dir.join(".github/plugin/plugin.json"),
+ r#"{"name":"my-plugin"}"#,
+ )
+ .unwrap();
let plugin_uri = format!("file://{}", plugin_dir.to_string_lossy());
let vscode_dir = dir.path().join(".vscode/agent-plugins");
- std::fs::write(vscode_dir.join("installed.json"), serde_json::json!({
- "installed": [{"marketplace": "github.com", "pluginUri": &plugin_uri}]
- }).to_string()).unwrap();
+ std::fs::write(
+ vscode_dir.join("installed.json"),
+ serde_json::json!({
+ "installed": [{"marketplace": "github.com", "pluginUri": &plugin_uri}]
+ })
+ .to_string(),
+ )
+ .unwrap();
// state.vscdb with plugin DISABLED
#[cfg(target_os = "macos")]
@@ -2278,16 +2500,28 @@ mod tests {
let vscode_user = dir.path().join(".config/Code/User");
std::fs::create_dir_all(vscode_user.join("globalStorage")).unwrap();
{
- let conn = rusqlite::Connection::open(vscode_user.join("globalStorage/state.vscdb")).unwrap();
- conn.execute("CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE, value TEXT)", []).unwrap();
+ let conn =
+ rusqlite::Connection::open(vscode_user.join("globalStorage/state.vscdb")).unwrap();
+ conn.execute(
+ "CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE, value TEXT)",
+ [],
+ )
+ .unwrap();
let val = serde_json::json!([[&plugin_uri, false]]).to_string();
- conn.execute("INSERT INTO ItemTable (key, value) VALUES ('agentPlugins.enablement', ?1)", [&val]).unwrap();
+ conn.execute(
+ "INSERT INTO ItemTable (key, value) VALUES ('agentPlugins.enablement', ?1)",
+ [&val],
+ )
+ .unwrap();
}
let adapter = adapter::copilot::CopilotAdapter::with_home(dir.path().to_path_buf());
let scanned = scanner::scan_plugins(&adapter);
assert_eq!(scanned.len(), 1);
- assert!(!scanned[0].enabled, "Scanner should detect VS Code disabled state from state.vscdb");
+ assert!(
+ !scanned[0].enabled,
+ "Scanner should detect VS Code disabled state from state.vscdb"
+ );
}
#[test]
@@ -2296,7 +2530,9 @@ mod tests {
let store = crate::store::Store::open(&dir.path().join("test.db")).unwrap();
// Plugin directory exists but has NO manifest file at all
- let plugin_dir = dir.path().join(".cursor/plugins/cache/mp/ghost-plugin/1.0.0");
+ let plugin_dir = dir
+ .path()
+ .join(".cursor/plugins/cache/mp/ghost-plugin/1.0.0");
std::fs::create_dir_all(&plugin_dir).unwrap();
// No plugin.json, no .cursor-plugin/plugin.json — nothing
@@ -2322,7 +2558,8 @@ mod tests {
let plugin_path = plugins_dir.join("lint.ts");
std::fs::write(&plugin_path, "export default {};").unwrap();
- let adapter = crate::adapter::opencode::OpencodeAdapter::with_home(dir.path().to_path_buf());
+ let adapter =
+ crate::adapter::opencode::OpencodeAdapter::with_home(dir.path().to_path_buf());
let adapters: Vec> = vec![Box::new(adapter)];
let scanned = scanner::scan_plugins(&*adapters[0]);
diff --git a/crates/hk-core/src/scanner.rs b/crates/hk-core/src/scanner.rs
index 6e24259..2507bf6 100644
--- a/crates/hk-core/src/scanner.rs
+++ b/crates/hk-core/src/scanner.rs
@@ -390,49 +390,55 @@ pub fn scan_mcp_servers(adapter: &dyn AgentAdapter) -> Vec {
/// Scan hooks from an agent adapter
pub fn scan_hooks(adapter: &dyn AgentAdapter) -> Vec {
- let config_path = adapter.hook_config_path();
- let config_created = file_created_time(&config_path);
- let config_modified = file_modified_time(&config_path);
-
+ let scope = ConfigScope::Global;
adapter
- .read_hooks()
+ .hook_config_paths_for(&scope)
.into_iter()
- .map(|hook| {
- let hook_name = format!(
- "{}:{}:{}",
- hook.event,
- hook.matcher.as_deref().unwrap_or("*"),
- hook.command
- );
- let description = format!("Runs `{}` on {} event", hook.command, hook.event);
-
- Extension {
- id: stable_id(&hook_name, "hook", adapter.name()),
- kind: ExtensionKind::Hook,
- name: hook_name,
- description,
- source: Source {
- origin: SourceOrigin::Agent,
- url: None,
- version: None,
- commit_hash: None,
- from_manifest: false,
- },
- agents: vec![adapter.name().to_string()],
- tags: vec![],
- pack: None,
- permissions: infer_hook_permissions(&hook.command),
- enabled: true,
- trust_score: None,
- installed_at: config_created,
- updated_at: config_modified,
-
- source_path: None,
- cli_parent_id: None,
- cli_meta: None,
- install_meta: None,
- scope: ConfigScope::Global,
- }
+ .flat_map(|config_path| {
+ let config_created = file_created_time(&config_path);
+ let config_modified = file_modified_time(&config_path);
+ let config_path_str = config_path.to_string_lossy().to_string();
+ let agent_name = adapter.name().to_string();
+ adapter
+ .read_hooks_from(&config_path)
+ .into_iter()
+ .map(move |hook| {
+ let hook_name = format!(
+ "{}:{}:{}",
+ hook.event,
+ hook.matcher.as_deref().unwrap_or("*"),
+ hook.command
+ );
+ let description = format!("Runs `{}` on {} event", hook.command, hook.event);
+
+ Extension {
+ id: stable_id(&hook_name, "hook", &agent_name),
+ kind: ExtensionKind::Hook,
+ name: hook_name,
+ description,
+ source: Source {
+ origin: SourceOrigin::Agent,
+ url: None,
+ version: None,
+ commit_hash: None,
+ from_manifest: false,
+ },
+ agents: vec![agent_name.clone()],
+ tags: vec![],
+ pack: None,
+ permissions: infer_hook_permissions(&hook.command),
+ enabled: hook.enabled,
+ trust_score: None,
+ installed_at: config_created,
+ updated_at: config_modified,
+
+ source_path: Some(config_path_str.clone()),
+ cli_parent_id: None,
+ cli_meta: None,
+ install_meta: None,
+ scope: ConfigScope::Global,
+ }
+ })
})
.collect()
}
@@ -1048,8 +1054,7 @@ pub fn scan_project_extensions(
}
// --- Project-scoped hooks ---
- if let Some(rel) = adapter.project_hook_config_relpath() {
- let hook_path = project_path.join(&rel);
+ for hook_path in adapter.hook_config_paths_for(&scope) {
if hook_path.is_file() {
let hook_path_str = hook_path.to_string_lossy().to_string();
let config_created = file_created_time(&hook_path);
@@ -1079,7 +1084,7 @@ pub fn scan_project_extensions(
tags: vec![],
pack: None,
permissions: infer_hook_permissions(&hook.command),
- enabled: true,
+ enabled: hook.enabled,
trust_score: None,
installed_at: config_created,
updated_at: config_modified,
@@ -1995,7 +2000,11 @@ pub fn scan_agent_configs(
for (owner_cwd, files) in adapter.external_project_memory() {
let scope = owner_cwd
.as_ref()
- .and_then(|cwd| projects.iter().find(|(_, path)| Path::new(path) == cwd.as_path()))
+ .and_then(|cwd| {
+ projects
+ .iter()
+ .find(|(_, path)| Path::new(path) == cwd.as_path())
+ })
.map(|(name, path)| ConfigScope::Project {
name: name.clone(),
path: path.clone(),
@@ -2272,7 +2281,10 @@ mod tests {
let exts = scan_plugins(&adapter);
let p = exts.iter().find(|e| e.name == "code-review").unwrap();
assert_eq!(p.source.origin, SourceOrigin::Git);
- assert_eq!(p.pack.as_deref(), Some("anthropics/claude-plugins-official"));
+ assert_eq!(
+ p.pack.as_deref(),
+ Some("anthropics/claude-plugins-official")
+ );
}
#[test]
diff --git a/crates/hk-core/src/service.rs b/crates/hk-core/src/service.rs
index aadc70e..954aaf6 100644
--- a/crates/hk-core/src/service.rs
+++ b/crates/hk-core/src/service.rs
@@ -2,11 +2,9 @@ use crate::{
HkError,
adapter::AgentAdapter,
auditor::{AuditInput, Auditor},
- deployer,
- manager,
+ deployer, manager,
models::*,
- scanner,
- skills_cli,
+ scanner, skills_cli,
store::Store,
};
use parking_lot::Mutex;
@@ -160,12 +158,8 @@ pub fn post_install_sync(
extensions.extend(exts);
}
}
- let sibling_id = scanner::stable_id_with_scope_for(
- skill_name,
- "skill",
- &sibling_name,
- target_scope,
- );
+ let sibling_id =
+ scanner::stable_id_with_scope_for(skill_name, "skill", &sibling_name, target_scope);
let _ = store.set_install_meta(&sibling_id, meta);
if let Some(p) = pack {
let _ = store.update_pack(&sibling_id, Some(p));
@@ -212,10 +206,7 @@ pub fn is_externally_managed(ext: &Extension) -> bool {
/// rescan reflect the change — and `Ok(false)` when the extension isn't externally
/// managed OR the CLI is unavailable, in which case the caller does its own
/// update. Errors from a CLI that ran but failed propagate.
-pub fn try_delegate_skill_update(
- store: &Mutex,
- ext: &Extension,
-) -> Result {
+pub fn try_delegate_skill_update(store: &Mutex, ext: &Extension) -> Result {
if !is_externally_managed(ext) {
return Ok(false);
}
@@ -227,7 +218,12 @@ pub fn try_delegate_skill_update(
// rescan would leave install_revision NULL and the row stuck on "update
// available". Record the upstream HEAD now (best-effort — the update already
// succeeded) so the check sees "up to date", mirroring the native path.
- if let Some(url) = ext.install_meta.as_ref().and_then(|m| m.url.clone()).or_else(|| ext.source.url.clone()) {
+ if let Some(url) = ext
+ .install_meta
+ .as_ref()
+ .and_then(|m| m.url.clone())
+ .or_else(|| ext.source.url.clone())
+ {
match manager::get_remote_head(&url) {
Ok(rev) => record_skill_revision(store, ext, &rev)?,
Err(e) => eprintln!("[hk] delegated update: could not record revision: {e}"),
@@ -240,7 +236,11 @@ pub fn try_delegate_skill_update(
/// skill row, so the git-based update check sees the skill as up to date after an
/// external (`skills` CLI) update. Mirrors how the native clone+deploy path
/// records the captured revision across sibling copies.
-fn record_skill_revision(store: &Mutex, ext: &Extension, revision: &str) -> Result<(), HkError> {
+fn record_skill_revision(
+ store: &Mutex,
+ ext: &Extension,
+ revision: &str,
+) -> Result<(), HkError> {
let store = store.lock();
let base = ext.install_meta.clone().unwrap_or_else(|| InstallMeta {
install_type: "git".into(),
@@ -263,7 +263,9 @@ fn record_skill_revision(store: &Mutex, ext: &Extension, revision: &str)
let siblings: Vec = store
.list_extensions(Some(ext.kind), None)?
.into_iter()
- .filter(|e| e.name == ext.name && e.source_path.is_some() && same_scope(&e.scope, &ext.scope))
+ .filter(|e| {
+ e.name == ext.name && e.source_path.is_some() && same_scope(&e.scope, &ext.scope)
+ })
.collect();
for sib in &siblings {
if let Err(e) = store.set_install_meta(&sib.id, &updated) {
@@ -385,9 +387,7 @@ pub fn bind_pack(store: &Store, ids: &[String], pack: Option<&str>) -> Result<()
// Normalize first so URLs/SSH paths get reduced to owner/repo before we
// touch the DB. Frontend also normalizes; this is defense-in-depth for
// any non-UI caller (CLI, future API client).
- let trimmed: Option = pack
- .map(normalize_pack)
- .filter(|s| !s.is_empty());
+ let trimmed: Option = pack.map(normalize_pack).filter(|s| !s.is_empty());
// Phase 1: always persist the pack column update so the user's typing
// is preserved even when we can't synthesize meta (invalid format).
@@ -901,30 +901,34 @@ pub fn delete_extension(
if !ext.agents.contains(&adapter.name().to_string()) {
continue;
}
- let Some(config_path) = adapter.hook_config_path_for(&ext.scope) else {
- continue;
- };
- for hook in adapter.read_hooks_from(&config_path) {
- let hook_name = format!(
- "{}:{}:{}",
- hook.event,
- hook.matcher.as_deref().unwrap_or("*"),
- hook.command
- );
- let candidate = scanner::stable_id_with_scope_for(
- &hook_name,
- "hook",
- adapter.name(),
- &ext.scope,
- );
- if candidate == id {
- deployer::remove_hook(
- &config_path,
- &hook.event,
- hook.matcher.as_deref(),
- &hook.command,
- adapter.hook_format(),
- )?;
+ let config_paths: Vec = ext
+ .source_path
+ .as_ref()
+ .map(|p| vec![std::path::PathBuf::from(p)])
+ .unwrap_or_else(|| adapter.hook_config_paths_for(&ext.scope));
+ for config_path in config_paths {
+ for hook in adapter.read_hooks_from(&config_path) {
+ let hook_name = format!(
+ "{}:{}:{}",
+ hook.event,
+ hook.matcher.as_deref().unwrap_or("*"),
+ hook.command
+ );
+ let candidate = scanner::stable_id_with_scope_for(
+ &hook_name,
+ "hook",
+ adapter.name(),
+ &ext.scope,
+ );
+ if candidate == id {
+ deployer::remove_hook(
+ &config_path,
+ &hook.event,
+ hook.matcher.as_deref(),
+ &hook.command,
+ adapter.hook_format(),
+ )?;
+ }
}
}
}
@@ -1275,9 +1279,7 @@ pub fn install_to_agent(
// `skill_dir_for_category` returns None for flat-layout agents, so
// non-Hermes targets fall through to their default skill dir.
let target_dir = hermes_category
- .and_then(|cat| {
- target_adapter.skill_dir_for_category(&ConfigScope::Global, cat)
- })
+ .and_then(|cat| target_adapter.skill_dir_for_category(&ConfigScope::Global, cat))
.or_else(|| target_adapter.skill_dirs().into_iter().next())
.ok_or_else(|| {
HkError::Internal(format!("No skill directory for agent '{}'", target_agent))
@@ -1346,30 +1348,34 @@ pub fn install_to_agent(
if !ext.agents.contains(&adapter.name().to_string()) {
continue;
}
- let Some(source_path) = adapter.hook_config_path_for(&ext.scope) else {
- continue;
- };
- for hook in adapter.read_hooks_from(&source_path) {
- let hook_name = format!(
- "{}:{}:{}",
- hook.event,
- hook.matcher.as_deref().unwrap_or("*"),
- hook.command
- );
- let candidate = scanner::stable_id_with_scope_for(
- &hook_name,
- "hook",
- adapter.name(),
- &ext.scope,
- );
- if candidate == extension_id {
- source_entry = Some(hook);
+ let config_paths: Vec = ext
+ .source_path
+ .as_ref()
+ .map(|p| vec![std::path::PathBuf::from(p)])
+ .unwrap_or_else(|| adapter.hook_config_paths_for(&ext.scope));
+ for source_path in config_paths {
+ for hook in adapter.read_hooks_from(&source_path) {
+ let hook_name = format!(
+ "{}:{}:{}",
+ hook.event,
+ hook.matcher.as_deref().unwrap_or("*"),
+ hook.command
+ );
+ let candidate = scanner::stable_id_with_scope_for(
+ &hook_name,
+ "hook",
+ adapter.name(),
+ &ext.scope,
+ );
+ if candidate == extension_id {
+ source_entry = Some(hook);
+ break;
+ }
+ }
+ if source_entry.is_some() {
break;
}
}
- if source_entry.is_some() {
- break;
- }
}
let mut entry = source_entry
.ok_or_else(|| HkError::Internal("Could not find source hook config".into()))?;
@@ -1387,6 +1393,16 @@ pub fn install_to_agent(
})?;
entry.event = translated_event;
+ // v1 install_to_agent always targets the GLOBAL hook config; bail
+ // out for agents that don't load user-level hooks (e.g. Kiro,
+ // kirodotdev/Kiro#5440) instead of writing a config that would
+ // silently never fire.
+ if !target_adapter.supports_global_hook_install() {
+ return Err(HkError::Validation(format!(
+ "{target_agent} does not load user-level (global) hooks yet; \
+ hooks for this agent can only be installed per-project"
+ )));
+ }
let config_path = target_adapter.hook_config_path();
deployer::deploy_hook(&config_path, &entry, target_adapter.hook_format())?;
@@ -1651,8 +1667,7 @@ mod tests {
let antigravity_parent = tmp.path().join("gemini/antigravity");
std::fs::create_dir_all(&antigravity_parent).unwrap();
let antigravity_skills = antigravity_parent.join("skills");
- std::os::unix::fs::symlink(tmp.path().join("claude/skills"), &antigravity_skills)
- .unwrap();
+ std::os::unix::fs::symlink(tmp.path().join("claude/skills"), &antigravity_skills).unwrap();
let antigravity_md = antigravity_skills.join("code-review/SKILL.md");
// Symlinked path should resolve to the same inode as claude_md.
assert_eq!(
@@ -2188,7 +2203,9 @@ mod tests {
let adapters: Vec> = vec![
Box::new(adapter::codex::CodexAdapter::with_home(home.to_path_buf())),
- Box::new(adapter::gemini::GeminiAdapter::with_home(home.to_path_buf())),
+ Box::new(adapter::gemini::GeminiAdapter::with_home(
+ home.to_path_buf(),
+ )),
];
let install_meta = InstallMeta {
@@ -2308,7 +2325,10 @@ mod tests {
// Validator will reject these — normalize just passes them through
// (trimmed) so the validator's error path stays the source of truth.
assert_eq!(normalize_pack("not-a-pack"), "not-a-pack");
- assert_eq!(normalize_pack("https://example.com/foo/bar"), "https://example.com/foo/bar");
+ assert_eq!(
+ normalize_pack("https://example.com/foo/bar"),
+ "https://example.com/foo/bar"
+ );
}
#[test]
@@ -2393,10 +2413,7 @@ mod tests {
.install_meta
.unwrap();
assert_eq!(meta.install_type, INSTALL_TYPE_MANUAL);
- assert_eq!(
- meta.url.as_deref(),
- Some("https://github.com/new/repo.git")
- );
+ assert_eq!(meta.url.as_deref(), Some("https://github.com/new/repo.git"));
}
#[test]
@@ -2593,10 +2610,9 @@ mod tests {
let store_raw = Store::open(&home.join("test.db")).unwrap();
let store = Mutex::new(store_raw);
- let adapters: Vec> =
- vec![Box::new(adapter::hermes::HermesAdapter::with_home(
- home.to_path_buf(),
- ))];
+ let adapters: Vec> = vec![Box::new(
+ adapter::hermes::HermesAdapter::with_home(home.to_path_buf()),
+ )];
// Scan + sync so the plugin extension lands in the store with an id.
let exts = scanner::scan_all(&adapters, &[]);
diff --git a/crates/hk-core/tests/toggle_integration.rs b/crates/hk-core/tests/toggle_integration.rs
index a084ece..e2ba275 100644
--- a/crates/hk-core/tests/toggle_integration.rs
+++ b/crates/hk-core/tests/toggle_integration.rs
@@ -313,7 +313,9 @@ fn test_sync_preserves_enabled_after_toggle() {
store.insert_extension(&ext).unwrap();
store.set_enabled("plugin-1", false).unwrap();
// Must set disabled_config so UPSERT knows HK manages this extension
- store.set_disabled_config("plugin-1", Some(r#"{"key":"val"}"#)).unwrap();
+ store
+ .set_disabled_config("plugin-1", Some(r#"{"key":"val"}"#))
+ .unwrap();
let scanned = sample_plugin("plugin-1", "claude"); // enabled: true from scanner
store.sync_extensions(&[scanned]).unwrap();
@@ -355,7 +357,9 @@ fn test_rescan_preserves_hk_managed_disable() {
let ext = sample_plugin("plugin-1", "claude");
store.sync_extensions(&[ext]).unwrap();
store.set_enabled("plugin-1", false).unwrap();
- store.set_disabled_config("plugin-1", Some(r#"{"plugin_key":"k","value":true}"#)).unwrap();
+ store
+ .set_disabled_config("plugin-1", Some(r#"{"plugin_key":"k","value":true}"#))
+ .unwrap();
// Scanner says enabled (stale) but HK has disabled_config
let ext_enabled = sample_plugin("plugin-1", "claude");
diff --git a/src/components/extensions/extension-detail.tsx b/src/components/extensions/extension-detail.tsx
index 2d67f5a..ef785e5 100644
--- a/src/components/extensions/extension-detail.tsx
+++ b/src/components/extensions/extension-detail.tsx
@@ -515,6 +515,15 @@ export function ExtensionDetail() {
const hookUnsupported =
group.kind === "hook" &&
AGENTS_WITHOUT_HOOKS.has(agent.name);
+ // Kiro supports hooks, but only loads workspace ones —
+ // user-level ~/.kiro/hooks/ is documented yet unimplemented
+ // (kirodotdev/Kiro#5440), and this install path is
+ // global-only. Mirrors supports_global_hook_install() in
+ // crates/hk-core/src/adapter/kiro.rs.
+ const kiroGlobalHooksPending =
+ group.kind === "hook" && agent.name === "kiro";
+ const hookBlocked =
+ hookUnsupported || kiroGlobalHooksPending;
const isHermes =
agent.name === "hermes" && group.kind === "skill";
return (
@@ -522,18 +531,20 @@ export function ExtensionDetail() {
key={agent.name}
disabled={
deploying === agent.name ||
- hookUnsupported ||
+ hookBlocked ||
projectScopeBlocked
}
title={
projectScopeBlocked
? t("detail.crossAgentSoon")
- : hookUnsupported
- ? t("detail.hooksNotSupported")
- : undefined
+ : kiroGlobalHooksPending
+ ? t("detail.kiroGlobalHooksPending")
+ : hookUnsupported
+ ? t("detail.hooksNotSupported")
+ : undefined
}
onClick={async () => {
- if (hookUnsupported || projectScopeBlocked) return;
+ if (hookBlocked || projectScopeBlocked) return;
if (isHermes) {
// Show category picker before deploying
const cats = await api
@@ -580,7 +591,7 @@ export function ExtensionDetail() {
}
}}
className={
- hookUnsupported || projectScopeBlocked
+ hookBlocked || projectScopeBlocked
? "flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-muted-foreground/50 cursor-not-allowed"
: "flex items-center gap-1.5 rounded-lg border border-border bg-primary/10 px-3 py-1.5 text-xs font-medium text-foreground hover:bg-primary/20 hover:border-ring disabled:opacity-50"
}
@@ -591,7 +602,7 @@ export function ExtensionDetail() {
)}
{agentDisplayName(agent.name)}
- {hookUnsupported && (
+ {hookBlocked && (
(N/A)
diff --git a/src/components/extensions/extension-filters.tsx b/src/components/extensions/extension-filters.tsx
index 7d62dd9..e2cff8a 100644
--- a/src/components/extensions/extension-filters.tsx
+++ b/src/components/extensions/extension-filters.tsx
@@ -43,6 +43,7 @@ const AGENT_FILTER_COLORS: Record = {
windsurf: "bg-agent-windsurf/10 text-agent-windsurf border-agent-windsurf/30",
opencode: "bg-agent-opencode/10 text-agent-opencode border-agent-opencode/30",
hermes: "bg-agent-hermes/10 text-agent-hermes border-agent-hermes/30",
+ kiro: "bg-agent-kiro/10 text-agent-kiro border-agent-kiro/30",
};
export function ExtensionFilters() {
diff --git a/src/components/onboarding/onboarding.tsx b/src/components/onboarding/onboarding.tsx
index 1d165e0..c4121fd 100644
--- a/src/components/onboarding/onboarding.tsx
+++ b/src/components/onboarding/onboarding.tsx
@@ -264,6 +264,7 @@ const FLOAT_DELAYS: Record<(typeof AGENT_ORDER)[number], number> = {
windsurf: 1.6,
opencode: 0.8,
hermes: 1.9,
+ kiro: 1.4,
};
const SCATTER_POSITIONS: Record<
(typeof AGENT_ORDER)[number],
@@ -278,6 +279,7 @@ const SCATTER_POSITIONS: Record<
windsurf: { x: 0, y: 108, r: -6 },
opencode: { x: 210, y: -10, r: 8 },
hermes: { x: -60, y: -115, r: 18 },
+ kiro: { x: 40, y: -126, r: -10 },
};
function HandAnnotation({
diff --git a/src/components/shared/agent-card.tsx b/src/components/shared/agent-card.tsx
index fe95ea2..66a14be 100644
--- a/src/components/shared/agent-card.tsx
+++ b/src/components/shared/agent-card.tsx
@@ -11,6 +11,7 @@ const CLICK_DURATIONS: Partial> = {
windsurf: 1800,
opencode: 4000,
antigravity: 800,
+ kiro: 1100,
};
export function AgentCard({ agent }: AgentCardProps) {
diff --git a/src/components/shared/agent-mascot/agent-mascot.tsx b/src/components/shared/agent-mascot/agent-mascot.tsx
index b208a2f..231de42 100644
--- a/src/components/shared/agent-mascot/agent-mascot.tsx
+++ b/src/components/shared/agent-mascot/agent-mascot.tsx
@@ -7,6 +7,7 @@ import { CursorMascot } from "./cursor-mascot";
import { FallbackMascot } from "./fallback-mascot";
import { GeminiMascot } from "./gemini-mascot";
import { HermesMascot } from "./hermes-mascot";
+import { KiroMascot } from "./kiro-mascot";
import { OpencodeMascot } from "./opencode-mascot";
import { WindsurfMascot } from "./windsurf-mascot";
@@ -63,6 +64,12 @@ const MASCOT_MAP: Record<
className: "mascot-hermes",
scale: 1,
},
+ kiro: {
+ component: KiroMascot,
+ className: "mascot-kiro",
+ scale: 1.12,
+ offsetY: 1,
+ },
};
export function AgentMascot({
diff --git a/src/components/shared/agent-mascot/kiro-mascot.tsx b/src/components/shared/agent-mascot/kiro-mascot.tsx
new file mode 100644
index 0000000..4e5eca3
--- /dev/null
+++ b/src/components/shared/agent-mascot/kiro-mascot.tsx
@@ -0,0 +1,36 @@
+interface MascotSvgProps {
+ size: number;
+}
+
+const KIRO_BODY_D =
+ "M7.97 16.376c-1.644 3.642 1.86 4.556 4.443 2.424.76 2.39 3.608.607 4.631-1.247 2.251-4.084 1.342-8.249 1.108-9.108-1.6-5.859-9.6-5.869-10.976.03-.323 1.033-.328 2.206-.507 3.423-.09.617-.16 1.009-.393 1.655-.139.373-.323.7-.62 1.257-.458.865-.264 2.53 2.101 1.665l.224-.1h-.01l-.001.001z";
+
+export function KiroMascot({ size }: MascotSvgProps) {
+ return (
+
+ );
+}
diff --git a/src/components/shared/agent-mascot/mascot.css b/src/components/shared/agent-mascot/mascot.css
index 3717a3d..e917626 100644
--- a/src/components/shared/agent-mascot/mascot.css
+++ b/src/components/shared/agent-mascot/mascot.css
@@ -5,6 +5,9 @@
:root {
--mascot-icon-color: #000000;
--mascot-bg-color: #ffffff;
+ --kiro-ghost-fill: #9046ff;
+ --kiro-ghost-stroke: #9046ff;
+ --kiro-eye-fill: #ffffff;
/* Hermes glint: white specular on the dark (light-theme) silhouette. */
--hermes-gleam: #ffffff;
}
@@ -12,6 +15,9 @@
.dark {
--mascot-icon-color: #ffffff;
--mascot-bg-color: #000000;
+ --kiro-ghost-fill: #9046ff;
+ --kiro-ghost-stroke: #b58cff;
+ --kiro-eye-fill: #ffffff;
/* On the white (dark-theme) silhouette a white glint would vanish, so the
light reads as a warm gold gleam instead — kept pale so it stays a soft
glint rather than a heavy gold band. */
@@ -1356,6 +1362,172 @@
}
}
+/* === Kiro === */
+.mascot-kiro .kiro-ghost {
+ transform-origin: 12px 13px;
+ filter: drop-shadow(0 1px 2px rgba(144, 70, 255, 0.28));
+}
+.mascot-kiro .kiro-echoes,
+.mascot-kiro .kiro-echo {
+ transform-box: fill-box;
+ transform-origin: center;
+}
+.mascot-kiro .kiro-echo {
+ fill: var(--kiro-ghost-fill);
+ opacity: 0;
+ filter: blur(0);
+}
+.mascot-kiro .kiro-body {
+ fill: var(--kiro-ghost-fill);
+ stroke: var(--kiro-ghost-stroke);
+ stroke-width: 0.7;
+ stroke-linejoin: round;
+ paint-order: stroke fill;
+}
+.mascot-kiro .kiro-eye {
+ fill: var(--kiro-eye-fill);
+ transform-box: fill-box;
+ transform-origin: center;
+}
+
+.mascot-kiro.is-animated .kiro-ghost {
+ animation: kiro-float 2.4s ease-in-out infinite;
+}
+.mascot-kiro.is-animated .kiro-eye {
+ animation: kiro-blink 2.8s ease-in-out infinite;
+}
+.mascot-kiro.is-clicked .kiro-ghost {
+ animation: kiro-dissolve-core 1.1s cubic-bezier(0.25, 0.75, 0.25, 1);
+}
+.mascot-kiro.is-clicked .kiro-eye {
+ animation: kiro-eye-fade 1.1s ease-in-out;
+}
+.mascot-kiro.is-clicked .kiro-echo-left {
+ animation: kiro-echo-left 1.1s ease-in-out;
+}
+.mascot-kiro.is-clicked .kiro-echo-right {
+ animation: kiro-echo-right 1.1s ease-in-out;
+}
+
+@keyframes kiro-float {
+ 0%,
+ 100% {
+ transform: translateY(0) rotate(0deg);
+ }
+ 45% {
+ transform: translateY(-3px) rotate(-3deg);
+ }
+ 72% {
+ transform: translateY(-1px) rotate(2deg);
+ }
+}
+@keyframes kiro-blink {
+ 0%,
+ 84%,
+ 89%,
+ 100% {
+ transform: scaleY(1);
+ }
+ 86% {
+ transform: scaleY(0.08);
+ }
+}
+@keyframes kiro-dissolve-core {
+ 0% {
+ filter: blur(0);
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+ 26% {
+ filter: blur(1.4px);
+ opacity: 0.58;
+ transform: translateY(-2px) scale(0.94, 1.06);
+ }
+ 52% {
+ filter: blur(2px);
+ opacity: 0.24;
+ transform: translateY(-4px) scale(0.86, 1.12);
+ }
+ 76% {
+ filter: blur(0.6px);
+ opacity: 0.72;
+ transform: translateY(-1px) scale(1.03, 0.97);
+ }
+ 100% {
+ filter: blur(0);
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+@keyframes kiro-eye-fade {
+ 0%,
+ 18%,
+ 88%,
+ 100% {
+ opacity: 1;
+ transform: scaleY(1);
+ }
+ 42%,
+ 62% {
+ opacity: 0;
+ transform: scaleY(0.08);
+ }
+}
+@keyframes kiro-echo-left {
+ 0% {
+ filter: blur(0);
+ opacity: 0;
+ transform: translate(0, 0) scale(1);
+ }
+ 22% {
+ filter: blur(0.8px);
+ opacity: 0.42;
+ transform: translate(-2.4px, -1px) scale(0.98, 1.04);
+ }
+ 48% {
+ filter: blur(2.8px);
+ opacity: 0.32;
+ transform: translate(-7px, -2.6px) scale(0.8, 1.18);
+ }
+ 74% {
+ filter: blur(1.2px);
+ opacity: 0.18;
+ transform: translate(-2px, -0.7px) scale(1.03, 0.98);
+ }
+ 100% {
+ filter: blur(0);
+ opacity: 0;
+ transform: translate(0, 0) scale(1);
+ }
+}
+@keyframes kiro-echo-right {
+ 0% {
+ filter: blur(0);
+ opacity: 0;
+ transform: translate(0, 0) scale(1);
+ }
+ 20% {
+ filter: blur(0.8px);
+ opacity: 0.38;
+ transform: translate(2.2px, 1px) scale(1.03, 0.99);
+ }
+ 50% {
+ filter: blur(2.7px);
+ opacity: 0.3;
+ transform: translate(6.6px, 2.6px) scale(0.86, 1.14);
+ }
+ 76% {
+ filter: blur(1px);
+ opacity: 0.16;
+ transform: translate(1.8px, 0.7px) scale(1.02, 0.98);
+ }
+ 100% {
+ filter: blur(0);
+ opacity: 0;
+ transform: translate(0, 0) scale(1);
+ }
+}
+
/* === Fallback === */
.mascot-fallback.is-animated .fallback-icon {
animation: fallback-pulse 2s ease-in-out infinite;
@@ -1393,6 +1565,7 @@
.mascot-windsurf.is-animated *,
.mascot-opencode.is-animated *,
.mascot-hermes.is-animated *,
+ .mascot-kiro.is-animated *,
.mascot-fallback.is-animated *,
.mascot-claude.is-clicked,
.mascot-claude.is-clicked *,
@@ -1410,6 +1583,8 @@
.mascot-opencode.is-clicked *,
.mascot-hermes.is-clicked,
.mascot-hermes.is-clicked *,
+ .mascot-kiro.is-clicked,
+ .mascot-kiro.is-clicked *,
.mascot-fallback.is-clicked * {
animation: none;
transition: none;
diff --git a/src/index.css b/src/index.css
index 851fe33..eeff416 100644
--- a/src/index.css
+++ b/src/index.css
@@ -97,6 +97,7 @@
--agent-windsurf: oklch(0.68 0.1 225);
--agent-opencode: oklch(0.52 0.07 250);
--agent-hermes: oklch(0.64 0.13 90);
+ --agent-kiro: oklch(0.58 0.15 305);
--toast-success-bg: oklch(0.94 0.06 155);
--toast-success-border: oklch(0.72 0.16 155);
--toast-success-text: oklch(0.4 0.1 155);
@@ -200,6 +201,7 @@
--agent-windsurf: oklch(0.8 0.1 225);
--agent-opencode: oklch(0.76 0.06 250);
--agent-hermes: oklch(0.83 0.15 90);
+ --agent-kiro: oklch(0.78 0.13 305);
--toast-success-bg: oklch(0.23 0.04 155);
--toast-success-border: oklch(0.55 0.14 155);
--toast-success-text: oklch(0.78 0.12 155);
@@ -297,6 +299,7 @@
--agent-windsurf: oklch(0.68 0.1 225);
--agent-opencode: oklch(0.52 0.07 250);
--agent-hermes: oklch(0.64 0.13 90);
+ --agent-kiro: oklch(0.58 0.15 305);
--toast-success-bg: oklch(0.94 0.06 155);
--toast-success-border: oklch(0.65 0.15 155);
--toast-success-text: oklch(0.4 0.1 155);
@@ -388,6 +391,7 @@
--agent-windsurf: oklch(0.8 0.1 225);
--agent-opencode: oklch(0.76 0.06 250);
--agent-hermes: oklch(0.83 0.15 90);
+ --agent-kiro: oklch(0.78 0.13 305);
--toast-success-bg: oklch(0.26 0.04 155);
--toast-success-border: oklch(0.55 0.14 155);
--toast-success-text: oklch(0.78 0.12 155);
@@ -491,6 +495,7 @@
--agent-windsurf: oklch(0.68 0.1 225);
--agent-opencode: oklch(0.52 0.07 250);
--agent-hermes: oklch(0.64 0.13 90);
+ --agent-kiro: oklch(0.58 0.15 305);
--toast-success-bg: oklch(0.94 0.06 155);
--toast-success-border: oklch(0.72 0.16 155);
--toast-success-text: oklch(0.4 0.1 155);
@@ -588,6 +593,7 @@
--agent-windsurf: oklch(0.8 0.1 225);
--agent-opencode: oklch(0.76 0.06 250);
--agent-hermes: oklch(0.83 0.15 90);
+ --agent-kiro: oklch(0.78 0.13 305);
--toast-success-bg: oklch(0.23 0.04 155);
--toast-success-border: oklch(0.55 0.14 155);
--toast-success-text: oklch(0.78 0.12 155);
@@ -663,6 +669,7 @@ html.dark[data-theme="tiesen"][data-web="true"] {
--color-agent-windsurf: var(--agent-windsurf);
--color-agent-opencode: var(--agent-opencode);
--color-agent-hermes: var(--agent-hermes);
+ --color-agent-kiro: var(--agent-kiro);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
diff --git a/src/lib/__tests__/agent-capabilities.test.ts b/src/lib/__tests__/agent-capabilities.test.ts
index 54dd9e3..3df889f 100644
--- a/src/lib/__tests__/agent-capabilities.test.ts
+++ b/src/lib/__tests__/agent-capabilities.test.ts
@@ -19,6 +19,7 @@ describe("canInstallAtScope", () => {
it("returns true at project scope for an agent that supports project skills", () => {
expect(canInstallAtScope("claude", "skill", PROJECT)).toBe(true);
+ expect(canInstallAtScope("kiro", "hook", PROJECT)).toBe(true);
});
it("returns false at project scope for Hermes (global-only, hermes-agent#4667)", () => {
diff --git a/src/lib/__tests__/types.test.ts b/src/lib/__tests__/types.test.ts
index 4f3c408..4376b17 100644
--- a/src/lib/__tests__/types.test.ts
+++ b/src/lib/__tests__/types.test.ts
@@ -313,6 +313,7 @@ describe("sortAgentNames", () => {
"cursor",
"claude",
"gemini",
+ "kiro",
]);
expect(result).toEqual([
"claude",
@@ -320,6 +321,7 @@ describe("sortAgentNames", () => {
"cursor",
"windsurf",
"opencode",
+ "kiro",
]);
});
@@ -337,6 +339,7 @@ describe("agentDisplayName", () => {
expect(agentDisplayName("cursor")).toBe("Cursor");
expect(agentDisplayName("windsurf")).toBe("Windsurf");
expect(agentDisplayName("opencode")).toBe("OpenCode");
+ expect(agentDisplayName("kiro")).toBe("Kiro");
});
it("capitalizes first letter for unknown agents", () => {
diff --git a/src/lib/agent-capabilities.ts b/src/lib/agent-capabilities.ts
index a4dfb3d..2c0b1d9 100644
--- a/src/lib/agent-capabilities.ts
+++ b/src/lib/agent-capabilities.ts
@@ -24,6 +24,7 @@ const PROJECT_INSTALL_SUPPORT: Record> = {
copilot: new Set(["skill"]), // MCP adapter completion deferred (v2)
opencode: new Set(["skill", "mcp"]), // hook unsupported (HookFormat::None)
hermes: new Set(), // global-only: no project skills (hermes-agent#4667)
+ kiro: new Set(["skill", "mcp", "hook", "cli"]), // project hooks work; GLOBAL hook install is blocked (kirodotdev/Kiro#5440, see supports_global_hook_install)
};
/** Whether the agent's adapter declares project-level support for this kind.
diff --git a/src/lib/i18n/locales/en/extensions.json b/src/lib/i18n/locales/en/extensions.json
index 52d3221..19204a3 100644
--- a/src/lib/i18n/locales/en/extensions.json
+++ b/src/lib/i18n/locales/en/extensions.json
@@ -86,6 +86,7 @@
"installToAgent": "Install to Agent",
"globalOnly": " · global only (project soon)",
"hooksNotSupported": "Hooks not supported",
+ "kiroGlobalHooksPending": "Kiro does not load user-level (global) hooks yet (upstream kirodotdev/Kiro#5440) - install this hook per-project instead",
"crossAgentSoon": "Cross-agent install in project scope is coming in a future release",
"installToSuccess": "Installed to {{agent}}. Takes effect in new sessions",
"installToFailed": "Failed to install to {{agent}}",
diff --git a/src/lib/i18n/locales/zh/extensions.json b/src/lib/i18n/locales/zh/extensions.json
index 0b796f2..c32f809 100644
--- a/src/lib/i18n/locales/zh/extensions.json
+++ b/src/lib/i18n/locales/zh/extensions.json
@@ -86,6 +86,7 @@
"installToAgent": "安装到智能体",
"globalOnly": " · 仅全局(项目级即将支持)",
"hooksNotSupported": "不支持 hook",
+ "kiroGlobalHooksPending": "Kiro 尚未加载用户级(全局)hook(上游 kirodotdev/Kiro#5440),请改用项目级安装",
"crossAgentSoon": "项目范围的跨智能体安装将在未来版本中支持",
"installToSuccess": "已安装到 {{agent}}。将在新会话中生效",
"installToFailed": "安装到 {{agent}} 失败",
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 628b579..e9466ff 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -386,6 +386,7 @@ export const AGENT_ORDER = [
"windsurf",
"opencode",
"hermes",
+ "kiro",
] as const;
/** Sort an array of agents (or agent-like objects with a `name` field) by a given order. */
@@ -410,6 +411,7 @@ const AGENT_DISPLAY_NAMES: Record = {
windsurf: "Windsurf",
opencode: "OpenCode",
hermes: "Hermes",
+ kiro: "Kiro",
};
/** Get the display name for an agent (e.g. "claude" → "Claude Code"). */