Skip to main content

muster/adapter/
hooks.rs

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
25/// Claude Code user settings relative to the user's home directory.
26const CLAUDE_SETTINGS: &str = ".claude/settings.json";
27/// Default Codex configuration directory relative to the user's home directory.
28const CODEX_CONFIG_DIR: &str = ".codex";
29/// Codex hook configuration file relative to the Codex configuration directory.
30const CODEX_HOOKS_FILE: &str = "hooks.json";
31/// Environment variable overriding Codex's configuration directory.
32const CODEX_HOME_ENV: &str = "CODEX_HOME";
33/// Gemini CLI user settings relative to the user's home directory.
34const GEMINI_SETTINGS: &str = ".gemini/settings.json";
35/// Copilot CLI's dedicated Muster hook relative to the user's home directory.
36const COPILOT_HOOK: &str = ".copilot/hooks/muster.json";
37/// Kimi Code configuration relative to the user's home directory.
38const KIMI_CONFIG: &str = ".kimi-code/config.toml";
39/// Amp's dedicated Muster plugin relative to the platform config directory.
40const AMP_PLUGIN: &str = "amp/plugins/muster.ts";
41/// OpenCode's dedicated Muster plugin relative to its XDG config directory.
42const OPENCODE_PLUGIN: &str = "opencode/plugins/muster.js";
43/// Environment variable controlling the XDG configuration root.
44const XDG_CONFIG_HOME_ENV: &str = "XDG_CONFIG_HOME";
45/// XDG configuration root used when the environment does not override it.
46const XDG_CONFIG_HOME_DEFAULT: &str = ".config";
47/// Lifecycle event used by JSON-configured providers.
48const SESSION_START_EVENT: &str = "SessionStart";
49/// Lifecycle event used by Copilot's camel-case hook format.
50const COPILOT_SESSION_START_EVENT: &str = "sessionStart";
51/// Copilot hook key holding the PowerShell command.
52#[cfg(windows)]
53const COPILOT_POWERSHELL_KEY: &str = "powershell";
54/// Copilot hook key holding the POSIX shell command.
55#[cfg(not(windows))]
56const COPILOT_BASH_KEY: &str = "bash";
57/// Kimi lifecycle matcher covering new, resumed, and reset sessions.
58const KIMI_SESSION_MATCHER: &str = "startup|resume|clear";
59/// Canonical lifecycle event accepted by the versioned wire protocol.
60const PROTOCOL_SESSION_STARTED: &str = "session_started";
61/// Hidden CLI subcommand containing provider lifecycle commands.
62const HOOK_SUBCOMMAND: &str = "hook";
63/// Hidden CLI action that records a provider lifecycle event.
64const CAPTURE_SUBCOMMAND: &str = "capture";
65/// Forms the `hook capture` invocation takes in configs: the shell command
66/// pair and the JSON argument-array pair, used to tell Stale from Missing.
67const STALE_MARKERS: [&str; 2] = ["hook capture", "\"hook\",\"capture\""];
68/// CLI argument identifying the provider that emitted a lifecycle event.
69const CAPTURE_PROVIDER_ARGUMENT: &str = "--provider";
70/// CLI argument identifying the provider process that invoked a capture hook.
71const CAPTURE_PROCESS_ID_ARGUMENT: &str = "--process-id";
72/// CLI argument identifying the provider process's immediate parent.
73const CAPTURE_PARENT_PROCESS_ID_ARGUMENT: &str = "--parent-process-id";
74/// Line-comment prefixes across provider config formats (TOML/shell-style
75/// and JS/TS), used to skip hooks the provider never evaluates.
76const COMMENT_PREFIXES: [&str; 2] = ["#", "//"];
77/// Maximum provider-config symlinks followed before treating the path as a
78/// cycle or an unreasonable chain.
79const MAX_PROVIDER_CONFIG_SYMLINKS: usize = 40;
80
81/// Failures while installing or receiving provider lifecycle integrations.
82#[derive(Debug, Error)]
83pub enum HookError {
84    /// No user directories could be resolved on this platform.
85    #[error("no user configuration directory is available")]
86    NoUserDirs,
87    /// The running executable path cannot be represented in provider config.
88    #[error("the muster executable path is not valid UTF-8")]
89    InvalidExecutable,
90    /// A hook payload did not contain a usable provider session identity.
91    #[error("the hook payload does not contain a session ID")]
92    MissingSessionId,
93    /// A hook did not report a valid parent provider process ID.
94    #[error("the hook did not report a valid provider process ID")]
95    MissingProviderProcessId,
96    /// A canonical event uses a protocol version this binary does not support.
97    #[error("unsupported agent protocol version {0}")]
98    UnsupportedProtocolVersion(u64),
99    /// A canonical event name is not part of the supported protocol version.
100    #[error("unsupported agent protocol event {0}")]
101    UnsupportedProtocolEvent(String),
102    /// A versioned event omitted its required event name.
103    #[error("the agent protocol event name is missing")]
104    MissingProtocolEvent,
105    /// A file could not be read.
106    #[error("could not read hook config {path}: {source}")]
107    Read {
108        /// File that failed to load.
109        path: PathBuf,
110        /// Underlying I/O error.
111        source: std::io::Error,
112    },
113    /// A file could not be written.
114    #[error("could not write hook config {path}: {source}")]
115    Write {
116        /// File that failed to update.
117        path: PathBuf,
118        /// Underlying I/O error.
119        source: std::io::Error,
120    },
121    /// Existing JSON configuration is invalid.
122    #[error("could not parse hook config {path}: {source}")]
123    Json {
124        /// Invalid configuration file.
125        path: PathBuf,
126        /// JSON parse failure.
127        source: serde_json::Error,
128    },
129    /// Existing TOML configuration is invalid.
130    #[error("could not parse hook config {path}: {source}")]
131    Toml {
132        /// Invalid configuration file.
133        path: PathBuf,
134        /// TOML parse failure.
135        source: toml_edit::TomlError,
136    },
137    /// Existing configuration has an incompatible field shape.
138    #[error("hook config {0} has an incompatible schema")]
139    Schema(PathBuf),
140    /// Reading hook JSON from stdin failed.
141    #[error("could not read the provider hook payload: {0}")]
142    PayloadRead(#[from] std::io::Error),
143    /// The provider hook payload is invalid JSON.
144    #[error("could not parse the provider hook payload: {0}")]
145    PayloadJson(#[from] serde_json::Error),
146    /// Encoding an owned provider plugin failed.
147    #[error("could not encode a provider integration: {0}")]
148    PluginEncoding(serde_json::Error),
149    /// A provider configuration path contains a symlink cycle or excessive
150    /// indirection.
151    #[error("provider hook config symlink chain is too deep at {0}")]
152    SymlinkDepth(PathBuf),
153    /// A provider without a managed integration file was asked for status.
154    #[error("provider {0} has no managed hook integration")]
155    UnmanagedProvider(AgentTool),
156}
157
158/// Installation state of one provider integration file.
159#[derive(Clone, Copy, Debug, Display, PartialEq, Eq)]
160#[strum(serialize_all = "lowercase")]
161pub enum HookState {
162    /// No muster entry exists in the provider's config.
163    Missing,
164    /// A muster entry exists but references another muster binary.
165    Stale,
166    /// The muster entry references the given executable.
167    Installed,
168}
169
170/// One provider integration's location and state.
171#[derive(Debug, Getters, CopyGetters, TypedBuilder)]
172pub struct HookStatus {
173    /// The provider this integration belongs to.
174    #[getset(get_copy = "pub")]
175    provider: AgentTool,
176    /// The config or plugin file the integration lives in.
177    #[getset(get = "pub")]
178    path: PathBuf,
179    /// Whether the integration is present and current.
180    #[getset(get_copy = "pub")]
181    state: HookState,
182}
183
184/// The exact artifact setup writes for one provider, compared verbatim when
185/// deciding Installed.
186enum ExpectedHook {
187    /// Command string in the grouped `SessionStart` JSON schema.
188    GroupedCommand(String),
189    /// Command string under Copilot's platform command key.
190    CopilotCommand(String),
191    /// Command string in a Kimi `[[hooks]]` table.
192    KimiCommand(String),
193    /// The complete plugin file contents.
194    Plugin(String),
195}
196
197/// Installs opt-in provider integrations and receives their session identities.
198pub struct ProviderHooks;
199
200impl ProviderHooks {
201    /// Installs idempotent user-level hooks/plugins for every supported provider.
202    /// Returns the paths checked or updated.
203    ///
204    /// # Errors
205    /// Returns a [`HookError`] without overwriting malformed existing configs.
206    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    /// Captures a provider session ID from hook JSON. Hooks outside a
223    /// Muster-owned agent process are deliberately ignored.
224    ///
225    /// # Errors
226    /// Returns a [`HookError`] for malformed payloads and a config error when the
227    /// session store cannot record the identity.
228    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    /// Resolves OpenCode's XDG configuration root independently of the
258    /// platform-native configuration directory.
259    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    /// The provider hook/plugin file locations paired with their provider, in installation order.
267    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    /// Reports each provider integration's state for `executable`, without
285    /// modifying anything. Detection is textual: a file that mentions the
286    /// executable's path is installed; one that mentions the capture
287    /// subcommand without that path was installed by another muster binary.
288    ///
289    /// # Errors
290    /// Returns a [`HookError`] when the user's directories cannot be resolved
291    /// or the executable path is not valid UTF-8.
292    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    /// Directory-injected form of [`Self::status`], used directly in tests.
309    ///
310    /// # Errors
311    /// Returns a [`HookError`] when the executable path is not valid UTF-8.
312    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    /// The exact artifact setup would write for `provider` and `executable`:
336    /// the canonical command string for command-backed providers, or the full
337    /// plugin file for plugin-backed ones. Installed means the provider's
338    /// canonical schema slot equals this, so no textual coincidence in
339    /// unrelated content can read as healthy.
340    ///
341    /// # Errors
342    /// Returns a [`HookError`] when the command or plugin cannot be built.
343    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            // External integrations own their configs; muster installs none.
357            AgentTool::Custom => return Err(HookError::UnmanagedProvider(provider)),
358        })
359    }
360
361    /// Textual state of one integration file, matched against every encoded
362    /// form of the executable path.
363    fn file_state(path: &Path, expected: &ExpectedHook) -> HookState {
364        let Ok(content) = fs::read_to_string(path) else {
365            // An unreadable config reads as absent; a later hooks setup surfaces the real error.
366            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            // Some muster capture text survives on evaluated lines but does
380            // not match what setup writes: another binary, provider, or shape.
381            HookState::Stale
382        } else {
383            HookState::Missing
384        }
385    }
386
387    /// The lines a provider actually evaluates: comments (TOML/shell `#`,
388    /// JS `//`) are dropped; JSON has no comments so it passes through.
389    /// Best effort: block comments are not tracked.
390    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    /// Whether evaluated content still carries a muster capture invocation:
404    /// the `hook capture` subcommand pair as commands write it, or the JSON
405    /// argument-array pair as plugins write it. A lone unrelated word like a
406    /// `capture` permission never counts.
407    fn has_stale_marker(active: &str) -> bool {
408        STALE_MARKERS.iter().any(|marker| active.contains(marker))
409    }
410
411    /// Whether the grouped `SessionStart` schema holds exactly `command`.
412    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    /// Whether Copilot's `sessionStart` schema holds exactly `command` under
434    /// the platform's command key.
435    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    /// Whether a Kimi `[[hooks]]` table holds exactly `command` with the
454    /// session event and matcher setup writes.
455    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        // Inline `hooks = [{...}]` arrays are the same hooks spelled the
477        // other valid way; verify them identically.
478        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    /// Installs every provider integration under explicit testable roots.
496    ///
497    /// # Errors
498    /// Returns a [`HookError`] if any existing config is malformed or a write
499    /// cannot complete.
500    #[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    /// Installs integrations with an explicit Codex configuration directory.
517    ///
518    /// # Errors
519    /// Returns a [`HookError`] if any existing config is malformed or a write
520    /// cannot complete.
521    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    /// Returns the CLI arguments for one provider's lifecycle callback.
557    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    /// Builds a POSIX shell command for provider hook formats that accept one
567    /// command string.
568    ///
569    /// # Errors
570    /// Returns a [`HookError`] if the executable cannot be safely quoted.
571    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    /// Builds a PowerShell invocation, including the call operator required for
580    /// a quoted executable path.
581    #[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    /// Reconciles Muster's provider-specific entry in a grouped `SessionStart`
591    /// hook array, replacing stale executable paths and removing duplicates.
592    ///
593    /// # Errors
594    /// Returns a [`HookError`] for malformed JSON, incompatible shapes, or I/O.
595    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    /// Whether `command` invokes Muster's capture receiver for `provider`,
651    /// regardless of the executable path installed by an earlier version.
652    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    /// Whether an entry became an empty Muster-owned group after duplicate
662    /// capture commands were removed.
663    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    /// Writes Copilot's dedicated user hook file.
674    ///
675    /// # Errors
676    /// Returns a [`HookError`] if the owned hook file cannot be written.
677    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    /// Reconciles Kimi's provider-specific `SessionStart` hook while preserving
695    /// unrelated TOML and hook entries.
696    ///
697    /// # Errors
698    /// Returns a [`HookError`] for malformed TOML, incompatible shapes, or I/O.
699    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        // An inline `hooks = [{...}]` array is valid, semantically identical
714        // TOML; normalize it so both spellings install and verify.
715        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    /// Returns an Amp plugin that reports `event.thread.id` on session start.
775    ///
776    /// # Errors
777    /// Returns a [`HookError`] if the executable cannot be JSON encoded.
778    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    /// Returns an OpenCode plugin that reports IDs from session lifecycle events.
807    ///
808    /// # Errors
809    /// Returns a [`HookError`] if the executable cannot be JSON encoded.
810    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    /// Extracts a provider session ID from the canonical protocol or a native
889    /// compatibility payload.
890    ///
891    /// # Errors
892    /// Returns a [`HookError`] for unsupported versions or absent identities.
893    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    /// Reads JSON or returns an empty object when the file does not exist.
918    ///
919    /// # Errors
920    /// Returns a [`HookError`] for I/O or parse failures.
921    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    /// Reads UTF-8 text or returns empty text when the file is absent.
933    ///
934    /// # Errors
935    /// Returns a [`HookError`] when an existing file cannot be read.
936    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    /// Returns an object field, creating it only when absent.
948    ///
949    /// # Errors
950    /// Returns a [`HookError`] when an existing field is not an object.
951    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    /// Returns an array field, creating it only when absent.
964    ///
965    /// # Errors
966    /// Returns a [`HookError`] when an existing field is not an array.
967    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    /// Serializes formatted JSON and writes it atomically.
980    ///
981    /// # Errors
982    /// Returns a [`HookError`] if serialization or writing fails.
983    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    /// Writes an owned integration file atomically while preserving an existing
993    /// target's permissions and any symlink used to reach it.
994    ///
995    /// # Errors
996    /// Returns a [`HookError`] if path resolution, writing, or replacement fails.
997    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    /// Resolves existing symlinks to their target without requiring the final
1041    /// target to exist, so atomic replacement leaves every alias intact.
1042    ///
1043    /// # Errors
1044    /// Returns a [`HookError`] when metadata or symlink resolution fails.
1045    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    /// Setup is idempotent and preserves unrelated JSON/TOML settings.
1087    #[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    /// Setup replaces owned grouped and Kimi hooks when the executable moves,
1121    /// removing duplicate copies without disturbing unrelated commands.
1122    #[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(&current_kimi).count(), 1);
1206        assert_eq!(kimi.matches(KIMI_SESSION_MATCHER).count(), 1);
1207        fs::remove_dir_all(root_path).unwrap();
1208    }
1209
1210    /// Grouped provider hooks fail closed when an existing group has no hook array.
1211    #[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    /// An explicit Codex home receives its hook rather than the default path.
1233    #[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    /// Canonical events and native camel-case hook payloads share one decoder.
1256    #[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    /// Versioned payloads reject unknown event names instead of interpreting
1276    /// unrelated provider data as a session lifecycle event.
1277    #[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    /// Quoted Windows paths use PowerShell's call operator and escape embedded
1292    /// single quotes.
1293    #[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    /// Owned Node integrations absorb asynchronous spawn and pipe errors when
1307    /// the configured Muster executable is no longer available.
1308    #[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 [&amp, &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    /// A path that is a strict prefix of the installed executable must not
1337    /// match as Installed. Setup with `/opt/muster-old/muster`; checking
1338    /// `/opt/muster-old/mus` (a prefix) must report every provider Stale,
1339    /// while the exact installed path reports Installed.
1340    #[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        // The strict prefix must not read as Installed.
1353        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        // The exact installed path must still report Installed.
1365        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    /// Unrelated text containing the word capture is not a muster hook.
1380    #[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    /// A Kimi config using the inline hooks spelling installs and verifies.
1413    #[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    /// A modified plugin file is stale: installed plugins must equal what
1445    /// setup writes, byte for byte.
1446    #[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    /// A provider token that merely begins with the expected value is not a
1472    /// working callback.
1473    #[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        // Corrupt Claude's callback into tokens clap would reject; every
1484        // ordinary token character must fail, not just the dash.
1485        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    /// Suffix matches after valid filename punctuation (colons, spaces) are
1515    /// rejected: boundaries come from writer delimiters, not path guessing.
1516    #[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    /// A marker never matches inside a longer path to another binary.
1544    #[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        // Installed for /usr/bin/muster; probed for the suffix /bin/muster.
1553        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    /// A hook invoking the wrong provider callback is not healthy.
1577    #[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        // Corrupt Claude's hook to invoke the Gemini callback.
1588        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    /// A hook the provider never evaluates (commented out) is not installed.
1610    #[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        // Disable the Kimi hook the way a user would: comment every line out.
1621        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    /// Status distinguishes missing, stale, and installed hook files.
1644    #[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        // Nothing installed yet: everything is missing.
1655        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        // Install for this executable: everything is installed.
1663        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        // A different binary path: the same files are now stale.
1672        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    /// `status_in` reports Installed for every provider when the executable path
1684    /// contains characters that JSON-encode differently from their raw form.
1685    /// A backslash is a valid POSIX path character that JSON encodes as `\\`,
1686    /// so the Amp and OpenCode plugin files embed the escaped form while
1687    /// `file_state` would previously search only for the raw path.
1688    #[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        // Path with a backslash: JSON encodes it as \\, so content.contains(raw)
1697        // would miss the Amp and OpenCode plugin files.
1698        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    /// Atomic replacement retains restrictive permissions from an existing
1716    /// provider settings file.
1717    #[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    /// Rewriting a symlinked provider config updates its target without
1739    /// replacing the dotfile-managed alias.
1740    #[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    /// First-time setup follows a relative dangling symlink and creates its
1765    /// target without replacing the dotfile-managed alias.
1766    #[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}