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"). */