diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/schema.rs | 1 | ||||
| -rw-r--r-- | src/cli/integration.rs | 9 | ||||
| -rw-r--r-- | src/detect/agents/kimi.rs | 101 | ||||
| -rw-r--r-- | src/detect/agents/mod.rs | 3 | ||||
| -rw-r--r-- | src/detect/mod.rs | 40 | ||||
| -rw-r--r-- | src/integration/assets/kimi/herdr-agent-state.sh | 76 | ||||
| -rw-r--r-- | src/integration/mod.rs | 402 |
7 files changed, 627 insertions, 5 deletions
diff --git a/src/api/schema.rs b/src/api/schema.rs index 483d4842..77af06b6 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -524,6 +524,7 @@ pub enum IntegrationTarget { Claude, Codex, Copilot, + Kimi, Opencode, Hermes, Qodercli, diff --git a/src/cli/integration.rs b/src/cli/integration.rs index d8eb027c..8001172e 100644 --- a/src/cli/integration.rs +++ b/src/cli/integration.rs @@ -103,13 +103,13 @@ fn parse_integration_target( ) -> std::io::Result<Option<IntegrationTarget>> { let Some(target) = args.first().map(|arg| arg.as_str()) else { eprintln!( - "usage: herdr integration {action} <pi|omp|claude|codex|copilot|opencode|hermes|qodercli>" + "usage: herdr integration {action} <pi|omp|claude|codex|copilot|kimi|opencode|hermes|qodercli>" ); return Ok(None); }; if args.len() != 1 { eprintln!( - "usage: herdr integration {action} <pi|omp|claude|codex|copilot|opencode|hermes|qodercli>" + "usage: herdr integration {action} <pi|omp|claude|codex|copilot|kimi|opencode|hermes|qodercli>" ); return Ok(None); } @@ -120,13 +120,14 @@ fn parse_integration_target( "claude" => IntegrationTarget::Claude, "codex" => IntegrationTarget::Codex, "copilot" => IntegrationTarget::Copilot, + "kimi" => IntegrationTarget::Kimi, "opencode" => IntegrationTarget::Opencode, "hermes" => IntegrationTarget::Hermes, "qodercli" => IntegrationTarget::Qodercli, _ => { eprintln!("unknown integration target: {target}"); eprintln!( - "currently supported: pi, omp, claude, codex, copilot, opencode, hermes, qodercli" + "currently supported: pi, omp, claude, codex, copilot, kimi, opencode, hermes, qodercli" ); return Ok(None); } @@ -142,6 +143,7 @@ fn print_integration_help() { eprintln!(" herdr integration install claude"); eprintln!(" herdr integration install codex"); eprintln!(" herdr integration install copilot"); + eprintln!(" herdr integration install kimi"); eprintln!(" herdr integration install opencode"); eprintln!(" herdr integration install hermes"); eprintln!(" herdr integration install qodercli"); @@ -150,6 +152,7 @@ fn print_integration_help() { eprintln!(" herdr integration uninstall claude"); eprintln!(" herdr integration uninstall codex"); eprintln!(" herdr integration uninstall copilot"); + eprintln!(" herdr integration uninstall kimi"); eprintln!(" herdr integration uninstall opencode"); eprintln!(" herdr integration uninstall hermes"); eprintln!(" herdr integration uninstall qodercli"); diff --git a/src/detect/agents/kimi.rs b/src/detect/agents/kimi.rs index 73d1a8b0..b42bf885 100644 --- a/src/detect/agents/kimi.rs +++ b/src/detect/agents/kimi.rs @@ -12,7 +12,23 @@ pub(super) fn detect(content: &str) -> AgentState { AgentState::Idle } +pub(super) fn has_visible_blocker(content: &str) -> bool { + has_current_approval_panel(content) || has_question_panel(content) +} + +pub(super) fn has_prompt_box(content: &str) -> bool { + has_editor_prompt_box(content) && has_footer_context(content) +} + +pub(super) fn has_visible_working(content: &str) -> bool { + has_kimi_working_status(content) +} + fn has_kimi_blocked_prompt(content: &str) -> bool { + if has_visible_blocker(content) { + return true; + } + let lower = content.to_lowercase(); lower.contains("requesting approval") && (lower.contains("approve once") || lower.contains("approve for this session")) @@ -43,6 +59,89 @@ fn has_kimi_working_status(content: &str) -> bool { .trim_start_matches(|c| ('\u{2800}'..='\u{28FF}').contains(&c)) .trim_start() .to_lowercase(); - rest.starts_with("thinking...") || rest.starts_with("using ") + rest.starts_with("thinking...") + || rest.starts_with("working...") + || rest.starts_with("using ") }) } + +fn has_current_approval_panel(content: &str) -> bool { + let lower = content.to_lowercase(); + has_approval_title(&lower) + && has_numeric_choose_hint(&lower) + && lower.contains("↵ confirm") + && (lower.contains("approve") || lower.contains("reject") || lower.contains("revise")) +} + +fn has_approval_title(lower_content: &str) -> bool { + lower_content.contains("run this command?") + || lower_content.contains("write this file?") + || lower_content.contains("apply these edits?") + || lower_content.contains("stop this task?") + || lower_content.contains("ready to build with this plan?") + || lower_content.lines().any(|line| { + let trimmed = line.trim_start_matches(|c: char| c == '▶' || c.is_whitespace()); + trimmed.starts_with("approve ") && trimmed.ends_with('?') + }) +} + +fn has_question_panel(content: &str) -> bool { + let lower = content.to_lowercase(); + content.lines().any(|line| line.trim() == "question") + && content + .lines() + .any(|line| line.trim_start().starts_with("? ")) + && lower.contains("↑↓ select") + && (lower.contains("↵ choose") || lower.contains("↵ toggle") || lower.contains("↵ save")) + && lower.contains("esc cancel") +} + +fn has_numeric_choose_hint(lower_content: &str) -> bool { + lower_content.contains(" choose") && lower_content.contains('1') && lower_content.contains('2') +} + +fn has_editor_prompt_box(content: &str) -> bool { + let lines: Vec<&str> = content.lines().collect(); + + for top_index in 0..lines.len() { + if !is_editor_top_border(lines[top_index]) { + continue; + } + + let mut saw_prompt = false; + for line in lines.iter().skip(top_index + 1) { + if is_editor_bottom_border(line) { + if saw_prompt { + return true; + } + break; + } + + saw_prompt |= is_editor_prompt_line(line); + } + } + + false +} + +fn is_editor_top_border(line: &str) -> bool { + let trimmed = line.trim(); + ((trimmed.starts_with('╭') && trimmed.ends_with('╮')) + || (trimmed.starts_with('├') && trimmed.ends_with('┤'))) + && trimmed.contains('─') +} + +fn is_editor_bottom_border(line: &str) -> bool { + let trimmed = line.trim(); + trimmed.starts_with('╰') && trimmed.ends_with('╯') && trimmed.contains('─') +} + +fn is_editor_prompt_line(line: &str) -> bool { + let trimmed = line.trim_start(); + let inner = trimmed.strip_prefix('│').unwrap_or(trimmed).trim_start(); + inner.starts_with('>') +} + +fn has_footer_context(content: &str) -> bool { + content.to_lowercase().contains("context: ") +} diff --git a/src/detect/agents/mod.rs b/src/detect/agents/mod.rs index a89b2f37..46a25f66 100644 --- a/src/detect/agents/mod.rs +++ b/src/detect/agents/mod.rs @@ -79,6 +79,7 @@ fn has_visible_blocker(agent: Agent, content: &str, state: AgentState) -> bool { // is known to be structural and live. Agent::Claude => claude_code::has_visible_blocker(content), Agent::Codex => codex::has_visible_blocker(content), + Agent::Kimi => kimi::has_visible_blocker(content), _ => false, } } @@ -91,6 +92,7 @@ fn has_visible_idle(agent: Agent, content: &str, state: AgentState) -> bool { match agent { Agent::Claude => claude_code::has_prompt_box(content), Agent::Codex => codex::has_prompt(content), + Agent::Kimi => kimi::has_prompt_box(content), _ => false, } } @@ -103,6 +105,7 @@ fn has_visible_working(agent: Agent, content: &str, state: AgentState) -> bool { match agent { Agent::Claude => claude_code::has_working_chrome(content), Agent::Codex => codex::has_visible_working(content), + Agent::Kimi => kimi::has_visible_working(content), _ => false, } } diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 6240d1b2..06536336 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -2124,6 +2124,26 @@ mod tests { } #[test] + fn kimi_current_approval_panel_is_visible_blocker() { + let screen = "────────────────────────────────────────────────────\n ▶ Run this command?\n\n $ git status --short\n\n ▶ 1. Approve once\n 2. Approve for this session\n 3. Reject\n 4. Reject with feedback\n\n ↑/↓ select · 1/2/3/4 choose · ↵ confirm\n────────────────────────────────────────────────────"; + let detection = detect_agent(Some(Agent::Kimi), screen); + + assert_eq!(detection.state, AgentState::Blocked); + assert!(detection.visible_blocker); + assert!(!detection.visible_idle); + } + + #[test] + fn kimi_question_panel_is_visible_blocker() { + let screen = "────────────────────────────────────────────────────\n question\n\n (○) Destination Submit\n\n ? Which destination should I use?\n\n → [1] Local checkout\n [2] Remote branch\n [3] Other\n\n ↑↓ select 1-3 / ↵ choose ←/→/tab switch esc cancel\n────────────────────────────────────────────────────"; + let detection = detect_agent(Some(Agent::Kimi), screen); + + assert_eq!(detection.state, AgentState::Blocked); + assert!(detection.visible_blocker); + assert!(!detection.visible_idle); + } + + #[test] fn kimi_approval_words_without_prompt_stay_idle() { assert_eq!(detect_kimi("approve?"), AgentState::Idle); assert_eq!(detect_kimi("continue? [y/n]"), AgentState::Idle); @@ -2146,6 +2166,15 @@ mod tests { } #[test] + fn kimi_working_braille_working_status_is_visible_working() { + let detection = detect_agent(Some(Agent::Kimi), "⠋ working..."); + + assert_eq!(detection.state, AgentState::Working); + assert!(detection.visible_working); + assert!(!detection.visible_idle); + } + + #[test] fn kimi_working_moon_spinner() { assert_eq!(detect_kimi("🌕"), AgentState::Working); assert_eq!(detect_kimi("🌗"), AgentState::Working); @@ -2175,6 +2204,17 @@ mod tests { assert_eq!(detect_kimi(screen), AgentState::Idle); } + #[test] + fn kimi_current_editor_box_is_visible_idle() { + let screen = "╭──────────────────────────────────────────────────╮\n│ > │\n╰──────────────────────────────────────────────────╯\nk2 ~/Projects/herdr /help: show commands\n context: 0.0%"; + let detection = detect_agent(Some(Agent::Kimi), screen); + + assert_eq!(detection.state, AgentState::Idle); + assert!(detection.visible_idle); + assert!(!detection.visible_working); + assert!(!detection.visible_blocker); + } + // ---- Kiro ---- #[test] diff --git a/src/integration/assets/kimi/herdr-agent-state.sh b/src/integration/assets/kimi/herdr-agent-state.sh new file mode 100644 index 00000000..3e0a9c29 --- /dev/null +++ b/src/integration/assets/kimi/herdr-agent-state.sh @@ -0,0 +1,76 @@ +#!/bin/sh +# installed by herdr +# managed by herdr; reinstalling or updating the integration overwrites this file. +# add custom hooks beside this file instead of editing it. +# HERDR_INTEGRATION_ID=kimi +# HERDR_INTEGRATION_VERSION=1 + +set -eu + +action="${1:-}" +cat >/dev/null 2>/dev/null || true + +case "$action" in + working|idle|blocked|release) ;; + *) exit 0 ;; +esac + +[ "${HERDR_ENV:-}" = "1" ] || exit 0 +[ -n "${HERDR_SOCKET_PATH:-}" ] || exit 0 +[ -n "${HERDR_PANE_ID:-}" ] || exit 0 +command -v python3 >/dev/null 2>&1 || exit 0 + +HERDR_ACTION="$action" python3 - <<'PY' +import json +import os +import random +import socket +import time + +source = "herdr:kimi" +action = os.environ.get("HERDR_ACTION", "") +pane_id = os.environ.get("HERDR_PANE_ID") +socket_path = os.environ.get("HERDR_SOCKET_PATH") + +if not pane_id or not socket_path: + raise SystemExit(0) + +request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}" +report_seq = time.time_ns() +if action == "release": + request = { + "id": request_id, + "method": "pane.release_agent", + "params": { + "pane_id": pane_id, + "source": source, + "agent": "kimi", + "seq": report_seq, + }, + } +else: + request = { + "id": request_id, + "method": "pane.report_agent", + "params": { + "pane_id": pane_id, + "source": source, + "agent": "kimi", + "state": action, + "seq": report_seq, + }, + } + +try: + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.settimeout(0.5) + client.connect(socket_path) + client.sendall((json.dumps(request) + "\n").encode()) + try: + client.recv(4096) + except Exception: + pass + client.close() +except Exception: + pass +PY diff --git a/src/integration/mod.rs b/src/integration/mod.rs index 31197318..f71ebce3 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -25,6 +25,25 @@ const CODEX_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; const CODEX_HOOK_ASSET: &str = include_str!("assets/codex/herdr-agent-state.sh"); const CODEX_INTEGRATION_VERSION: u32 = 5; const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME"; +const KIMI_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; +const KIMI_HOOK_ASSET: &str = include_str!("assets/kimi/herdr-agent-state.sh"); +const KIMI_INTEGRATION_VERSION: u32 = 1; +const KIMI_CODE_HOME_ENV_VAR: &str = "KIMI_CODE_HOME"; +const KIMI_CONFIG_BLOCK_BEGIN: &str = "# >>> herdr kimi integration"; +const KIMI_CONFIG_BLOCK_END: &str = "# <<< herdr kimi integration"; +const KIMI_MIN_VERSION: &str = "0.8.0"; +const KIMI_HOOK_EVENTS: [(&str, &str); 10] = [ + ("SessionStart", "idle"), + ("UserPromptSubmit", "working"), + ("PreToolUse", "working"), + ("PermissionRequest", "blocked"), + ("PermissionResult", "working"), + ("PostToolUse", "working"), + ("PostToolUseFailure", "working"), + ("Stop", "idle"), + ("StopFailure", "idle"), + ("SessionEnd", "release"), +]; const COPILOT_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; const COPILOT_HOOK_ASSET: &str = include_str!("assets/copilot/herdr-agent-state.sh"); const COPILOT_INTEGRATION_VERSION: u32 = 1; @@ -58,6 +77,12 @@ pub(crate) struct CodexInstallPaths { } #[derive(Debug)] +pub(crate) struct KimiInstallPaths { + pub hook_path: PathBuf, + pub config_path: PathBuf, +} + +#[derive(Debug)] pub(crate) struct CopilotInstallPaths { pub hook_path: PathBuf, pub settings_path: PathBuf, @@ -166,6 +191,14 @@ pub(crate) struct CodexUninstallResult { } #[derive(Debug)] +pub(crate) struct KimiUninstallResult { + pub hook_path: PathBuf, + pub config_path: PathBuf, + pub removed_hook_file: bool, + pub updated_config: bool, +} + +#[derive(Debug)] pub(crate) struct CopilotUninstallResult { pub hook_path: PathBuf, pub settings_path: PathBuf, @@ -258,6 +291,17 @@ pub(crate) fn install_target( ), ] } + crate::api::schema::IntegrationTarget::Kimi => { + let installed = install_kimi()?; + vec![ + format!( + "installed kimi integration hook to {}", + installed.hook_path.display() + ), + format!("ensured kimi config at {}", installed.config_path.display()), + format!("requires kimi code {KIMI_MIN_VERSION} or newer"), + ] + } crate::api::schema::IntegrationTarget::Opencode => { let installed = install_opencode()?; vec![format!( @@ -414,6 +458,33 @@ pub(crate) fn uninstall_target( } messages } + crate::api::schema::IntegrationTarget::Kimi => { + let result = uninstall_kimi()?; + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed kimi hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no kimi hook found at {}", + result.hook_path.display() + )); + } + if result.updated_config { + messages.push(format!( + "removed herdr kimi hook entries from {}", + result.config_path.display() + )); + } else { + messages.push(format!( + "no herdr kimi hook entries found in {}", + result.config_path.display() + )); + } + messages + } crate::api::schema::IntegrationTarget::Opencode => { let result = uninstall_opencode()?; if result.removed_plugin { @@ -497,6 +568,7 @@ pub(crate) fn integration_target_label( crate::api::schema::IntegrationTarget::Claude => "claude", crate::api::schema::IntegrationTarget::Codex => "codex", crate::api::schema::IntegrationTarget::Copilot => "copilot", + crate::api::schema::IntegrationTarget::Kimi => "kimi", crate::api::schema::IntegrationTarget::Opencode => "opencode", crate::api::schema::IntegrationTarget::Hermes => "hermes", crate::api::schema::IntegrationTarget::Qodercli => "qodercli", @@ -510,6 +582,7 @@ fn integration_target_command(target: crate::api::schema::IntegrationTarget) -> crate::api::schema::IntegrationTarget::Claude => "claude", crate::api::schema::IntegrationTarget::Codex => "codex", crate::api::schema::IntegrationTarget::Copilot => "copilot", + crate::api::schema::IntegrationTarget::Kimi => "kimi", crate::api::schema::IntegrationTarget::Opencode => "opencode", crate::api::schema::IntegrationTarget::Hermes => "hermes", crate::api::schema::IntegrationTarget::Qodercli => "qodercli", @@ -586,7 +659,7 @@ fn integration_specs() -> [( crate::api::schema::IntegrationTarget, io::Result<PathBuf>, u32, -); 8] { +); 9] { [ ( crate::api::schema::IntegrationTarget::Pi, @@ -614,6 +687,11 @@ fn integration_specs() -> [( COPILOT_INTEGRATION_VERSION, ), ( + crate::api::schema::IntegrationTarget::Kimi, + kimi_dir().map(|dir| dir.join("hooks").join(KIMI_HOOK_INSTALL_NAME)), + KIMI_INTEGRATION_VERSION, + ), + ( crate::api::schema::IntegrationTarget::Opencode, opencode_dir().map(|dir| dir.join("plugins").join(OPENCODE_PLUGIN_INSTALL_NAME)), OPENCODE_INTEGRATION_VERSION, @@ -933,6 +1011,39 @@ pub(crate) fn install_codex() -> io::Result<CodexInstallPaths> { }) } +pub(crate) fn install_kimi() -> io::Result<KimiInstallPaths> { + let dir = kimi_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "kimi code config directory not found at {}. install kimi code first", + dir.display() + ))); + } + + let hooks_dir = dir.join("hooks"); + fs::create_dir_all(&hooks_dir)?; + + let hook_path = hooks_dir.join(KIMI_HOOK_INSTALL_NAME); + fs::write(&hook_path, KIMI_HOOK_ASSET)?; + make_executable(&hook_path)?; + + let config_path = dir.join("config.toml"); + let existing_config = if config_path.is_file() { + fs::read_to_string(&config_path)? + } else { + String::new() + }; + let new_config = build_kimi_config_with_hooks(&existing_config, &hook_path); + if new_config != existing_config { + fs::write(&config_path, new_config)?; + } + + Ok(KimiInstallPaths { + hook_path, + config_path, + }) +} + pub(crate) fn install_copilot() -> io::Result<CopilotInstallPaths> { let dir = copilot_dir()?; if !dir.is_dir() { @@ -1220,6 +1331,31 @@ pub(crate) fn uninstall_codex() -> io::Result<CodexUninstallResult> { }) } +pub(crate) fn uninstall_kimi() -> io::Result<KimiUninstallResult> { + let kimi_dir = kimi_dir()?; + let hook_path = kimi_dir.join("hooks").join(KIMI_HOOK_INSTALL_NAME); + let config_path = kimi_dir.join("config.toml"); + let mut updated_config = false; + + if config_path.is_file() { + let existing_config = fs::read_to_string(&config_path)?; + let new_config = remove_kimi_config_block(&existing_config); + if new_config != existing_config { + fs::write(&config_path, new_config)?; + updated_config = true; + } + } + + let removed_hook_file = remove_file_if_exists(&hook_path)?; + + Ok(KimiUninstallResult { + hook_path, + config_path, + removed_hook_file, + updated_config, + }) +} + pub(crate) fn uninstall_copilot() -> io::Result<CopilotUninstallResult> { let copilot_dir = copilot_dir()?; let hook_path = copilot_dir.join("hooks").join(COPILOT_HOOK_INSTALL_NAME); @@ -1878,6 +2014,95 @@ fn build_codex_config_with_hooks(content: &str) -> String { join_toml_lines(lines, trailing_newline) } +fn build_kimi_config_with_hooks(content: &str, hook_path: &Path) -> String { + let mut result = remove_kimi_config_block(content) + .trim_end_matches('\n') + .to_string(); + if !result.is_empty() { + result.push('\n'); + result.push('\n'); + } + + result.push_str(KIMI_CONFIG_BLOCK_BEGIN); + result.push('\n'); + for (event, action) in KIMI_HOOK_EVENTS { + result.push_str(&kimi_hook_table(event, hook_path, action)); + } + result.push_str(KIMI_CONFIG_BLOCK_END); + result.push('\n'); + result +} + +fn kimi_hook_table(event: &str, hook_path: &Path, action: &str) -> String { + let command = format!( + "bash {} {action}", + shell_single_quote(&hook_path.display().to_string()) + ); + format!( + "[[hooks]]\nevent = {}\ncommand = {}\ntimeout = 10\n\n", + toml_basic_string(event), + toml_basic_string(&command) + ) +} + +fn remove_kimi_config_block(content: &str) -> String { + let trailing_newline = content.ends_with('\n'); + let mut lines = Vec::new(); + let mut in_block = false; + let mut removed_block = false; + + for line in content.lines() { + if line.trim() == KIMI_CONFIG_BLOCK_BEGIN { + in_block = true; + removed_block = true; + continue; + } + if in_block { + if line.trim() == KIMI_CONFIG_BLOCK_END { + in_block = false; + } + continue; + } + lines.push(line.to_string()); + } + + if !removed_block { + return content.to_string(); + } + + let mut result = join_toml_lines(lines, trailing_newline); + while result.ends_with("\n\n") { + result.pop(); + } + if result == "\n" { + String::new() + } else { + result + } +} + +fn toml_basic_string(value: &str) -> String { + let mut result = String::with_capacity(value.len() + 2); + result.push('"'); + for ch in value.chars() { + match ch { + '"' => result.push_str("\\\""), + '\\' => result.push_str("\\\\"), + '\u{08}' => result.push_str("\\b"), + '\t' => result.push_str("\\t"), + '\n' => result.push_str("\\n"), + '\u{0c}' => result.push_str("\\f"), + '\r' => result.push_str("\\r"), + ch if ch <= '\u{1f}' || ch == '\u{7f}' => { + result.push_str(&format!("\\u{:04X}", ch as u32)); + } + ch => result.push(ch), + } + } + result.push('"'); + result +} + fn join_toml_lines(lines: Vec<String>, trailing_newline: bool) -> String { let mut result = lines.join("\n"); if trailing_newline || result.is_empty() { @@ -1954,6 +2179,10 @@ fn codex_dir() -> io::Result<PathBuf> { config_dir_from_env_or_home(CODEX_HOME_ENV_VAR, &[".codex"]) } +fn kimi_dir() -> io::Result<PathBuf> { + config_dir_from_env_or_home(KIMI_CODE_HOME_ENV_VAR, &[".kimi-code"]) +} + fn copilot_dir() -> io::Result<PathBuf> { config_dir_from_env_or_home(COPILOT_HOME_ENV_VAR, &[".copilot"]) } @@ -2032,9 +2261,39 @@ mod tests { std::env::remove_var(CLAUDE_CONFIG_DIR_ENV_VAR); std::env::remove_var(CODEX_HOME_ENV_VAR); std::env::remove_var(COPILOT_HOME_ENV_VAR); + std::env::remove_var(KIMI_CODE_HOME_ENV_VAR); std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR); } + fn kimi_hook_command(hook_path: &Path, action: &str) -> String { + format!( + "bash {} {action}", + shell_single_quote(&hook_path.display().to_string()) + ) + } + + fn kimi_config_hooks(config: &str) -> Vec<toml::Value> { + let parsed: toml::Value = toml::from_str(config).unwrap(); + parsed + .get("hooks") + .and_then(toml::Value::as_array) + .cloned() + .unwrap_or_default() + } + + fn assert_kimi_hook(config: &str, hook_path: &Path, event: &str, action: &str) { + let command = kimi_hook_command(hook_path, action); + let hooks = kimi_config_hooks(config); + assert!( + hooks.iter().any(|hook| { + hook.get("event").and_then(toml::Value::as_str) == Some(event) + && hook.get("command").and_then(toml::Value::as_str) == Some(command.as_str()) + && hook.get("timeout").and_then(toml::Value::as_integer) == Some(10) + }), + "missing kimi hook for {event} -> {action}" + ); + } + fn unique_base() -> PathBuf { clear_integration_path_env(); std::env::temp_dir().join(format!( @@ -2875,6 +3134,143 @@ mod tests { } #[test] + fn install_kimi_writes_hook_and_updates_config() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let kimi_dir = home.join(".kimi-code"); + fs::create_dir_all(&kimi_dir).unwrap(); + fs::write( + kimi_dir.join("config.toml"), + "default_model = \"moonshot\"\n\n[[hooks]]\nevent = \"Notification\"\nmatcher = \"task.completed\"\ncommand = \"echo keep\"\ntimeout = 3\n", + ) + .unwrap(); + std::env::set_var("HOME", &home); + + let installed = install_kimi().unwrap(); + let hook_content = fs::read_to_string(&installed.hook_path).unwrap(); + let config = fs::read_to_string(&installed.config_path).unwrap(); + let hooks = kimi_config_hooks(&config); + + assert_eq!( + installed.hook_path, + kimi_dir.join("hooks").join(KIMI_HOOK_INSTALL_NAME) + ); + assert_eq!(installed.config_path, kimi_dir.join("config.toml")); + assert_eq!(hook_content, KIMI_HOOK_ASSET); + assert_eq!(hooks.len(), 11); + assert!(config.contains("default_model = \"moonshot\"")); + assert!(config.contains("command = \"echo keep\"")); + assert!(config.contains(KIMI_CONFIG_BLOCK_BEGIN)); + assert!(config.contains(KIMI_CONFIG_BLOCK_END)); + for (event, action) in KIMI_HOOK_EVENTS { + assert_kimi_hook(&config, &installed.hook_path, event, action); + } + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn install_kimi_uses_kimi_code_home_env() { + let _lock = integration_env_lock(); + let base = unique_base(); + let kimi_dir = base.join("custom-kimi"); + fs::create_dir_all(&kimi_dir).unwrap(); + std::env::set_var(KIMI_CODE_HOME_ENV_VAR, &kimi_dir); + + let installed = install_kimi().unwrap(); + + assert_eq!( + installed.hook_path, + kimi_dir.join("hooks").join(KIMI_HOOK_INSTALL_NAME) + ); + assert_eq!(installed.config_path, kimi_dir.join("config.toml")); + + clear_integration_path_env(); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn install_kimi_is_idempotent_for_config_block() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let kimi_dir = home.join(".kimi-code"); + fs::create_dir_all(&kimi_dir).unwrap(); + std::env::set_var("HOME", &home); + + install_kimi().unwrap(); + install_kimi().unwrap(); + + let config = fs::read_to_string(kimi_dir.join("config.toml")).unwrap(); + let hooks = kimi_config_hooks(&config); + + assert_eq!(config.matches(KIMI_CONFIG_BLOCK_BEGIN).count(), 1); + assert_eq!(config.matches(KIMI_CONFIG_BLOCK_END).count(), 1); + assert_eq!(hooks.len(), KIMI_HOOK_EVENTS.len()); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn uninstall_kimi_removes_hook_and_config_block_preserves_other_hooks() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let kimi_dir = home.join(".kimi-code"); + fs::create_dir_all(&kimi_dir).unwrap(); + std::env::set_var("HOME", &home); + + let installed = install_kimi().unwrap(); + fs::write( + &installed.config_path, + format!( + "default_model = \"moonshot\"\n\n[[hooks]]\nevent = \"Notification\"\ncommand = \"echo keep\"\n\n{}", + fs::read_to_string(&installed.config_path).unwrap() + ), + ) + .unwrap(); + + let result = uninstall_kimi().unwrap(); + let config = fs::read_to_string(kimi_dir.join("config.toml")).unwrap(); + let hooks = kimi_config_hooks(&config); + + assert!(result.removed_hook_file); + assert!(result.updated_config); + assert!(!result.hook_path.exists()); + assert!(config.contains("default_model = \"moonshot\"")); + assert!(config.contains("command = \"echo keep\"")); + assert!(!config.contains(KIMI_CONFIG_BLOCK_BEGIN)); + assert!(!config.contains(KIMI_CONFIG_BLOCK_END)); + assert_eq!(hooks.len(), 1); + assert_eq!( + hooks[0].get("event").and_then(toml::Value::as_str), + Some("Notification") + ); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn install_kimi_errors_when_config_dir_missing() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + fs::create_dir_all(&home).unwrap(); + std::env::set_var("HOME", &home); + + let err = install_kimi().unwrap_err().to_string(); + + assert!(err.contains("kimi code config directory not found")); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] fn install_copilot_writes_hook_and_updates_settings() { let _lock = integration_env_lock(); let base = unique_base(); @@ -3243,6 +3639,10 @@ mod tests { assert!(CODEX_HOOK_ASSET.contains("pane.report_agent_session")); assert!(!CODEX_HOOK_ASSET.contains("\"state\": action")); assert!(!CODEX_HOOK_ASSET.contains("pane.release_agent")); + assert!(KIMI_HOOK_ASSET.contains("source = \"herdr:kimi\"")); + assert!(KIMI_HOOK_ASSET.contains("pane.report_agent")); + assert!(KIMI_HOOK_ASSET.contains("pane.release_agent")); + assert!(!KIMI_HOOK_ASSET.contains("agent_session_id")); assert!(COPILOT_HOOK_ASSET.contains("agent_session_id")); assert!(COPILOT_HOOK_ASSET.contains("notification_type")); assert!(COPILOT_HOOK_ASSET.contains("ask_user")); |
