aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/integration/assets/claude/herdr-agent-state.ps12
-rw-r--r--src/integration/assets/claude/herdr-agent-state.sh2
-rw-r--r--src/integration/claude_settings.rs65
-rw-r--r--src/integration/mod.rs2
-rw-r--r--src/integration/tests.rs24
5 files changed, 81 insertions, 14 deletions
diff --git a/src/integration/assets/claude/herdr-agent-state.ps1 b/src/integration/assets/claude/herdr-agent-state.ps1
index fc596865..d62c5b9f 100644
--- a/src/integration/assets/claude/herdr-agent-state.ps1
+++ b/src/integration/assets/claude/herdr-agent-state.ps1
@@ -2,7 +2,7 @@
# managed by herdr; reinstalling or updating the integration overwrites this file.
# add custom hooks beside this file instead of editing it.
# HERDR_INTEGRATION_ID=claude
-# HERDR_INTEGRATION_VERSION=9
+# HERDR_INTEGRATION_VERSION=10
param([string]$Action = "")
diff --git a/src/integration/assets/claude/herdr-agent-state.sh b/src/integration/assets/claude/herdr-agent-state.sh
index 5d11a80c..e0b88ce7 100644
--- a/src/integration/assets/claude/herdr-agent-state.sh
+++ b/src/integration/assets/claude/herdr-agent-state.sh
@@ -3,7 +3,7 @@
# managed by herdr; reinstalling or updating the integration overwrites this file.
# add custom hooks beside this file instead of editing it.
# HERDR_INTEGRATION_ID=claude
-# HERDR_INTEGRATION_VERSION=9
+# HERDR_INTEGRATION_VERSION=10
set -eu
diff --git a/src/integration/claude_settings.rs b/src/integration/claude_settings.rs
index 5bc61d75..5fa2df88 100644
--- a/src/integration/claude_settings.rs
+++ b/src/integration/claude_settings.rs
@@ -14,6 +14,10 @@ use super::config_edit::{
is_matching_command_hook,
};
+// Claude's documented SessionStart sources. Grok imports Claude hooks but uses
+// `new`/`load`; filter before it starts an unnecessary hook process.
+const SESSION_START_MATCHER: &str = "^(startup|resume|clear|compact|fork)$";
+
struct HookRemoval {
event: &'static str,
actions: &'static [&'static str],
@@ -74,7 +78,7 @@ pub(crate) fn install(content: &str, settings_path: &Path, hook_path: &Path) ->
"SessionStart",
hook_command(hook_path, Some("session")),
10,
- Some("*"),
+ Some(SESSION_START_MATCHER),
)?;
if desired == original {
@@ -345,7 +349,7 @@ fn removal_commands(policy: &HookRemoval, hook_path: &Path) -> Vec<String> {
fn canonical_hook_value(hook_path: &Path) -> Value {
serde_json_value!({
- "matcher": "*",
+ "matcher": SESSION_START_MATCHER,
"hooks": [{
"type": "command",
"command": hook_command(hook_path, Some("session")),
@@ -357,7 +361,7 @@ fn canonical_hook_value(hook_path: &Path) -> Value {
fn canonical_hook_input(hook_path: &Path) -> CstInputValue {
let command = hook_command(hook_path, Some("session"));
json!({
- matcher: "*",
+ matcher: SESSION_START_MATCHER,
hooks: [{
"type": "command",
command: command,
@@ -515,7 +519,7 @@ fn append_to_container(
fn canonical_hook_json(hook_path: &Path) -> io::Result<String> {
let command = serde_json::to_string(&hook_command(hook_path, Some("session")))?;
Ok(format!(
- "{{\"matcher\":\"*\",\"hooks\":[{{\"type\":\"command\",\"command\":{command},\"timeout\":10}}]}}"
+ "{{\"matcher\":\"{SESSION_START_MATCHER}\",\"hooks\":[{{\"type\":\"command\",\"command\":{command},\"timeout\":10}}]}}"
))
}
@@ -676,11 +680,29 @@ mod tests {
}
#[test]
+ fn install_scopes_claude_session_start_sources() {
+ let (settings_path, hook_path) = paths();
+ let installed = install("{}", settings_path, hook_path).unwrap();
+ let settings: Value = serde_json::from_str(&installed).unwrap();
+ let matcher = settings["hooks"]["SessionStart"][0]["matcher"]
+ .as_str()
+ .unwrap();
+ assert_eq!(matcher, "^(startup|resume|clear|compact|fork)$");
+ let pattern = regex::Regex::new(matcher).unwrap();
+ for source in ["startup", "resume", "clear", "compact", "fork"] {
+ assert!(pattern.is_match(source), "Claude source: {source}");
+ }
+ for source in ["new", "load", "", "future-source", "startup-extra"] {
+ assert!(!pattern.is_match(source), "non-Claude source: {source}");
+ }
+ }
+
+ #[test]
fn install_is_a_byte_exact_noop_for_a_canonical_hook() {
let (settings_path, hook_path) = paths();
let command = serde_json::to_string(&hook_command(hook_path, Some("session"))).unwrap();
let input = format!(
- "{{\"hooks\":{{\"SessionStart\":[{{\"hooks\":[{{\"timeout\":10,\"command\":{command},\"type\":\"command\"}}],\"matcher\":\"*\"}}]}},\"escaped\":\"\\u0061\"}} \r\n\r\n"
+ "{{\"hooks\":{{\"SessionStart\":[{{\"hooks\":[{{\"timeout\":10,\"command\":{command},\"type\":\"command\"}}],\"matcher\":\"{SESSION_START_MATCHER}\"}}]}},\"escaped\":\"\\u0061\"}} \r\n\r\n"
);
let updated = install(&input, settings_path, hook_path).unwrap();
@@ -689,6 +711,39 @@ mod tests {
}
#[test]
+ fn install_migrates_wildcard_session_start_and_preserves_user_hook() {
+ let (settings_path, hook_path) = paths();
+ let command = serde_json::to_string(&hook_command(hook_path, Some("session"))).unwrap();
+ let user_hook = r#"{ "type" : "command", "command" : "echo keep", "timeout" : 3 }"#;
+ let input = format!(
+ "{{\n \"hooks\": {{\n \"SessionStart\": [{{\"matcher\":\"*\",\"hooks\":[{{\"type\":\"command\",\"command\":{command},\"timeout\":10}},{user_hook}]}}]\n }}\n}}\n\n"
+ );
+ let installed = install(&input, settings_path, hook_path).unwrap();
+ assert!(installed.contains(user_hook));
+ assert!(installed.ends_with("}\n\n"));
+ let settings: Value = serde_json::from_str(&installed).unwrap();
+ let groups = settings["hooks"]["SessionStart"].as_array().unwrap();
+ assert_eq!(groups.len(), 2);
+ assert_eq!(groups[0]["matcher"], "*");
+ assert_eq!(groups[0]["hooks"].as_array().unwrap().len(), 1);
+ assert_eq!(groups[0]["hooks"][0]["command"], "echo keep");
+ assert_eq!(groups[1], canonical_hook_value(hook_path));
+ assert_eq!(
+ install(&installed, settings_path, hook_path).unwrap(),
+ installed
+ );
+
+ let removed = uninstall(&installed, settings_path, hook_path).unwrap();
+ assert!(removed.contains(user_hook));
+ assert!(!removed.contains(&command));
+ let settings: Value = serde_json::from_str(&removed).unwrap();
+ assert_eq!(
+ settings["hooks"]["SessionStart"].as_array().unwrap().len(),
+ 1
+ );
+ }
+
+ #[test]
fn install_preserves_canonical_session_start_position_during_migration() {
let (settings_path, hook_path) = paths();
let canonical = canonical_hook_json(hook_path).unwrap();
diff --git a/src/integration/mod.rs b/src/integration/mod.rs
index 55dcb2be..a40ad970 100644
--- a/src/integration/mod.rs
+++ b/src/integration/mod.rs
@@ -38,7 +38,7 @@ const CLAUDE_HOOK_ASSET: &str = if cfg!(windows) {
} else {
include_str!("assets/claude/herdr-agent-state.sh")
};
-const CLAUDE_INTEGRATION_VERSION: u32 = 9;
+const CLAUDE_INTEGRATION_VERSION: u32 = 10;
const CODEX_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
"herdr-agent-state.ps1"
} else {
diff --git a/src/integration/tests.rs b/src/integration/tests.rs
index e8aad72b..4c2178cc 100644
--- a/src/integration/tests.rs
+++ b/src/integration/tests.rs
@@ -957,7 +957,10 @@ fn install_claude_writes_hook_and_updates_settings() {
);
assert_eq!(hook_content, CLAUDE_HOOK_ASSET);
assert!(settings["permissions"]["allow"].is_array());
- assert_eq!(settings["hooks"]["SessionStart"][0]["matcher"], "*");
+ assert_eq!(
+ settings["hooks"]["SessionStart"][0]["matcher"],
+ "^(startup|resume|clear|compact|fork)$"
+ );
assert!(settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
@@ -1105,7 +1108,7 @@ fn install_claude_removes_deprecated_completion_hooks_and_preserves_user_hooks()
}
#[test]
-fn claude_v1_integration_status_is_outdated() {
+fn claude_v9_integration_status_is_outdated_until_reinstalled() {
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
@@ -1114,7 +1117,7 @@ fn claude_v1_integration_status_is_outdated() {
let hook_path = claude_hooks_dir.join(CLAUDE_HOOK_INSTALL_NAME);
fs::write(
&hook_path,
- "#!/bin/sh\n# HERDR_INTEGRATION_ID=claude\n# HERDR_INTEGRATION_VERSION=1\n",
+ "#!/bin/sh\n# HERDR_INTEGRATION_ID=claude\n# HERDR_INTEGRATION_VERSION=9\n",
)
.unwrap();
std::env::set_var("HOME", &home);
@@ -1126,10 +1129,19 @@ fn claude_v1_integration_status_is_outdated() {
.unwrap();
assert_eq!(claude.path, hook_path);
- assert_eq!(claude.installed_version, Some(1));
- assert_eq!(claude.expected_version, 9);
+ assert_eq!(claude.installed_version, Some(9));
+ assert_eq!(claude.expected_version, 10);
assert_eq!(claude.state, IntegrationStatusKind::Outdated);
+ install_claude().unwrap();
+ let status = integration_status_at(
+ crate::api::schema::IntegrationTarget::Claude,
+ hook_path,
+ CLAUDE_INTEGRATION_VERSION,
+ );
+ assert_eq!(status.installed_version, Some(10));
+ assert_eq!(status.state, IntegrationStatusKind::Current);
+
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
}
@@ -1157,7 +1169,7 @@ fn claude_v2_integration_status_is_outdated() {
assert_eq!(claude.path, hook_path);
assert_eq!(claude.installed_version, Some(2));
- assert_eq!(claude.expected_version, 9);
+ assert_eq!(claude.expected_version, 10);
assert_eq!(claude.state, IntegrationStatusKind::Outdated);
std::env::remove_var("HOME");