1use std::{
2 env, fs,
3 io::{Read, Write},
4 path::{Path, PathBuf},
5};
6
7use atomic_write_file::AtomicWriteFile;
8use directories::BaseDirs;
9use getset::{CopyGetters, Getters};
10use serde_json::{Map, Value, json};
11use strum::Display;
12use thiserror::Error;
13use toml_edit::{ArrayOfTables, DocumentMut, Item, Table};
14use typed_builder::TypedBuilder;
15
16use crate::{
17 constants::MUSTER_AGENT_SESSION_ENV,
18 domain::{
19 agent_session::{AgentProcessId, AgentSessionId, NativeSessionId},
20 port::AgentSessionStore,
21 process::{AGENT_PROTOCOL_VERSION, AgentTool},
22 },
23};
24
25const CLAUDE_SETTINGS: &str = ".claude/settings.json";
27const CODEX_CONFIG_DIR: &str = ".codex";
29const CODEX_HOOKS_FILE: &str = "hooks.json";
31const CODEX_HOME_ENV: &str = "CODEX_HOME";
33const GEMINI_SETTINGS: &str = ".gemini/settings.json";
35const COPILOT_HOOK: &str = ".copilot/hooks/muster.json";
37const KIMI_CONFIG: &str = ".kimi-code/config.toml";
39const AMP_PLUGIN: &str = "amp/plugins/muster.ts";
41const OPENCODE_PLUGIN: &str = "opencode/plugins/muster.js";
43const XDG_CONFIG_HOME_ENV: &str = "XDG_CONFIG_HOME";
45const XDG_CONFIG_HOME_DEFAULT: &str = ".config";
47const SESSION_START_EVENT: &str = "SessionStart";
49const COPILOT_SESSION_START_EVENT: &str = "sessionStart";
51#[cfg(windows)]
53const COPILOT_POWERSHELL_KEY: &str = "powershell";
54#[cfg(not(windows))]
56const COPILOT_BASH_KEY: &str = "bash";
57const KIMI_SESSION_MATCHER: &str = "startup|resume|clear";
59const PROTOCOL_SESSION_STARTED: &str = "session_started";
61const HOOK_SUBCOMMAND: &str = "hook";
63const CAPTURE_SUBCOMMAND: &str = "capture";
65const STALE_MARKERS: [&str; 2] = ["hook capture", "\"hook\",\"capture\""];
68const CAPTURE_PROVIDER_ARGUMENT: &str = "--provider";
70const CAPTURE_PROCESS_ID_ARGUMENT: &str = "--process-id";
72const CAPTURE_PARENT_PROCESS_ID_ARGUMENT: &str = "--parent-process-id";
74const COMMENT_PREFIXES: [&str; 2] = ["#", "//"];
77const MAX_PROVIDER_CONFIG_SYMLINKS: usize = 40;
80
81#[derive(Debug, Error)]
83pub enum HookError {
84 #[error("no user configuration directory is available")]
86 NoUserDirs,
87 #[error("the muster executable path is not valid UTF-8")]
89 InvalidExecutable,
90 #[error("the hook payload does not contain a session ID")]
92 MissingSessionId,
93 #[error("the hook did not report a valid provider process ID")]
95 MissingProviderProcessId,
96 #[error("unsupported agent protocol version {0}")]
98 UnsupportedProtocolVersion(u64),
99 #[error("unsupported agent protocol event {0}")]
101 UnsupportedProtocolEvent(String),
102 #[error("the agent protocol event name is missing")]
104 MissingProtocolEvent,
105 #[error("could not read hook config {path}: {source}")]
107 Read {
108 path: PathBuf,
110 source: std::io::Error,
112 },
113 #[error("could not write hook config {path}: {source}")]
115 Write {
116 path: PathBuf,
118 source: std::io::Error,
120 },
121 #[error("could not parse hook config {path}: {source}")]
123 Json {
124 path: PathBuf,
126 source: serde_json::Error,
128 },
129 #[error("could not parse hook config {path}: {source}")]
131 Toml {
132 path: PathBuf,
134 source: toml_edit::TomlError,
136 },
137 #[error("hook config {0} has an incompatible schema")]
139 Schema(PathBuf),
140 #[error("could not read the provider hook payload: {0}")]
142 PayloadRead(#[from] std::io::Error),
143 #[error("could not parse the provider hook payload: {0}")]
145 PayloadJson(#[from] serde_json::Error),
146 #[error("could not encode a provider integration: {0}")]
148 PluginEncoding(serde_json::Error),
149 #[error("provider hook config symlink chain is too deep at {0}")]
152 SymlinkDepth(PathBuf),
153 #[error("provider {0} has no managed hook integration")]
155 UnmanagedProvider(AgentTool),
156}
157
158#[derive(Clone, Copy, Debug, Display, PartialEq, Eq)]
160#[strum(serialize_all = "lowercase")]
161pub enum HookState {
162 Missing,
164 Stale,
166 Installed,
168}
169
170#[derive(Debug, Getters, CopyGetters, TypedBuilder)]
172pub struct HookStatus {
173 #[getset(get_copy = "pub")]
175 provider: AgentTool,
176 #[getset(get = "pub")]
178 path: PathBuf,
179 #[getset(get_copy = "pub")]
181 state: HookState,
182}
183
184enum ExpectedHook {
187 GroupedCommand(String),
189 CopilotCommand(String),
191 KimiCommand(String),
193 Plugin(String),
195}
196
197pub struct ProviderHooks;
199
200impl ProviderHooks {
201 pub fn setup(executable: &Path) -> Result<Vec<PathBuf>, HookError> {
207 let dirs = BaseDirs::new().ok_or(HookError::NoUserDirs)?;
208 let xdg_config = Self::xdg_config_dir(dirs.home_dir());
209 let codex_home = env::var_os(CODEX_HOME_ENV)
210 .map(PathBuf::from)
211 .filter(|path| path.is_absolute())
212 .unwrap_or_else(|| dirs.home_dir().join(CODEX_CONFIG_DIR));
213 Self::setup_in_with_codex(
214 executable,
215 dirs.home_dir(),
216 dirs.config_dir(),
217 &xdg_config,
218 &codex_home,
219 )
220 }
221
222 pub fn capture(
229 store: &dyn AgentSessionStore,
230 provider: AgentTool,
231 process_id: u32,
232 parent_process_id: Option<u32>,
233 mut input: impl Read,
234 ) -> Result<bool, crate::error::MusterError> {
235 let Some(internal) = std::env::var_os(MUSTER_AGENT_SESSION_ENV) else {
236 return Ok(false);
237 };
238 let Some(internal) = internal.to_str() else {
239 return Err(HookError::MissingSessionId.into());
240 };
241 let internal =
242 AgentSessionId::try_new(internal).map_err(|_| HookError::MissingSessionId)?;
243 let process_id =
244 AgentProcessId::try_new(process_id).map_err(|_| HookError::MissingProviderProcessId)?;
245 let parent_process_id = parent_process_id
246 .map(AgentProcessId::try_new)
247 .transpose()
248 .map_err(|_| HookError::MissingProviderProcessId)?;
249 let mut raw = String::new();
250 input.read_to_string(&mut raw).map_err(HookError::from)?;
251 let payload: Value = serde_json::from_str(&raw).map_err(HookError::from)?;
252 let native = Self::native_id(&payload)?;
253 store.capture_native_id(&internal, provider, process_id, parent_process_id, native)?;
254 Ok(true)
255 }
256
257 fn xdg_config_dir(home: &Path) -> PathBuf {
260 env::var_os(XDG_CONFIG_HOME_ENV)
261 .map(PathBuf::from)
262 .filter(|path| path.is_absolute())
263 .unwrap_or_else(|| home.join(XDG_CONFIG_HOME_DEFAULT))
264 }
265
266 fn provider_paths(
268 home: &Path,
269 config: &Path,
270 xdg_config: &Path,
271 codex_home: &Path,
272 ) -> [(AgentTool, PathBuf); 7] {
273 [
274 (AgentTool::Claude, home.join(CLAUDE_SETTINGS)),
275 (AgentTool::Codex, codex_home.join(CODEX_HOOKS_FILE)),
276 (AgentTool::Gemini, home.join(GEMINI_SETTINGS)),
277 (AgentTool::Copilot, home.join(COPILOT_HOOK)),
278 (AgentTool::Kimi, home.join(KIMI_CONFIG)),
279 (AgentTool::Amp, config.join(AMP_PLUGIN)),
280 (AgentTool::Opencode, xdg_config.join(OPENCODE_PLUGIN)),
281 ]
282 }
283
284 pub fn status(executable: &Path) -> Result<Vec<HookStatus>, HookError> {
293 let dirs = BaseDirs::new().ok_or(HookError::NoUserDirs)?;
294 let xdg_config = Self::xdg_config_dir(dirs.home_dir());
295 let codex_home = env::var_os(CODEX_HOME_ENV)
296 .map(PathBuf::from)
297 .filter(|path| path.is_absolute())
298 .unwrap_or_else(|| dirs.home_dir().join(CODEX_CONFIG_DIR));
299 Self::status_in(
300 executable,
301 dirs.home_dir(),
302 dirs.config_dir(),
303 &xdg_config,
304 &codex_home,
305 )
306 }
307
308 fn status_in(
313 executable: &Path,
314 home: &Path,
315 config: &Path,
316 xdg_config: &Path,
317 codex_home: &Path,
318 ) -> Result<Vec<HookStatus>, HookError> {
319 let executable = executable.to_str().ok_or(HookError::InvalidExecutable)?;
320 let pairs = Self::provider_paths(home, config, xdg_config, codex_home);
321 pairs
322 .into_iter()
323 .map(|(provider, path)| {
324 let expected = Self::expected_hook(executable, provider)?;
325 let state = Self::file_state(&path, &expected);
326 Ok(HookStatus::builder()
327 .provider(provider)
328 .path(path)
329 .state(state)
330 .build())
331 })
332 .collect()
333 }
334
335 fn expected_hook(executable: &str, provider: AgentTool) -> Result<ExpectedHook, HookError> {
344 #[cfg(windows)]
345 let command = Self::powershell_hook_command(executable, provider);
346 #[cfg(not(windows))]
347 let command = Self::posix_hook_command(executable, provider)?;
348 Ok(match provider {
349 AgentTool::Claude | AgentTool::Codex | AgentTool::Gemini => {
350 ExpectedHook::GroupedCommand(command)
351 },
352 AgentTool::Copilot => ExpectedHook::CopilotCommand(command),
353 AgentTool::Kimi => ExpectedHook::KimiCommand(command),
354 AgentTool::Amp => ExpectedHook::Plugin(Self::amp_plugin(executable)?),
355 AgentTool::Opencode => ExpectedHook::Plugin(Self::opencode_plugin(executable)?),
356 AgentTool::Custom => return Err(HookError::UnmanagedProvider(provider)),
358 })
359 }
360
361 fn file_state(path: &Path, expected: &ExpectedHook) -> HookState {
364 let Ok(content) = fs::read_to_string(path) else {
365 return HookState::Missing;
367 };
368 let installed = match expected {
369 ExpectedHook::GroupedCommand(command) => {
370 Self::grouped_json_has_exact(&content, command)
371 },
372 ExpectedHook::CopilotCommand(command) => Self::copilot_has_exact(&content, command),
373 ExpectedHook::KimiCommand(command) => Self::kimi_has_exact(&content, command),
374 ExpectedHook::Plugin(plugin) => content.trim_end() == plugin.trim_end(),
375 };
376 if installed {
377 HookState::Installed
378 } else if Self::has_stale_marker(&Self::active_content(&content)) {
379 HookState::Stale
382 } else {
383 HookState::Missing
384 }
385 }
386
387 fn active_content(content: &str) -> String {
391 content
392 .lines()
393 .filter(|line| {
394 let trimmed = line.trim_start();
395 COMMENT_PREFIXES
396 .iter()
397 .all(|prefix| !trimmed.starts_with(prefix))
398 })
399 .collect::<Vec<_>>()
400 .join("\n")
401 }
402
403 fn has_stale_marker(active: &str) -> bool {
408 STALE_MARKERS.iter().any(|marker| active.contains(marker))
409 }
410
411 fn grouped_json_has_exact(content: &str, command: &str) -> bool {
413 let Ok(root) = serde_json::from_str::<Value>(content) else {
414 return false;
415 };
416 root.get("hooks")
417 .and_then(|hooks| hooks.get(SESSION_START_EVENT))
418 .and_then(Value::as_array)
419 .is_some_and(|entries| {
420 entries.iter().any(|entry| {
421 entry
422 .get("hooks")
423 .and_then(Value::as_array)
424 .is_some_and(|members| {
425 members.iter().any(|member| {
426 member.get("command").and_then(Value::as_str) == Some(command)
427 })
428 })
429 })
430 })
431 }
432
433 fn copilot_has_exact(content: &str, command: &str) -> bool {
436 #[cfg(windows)]
437 let command_key = COPILOT_POWERSHELL_KEY;
438 #[cfg(not(windows))]
439 let command_key = COPILOT_BASH_KEY;
440 let Ok(root) = serde_json::from_str::<Value>(content) else {
441 return false;
442 };
443 root.get("hooks")
444 .and_then(|hooks| hooks.get(COPILOT_SESSION_START_EVENT))
445 .and_then(Value::as_array)
446 .is_some_and(|members| {
447 members
448 .iter()
449 .any(|member| member.get(command_key).and_then(Value::as_str) == Some(command))
450 })
451 }
452
453 fn kimi_has_exact(content: &str, command: &str) -> bool {
456 let Ok(document) = content.parse::<DocumentMut>() else {
457 return false;
458 };
459 let matches_hook = |event: Option<&str>, matcher: Option<&str>, cmd: Option<&str>| {
460 event == Some(SESSION_START_EVENT)
461 && matcher == Some(KIMI_SESSION_MATCHER)
462 && cmd == Some(command)
463 };
464 let standard = document
465 .get("hooks")
466 .and_then(Item::as_array_of_tables)
467 .is_some_and(|hooks| {
468 hooks.iter().any(|hook| {
469 matches_hook(
470 hook.get("event").and_then(Item::as_str),
471 hook.get("matcher").and_then(Item::as_str),
472 hook.get("command").and_then(Item::as_str),
473 )
474 })
475 });
476 let inline = document
479 .get("hooks")
480 .and_then(Item::as_array)
481 .is_some_and(|hooks| {
482 hooks.iter().any(|hook| {
483 hook.as_inline_table().is_some_and(|table| {
484 matches_hook(
485 table.get("event").and_then(toml_edit::Value::as_str),
486 table.get("matcher").and_then(toml_edit::Value::as_str),
487 table.get("command").and_then(toml_edit::Value::as_str),
488 )
489 })
490 })
491 });
492 standard || inline
493 }
494
495 #[cfg(test)]
501 fn setup_in(
502 executable: &Path,
503 home: &Path,
504 config: &Path,
505 xdg_config: &Path,
506 ) -> Result<Vec<PathBuf>, HookError> {
507 Self::setup_in_with_codex(
508 executable,
509 home,
510 config,
511 xdg_config,
512 &home.join(CODEX_CONFIG_DIR),
513 )
514 }
515
516 fn setup_in_with_codex(
522 executable: &Path,
523 home: &Path,
524 config: &Path,
525 xdg_config: &Path,
526 codex_home: &Path,
527 ) -> Result<Vec<PathBuf>, HookError> {
528 let executable = executable.to_str().ok_or(HookError::InvalidExecutable)?;
529 #[cfg(windows)]
530 let kimi_command = Self::powershell_hook_command(executable, AgentTool::Kimi);
531 #[cfg(not(windows))]
532 let kimi_command = Self::posix_hook_command(executable, AgentTool::Kimi)?;
533 #[cfg(windows)]
534 let copilot_command = Self::powershell_hook_command(executable, AgentTool::Copilot);
535 #[cfg(not(windows))]
536 let copilot_command = Self::posix_hook_command(executable, AgentTool::Copilot)?;
537 let pairs = Self::provider_paths(home, config, xdg_config, codex_home);
538 let paths: Vec<PathBuf> = pairs.iter().map(|(_, path)| path.clone()).collect();
539 #[cfg(windows)]
540 for (provider, path) in pairs[..3].iter().map(|(p, path)| (*p, path)) {
541 let command = Self::powershell_hook_command(executable, provider);
542 Self::install_grouped_json(path, provider, &command)?;
543 }
544 #[cfg(not(windows))]
545 for (provider, path) in pairs[..3].iter().map(|(p, path)| (*p, path)) {
546 let command = Self::posix_hook_command(executable, provider)?;
547 Self::install_grouped_json(path, provider, &command)?;
548 }
549 Self::install_copilot(&paths[3], &copilot_command)?;
550 Self::install_kimi(&paths[4], AgentTool::Kimi, &kimi_command)?;
551 Self::write_text(&paths[5], &Self::amp_plugin(executable)?)?;
552 Self::write_text(&paths[6], &Self::opencode_plugin(executable)?)?;
553 Ok(paths)
554 }
555
556 fn capture_arguments(provider: AgentTool) -> [String; 4] {
558 [
559 HOOK_SUBCOMMAND.to_string(),
560 CAPTURE_SUBCOMMAND.to_string(),
561 CAPTURE_PROVIDER_ARGUMENT.to_string(),
562 provider.protocol_token().to_string(),
563 ]
564 }
565
566 fn posix_hook_command(executable: &str, provider: AgentTool) -> Result<String, HookError> {
572 let executable = shlex::try_quote(executable).map_err(|_| HookError::InvalidExecutable)?;
573 Ok(format!(
574 "{executable} {} {CAPTURE_PROCESS_ID_ARGUMENT} \"$PPID\" {CAPTURE_PARENT_PROCESS_ID_ARGUMENT} \"$(ps -o ppid= -p \"$PPID\" | tr -d '[:space:]')\"",
575 Self::capture_arguments(provider).join(" "),
576 ))
577 }
578
579 #[cfg(any(windows, test))]
582 fn powershell_hook_command(executable: &str, provider: AgentTool) -> String {
583 let executable = executable.replace('\'', "''");
584 format!(
585 "$provider = (Get-CimInstance -ClassName Win32_Process -Filter \"ProcessId=$PID\").ParentProcessId; $parent = (Get-CimInstance -ClassName Win32_Process -Filter \"ProcessId=$provider\").ParentProcessId; & '{executable}' {} {CAPTURE_PROCESS_ID_ARGUMENT} $provider {CAPTURE_PARENT_PROCESS_ID_ARGUMENT} $parent",
586 Self::capture_arguments(provider).join(" "),
587 )
588 }
589
590 fn install_grouped_json(
596 path: &Path,
597 provider: AgentTool,
598 command: &str,
599 ) -> Result<(), HookError> {
600 let mut root = Self::read_json(path)?;
601 let object = root
602 .as_object_mut()
603 .ok_or_else(|| HookError::Schema(path.to_path_buf()))?;
604 let hooks = Self::object_entry(object, "hooks", path)?;
605 let entries = Self::array_entry(hooks, SESSION_START_EVENT, path)?;
606 let mut installed = false;
607 let mut changed = false;
608 for entry in entries.iter_mut() {
609 let hooks = entry
610 .as_object_mut()
611 .and_then(|entry| entry.get_mut("hooks"))
612 .and_then(Value::as_array_mut)
613 .ok_or_else(|| HookError::Schema(path.to_path_buf()))?;
614 hooks.retain_mut(|hook| {
615 let Some(existing) = hook.get("command").and_then(Value::as_str) else {
616 return true;
617 };
618 if !Self::is_provider_capture_command(existing, provider) {
619 return true;
620 }
621 if installed {
622 changed = true;
623 return false;
624 }
625 installed = true;
626 if existing != command {
627 if let Some(object) = hook.as_object_mut() {
628 object.insert("command".to_string(), Value::String(command.to_string()));
629 }
630 changed = true;
631 }
632 true
633 });
634 }
635 let entry_count = entries.len();
636 entries.retain(|entry| !Self::is_empty_owned_hook_group(entry));
637 changed |= entries.len() != entry_count;
638 if !installed {
639 entries.push(json!({
640 "hooks": [{ "type": "command", "command": command }]
641 }));
642 changed = true;
643 }
644 if changed {
645 Self::write_json(path, &root)?;
646 }
647 Ok(())
648 }
649
650 fn is_provider_capture_command(command: &str, provider: AgentTool) -> bool {
653 shlex::split(command).is_some_and(|arguments| {
654 arguments
655 .as_slice()
656 .windows(Self::capture_arguments(provider).len())
657 .any(|arguments| arguments == Self::capture_arguments(provider))
658 })
659 }
660
661 fn is_empty_owned_hook_group(entry: &Value) -> bool {
664 entry.as_object().is_some_and(|entry| {
665 entry.len() == 1
666 && entry
667 .get("hooks")
668 .and_then(Value::as_array)
669 .is_some_and(Vec::is_empty)
670 })
671 }
672
673 fn install_copilot(path: &Path, command: &str) -> Result<(), HookError> {
678 #[cfg(windows)]
679 let command_key = COPILOT_POWERSHELL_KEY;
680 #[cfg(not(windows))]
681 let command_key = COPILOT_BASH_KEY;
682 let hook = json!({
683 "version": 1,
684 "hooks": {
685 COPILOT_SESSION_START_EVENT: [{
686 "type": "command",
687 command_key: command
688 }]
689 }
690 });
691 Self::write_json(path, &hook)
692 }
693
694 fn install_kimi(path: &Path, provider: AgentTool, command: &str) -> Result<(), HookError> {
700 let raw = Self::read_text(path)?;
701 let mut document = if raw.trim().is_empty() {
702 DocumentMut::new()
703 } else {
704 raw.parse::<DocumentMut>()
705 .map_err(|source| HookError::Toml {
706 path: path.to_path_buf(),
707 source,
708 })?
709 };
710 if document.get("hooks").is_none() {
711 document["hooks"] = Item::ArrayOfTables(ArrayOfTables::new());
712 }
713 if let Some(inline) = document["hooks"].as_array().cloned() {
716 let mut tables = ArrayOfTables::new();
717 for value in inline.iter() {
718 let table = value
719 .as_inline_table()
720 .cloned()
721 .map(toml_edit::InlineTable::into_table)
722 .ok_or_else(|| HookError::Schema(path.to_path_buf()))?;
723 tables.push(table);
724 }
725 document["hooks"] = Item::ArrayOfTables(tables);
726 }
727 let hooks = document["hooks"]
728 .as_array_of_tables_mut()
729 .ok_or_else(|| HookError::Schema(path.to_path_buf()))?;
730 let mut installed = false;
731 let mut changed = false;
732 let mut duplicates = Vec::new();
733 for (index, hook) in hooks.iter_mut().enumerate() {
734 let Some(existing) = hook.get("command").and_then(Item::as_str) else {
735 continue;
736 };
737 if hook.get("event").and_then(Item::as_str) != Some(SESSION_START_EVENT)
738 || !Self::is_provider_capture_command(existing, provider)
739 {
740 continue;
741 }
742 if installed {
743 duplicates.push(index);
744 continue;
745 }
746 installed = true;
747 if existing != command {
748 hook["command"] = toml_edit::value(command);
749 changed = true;
750 }
751 if hook.get("matcher").and_then(Item::as_str) != Some(KIMI_SESSION_MATCHER) {
752 hook["matcher"] = toml_edit::value(KIMI_SESSION_MATCHER);
753 changed = true;
754 }
755 }
756 for index in duplicates.into_iter().rev() {
757 hooks.remove(index);
758 changed = true;
759 }
760 if !installed {
761 let mut hook = Table::new();
762 hook["event"] = toml_edit::value(SESSION_START_EVENT);
763 hook["matcher"] = toml_edit::value(KIMI_SESSION_MATCHER);
764 hook["command"] = toml_edit::value(command);
765 hooks.push(hook);
766 changed = true;
767 }
768 if changed {
769 Self::write_text(path, &document.to_string())?;
770 }
771 Ok(())
772 }
773
774 fn amp_plugin(executable: &str) -> Result<String, HookError> {
779 let executable = serde_json::to_string(executable).map_err(HookError::PluginEncoding)?;
780 let owner =
781 serde_json::to_string(MUSTER_AGENT_SESSION_ENV).map_err(HookError::PluginEncoding)?;
782 let arguments = serde_json::to_string(&Self::capture_arguments(AgentTool::Amp))
783 .map_err(HookError::PluginEncoding)?;
784 Ok(format!(
785 r#"import {{ spawn }} from "node:child_process"
786import type {{ PluginAPI }} from "@ampcode/plugin"
787
788const executable = {executable}
789const active = Boolean(process.env[{owner}])
790
791const capture = (sessionId: string) => {{
792 if (!active) return
793 const child = spawn(executable, [...{arguments}, "{CAPTURE_PROCESS_ID_ARGUMENT}", process.pid.toString(), "{CAPTURE_PARENT_PROCESS_ID_ARGUMENT}", process.ppid.toString()], {{ stdio: ["pipe", "ignore", "ignore"] }})
794 child.on("error", () => {{}})
795 child.stdin.on("error", () => {{}})
796 child.stdin.end(JSON.stringify({{ version: {AGENT_PROTOCOL_VERSION}, event: "session_started", session_id: sessionId }}))
797}}
798
799export default function musterSession(amp: PluginAPI) {{
800 amp.on("session.start", (event) => capture(event.thread.id))
801}}
802"#
803 ))
804 }
805
806 fn opencode_plugin(executable: &str) -> Result<String, HookError> {
811 let executable = serde_json::to_string(executable).map_err(HookError::PluginEncoding)?;
812 let owner =
813 serde_json::to_string(MUSTER_AGENT_SESSION_ENV).map_err(HookError::PluginEncoding)?;
814 let arguments = serde_json::to_string(&Self::capture_arguments(AgentTool::Opencode))
815 .map_err(HookError::PluginEncoding)?;
816 Ok(format!(
817 r#"import {{ spawn }} from "node:child_process"
818
819const executable = {executable}
820const active = Boolean(process.env[{owner}])
821const sessionParents = new Map()
822let activeSessionId
823let capturedSessionId
824let pendingSessionId
825let captureInFlight = false
826
827const flush = () => {{
828 if (!active || captureInFlight || !pendingSessionId || pendingSessionId === capturedSessionId) return
829 const sessionId = pendingSessionId
830 pendingSessionId = undefined
831 captureInFlight = true
832 const child = spawn(executable, [...{arguments}, "{CAPTURE_PROCESS_ID_ARGUMENT}", process.pid.toString(), "{CAPTURE_PARENT_PROCESS_ID_ARGUMENT}", process.ppid.toString()], {{ stdio: ["pipe", "ignore", "ignore"] }})
833 let settled = false
834 const complete = (succeeded) => {{
835 if (settled) return
836 settled = true
837 if (succeeded) capturedSessionId = sessionId
838 captureInFlight = false
839 flush()
840 }}
841 child.on("error", () => complete(false))
842 child.on("exit", (code) => complete(code === 0))
843 child.stdin.on("error", () => complete(false))
844 child.stdin.end(JSON.stringify({{ version: {AGENT_PROTOCOL_VERSION}, event: "session_started", session_id: sessionId }}))
845}}
846
847const capture = (sessionId) => {{
848 if (!active || !sessionId || sessionId === capturedSessionId || sessionId === pendingSessionId) return
849 pendingSessionId = sessionId
850 flush()
851}}
852
853export const MusterSession = async ({{ client }}) => {{
854 const known = await client.session.list().catch(() => undefined)
855 for (const info of known?.data ?? []) sessionParents.set(info.id, info.parentID)
856
857 const select = (sessionId) => {{
858 if (!sessionParents.has(sessionId) || sessionParents.get(sessionId)) return
859 activeSessionId = sessionId
860 capture(sessionId)
861 }}
862
863 return {{
864 event: async ({{ event }}) => {{
865 if (event.type === "session.created" || event.type === "session.updated") {{
866 const info = event.properties?.info ?? event.properties?.session ?? event.properties
867 const sessionId = info?.id ?? info?.sessionID
868 if (sessionId) sessionParents.set(sessionId, info?.parentID)
869 if (sessionId === activeSessionId && !info?.parentID) capture(sessionId)
870 return
871 }}
872 if (event.type === "session.deleted") {{
873 const info = event.properties?.info ?? event.properties?.session ?? event.properties
874 const sessionId = info?.id ?? info?.sessionID
875 if (sessionId) sessionParents.delete(sessionId)
876 if (sessionId === activeSessionId) activeSessionId = undefined
877 return
878 }}
879 if (event.type === "tui.session.select") select(event.properties?.sessionID)
880 }},
881 "chat.message": async (input) => select(input.sessionID),
882 }}
883}}
884"#
885 ))
886 }
887
888 fn native_id(payload: &Value) -> Result<NativeSessionId, HookError> {
894 if let Some(version) = payload.get("version") {
895 let version = version
896 .as_u64()
897 .ok_or(HookError::UnsupportedProtocolVersion(u64::MAX))?;
898 if version != u64::from(AGENT_PROTOCOL_VERSION) {
899 return Err(HookError::UnsupportedProtocolVersion(version));
900 }
901 let event = payload
902 .get("event")
903 .and_then(Value::as_str)
904 .ok_or(HookError::MissingProtocolEvent)?;
905 if event != PROTOCOL_SESSION_STARTED {
906 return Err(HookError::UnsupportedProtocolEvent(event.to_string()));
907 }
908 }
909 let native = payload
910 .get("session_id")
911 .or_else(|| payload.get("sessionId"))
912 .and_then(Value::as_str)
913 .ok_or(HookError::MissingSessionId)?;
914 NativeSessionId::try_new(native).map_err(|_| HookError::MissingSessionId)
915 }
916
917 fn read_json(path: &Path) -> Result<Value, HookError> {
922 let raw = Self::read_text(path)?;
923 if raw.trim().is_empty() {
924 return Ok(Value::Object(Map::new()));
925 }
926 serde_json::from_str(&raw).map_err(|source| HookError::Json {
927 path: path.to_path_buf(),
928 source,
929 })
930 }
931
932 fn read_text(path: &Path) -> Result<String, HookError> {
937 match fs::read_to_string(path) {
938 Ok(raw) => Ok(raw),
939 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
940 Err(source) => Err(HookError::Read {
941 path: path.to_path_buf(),
942 source,
943 }),
944 }
945 }
946
947 fn object_entry<'a>(
952 parent: &'a mut Map<String, Value>,
953 key: &str,
954 path: &Path,
955 ) -> Result<&'a mut Map<String, Value>, HookError> {
956 parent
957 .entry(key)
958 .or_insert_with(|| Value::Object(Map::new()))
959 .as_object_mut()
960 .ok_or_else(|| HookError::Schema(path.to_path_buf()))
961 }
962
963 fn array_entry<'a>(
968 parent: &'a mut Map<String, Value>,
969 key: &str,
970 path: &Path,
971 ) -> Result<&'a mut Vec<Value>, HookError> {
972 parent
973 .entry(key)
974 .or_insert_with(|| Value::Array(Vec::new()))
975 .as_array_mut()
976 .ok_or_else(|| HookError::Schema(path.to_path_buf()))
977 }
978
979 fn write_json(path: &Path, value: &Value) -> Result<(), HookError> {
984 let mut raw = serde_json::to_string_pretty(value).map_err(|source| HookError::Json {
985 path: path.to_path_buf(),
986 source,
987 })?;
988 raw.push('\n');
989 Self::write_text(path, &raw)
990 }
991
992 fn write_text(path: &Path, raw: &str) -> Result<(), HookError> {
998 let destination = Self::write_destination(path)?;
999 if let Some(parent) = destination.parent() {
1000 fs::create_dir_all(parent).map_err(|source| HookError::Write {
1001 path: parent.to_path_buf(),
1002 source,
1003 })?;
1004 }
1005 if fs::read_to_string(&destination).is_ok_and(|current| current == raw) {
1006 return Ok(());
1007 }
1008 let permissions = match fs::metadata(&destination) {
1009 Ok(metadata) => Some(metadata.permissions()),
1010 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
1011 Err(source) => {
1012 return Err(HookError::Read {
1013 path: destination,
1014 source,
1015 });
1016 },
1017 };
1018 let mut file = AtomicWriteFile::open(&destination).map_err(|source| HookError::Write {
1019 path: destination.clone(),
1020 source,
1021 })?;
1022 file.write_all(raw.as_bytes())
1023 .map_err(|source| HookError::Write {
1024 path: destination.clone(),
1025 source,
1026 })?;
1027 if let Some(permissions) = permissions {
1028 file.set_permissions(permissions)
1029 .map_err(|source| HookError::Write {
1030 path: destination.clone(),
1031 source,
1032 })?;
1033 }
1034 file.commit().map_err(|source| HookError::Write {
1035 path: destination,
1036 source,
1037 })
1038 }
1039
1040 fn write_destination(path: &Path) -> Result<PathBuf, HookError> {
1046 let mut destination = path.to_path_buf();
1047 for depth in 0..=MAX_PROVIDER_CONFIG_SYMLINKS {
1048 match fs::symlink_metadata(&destination) {
1049 Ok(metadata) if metadata.file_type().is_symlink() => {
1050 if depth == MAX_PROVIDER_CONFIG_SYMLINKS {
1051 return Err(HookError::SymlinkDepth(destination));
1052 }
1053 let target = fs::read_link(&destination).map_err(|source| HookError::Read {
1054 path: destination.clone(),
1055 source,
1056 })?;
1057 destination = if target.is_absolute() {
1058 target
1059 } else {
1060 match destination.parent() {
1061 Some(parent) => parent.join(target),
1062 None => target,
1063 }
1064 };
1065 },
1066 Ok(_) => return Ok(destination),
1067 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1068 return Ok(destination);
1069 },
1070 Err(source) => {
1071 return Err(HookError::Read {
1072 path: destination,
1073 source,
1074 });
1075 },
1076 }
1077 }
1078 Err(HookError::SymlinkDepth(destination))
1079 }
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084 use super::*;
1085
1086 #[test]
1088 fn setup_preserves_configs_without_duplicating_hooks() {
1089 let root = std::env::temp_dir().join(format!("muster-hooks-{}", uuid::Uuid::new_v4()));
1090 let home = root.join("home");
1091 let config = root.join("config");
1092 let xdg_config = root.join("xdg");
1093 let claude = home.join(CLAUDE_SETTINGS);
1094 let kimi = home.join(KIMI_CONFIG);
1095 ProviderHooks::write_text(&claude, "{\"theme\":\"dark\"}").unwrap();
1096 ProviderHooks::write_text(&kimi, "model = \"kimi\"\n").unwrap();
1097
1098 let paths =
1099 ProviderHooks::setup_in(Path::new("/opt/muster"), &home, &config, &xdg_config).unwrap();
1100 ProviderHooks::setup_in(Path::new("/opt/muster"), &home, &config, &xdg_config).unwrap();
1101
1102 let claude: Value = serde_json::from_str(&fs::read_to_string(claude).unwrap()).unwrap();
1103 assert_eq!(claude["theme"], "dark");
1104 assert_eq!(
1105 claude["hooks"][SESSION_START_EVENT]
1106 .as_array()
1107 .unwrap()
1108 .len(),
1109 1
1110 );
1111 let kimi = fs::read_to_string(kimi).unwrap();
1112 assert!(kimi.contains("model = \"kimi\""));
1113 assert_eq!(kimi.matches("event = \"SessionStart\"").count(), 1);
1114 assert_eq!(paths[6], xdg_config.join(OPENCODE_PLUGIN));
1115 assert!(paths[6].is_file());
1116 assert!(!config.join(OPENCODE_PLUGIN).exists());
1117 fs::remove_dir_all(root).unwrap();
1118 }
1119
1120 #[test]
1123 fn setup_reconciles_outdated_hook_commands() {
1124 const OLD_EXECUTABLE: &str = "/old/muster";
1125 const NEW_EXECUTABLE: &str = "/new/muster";
1126 const UNRELATED_COMMAND: &str = "notify-session";
1127
1128 let root_path =
1129 std::env::temp_dir().join(format!("muster-hook-upgrade-{}", uuid::Uuid::new_v4()));
1130 let home = root_path.join("home");
1131 let config = root_path.join("config");
1132 let xdg_config = root_path.join("xdg");
1133 let claude = home.join(CLAUDE_SETTINGS);
1134 let kimi = home.join(KIMI_CONFIG);
1135 let outdated =
1136 ProviderHooks::posix_hook_command(OLD_EXECUTABLE, AgentTool::Claude).unwrap();
1137 let current = ProviderHooks::posix_hook_command(NEW_EXECUTABLE, AgentTool::Claude).unwrap();
1138 let outdated_kimi =
1139 ProviderHooks::posix_hook_command(OLD_EXECUTABLE, AgentTool::Kimi).unwrap();
1140 let current_kimi =
1141 ProviderHooks::posix_hook_command(NEW_EXECUTABLE, AgentTool::Kimi).unwrap();
1142 ProviderHooks::write_json(
1143 &claude,
1144 &json!({
1145 "hooks": {
1146 SESSION_START_EVENT: [
1147 {
1148 "hooks": [
1149 { "type": "command", "command": outdated },
1150 { "type": "command", "command": UNRELATED_COMMAND }
1151 ]
1152 },
1153 {
1154 "hooks": [
1155 { "type": "command", "command": current }
1156 ]
1157 }
1158 ]
1159 }
1160 }),
1161 )
1162 .unwrap();
1163 let mut kimi_config = DocumentMut::new();
1164 kimi_config["hooks"] = Item::ArrayOfTables(ArrayOfTables::new());
1165 for matcher in ["startup", KIMI_SESSION_MATCHER] {
1166 let mut hook = Table::new();
1167 hook["event"] = toml_edit::value(SESSION_START_EVENT);
1168 hook["matcher"] = toml_edit::value(matcher);
1169 hook["command"] = toml_edit::value(&outdated_kimi);
1170 kimi_config["hooks"]
1171 .as_array_of_tables_mut()
1172 .unwrap()
1173 .push(hook);
1174 }
1175 ProviderHooks::write_text(&kimi, &kimi_config.to_string()).unwrap();
1176
1177 ProviderHooks::setup_in(Path::new(NEW_EXECUTABLE), &home, &config, &xdg_config).unwrap();
1178
1179 let config: Value = serde_json::from_str(&fs::read_to_string(&claude).unwrap()).unwrap();
1180 let commands = config["hooks"][SESSION_START_EVENT]
1181 .as_array()
1182 .unwrap()
1183 .iter()
1184 .filter_map(|entry| entry.get("hooks").and_then(Value::as_array))
1185 .flatten()
1186 .filter_map(|hook| hook.get("command").and_then(Value::as_str))
1187 .collect::<Vec<_>>();
1188 assert_eq!(
1189 commands
1190 .iter()
1191 .filter(|command| **command == current.as_str())
1192 .count(),
1193 1
1194 );
1195 assert_eq!(
1196 commands
1197 .iter()
1198 .filter(|command| **command == UNRELATED_COMMAND)
1199 .count(),
1200 1
1201 );
1202 assert!(!commands.contains(&outdated.as_str()));
1203 let kimi = fs::read_to_string(kimi).unwrap();
1204 assert!(!kimi.contains(&outdated_kimi));
1205 assert_eq!(kimi.matches(¤t_kimi).count(), 1);
1206 assert_eq!(kimi.matches(KIMI_SESSION_MATCHER).count(), 1);
1207 fs::remove_dir_all(root_path).unwrap();
1208 }
1209
1210 #[test]
1212 fn setup_rejects_a_malformed_nested_hook_group() {
1213 let root =
1214 std::env::temp_dir().join(format!("muster-hook-schema-{}", uuid::Uuid::new_v4()));
1215 let path = root.join(CLAUDE_SETTINGS);
1216 ProviderHooks::write_json(
1217 &path,
1218 &json!({
1219 "hooks": {
1220 SESSION_START_EVENT: [{ "hooks": "not-an-array" }]
1221 }
1222 }),
1223 )
1224 .unwrap();
1225
1226 let result = ProviderHooks::install_grouped_json(&path, AgentTool::Claude, "muster hook");
1227
1228 assert!(matches!(result, Err(HookError::Schema(error_path)) if error_path == path));
1229 fs::remove_dir_all(root).unwrap();
1230 }
1231
1232 #[test]
1234 fn setup_uses_the_configured_codex_home() {
1235 let root = std::env::temp_dir().join(format!("muster-codex-home-{}", uuid::Uuid::new_v4()));
1236 let home = root.join("home");
1237 let config = root.join("config");
1238 let xdg_config = root.join("xdg");
1239 let codex_home = root.join("custom-codex");
1240
1241 let paths = ProviderHooks::setup_in_with_codex(
1242 Path::new("/tmp/muster"),
1243 &home,
1244 &config,
1245 &xdg_config,
1246 &codex_home,
1247 )
1248 .unwrap();
1249
1250 assert_eq!(paths[1], codex_home.join("hooks.json"));
1251 assert!(paths[1].is_file());
1252 fs::remove_dir_all(root).unwrap();
1253 }
1254
1255 #[test]
1257 fn decodes_protocol_and_compatibility_payloads() {
1258 let protocol = json!({
1259 "version": AGENT_PROTOCOL_VERSION,
1260 "event": "session_started",
1261 "session_id": "native-one"
1262 });
1263 let native = json!({ "sessionId": "native-two" });
1264
1265 assert_eq!(
1266 ProviderHooks::native_id(&protocol).unwrap().as_ref(),
1267 "native-one"
1268 );
1269 assert_eq!(
1270 ProviderHooks::native_id(&native).unwrap().as_ref(),
1271 "native-two"
1272 );
1273 }
1274
1275 #[test]
1278 fn rejects_unknown_versioned_protocol_events() {
1279 let event = json!({
1280 "version": AGENT_PROTOCOL_VERSION,
1281 "event": "session_closed",
1282 "session_id": "native-one"
1283 });
1284
1285 assert!(matches!(
1286 ProviderHooks::native_id(&event),
1287 Err(HookError::UnsupportedProtocolEvent(name)) if name == "session_closed"
1288 ));
1289 }
1290
1291 #[test]
1294 fn powershell_commands_invoke_quoted_executables() {
1295 let command = ProviderHooks::powershell_hook_command(
1296 r"C:\Program Files\Muster's\muster.exe",
1297 AgentTool::Copilot,
1298 );
1299
1300 assert_eq!(
1301 command,
1302 r#"$provider = (Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$PID").ParentProcessId; $parent = (Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$provider").ParentProcessId; & 'C:\Program Files\Muster''s\muster.exe' hook capture --provider copilot --process-id $provider --parent-process-id $parent"#
1303 );
1304 }
1305
1306 #[test]
1309 fn generated_plugins_handle_capture_process_errors() {
1310 let amp = ProviderHooks::amp_plugin("/missing/muster").unwrap();
1311 let opencode = ProviderHooks::opencode_plugin("/missing/muster").unwrap();
1312
1313 assert!(amp.contains("if (!active) return"));
1314 assert!(opencode.contains("let pendingSessionId"));
1315 assert!(opencode.contains("let captureInFlight = false"));
1316 assert!(opencode.contains("const sessionParents = new Map()"));
1317 assert!(opencode.contains("activeSessionId = sessionId"));
1318 assert!(opencode.contains("sessionId === activeSessionId && !info?.parentID"));
1319 assert!(opencode.contains(r#""chat.message": async (input) => select(input.sessionID)"#));
1320 assert!(opencode.contains("sessionParents.get(sessionId)"));
1321 assert!(!opencode.contains("capture(info?.id"));
1322 assert!(opencode.contains("pendingSessionId = sessionId"));
1323 assert!(opencode.contains(r#"child.on("error", () => complete(false))"#));
1324 assert!(opencode.contains(r#"child.stdin.on("error", () => complete(false))"#));
1325 assert!(amp.contains(CAPTURE_PROVIDER_ARGUMENT));
1326 assert!(amp.contains("amp"));
1327 assert!(opencode.contains(CAPTURE_PROVIDER_ARGUMENT));
1328 assert!(opencode.contains(AgentTool::Opencode.protocol_token()));
1329 for plugin in [&, &opencode] {
1330 assert!(plugin.contains(MUSTER_AGENT_SESSION_ENV));
1331 }
1332 assert!(amp.contains(r#"child.on("error", () => {})"#));
1333 assert!(amp.contains(r#"child.stdin.on("error", () => {})"#));
1334 }
1335
1336 #[test]
1341 fn status_does_not_match_path_prefixes() {
1342 let root = std::env::temp_dir().join(format!("muster-prefix-{}", uuid::Uuid::new_v4()));
1343 let home = root.join("home");
1344 let config = root.join("config");
1345 let xdg = root.join("xdg");
1346 let codex = home.join(CODEX_CONFIG_DIR);
1347 let installed_exe = Path::new("/opt/muster-old/muster");
1348 let prefix_exe = Path::new("/opt/muster-old/mus");
1349
1350 ProviderHooks::setup_in_with_codex(installed_exe, &home, &config, &xdg, &codex).unwrap();
1351
1352 let prefix_statuses =
1354 ProviderHooks::status_in(prefix_exe, &home, &config, &xdg, &codex).unwrap();
1355 for status in &prefix_statuses {
1356 assert_ne!(
1357 status.state(),
1358 HookState::Installed,
1359 "prefix path matched as Installed for provider {}",
1360 status.provider()
1361 );
1362 }
1363
1364 let exact_statuses =
1366 ProviderHooks::status_in(installed_exe, &home, &config, &xdg, &codex).unwrap();
1367 for status in &exact_statuses {
1368 assert_eq!(
1369 status.state(),
1370 HookState::Installed,
1371 "exact path not Installed for provider {}",
1372 status.provider()
1373 );
1374 }
1375
1376 fs::remove_dir_all(root).unwrap();
1377 }
1378
1379 #[test]
1381 fn status_ignores_unrelated_capture_text() {
1382 let root = std::env::temp_dir().join(format!("muster-unrel-{}", uuid::Uuid::new_v4()));
1383 let home = root.join("home");
1384 fs::create_dir_all(home.join(".claude")).unwrap();
1385 fs::write(
1386 home.join(CLAUDE_SETTINGS),
1387 "{\"permissions\":{\"allow\":[\"Bash(asciinema capture:*)\"]}}",
1388 )
1389 .unwrap();
1390
1391 let statuses = ProviderHooks::status_in(
1392 Path::new("/opt/muster/muster"),
1393 &home,
1394 &root.join("config"),
1395 &root.join("xdg"),
1396 &home.join(CODEX_CONFIG_DIR),
1397 )
1398 .unwrap();
1399
1400 let claude_status = statuses
1401 .iter()
1402 .find(|status| status.provider() == AgentTool::Claude)
1403 .expect("claude status present");
1404 assert_eq!(
1405 claude_status.state(),
1406 HookState::Missing,
1407 "an unrelated capture permission is not a stale muster hook"
1408 );
1409 fs::remove_dir_all(root).unwrap();
1410 }
1411
1412 #[test]
1414 fn kimi_inline_hook_arrays_install_and_verify() {
1415 let root = std::env::temp_dir().join(format!("muster-inline-{}", uuid::Uuid::new_v4()));
1416 let home = root.join("home");
1417 let config = root.join("config");
1418 let xdg = root.join("xdg");
1419 let codex = home.join(CODEX_CONFIG_DIR);
1420 fs::create_dir_all(home.join(".kimi-code")).unwrap();
1421 fs::write(
1422 home.join(KIMI_CONFIG),
1423 "hooks = [{ event = \"Other\", command = \"true\" }]\n",
1424 )
1425 .unwrap();
1426 let exe = Path::new("/opt/muster/muster");
1427
1428 ProviderHooks::setup_in_with_codex(exe, &home, &config, &xdg, &codex).unwrap();
1429 let statuses = ProviderHooks::status_in(exe, &home, &config, &xdg, &codex).unwrap();
1430
1431 let kimi_status = statuses
1432 .iter()
1433 .find(|status| status.provider() == AgentTool::Kimi)
1434 .expect("kimi status present");
1435 assert_eq!(kimi_status.state(), HookState::Installed);
1436 let rewritten = fs::read_to_string(home.join(KIMI_CONFIG)).unwrap();
1437 assert!(
1438 rewritten.contains("event = \"Other\""),
1439 "the unrelated inline hook survives normalization"
1440 );
1441 fs::remove_dir_all(root).unwrap();
1442 }
1443
1444 #[test]
1447 fn status_rejects_a_tampered_plugin() {
1448 let root = std::env::temp_dir().join(format!("muster-tamper-{}", uuid::Uuid::new_v4()));
1449 let home = root.join("home");
1450 let config = root.join("config");
1451 let xdg = root.join("xdg");
1452 let codex = home.join(CODEX_CONFIG_DIR);
1453 fs::create_dir_all(&home).unwrap();
1454 let exe = Path::new("/opt/muster/muster");
1455 ProviderHooks::setup_in_with_codex(exe, &home, &config, &xdg, &codex).unwrap();
1456 let plugin = config.join(AMP_PLUGIN);
1457 let mut tampered = fs::read_to_string(&plugin).unwrap();
1458 tampered.push_str("\nconsole.log(\"extra\")\n");
1459 fs::write(&plugin, tampered).unwrap();
1460
1461 let statuses = ProviderHooks::status_in(exe, &home, &config, &xdg, &codex).unwrap();
1462
1463 let amp_status = statuses
1464 .iter()
1465 .find(|status| status.provider() == AgentTool::Amp)
1466 .expect("amp status present");
1467 assert_eq!(amp_status.state(), HookState::Stale);
1468 fs::remove_dir_all(root).unwrap();
1469 }
1470
1471 #[test]
1474 fn status_rejects_a_provider_token_prefix() {
1475 let root = std::env::temp_dir().join(format!("muster-tokpre-{}", uuid::Uuid::new_v4()));
1476 let home = root.join("home");
1477 let config = root.join("config");
1478 let xdg = root.join("xdg");
1479 let codex = home.join(CODEX_CONFIG_DIR);
1480 fs::create_dir_all(&home).unwrap();
1481 let exe = Path::new("/opt/muster/muster");
1482 ProviderHooks::setup_in_with_codex(exe, &home, &config, &xdg, &codex).unwrap();
1483 let claude = home.join(CLAUDE_SETTINGS);
1486 let installed = fs::read_to_string(&claude).unwrap();
1487 for suffix in ["-old", ".old", "+x", ":x"] {
1488 let broken = installed.replace(
1489 &format!(
1490 "{CAPTURE_PROVIDER_ARGUMENT} {}",
1491 AgentTool::Claude.protocol_token()
1492 ),
1493 &format!(
1494 "{CAPTURE_PROVIDER_ARGUMENT} {}{suffix}",
1495 AgentTool::Claude.protocol_token()
1496 ),
1497 );
1498 fs::write(&claude, broken).unwrap();
1499 let statuses = ProviderHooks::status_in(exe, &home, &config, &xdg, &codex).unwrap();
1500 let claude_status = statuses
1501 .iter()
1502 .find(|status| status.provider() == AgentTool::Claude)
1503 .expect("claude status present");
1504 assert_eq!(
1505 claude_status.state(),
1506 HookState::Stale,
1507 "'{suffix}' must break the callback"
1508 );
1509 }
1510 fs::write(&claude, installed).unwrap();
1511 fs::remove_dir_all(root).unwrap();
1512 }
1513
1514 #[test]
1517 fn status_rejects_suffixes_behind_filename_punctuation() {
1518 for prefix in ["/prefix:", "/pre fix"] {
1519 let root = std::env::temp_dir().join(format!("muster-punct-{}", uuid::Uuid::new_v4()));
1520 let home = root.join("home");
1521 let config = root.join("config");
1522 let xdg = root.join("xdg");
1523 let codex = home.join(CODEX_CONFIG_DIR);
1524 fs::create_dir_all(&home).unwrap();
1525 let other = format!("{prefix}/bin/muster");
1526 ProviderHooks::setup_in_with_codex(Path::new(&other), &home, &config, &xdg, &codex)
1527 .unwrap();
1528
1529 let statuses =
1530 ProviderHooks::status_in(Path::new("/bin/muster"), &home, &config, &xdg, &codex)
1531 .unwrap();
1532
1533 assert!(
1534 statuses
1535 .iter()
1536 .all(|status| status.state() == HookState::Stale),
1537 "a suffix behind '{prefix}' must not read installed"
1538 );
1539 fs::remove_dir_all(root).unwrap();
1540 }
1541 }
1542
1543 #[test]
1545 fn status_rejects_a_path_suffix_of_another_executable() {
1546 let root = std::env::temp_dir().join(format!("muster-suffix-{}", uuid::Uuid::new_v4()));
1547 let home = root.join("home");
1548 let config = root.join("config");
1549 let xdg = root.join("xdg");
1550 let codex = home.join(CODEX_CONFIG_DIR);
1551 fs::create_dir_all(&home).unwrap();
1552 let installed = Path::new("/usr/bin/muster");
1554 ProviderHooks::setup_in_with_codex(installed, &home, &config, &xdg, &codex).unwrap();
1555
1556 let statuses =
1557 ProviderHooks::status_in(Path::new("/bin/muster"), &home, &config, &xdg, &codex)
1558 .unwrap();
1559
1560 assert!(
1561 statuses
1562 .iter()
1563 .all(|status| status.state() == HookState::Stale),
1564 "a suffix of another executable's path is stale, not installed"
1565 );
1566 let exact = ProviderHooks::status_in(installed, &home, &config, &xdg, &codex).unwrap();
1567 assert!(
1568 exact
1569 .iter()
1570 .all(|status| status.state() == HookState::Installed),
1571 "the exact executable still reads installed"
1572 );
1573 fs::remove_dir_all(root).unwrap();
1574 }
1575
1576 #[test]
1578 fn status_rejects_a_wrong_provider_callback() {
1579 let root = std::env::temp_dir().join(format!("muster-wrongp-{}", uuid::Uuid::new_v4()));
1580 let home = root.join("home");
1581 let config = root.join("config");
1582 let xdg = root.join("xdg");
1583 let codex = home.join(CODEX_CONFIG_DIR);
1584 fs::create_dir_all(&home).unwrap();
1585 let exe = Path::new("/opt/muster/muster");
1586 ProviderHooks::setup_in_with_codex(exe, &home, &config, &xdg, &codex).unwrap();
1587 let claude = home.join(CLAUDE_SETTINGS);
1589 let wrong = fs::read_to_string(&claude).unwrap().replace(
1590 AgentTool::Claude.protocol_token(),
1591 AgentTool::Gemini.protocol_token(),
1592 );
1593 fs::write(&claude, wrong).unwrap();
1594
1595 let statuses = ProviderHooks::status_in(exe, &home, &config, &xdg, &codex).unwrap();
1596
1597 let claude_status = statuses
1598 .iter()
1599 .find(|status| status.provider() == AgentTool::Claude)
1600 .expect("claude status present");
1601 assert_eq!(
1602 claude_status.state(),
1603 HookState::Stale,
1604 "a wrong-provider callback needs hooks setup again"
1605 );
1606 fs::remove_dir_all(root).unwrap();
1607 }
1608
1609 #[test]
1611 fn status_ignores_commented_out_hooks() {
1612 let root = std::env::temp_dir().join(format!("muster-comment-{}", uuid::Uuid::new_v4()));
1613 let home = root.join("home");
1614 let config = root.join("config");
1615 let xdg = root.join("xdg");
1616 let codex = home.join(CODEX_CONFIG_DIR);
1617 fs::create_dir_all(&home).unwrap();
1618 let exe = Path::new("/opt/muster/muster");
1619 ProviderHooks::setup_in_with_codex(exe, &home, &config, &xdg, &codex).unwrap();
1620 let kimi = home.join(KIMI_CONFIG);
1622 let disabled: String = fs::read_to_string(&kimi)
1623 .unwrap()
1624 .lines()
1625 .map(|line| format!("# {line}\n"))
1626 .collect();
1627 fs::write(&kimi, disabled).unwrap();
1628
1629 let statuses = ProviderHooks::status_in(exe, &home, &config, &xdg, &codex).unwrap();
1630
1631 let kimi_status = statuses
1632 .iter()
1633 .find(|status| status.provider() == AgentTool::Kimi)
1634 .expect("kimi status present");
1635 assert_eq!(
1636 kimi_status.state(),
1637 HookState::Missing,
1638 "a fully commented hook is not active"
1639 );
1640 fs::remove_dir_all(root).unwrap();
1641 }
1642
1643 #[test]
1645 fn status_reports_missing_stale_and_installed() {
1646 let root = std::env::temp_dir().join(format!("muster-status-{}", uuid::Uuid::new_v4()));
1647 let home = root.join("home");
1648 let config = root.join("config");
1649 let xdg = root.join("xdg");
1650 let codex = home.join(CODEX_CONFIG_DIR);
1651 fs::create_dir_all(&home).unwrap();
1652 let exe = Path::new("/opt/muster/muster");
1653
1654 let before = ProviderHooks::status_in(exe, &home, &config, &xdg, &codex).unwrap();
1656 assert!(
1657 before
1658 .iter()
1659 .all(|status| status.state() == HookState::Missing)
1660 );
1661
1662 ProviderHooks::setup_in_with_codex(exe, &home, &config, &xdg, &codex).unwrap();
1664 let after = ProviderHooks::status_in(exe, &home, &config, &xdg, &codex).unwrap();
1665 assert!(
1666 after
1667 .iter()
1668 .all(|status| status.state() == HookState::Installed)
1669 );
1670
1671 let moved = Path::new("/elsewhere/muster");
1673 let stale = ProviderHooks::status_in(moved, &home, &config, &xdg, &codex).unwrap();
1674 assert!(
1675 stale
1676 .iter()
1677 .all(|status| status.state() == HookState::Stale)
1678 );
1679
1680 fs::remove_dir_all(root).unwrap();
1681 }
1682
1683 #[test]
1689 fn status_detects_installed_when_executable_has_special_chars() {
1690 let root =
1691 std::env::temp_dir().join(format!("muster-status-encoded-{}", uuid::Uuid::new_v4()));
1692 let home = root.join("home");
1693 let config = root.join("config");
1694 let xdg = root.join("xdg");
1695 let codex = home.join(CODEX_CONFIG_DIR);
1696 let exe_path_str = format!("{}/mu\\ster/muster", root.display());
1699 let exe_path = Path::new(&exe_path_str);
1700
1701 ProviderHooks::setup_in_with_codex(exe_path, &home, &config, &xdg, &codex).unwrap();
1702 let statuses = ProviderHooks::status_in(exe_path, &home, &config, &xdg, &codex).unwrap();
1703
1704 for status in &statuses {
1705 assert_eq!(
1706 status.state(),
1707 HookState::Installed,
1708 "{} should be Installed",
1709 status.provider()
1710 );
1711 }
1712 fs::remove_dir_all(root).unwrap();
1713 }
1714
1715 #[cfg(unix)]
1718 #[test]
1719 fn atomic_writes_preserve_existing_permissions() {
1720 use std::os::unix::fs::PermissionsExt;
1721
1722 const PRIVATE_MODE: u32 = 0o600;
1723 const PERMISSION_MASK: u32 = 0o777;
1724 let root = std::env::temp_dir().join(format!("muster-hook-mode-{}", uuid::Uuid::new_v4()));
1725 let path = root.join("settings.json");
1726 ProviderHooks::write_text(&path, "old").unwrap();
1727 fs::set_permissions(&path, fs::Permissions::from_mode(PRIVATE_MODE)).unwrap();
1728
1729 ProviderHooks::write_text(&path, "new").unwrap();
1730
1731 assert_eq!(
1732 fs::metadata(&path).unwrap().permissions().mode() & PERMISSION_MASK,
1733 PRIVATE_MODE
1734 );
1735 fs::remove_dir_all(root).unwrap();
1736 }
1737
1738 #[cfg(unix)]
1741 #[test]
1742 fn atomic_writes_preserve_provider_config_symlinks() {
1743 use std::os::unix::fs::symlink;
1744
1745 let root =
1746 std::env::temp_dir().join(format!("muster-hook-symlink-{}", uuid::Uuid::new_v4()));
1747 let target = root.join("managed/settings.json");
1748 let link = root.join("settings.json");
1749 ProviderHooks::write_text(&target, "old").unwrap();
1750 symlink(&target, &link).unwrap();
1751
1752 ProviderHooks::write_text(&link, "new").unwrap();
1753
1754 assert!(
1755 fs::symlink_metadata(&link)
1756 .unwrap()
1757 .file_type()
1758 .is_symlink()
1759 );
1760 assert_eq!(fs::read_to_string(target).unwrap(), "new");
1761 fs::remove_dir_all(root).unwrap();
1762 }
1763
1764 #[cfg(unix)]
1767 #[test]
1768 fn atomic_writes_preserve_dangling_provider_config_symlinks() {
1769 use std::os::unix::fs::symlink;
1770
1771 const RELATIVE_TARGET: &str = "managed/settings.json";
1772 let root =
1773 std::env::temp_dir().join(format!("muster-hook-dangling-{}", uuid::Uuid::new_v4()));
1774 let target = root.join(RELATIVE_TARGET);
1775 let link = root.join("settings.json");
1776 fs::create_dir_all(&root).unwrap();
1777 symlink(RELATIVE_TARGET, &link).unwrap();
1778
1779 ProviderHooks::write_text(&link, "new").unwrap();
1780
1781 assert!(
1782 fs::symlink_metadata(&link)
1783 .unwrap()
1784 .file_type()
1785 .is_symlink()
1786 );
1787 assert_eq!(fs::read_to_string(target).unwrap(), "new");
1788 fs::remove_dir_all(root).unwrap();
1789 }
1790}