aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorqer <wbxl2000@outlook.com>2026-06-04 18:52:29 +0800
committerGitHub <noreply@github.com>2026-06-04 13:52:29 +0300
commit66b0f6b2d6e1c63c3a7aa0d26cff91cf750232aa (patch)
treec114ee4571705357540d7df3ac94349234c77ff3 /src
parentcb18fe52b55f1a627387c829074e9d24b9cefb28 (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')
-rw-r--r--src/api/schema.rs1
-rw-r--r--src/cli/integration.rs9
-rw-r--r--src/detect/agents/kimi.rs101
-rw-r--r--src/detect/agents/mod.rs3
-rw-r--r--src/detect/mod.rs40
-rw-r--r--src/integration/assets/kimi/herdr-agent-state.sh76
-rw-r--r--src/integration/mod.rs402
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"));