diff options
| author | qer <wbxl2000@outlook.com> | 2026-06-04 18:52:29 +0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-06-04 13:52:29 +0300 |
| commit | 66b0f6b2d6e1c63c3a7aa0d26cff91cf750232aa (patch) | |
| tree | c114ee4571705357540d7df3ac94349234c77ff3 /src/integration | |
| parent | cb18fe52b55f1a627387c829074e9d24b9cefb28 (diff) | |
feat: add kimi code cli integration (#463)
* feat: add kimi code cli integration
refs #431
* fix: improve kimi code screen detection
refs #431
Diffstat (limited to 'src/integration')
| -rw-r--r-- | src/integration/assets/kimi/herdr-agent-state.sh | 76 | ||||
| -rw-r--r-- | src/integration/mod.rs | 402 |
2 files changed, 477 insertions, 1 deletions
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")); |
