aboutsummaryrefslogtreecommitdiff
path: root/src/integration/mod.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-09 15:59:34 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-09 15:59:34 +0300
commitdf7880c54b8b5d337421cb4b1dd8daea27008801 (patch)
tree4d0cc6290ad71c413bac71cfaab4ef41ea017ecb /src/integration/mod.rs
parenteeacb45b4b6ab4446c5bea1331f743fee5e5b9f8 (diff)
feat: update agent lifecycle integrations
Diffstat (limited to 'src/integration/mod.rs')
-rw-r--r--src/integration/mod.rs580
1 files changed, 359 insertions, 221 deletions
diff --git a/src/integration/mod.rs b/src/integration/mod.rs
index 07318213..36178b8d 100644
--- a/src/integration/mod.rs
+++ b/src/integration/mod.rs
@@ -51,23 +51,12 @@ const KIMI_HOOK_ASSET: &str = if cfg!(windows) {
} else {
include_str!("assets/kimi/herdr-agent-state.sh")
};
-const KIMI_INTEGRATION_VERSION: u32 = 1;
+const KIMI_INTEGRATION_VERSION: u32 = 2;
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 KIMI_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")];
const COPILOT_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
"herdr-agent-state.ps1"
} else {
@@ -78,8 +67,20 @@ const COPILOT_HOOK_ASSET: &str = if cfg!(windows) {
} else {
include_str!("assets/copilot/herdr-agent-state.sh")
};
-const COPILOT_INTEGRATION_VERSION: u32 = 1;
+const COPILOT_INTEGRATION_VERSION: u32 = 2;
const COPILOT_HOME_ENV_VAR: &str = "COPILOT_HOME";
+const COPILOT_HOOK_EVENTS: [&str; 1] = ["SessionStart"];
+const COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS: [&str; 9] = [
+ "UserPromptSubmit",
+ "PreToolUse",
+ "PostToolUse",
+ "PostToolUseFailure",
+ "Stop",
+ "agentStop",
+ "SessionEnd",
+ "notification",
+ "sessionStart",
+];
const DROID_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
"herdr-agent-state.ps1"
} else {
@@ -90,10 +91,25 @@ const DROID_HOOK_ASSET: &str = if cfg!(windows) {
} else {
include_str!("assets/droid/herdr-agent-state.sh")
};
-const DROID_INTEGRATION_VERSION: u32 = 1;
+const DROID_INTEGRATION_VERSION: u32 = 2;
+const DROID_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")];
+const DROID_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 9] = [
+ ("SessionStart", "idle"),
+ ("UserPromptSubmit", "working"),
+ ("PreToolUse", "working"),
+ ("PostToolUse", "working"),
+ ("Notification", "blocked"),
+ ("Stop", "idle"),
+ ("SubagentStop", "working"),
+ ("PreCompact", "working"),
+ ("SessionEnd", "release"),
+];
const OPENCODE_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js";
const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-state.js");
-const OPENCODE_INTEGRATION_VERSION: u32 = 4;
+const OPENCODE_INTEGRATION_VERSION: u32 = 5;
+const KILO_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js";
+const KILO_PLUGIN_ASSET: &str = include_str!("assets/kilo/herdr-agent-state.js");
+const KILO_INTEGRATION_VERSION: u32 = 1;
const HERMES_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state";
const HERMES_PLUGIN_MANIFEST_INSTALL_NAME: &str = "plugin.yaml";
const HERMES_PLUGIN_INIT_INSTALL_NAME: &str = "__init__.py";
@@ -110,8 +126,23 @@ const QODERCLI_HOOK_ASSET: &str = if cfg!(windows) {
} else {
include_str!("assets/qodercli/herdr-agent-state.sh")
};
-const QODERCLI_INTEGRATION_VERSION: u32 = 1;
+const QODERCLI_INTEGRATION_VERSION: u32 = 2;
const QODERCLI_CONFIG_DIR_ENV_VAR: &str = "QODER_CONFIG_DIR";
+const QODERCLI_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")];
+const QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 12] = [
+ ("SessionStart", "idle"),
+ ("UserPromptSubmit", "working"),
+ ("PreToolUse", "working"),
+ ("PostToolUse", "working"),
+ ("PostToolUseFailure", "working"),
+ ("SubagentStart", "working"),
+ ("SubagentStop", "working"),
+ ("PreCompact", "working"),
+ ("Notification", "blocked"),
+ ("PermissionRequest", "blocked"),
+ ("Stop", "idle"),
+ ("SessionEnd", "release"),
+];
const CURSOR_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
const CURSOR_HOOK_ASSET: &str = include_str!("assets/cursor/herdr-agent-state.sh");
const CURSOR_INTEGRATION_VERSION: u32 = 1;
@@ -157,6 +188,11 @@ pub(crate) struct OpenCodeInstallPaths {
}
#[derive(Debug)]
+pub(crate) struct KiloInstallPaths {
+ pub plugin_path: PathBuf,
+}
+
+#[derive(Debug)]
pub(crate) struct OmpInstallPaths {
pub extension_path: PathBuf,
pub removed_legacy_pi_extension: bool,
@@ -300,6 +336,12 @@ pub(crate) struct OpenCodeUninstallResult {
}
#[derive(Debug)]
+pub(crate) struct KiloUninstallResult {
+ pub plugin_path: PathBuf,
+ pub removed_plugin: bool,
+}
+
+#[derive(Debug)]
pub(crate) struct HermesUninstallResult {
pub plugin_dir: PathBuf,
pub config_path: PathBuf,
@@ -423,6 +465,13 @@ pub(crate) fn install_target(
installed.plugin_path.display()
)]
}
+ crate::api::schema::IntegrationTarget::Kilo => {
+ let installed = install_kilo()?;
+ vec![format!(
+ "installed kilo integration plugin to {}",
+ installed.plugin_path.display()
+ )]
+ }
crate::api::schema::IntegrationTarget::Hermes => {
let installed = install_hermes()?;
vec![
@@ -661,6 +710,20 @@ pub(crate) fn uninstall_target(
)]
}
}
+ crate::api::schema::IntegrationTarget::Kilo => {
+ let result = uninstall_kilo()?;
+ if result.removed_plugin {
+ vec![format!(
+ "removed kilo integration plugin at {}",
+ result.plugin_path.display()
+ )]
+ } else {
+ vec![format!(
+ "no kilo integration plugin found at {}",
+ result.plugin_path.display()
+ )]
+ }
+ }
crate::api::schema::IntegrationTarget::Hermes => {
let result = uninstall_hermes()?;
let mut messages = Vec::new();
@@ -760,6 +823,7 @@ pub(crate) fn integration_target_label(
crate::api::schema::IntegrationTarget::Droid => "droid",
crate::api::schema::IntegrationTarget::Kimi => "kimi",
crate::api::schema::IntegrationTarget::Opencode => "opencode",
+ crate::api::schema::IntegrationTarget::Kilo => "kilo",
crate::api::schema::IntegrationTarget::Hermes => "hermes",
crate::api::schema::IntegrationTarget::Qodercli => "qodercli",
crate::api::schema::IntegrationTarget::Cursor => "cursor",
@@ -782,6 +846,7 @@ fn integration_target_command_names(
crate::api::schema::IntegrationTarget::Droid => &["droid"],
crate::api::schema::IntegrationTarget::Kimi => &["kimi"],
crate::api::schema::IntegrationTarget::Opencode => &["opencode"],
+ crate::api::schema::IntegrationTarget::Kilo => &["kilo", "kilo-code"],
crate::api::schema::IntegrationTarget::Hermes => &["hermes"],
crate::api::schema::IntegrationTarget::Qodercli => qodercli_command_names(),
crate::api::schema::IntegrationTarget::Cursor => cursor_command_names(),
@@ -989,7 +1054,7 @@ fn integration_specs() -> [(
crate::api::schema::IntegrationTarget,
io::Result<PathBuf>,
u32,
-); 11] {
+); 12] {
[
(
crate::api::schema::IntegrationTarget::Pi,
@@ -1032,6 +1097,11 @@ fn integration_specs() -> [(
OPENCODE_INTEGRATION_VERSION,
),
(
+ crate::api::schema::IntegrationTarget::Kilo,
+ kilo_dir().map(|dir| dir.join("plugin").join(KILO_PLUGIN_INSTALL_NAME)),
+ KILO_INTEGRATION_VERSION,
+ ),
+ (
crate::api::schema::IntegrationTarget::Hermes,
hermes_plugin_dir().map(|dir| dir.join(HERMES_PLUGIN_INIT_INSTALL_NAME)),
HERMES_INTEGRATION_VERSION,
@@ -1385,34 +1455,15 @@ pub(crate) fn install_copilot() -> io::Result<CopilotInstallPaths> {
"copilot settings hooks",
)?;
let command = hook_command(&hook_path, None);
- for event in [
- "SessionStart",
- "UserPromptSubmit",
- "PreToolUse",
- "PostToolUse",
- "PostToolUseFailure",
- "Stop",
- "agentStop",
- "SessionEnd",
- "notification",
- ] {
+ for event in COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS {
remove_direct_hook_commands(hooks, event, &hook_path, None)?;
}
- ensure_direct_command_hook(hooks, "SessionStart", command.clone(), 10, None)?;
- ensure_direct_command_hook(hooks, "UserPromptSubmit", command.clone(), 10, None)?;
- ensure_direct_command_hook(hooks, "PreToolUse", command.clone(), 10, None)?;
- ensure_direct_command_hook(hooks, "PostToolUse", command.clone(), 10, None)?;
- ensure_direct_command_hook(hooks, "PostToolUseFailure", command.clone(), 10, None)?;
- ensure_direct_command_hook(hooks, "Stop", command.clone(), 10, None)?;
- ensure_direct_command_hook(hooks, "agentStop", command.clone(), 10, None)?;
- ensure_direct_command_hook(hooks, "SessionEnd", command.clone(), 10, None)?;
- ensure_direct_command_hook(
- hooks,
- "notification",
- command,
- 10,
- Some("permission_prompt|elicitation_dialog|agent_idle"),
- )?;
+ for event in COPILOT_HOOK_EVENTS {
+ remove_direct_hook_commands(hooks, event, &hook_path, None)?;
+ }
+ for event in COPILOT_HOOK_EVENTS {
+ ensure_direct_command_hook(hooks, event, command.clone(), 10, None)?;
+ }
remove_legacy_bash_hook_file(&hook_path)?;
fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
@@ -1458,13 +1509,21 @@ pub(crate) fn install_droid() -> io::Result<DroidInstallPaths> {
"droid settings hooks",
)?;
remove_hook_commands(hooks, "SessionStart", &hook_path, None)?;
- ensure_command_hook(
- hooks,
- "SessionStart",
- hook_command(&hook_path, None),
- 10,
- None,
- )?;
+ for (event, action) in DROID_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ for (event, action) in DROID_HOOK_EVENTS {
+ remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ for (event, action) in DROID_HOOK_EVENTS {
+ ensure_command_hook(
+ hooks,
+ event,
+ hook_command(&hook_path, Some(action)),
+ 10,
+ None,
+ )?;
+ }
remove_legacy_bash_hook_file(&hook_path)?;
fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
@@ -1483,6 +1542,14 @@ pub(crate) fn install_droid() -> io::Result<DroidInstallPaths> {
"droid hooks file hooks",
)? {
updated_legacy_hooks = remove_hook_commands(hooks, "SessionStart", &hook_path, None)?;
+ for (event, action) in DROID_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ updated_legacy_hooks |=
+ remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ for (event, action) in DROID_HOOK_EVENTS {
+ updated_legacy_hooks |=
+ remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
}
if updated_legacy_hooks {
fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
@@ -1515,6 +1582,24 @@ pub(crate) fn install_opencode() -> io::Result<OpenCodeInstallPaths> {
Ok(OpenCodeInstallPaths { plugin_path })
}
+pub(crate) fn install_kilo() -> io::Result<KiloInstallPaths> {
+ let dir = kilo_dir()?;
+ if !dir.is_dir() {
+ return Err(io::Error::other(format!(
+ "kilo config directory not found at {}. install kilo first",
+ dir.display()
+ )));
+ }
+
+ let plugins_dir = dir.join("plugin");
+ fs::create_dir_all(&plugins_dir)?;
+
+ let plugin_path = plugins_dir.join(KILO_PLUGIN_INSTALL_NAME);
+ fs::write(&plugin_path, KILO_PLUGIN_ASSET)?;
+
+ Ok(KiloInstallPaths { plugin_path })
+}
+
pub(crate) fn install_hermes() -> io::Result<HermesInstallPaths> {
let dir = hermes_dir()?;
if !dir.is_dir() {
@@ -1724,20 +1809,12 @@ pub(crate) fn uninstall_copilot() -> io::Result<CopilotUninstallResult> {
"copilot settings",
"copilot settings hooks",
)? {
- updated_settings |=
- remove_direct_hook_commands(hooks, "SessionStart", &hook_path, None)?;
- updated_settings |=
- remove_direct_hook_commands(hooks, "UserPromptSubmit", &hook_path, None)?;
- updated_settings |= remove_direct_hook_commands(hooks, "PreToolUse", &hook_path, None)?;
- updated_settings |=
- remove_direct_hook_commands(hooks, "PostToolUse", &hook_path, None)?;
- updated_settings |=
- remove_direct_hook_commands(hooks, "PostToolUseFailure", &hook_path, None)?;
- updated_settings |= remove_direct_hook_commands(hooks, "Stop", &hook_path, None)?;
- updated_settings |= remove_direct_hook_commands(hooks, "agentStop", &hook_path, None)?;
- updated_settings |= remove_direct_hook_commands(hooks, "SessionEnd", &hook_path, None)?;
- updated_settings |=
- remove_direct_hook_commands(hooks, "notification", &hook_path, None)?;
+ for event in COPILOT_HOOK_EVENTS {
+ updated_settings |= remove_direct_hook_commands(hooks, event, &hook_path, None)?;
+ }
+ for event in COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ updated_settings |= remove_direct_hook_commands(hooks, event, &hook_path, None)?;
+ }
}
if updated_settings {
@@ -1776,6 +1853,12 @@ pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> {
"droid hooks file hooks",
)? {
updated_hooks |= remove_hook_commands(hooks, "SessionStart", &hook_path, None)?;
+ for (event, action) in DROID_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ updated_hooks |= remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ for (event, action) in DROID_HOOK_EVENTS {
+ updated_hooks |= remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
}
if updated_hooks {
@@ -1798,6 +1881,12 @@ pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> {
"droid settings hooks",
)? {
updated_settings = remove_hook_commands(hooks, "SessionStart", &hook_path, None)?;
+ for (event, action) in DROID_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ for (event, action) in DROID_HOOK_EVENTS {
+ updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
}
if updated_settings {
@@ -1830,6 +1919,16 @@ pub(crate) fn uninstall_opencode() -> io::Result<OpenCodeUninstallResult> {
})
}
+pub(crate) fn uninstall_kilo() -> io::Result<KiloUninstallResult> {
+ let plugin_path = kilo_dir()?.join("plugin").join(KILO_PLUGIN_INSTALL_NAME);
+ let removed_plugin = remove_file_if_exists(&plugin_path)?;
+
+ Ok(KiloUninstallResult {
+ plugin_path,
+ removed_plugin,
+ })
+}
+
pub(crate) fn uninstall_hermes() -> io::Result<HermesUninstallResult> {
let dir = hermes_dir()?;
let plugin_dir = hermes_plugin_dir()?;
@@ -1874,9 +1973,7 @@ pub(crate) fn install_qodercli() -> io::Result<QodercliInstallPaths> {
// settings.json (per https://docs.qoder.com/zh/cli/hooks): a top-level
// `hooks` object keyed by event name, each entry holding a matcher + a
// list of `{type: "command", command, timeout?}` invocations. The hook
- // script reads the event payload from stdin via `hook_event_name` so the
- // installation never depends on a `QODER_HOOK_EVENT` environment
- // variable.
+ // script reads the event payload from stdin via `hook_event_name`.
let settings_path = dir.join("settings.json");
let mut settings = if settings_path.is_file() {
serde_json::from_str::<Value>(&fs::read_to_string(&settings_path)?).map_err(|err| {
@@ -1895,57 +1992,21 @@ pub(crate) fn install_qodercli() -> io::Result<QodercliInstallPaths> {
"qodercli settings",
"qodercli settings hooks",
)?;
- // SubagentStop is intentionally *not* mapped to working: the hook script
- // returns early on it (mirroring assets/claude/herdr-agent-state.sh) so
- // that recap/away-summary frames cannot revive an idle pane.
- remove_hook_commands(hooks, "SessionStart", &hook_path, Some("idle"))?;
- remove_hook_commands(hooks, "UserPromptSubmit", &hook_path, Some("working"))?;
- remove_hook_commands(hooks, "PreToolUse", &hook_path, Some("working"))?;
- remove_hook_commands(hooks, "PermissionRequest", &hook_path, Some("blocked"))?;
- remove_hook_commands(hooks, "Stop", &hook_path, Some("idle"))?;
- remove_hook_commands(hooks, "SessionEnd", &hook_path, Some("release"))?;
- ensure_command_hook(
- hooks,
- "SessionStart",
- hook_command(&hook_path, Some("idle")),
- 10,
- Some("*"),
- )?;
- ensure_command_hook(
- hooks,
- "UserPromptSubmit",
- hook_command(&hook_path, Some("working")),
- 10,
- Some("*"),
- )?;
- ensure_command_hook(
- hooks,
- "PreToolUse",
- hook_command(&hook_path, Some("working")),
- 10,
- Some("*"),
- )?;
- ensure_command_hook(
- hooks,
- "PermissionRequest",
- hook_command(&hook_path, Some("blocked")),
- 10,
- Some("*"),
- )?;
- ensure_command_hook(
- hooks,
- "Stop",
- hook_command(&hook_path, Some("idle")),
- 10,
- Some("*"),
- )?;
- ensure_command_hook(
- hooks,
- "SessionEnd",
- hook_command(&hook_path, Some("release")),
- 10,
- Some("*"),
- )?;
+ for (event, action) in QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ for (event, action) in QODERCLI_HOOK_EVENTS {
+ remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ for (event, action) in QODERCLI_HOOK_EVENTS {
+ ensure_command_hook(
+ hooks,
+ event,
+ hook_command(&hook_path, Some(action)),
+ 10,
+ Some("*"),
+ )?;
+ }
remove_legacy_bash_hook_file(&hook_path)?;
fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
@@ -2035,17 +2096,12 @@ pub(crate) fn uninstall_qodercli() -> io::Result<QodercliUninstallResult> {
"qodercli settings",
"qodercli settings hooks",
)? {
- updated_settings |=
- remove_hook_commands(hooks, "SessionStart", &hook_path, Some("idle"))?;
- updated_settings |=
- remove_hook_commands(hooks, "UserPromptSubmit", &hook_path, Some("working"))?;
- updated_settings |=
- remove_hook_commands(hooks, "PreToolUse", &hook_path, Some("working"))?;
- updated_settings |=
- remove_hook_commands(hooks, "PermissionRequest", &hook_path, Some("blocked"))?;
- updated_settings |= remove_hook_commands(hooks, "Stop", &hook_path, Some("idle"))?;
- updated_settings |=
- remove_hook_commands(hooks, "SessionEnd", &hook_path, Some("release"))?;
+ for (event, action) in QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ for (event, action) in QODERCLI_HOOK_EVENTS {
+ updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
}
if updated_settings {
@@ -2206,8 +2262,8 @@ fn ensure_command_hook(
// Claude and Codex use nested hook groups:
// { "matcher": "...", "hooks": [{ "type": "command", ... }] }
-// Copilot and Qoder CLI use the flatter settings shape:
-// { "type": "command", "matcher": "...", "command": "...", ... }
+// Copilot uses the flatter settings shape:
+// { "type": "command", "matcher": "...", "bash": "...", ... }
// Keep the helpers separate so install/uninstall preserves unrelated hooks in
// each agent's native format instead of normalizing user configuration.
fn ensure_direct_command_hook(
@@ -2223,13 +2279,18 @@ fn ensure_direct_command_hook(
.as_array_mut()
.ok_or_else(|| io::Error::other(format!("hook entries for {event} must be an array")))?;
+ let command_field = direct_command_field();
if let Some(entry) = entries.iter_mut().find(|entry| {
entry.get("type").and_then(Value::as_str) == Some("command")
- && entry.get("command").and_then(Value::as_str) == Some(command.as_str())
+ && is_matching_direct_command_entry(entry, command.as_str())
}) {
let Some(entry_object) = entry.as_object_mut() else {
return Ok(());
};
+ entry_object.remove("command");
+ entry_object.remove("bash");
+ entry_object.remove("powershell");
+ entry_object.insert(command_field.to_string(), Value::String(command.clone()));
entry_object.insert("timeoutSec".to_string(), Value::Number(timeout_sec.into()));
match matcher {
Some(matcher) => {
@@ -2247,12 +2308,26 @@ fn ensure_direct_command_hook(
if let Some(matcher) = matcher {
entry.insert("matcher".to_string(), Value::String(matcher.to_string()));
}
- entry.insert("command".to_string(), Value::String(command));
+ entry.insert(command_field.to_string(), Value::String(command));
entry.insert("timeoutSec".to_string(), Value::Number(timeout_sec.into()));
entries.push(Value::Object(entry));
Ok(())
}
+fn direct_command_field() -> &'static str {
+ if cfg!(windows) {
+ "powershell"
+ } else {
+ "bash"
+ }
+}
+
+fn is_matching_direct_command_entry(entry: &Value, command: &str) -> bool {
+ entry.get("command").and_then(Value::as_str) == Some(command)
+ || entry.get("bash").and_then(Value::as_str) == Some(command)
+ || entry.get("powershell").and_then(Value::as_str) == Some(command)
+}
+
fn remove_command_hook(
hooks: &mut Map<String, Value>,
event: &str,
@@ -2311,7 +2386,7 @@ fn remove_direct_command_hook(
let before = entries.len();
entries.retain(|entry| {
!(entry.get("type").and_then(Value::as_str) == Some("command")
- && entry.get("command").and_then(Value::as_str) == Some(command))
+ && is_matching_direct_command_entry(entry, command))
});
let removed = entries.len() != before;
if entries.is_empty() {
@@ -3061,6 +3136,10 @@ fn opencode_dir() -> io::Result<PathBuf> {
Ok(home_dir()?.join(".config/opencode"))
}
+fn kilo_dir() -> io::Result<PathBuf> {
+ Ok(home_dir()?.join(".config/kilo"))
+}
+
fn hermes_dir() -> io::Result<PathBuf> {
Ok(home_dir()?.join(".hermes"))
}
@@ -3192,6 +3271,7 @@ mod tests {
assert!(!integration_target_supported(IntegrationTarget::Pi));
assert!(!integration_target_supported(IntegrationTarget::Omp));
assert!(!integration_target_supported(IntegrationTarget::Opencode));
+ assert!(!integration_target_supported(IntegrationTarget::Kilo));
assert!(!integration_target_supported(IntegrationTarget::Hermes));
assert!(!integration_target_supported(IntegrationTarget::Cursor));
@@ -3218,12 +3298,14 @@ mod tests {
fs::write(bin.join("pi.cmd"), "@echo off\r\n").unwrap();
fs::write(bin.join("omp.cmd"), "@echo off\r\n").unwrap();
fs::write(bin.join("opencode.cmd"), "@echo off\r\n").unwrap();
+ fs::write(bin.join("kilo.cmd"), "@echo off\r\n").unwrap();
fs::write(bin.join("hermes.exe"), "").unwrap();
fs::write(bin.join("cursor-agent.cmd"), "@echo off\r\n").unwrap();
assert!(!integration_target_available(IntegrationTarget::Pi));
assert!(!integration_target_available(IntegrationTarget::Omp));
assert!(!integration_target_available(IntegrationTarget::Opencode));
+ assert!(!integration_target_available(IntegrationTarget::Kilo));
assert!(!integration_target_available(IntegrationTarget::Hermes));
assert!(!integration_target_available(IntegrationTarget::Cursor));
@@ -4362,7 +4444,7 @@ mod tests {
);
assert_eq!(installed.config_path, kimi_dir.join("config.toml"));
assert_eq!(hook_content, KIMI_HOOK_ASSET);
- assert_eq!(hooks.len(), 11);
+ assert_eq!(hooks.len(), KIMI_HOOK_EVENTS.len() + 1);
assert!(config.contains("default_model = \"moonshot\""));
assert!(config.contains("command = \"echo keep\""));
assert!(config.contains(KIMI_CONFIG_BLOCK_BEGIN));
@@ -4481,9 +4563,17 @@ mod tests {
let home = base.join("home");
let copilot_dir = home.join(".copilot");
fs::create_dir_all(&copilot_dir).unwrap();
+ let hook_path = copilot_dir.join("hooks").join(COPILOT_HOOK_INSTALL_NAME);
+ let stale_session_start_command = format!(
+ "bash {}",
+ shell_single_quote(&hook_path.display().to_string())
+ );
fs::write(
copilot_dir.join("settings.json"),
- r#"{"theme":"dark","hooks":{"PreToolUse":[{"type":"command","command":"echo keep","timeoutSec":10}]}}"#,
+ format!(
+ r#"{{"theme":"dark","hooks":{{"PreToolUse":[{{"type":"command","command":"echo keep","timeoutSec":10}}],"sessionStart":[{{"type":"command","bash":{},"timeoutSec":10}}]}}}}"#,
+ serde_json::to_string(&stale_session_start_command).unwrap()
+ ),
)
.unwrap();
std::env::set_var("HOME", &home);
@@ -4500,35 +4590,28 @@ mod tests {
assert_eq!(installed.settings_path, copilot_dir.join("settings.json"));
assert_eq!(hook_content, COPILOT_HOOK_ASSET);
assert_eq!(settings["theme"], "dark");
- assert_eq!(settings["hooks"]["PreToolUse"].as_array().unwrap().len(), 2);
+ assert_eq!(settings["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
assert_eq!(settings["hooks"]["PreToolUse"][0]["command"], "echo keep");
- assert!(settings["hooks"]["PreToolUse"][1]["command"]
- .as_str()
- .unwrap()
- .contains(COPILOT_HOOK_INSTALL_NAME));
- assert!(settings["hooks"]["PostToolUse"][0].get("matcher").is_none());
- assert!(settings["hooks"]["PostToolUseFailure"][0]
- .get("matcher")
- .is_none());
- assert_eq!(
- settings["hooks"]["notification"][0]["matcher"],
- "permission_prompt|elicitation_dialog|agent_idle"
- );
- assert!(settings["hooks"]["Stop"][0]["command"]
- .as_str()
- .unwrap()
- .contains(COPILOT_HOOK_INSTALL_NAME));
- assert!(settings["hooks"]["agentStop"][0]["command"]
+ assert!(settings["hooks"]["SessionStart"][0][direct_command_field()]
.as_str()
.unwrap()
.contains(COPILOT_HOOK_INSTALL_NAME));
+ for event in COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ if let Some(entries) = settings["hooks"].get(event) {
+ assert!(
+ !entries.to_string().contains(COPILOT_HOOK_INSTALL_NAME),
+ "expected herdr hooks.{event} entries to be removed"
+ );
+ }
+ }
+ assert!(settings["hooks"].get("sessionStart").is_none());
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
}
#[test]
- fn copilot_v1_integration_status_is_current() {
+ fn copilot_v1_integration_status_is_outdated() {
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
@@ -4550,8 +4633,8 @@ mod tests {
assert_eq!(copilot.path, hook_path);
assert_eq!(copilot.installed_version, Some(1));
- assert_eq!(copilot.expected_version, 1);
- assert_eq!(copilot.state, IntegrationStatusKind::Current);
+ assert_eq!(copilot.expected_version, COPILOT_INTEGRATION_VERSION);
+ assert_eq!(copilot.state, IntegrationStatusKind::Outdated);
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
@@ -4580,22 +4663,13 @@ mod tests {
settings["hooks"]["SessionStart"].as_array().unwrap().len(),
1
);
- assert_eq!(settings["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
- assert_eq!(
- settings["hooks"]["PostToolUse"].as_array().unwrap().len(),
- 1
- );
- assert_eq!(
- settings["hooks"]["PostToolUseFailure"]
- .as_array()
- .unwrap()
- .len(),
- 1
- );
- assert_eq!(
- settings["hooks"]["notification"].as_array().unwrap().len(),
- 1
- );
+ for event in COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ assert!(
+ settings["hooks"].get(event).is_none(),
+ "expected hooks.{event} to be absent"
+ );
+ }
+ assert!(settings["hooks"].get("sessionStart").is_none());
clear_integration_path_env();
let _ = fs::remove_dir_all(base);
@@ -4618,14 +4692,14 @@ mod tests {
let settings = serde_json::json!({
"hooks": {
"PreToolUse": [
- {"type": "command", "command": command, "timeoutSec": 10},
+ {"type": "command", direct_command_field(): command, "timeoutSec": 10},
{"type": "command", "command": "echo keep", "timeoutSec": 10}
],
- "PostToolUse": [{"type": "command", "command": command, "timeoutSec": 10}],
+ "PostToolUse": [{"type": "command", direct_command_field(): command, "timeoutSec": 10}],
"notification": [{
"type": "command",
"matcher": "permission_prompt|elicitation_dialog|agent_idle",
- "command": command,
+ direct_command_field(): command,
"timeoutSec": 10
}]
}
@@ -4721,6 +4795,15 @@ mod tests {
assert!(settings["hooks"]["SessionStart"][0]
.get("matcher")
.is_none());
+ for (event, action) in DROID_HOOK_EVENTS {
+ let command = settings["hooks"][event][0]["hooks"][0]["command"]
+ .as_str()
+ .unwrap();
+ assert!(
+ command.contains(DROID_HOOK_INSTALL_NAME) && command.ends_with(action),
+ "expected droid {event} hook command to end with {action}, got {command}"
+ );
+ }
assert_eq!(legacy_hooks["hooks"]["PreToolUse"][0]["matcher"], "Read");
assert!(legacy_hooks["hooks"].get("SessionStart").is_none());
@@ -4743,17 +4826,20 @@ mod tests {
let settings: Value =
serde_json::from_str(&fs::read_to_string(droid_dir.join("settings.json")).unwrap())
.unwrap();
- assert_eq!(
- settings["hooks"]["SessionStart"].as_array().unwrap().len(),
- 1
- );
+ for (event, _) in DROID_HOOK_EVENTS {
+ assert_eq!(
+ settings["hooks"][event].as_array().unwrap().len(),
+ 1,
+ "expected hooks.{event} to be idempotent"
+ );
+ }
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
}
#[test]
- fn droid_v1_integration_status_is_current() {
+ fn droid_v1_integration_status_is_outdated() {
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
@@ -4775,8 +4861,8 @@ mod tests {
assert_eq!(droid.path, hook_path);
assert_eq!(droid.installed_version, Some(1));
- assert_eq!(droid.expected_version, 1);
- assert_eq!(droid.state, IntegrationStatusKind::Current);
+ assert_eq!(droid.expected_version, DROID_INTEGRATION_VERSION);
+ assert_eq!(droid.state, IntegrationStatusKind::Outdated);
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
@@ -4925,6 +5011,67 @@ mod tests {
}
#[test]
+ fn install_kilo_writes_plugin_to_plugin_dir() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let kilo_dir = home.join(".config/kilo");
+ fs::create_dir_all(&kilo_dir).unwrap();
+ std::env::set_var("HOME", &home);
+
+ let installed = install_kilo().unwrap();
+ let plugin_content = fs::read_to_string(&installed.plugin_path).unwrap();
+
+ assert_eq!(
+ installed.plugin_path,
+ kilo_dir.join("plugin").join(KILO_PLUGIN_INSTALL_NAME)
+ );
+ assert_eq!(plugin_content, KILO_PLUGIN_ASSET);
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn uninstall_kilo_removes_plugin_when_present() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let kilo_plugin_dir = home.join(".config/kilo/plugin");
+ fs::create_dir_all(&kilo_plugin_dir).unwrap();
+ fs::write(
+ kilo_plugin_dir.join(KILO_PLUGIN_INSTALL_NAME),
+ KILO_PLUGIN_ASSET,
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let result = uninstall_kilo().unwrap();
+
+ assert!(result.removed_plugin);
+ assert!(!result.plugin_path.exists());
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn install_kilo_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_kilo().unwrap_err().to_string();
+
+ assert!(err.contains("kilo config directory not found"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
fn install_hermes_writes_plugin_and_enables_it() {
let _lock = integration_env_lock();
let base = unique_base();
@@ -5216,34 +5363,37 @@ mod tests {
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!(KIMI_HOOK_ASSET.contains("agent_session_id"));
+ assert!(KIMI_HOOK_ASSET.contains("pane.report_agent_session"));
+ assert!(!KIMI_HOOK_ASSET.contains("\"state\": action"));
+ assert!(!KIMI_HOOK_ASSET.contains("pane.release_agent"));
assert!(COPILOT_HOOK_ASSET.contains("agent_session_id"));
- assert!(COPILOT_HOOK_ASSET.contains("notification_type"));
- assert!(COPILOT_HOOK_ASSET.contains("ask_user"));
- assert!(COPILOT_HOOK_ASSET.contains("exit_plan_mode"));
- assert!(DROID_HOOK_ASSET.contains("hook_event_name"));
- assert!(DROID_HOOK_ASSET.contains("SessionStart"));
+ assert!(COPILOT_HOOK_ASSET.contains("pane.report_agent_session"));
+ assert!(!COPILOT_HOOK_ASSET.contains("\"state\":"));
+ assert!(!COPILOT_HOOK_ASSET.contains("pane.release_agent"));
assert!(DROID_HOOK_ASSET.contains("agent_session_id"));
assert!(DROID_HOOK_ASSET.contains("pane.report_agent_session"));
- assert!(!DROID_HOOK_ASSET.contains("\"state\":"));
+ assert!(!DROID_HOOK_ASSET.contains("\"state\": action"));
assert!(!DROID_HOOK_ASSET.contains("pane.release_agent"));
assert!(OPENCODE_PLUGIN_ASSET.contains("properties?.sessionID"));
- assert!(OPENCODE_PLUGIN_ASSET.contains("agent_session_id: sessionID"));
+ assert!(OPENCODE_PLUGIN_ASSET.contains("params.agent_session_id = sessionID"));
assert!(OPENCODE_PLUGIN_ASSET.contains("pane.report_agent_session"));
- assert!(!OPENCODE_PLUGIN_ASSET.contains("reportState"));
- assert!(!OPENCODE_PLUGIN_ASSET.contains("pane.release_agent"));
+ assert!(OPENCODE_PLUGIN_ASSET.contains("reportState"));
+ assert!(OPENCODE_PLUGIN_ASSET.contains("pane.release_agent"));
+ assert!(KILO_PLUGIN_ASSET.contains("SOURCE = \"herdr:kilo\""));
+ assert!(KILO_PLUGIN_ASSET.contains("AGENT = \"kilo\""));
+ assert!(KILO_PLUGIN_ASSET.contains("pane.report_agent_session"));
+ assert!(KILO_PLUGIN_ASSET.contains("reportState"));
+ assert!(KILO_PLUGIN_ASSET.contains("pane.release_agent"));
assert!(HERMES_PLUGIN_INIT_ASSET.contains("session_id = _session_id(kwargs)"));
assert!(HERMES_PLUGIN_INIT_ASSET.contains("agent_session_id"));
assert!(HERMES_PLUGIN_INIT_ASSET.contains("pane.report_agent\","));
assert!(HERMES_PLUGIN_INIT_ASSET.contains("pane.release_agent"));
- // Qoder hook reads the event from the stdin JSON payload (per
- // https://docs.qoder.com/zh/cli/hooks). Make sure the bundled script
- // never reaches for a QODER_HOOK_EVENT environment variable.
assert!(QODERCLI_HOOK_ASSET.contains("HERDR_HOOK_INPUT_FILE"));
- assert!(QODERCLI_HOOK_ASSET.contains("hook_event_name"));
assert!(QODERCLI_HOOK_ASSET.contains("agent_session_id"));
+ assert!(QODERCLI_HOOK_ASSET.contains("pane.report_agent_session"));
+ assert!(!QODERCLI_HOOK_ASSET.contains("\"state\": action"));
+ assert!(!QODERCLI_HOOK_ASSET.contains("pane.release_agent"));
assert!(!QODERCLI_HOOK_ASSET.contains("QODER_HOOK_EVENT"));
assert!(CURSOR_HOOK_ASSET.contains("HERDR_INTEGRATION_ID=cursor"));
assert!(CURSOR_HOOK_ASSET.contains("conversation_id"));
@@ -5285,18 +5435,16 @@ mod tests {
.get("hooks")
.and_then(Value::as_object)
.expect("hooks should be present");
- for event in [
- "SessionStart",
- "UserPromptSubmit",
- "PreToolUse",
- "PermissionRequest",
- "Stop",
- "SessionEnd",
- ] {
+ for (event, action) in QODERCLI_HOOK_EVENTS {
assert!(
hooks.contains_key(event),
"expected hooks.{event} to be registered"
);
+ let command = hooks[event][0]["hooks"][0]["command"].as_str().unwrap();
+ assert!(
+ command.contains(QODERCLI_HOOK_INSTALL_NAME) && command.ends_with(action),
+ "expected qodercli {event} hook command to end with {action}, got {command}"
+ );
}
// Pre-existing settings keys must be preserved.
assert!(settings.get("permissions").is_some());
@@ -5320,14 +5468,7 @@ mod tests {
serde_json::from_str(&fs::read_to_string(qoder_dir.join("settings.json")).unwrap())
.unwrap();
let hooks = settings.get("hooks").and_then(Value::as_object).unwrap();
- for event in [
- "SessionStart",
- "UserPromptSubmit",
- "PreToolUse",
- "PermissionRequest",
- "Stop",
- "SessionEnd",
- ] {
+ for (event, _) in QODERCLI_HOOK_EVENTS {
let entries = hooks.get(event).and_then(Value::as_array).unwrap();
assert_eq!(
entries.len(),
@@ -5353,7 +5494,7 @@ mod tests {
let mut settings: Value =
serde_json::from_str(&fs::read_to_string(qoder_dir.join("settings.json")).unwrap())
.unwrap();
- settings["hooks"]["UserPromptSubmit"]
+ settings["hooks"]["SessionStart"]
.as_array_mut()
.unwrap()
.push(json!({
@@ -5374,10 +5515,7 @@ mod tests {
serde_json::from_str(&fs::read_to_string(qoder_dir.join("settings.json")).unwrap())
.unwrap();
let hooks = settings.get("hooks").and_then(Value::as_object).unwrap();
- let remaining = hooks
- .get("UserPromptSubmit")
- .and_then(Value::as_array)
- .unwrap();
+ let remaining = hooks.get("SessionStart").and_then(Value::as_array).unwrap();
assert_eq!(remaining.len(), 1);
let cmd = remaining[0]["hooks"][0]["command"].as_str().unwrap();
assert_eq!(cmd, "echo user-defined");