use std::{
env, fs,
io::{Read, Write},
path::{Path, PathBuf},
};
use atomic_write_file::AtomicWriteFile;
use directories::BaseDirs;
use getset::{CopyGetters, Getters};
use serde_json::{Map, Value, json};
use strum::Display;
use thiserror::Error;
use toml_edit::{ArrayOfTables, DocumentMut, Item, Table};
use typed_builder::TypedBuilder;
use crate::{
constants::MUSTER_AGENT_SESSION_ENV,
domain::{
agent_session::{AgentProcessId, AgentSessionId, NativeSessionId},
port::AgentSessionStore,
process::{AGENT_PROTOCOL_VERSION, AgentTool},
},
};
const CLAUDE_SETTINGS: &str = ".claude/settings.json";
const CODEX_CONFIG_DIR: &str = ".codex";
const CODEX_HOOKS_FILE: &str = "hooks.json";
const CODEX_HOME_ENV: &str = "CODEX_HOME";
const GEMINI_SETTINGS: &str = ".gemini/settings.json";
const COPILOT_HOOK: &str = ".copilot/hooks/muster.json";
const KIMI_CONFIG: &str = ".kimi-code/config.toml";
const AMP_PLUGIN: &str = "amp/plugins/muster.ts";
const OPENCODE_PLUGIN: &str = "opencode/plugins/muster.js";
const XDG_CONFIG_HOME_ENV: &str = "XDG_CONFIG_HOME";
const XDG_CONFIG_HOME_DEFAULT: &str = ".config";
const SESSION_START_EVENT: &str = "SessionStart";
const COPILOT_SESSION_START_EVENT: &str = "sessionStart";
#[cfg(windows)]
const COPILOT_POWERSHELL_KEY: &str = "powershell";
#[cfg(not(windows))]
const COPILOT_BASH_KEY: &str = "bash";
const KIMI_SESSION_MATCHER: &str = "startup|resume|clear";
const PROTOCOL_SESSION_STARTED: &str = "session_started";
const HOOK_SUBCOMMAND: &str = "hook";
const CAPTURE_SUBCOMMAND: &str = "capture";
const STALE_MARKERS: [&str; 2] = ["hook capture", "\"hook\",\"capture\""];
const CAPTURE_PROVIDER_ARGUMENT: &str = "--provider";
const CAPTURE_PROCESS_ID_ARGUMENT: &str = "--process-id";
const CAPTURE_PARENT_PROCESS_ID_ARGUMENT: &str = "--parent-process-id";
const COMMENT_PREFIXES: [&str; 2] = ["#", "//"];
const MAX_PROVIDER_CONFIG_SYMLINKS: usize = 40;
#[derive(Debug, Error)]
pub enum HookError {
#[error("no user configuration directory is available")]
NoUserDirs,
#[error("the muster executable path is not valid UTF-8")]
InvalidExecutable,
#[error("the hook payload does not contain a session ID")]
MissingSessionId,
#[error("the hook did not report a valid provider process ID")]
MissingProviderProcessId,
#[error("unsupported agent protocol version {0}")]
UnsupportedProtocolVersion(u64),
#[error("unsupported agent protocol event {0}")]
UnsupportedProtocolEvent(String),
#[error("the agent protocol event name is missing")]
MissingProtocolEvent,
#[error("could not read hook config {path}: {source}")]
Read {
path: PathBuf,
source: std::io::Error,
},
#[error("could not write hook config {path}: {source}")]
Write {
path: PathBuf,
source: std::io::Error,
},
#[error("could not parse hook config {path}: {source}")]
Json {
path: PathBuf,
source: serde_json::Error,
},
#[error("could not parse hook config {path}: {source}")]
Toml {
path: PathBuf,
source: toml_edit::TomlError,
},
#[error("hook config {0} has an incompatible schema")]
Schema(PathBuf),
#[error("could not read the provider hook payload: {0}")]
PayloadRead(#[from] std::io::Error),
#[error("could not parse the provider hook payload: {0}")]
PayloadJson(#[from] serde_json::Error),
#[error("could not encode a provider integration: {0}")]
PluginEncoding(serde_json::Error),
#[error("provider hook config symlink chain is too deep at {0}")]
SymlinkDepth(PathBuf),
#[error("provider {0} has no managed hook integration")]
UnmanagedProvider(AgentTool),
}
#[derive(Clone, Copy, Debug, Display, PartialEq, Eq)]
#[strum(serialize_all = "lowercase")]
pub enum HookState {
Missing,
Stale,
Installed,
}
#[derive(Debug, Getters, CopyGetters, TypedBuilder)]
pub struct HookStatus {
#[getset(get_copy = "pub")]
provider: AgentTool,
#[getset(get = "pub")]
path: PathBuf,
#[getset(get_copy = "pub")]
state: HookState,
}
enum ExpectedHook {
GroupedCommand(String),
CopilotCommand(String),
KimiCommand(String),
Plugin(String),
}
pub struct ProviderHooks;
impl ProviderHooks {
pub fn setup(executable: &Path) -> Result<Vec<PathBuf>, HookError> {
let dirs = BaseDirs::new().ok_or(HookError::NoUserDirs)?;
let xdg_config = Self::xdg_config_dir(dirs.home_dir());
let codex_home = env::var_os(CODEX_HOME_ENV)
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.unwrap_or_else(|| dirs.home_dir().join(CODEX_CONFIG_DIR));
Self::setup_in_with_codex(
executable,
dirs.home_dir(),
dirs.config_dir(),
&xdg_config,
&codex_home,
)
}
pub fn capture(
store: &dyn AgentSessionStore,
provider: AgentTool,
process_id: u32,
parent_process_id: Option<u32>,
mut input: impl Read,
) -> Result<bool, crate::error::MusterError> {
let Some(internal) = std::env::var_os(MUSTER_AGENT_SESSION_ENV) else {
return Ok(false);
};
let Some(internal) = internal.to_str() else {
return Err(HookError::MissingSessionId.into());
};
let internal =
AgentSessionId::try_new(internal).map_err(|_| HookError::MissingSessionId)?;
let process_id =
AgentProcessId::try_new(process_id).map_err(|_| HookError::MissingProviderProcessId)?;
let parent_process_id = parent_process_id
.map(AgentProcessId::try_new)
.transpose()
.map_err(|_| HookError::MissingProviderProcessId)?;
let mut raw = String::new();
input.read_to_string(&mut raw).map_err(HookError::from)?;
let payload: Value = serde_json::from_str(&raw).map_err(HookError::from)?;
let native = Self::native_id(&payload)?;
store.capture_native_id(&internal, provider, process_id, parent_process_id, native)?;
Ok(true)
}
fn xdg_config_dir(home: &Path) -> PathBuf {
env::var_os(XDG_CONFIG_HOME_ENV)
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.unwrap_or_else(|| home.join(XDG_CONFIG_HOME_DEFAULT))
}
fn provider_paths(
home: &Path,
config: &Path,
xdg_config: &Path,
codex_home: &Path,
) -> [(AgentTool, PathBuf); 7] {
[
(AgentTool::Claude, home.join(CLAUDE_SETTINGS)),
(AgentTool::Codex, codex_home.join(CODEX_HOOKS_FILE)),
(AgentTool::Gemini, home.join(GEMINI_SETTINGS)),
(AgentTool::Copilot, home.join(COPILOT_HOOK)),
(AgentTool::Kimi, home.join(KIMI_CONFIG)),
(AgentTool::Amp, config.join(AMP_PLUGIN)),
(AgentTool::Opencode, xdg_config.join(OPENCODE_PLUGIN)),
]
}
pub fn status(executable: &Path) -> Result<Vec<HookStatus>, HookError> {
let dirs = BaseDirs::new().ok_or(HookError::NoUserDirs)?;
let xdg_config = Self::xdg_config_dir(dirs.home_dir());
let codex_home = env::var_os(CODEX_HOME_ENV)
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.unwrap_or_else(|| dirs.home_dir().join(CODEX_CONFIG_DIR));
Self::status_in(
executable,
dirs.home_dir(),
dirs.config_dir(),
&xdg_config,
&codex_home,
)
}
fn status_in(
executable: &Path,
home: &Path,
config: &Path,
xdg_config: &Path,
codex_home: &Path,
) -> Result<Vec<HookStatus>, HookError> {
let executable = executable.to_str().ok_or(HookError::InvalidExecutable)?;
let pairs = Self::provider_paths(home, config, xdg_config, codex_home);
pairs
.into_iter()
.map(|(provider, path)| {
let expected = Self::expected_hook(executable, provider)?;
let state = Self::file_state(&path, &expected);
Ok(HookStatus::builder()
.provider(provider)
.path(path)
.state(state)
.build())
})
.collect()
}
fn expected_hook(executable: &str, provider: AgentTool) -> Result<ExpectedHook, HookError> {
#[cfg(windows)]
let command = Self::powershell_hook_command(executable, provider);
#[cfg(not(windows))]
let command = Self::posix_hook_command(executable, provider)?;
Ok(match provider {
AgentTool::Claude | AgentTool::Codex | AgentTool::Gemini => {
ExpectedHook::GroupedCommand(command)
},
AgentTool::Copilot => ExpectedHook::CopilotCommand(command),
AgentTool::Kimi => ExpectedHook::KimiCommand(command),
AgentTool::Amp => ExpectedHook::Plugin(Self::amp_plugin(executable)?),
AgentTool::Opencode => ExpectedHook::Plugin(Self::opencode_plugin(executable)?),
AgentTool::Custom => return Err(HookError::UnmanagedProvider(provider)),
})
}
fn file_state(path: &Path, expected: &ExpectedHook) -> HookState {
let Ok(content) = fs::read_to_string(path) else {
return HookState::Missing;
};
let installed = match expected {
ExpectedHook::GroupedCommand(command) => {
Self::grouped_json_has_exact(&content, command)
},
ExpectedHook::CopilotCommand(command) => Self::copilot_has_exact(&content, command),
ExpectedHook::KimiCommand(command) => Self::kimi_has_exact(&content, command),
ExpectedHook::Plugin(plugin) => content.trim_end() == plugin.trim_end(),
};
if installed {
HookState::Installed
} else if Self::has_stale_marker(&Self::active_content(&content)) {
HookState::Stale
} else {
HookState::Missing
}
}
fn active_content(content: &str) -> String {
content
.lines()
.filter(|line| {
let trimmed = line.trim_start();
COMMENT_PREFIXES
.iter()
.all(|prefix| !trimmed.starts_with(prefix))
})
.collect::<Vec<_>>()
.join("\n")
}
fn has_stale_marker(active: &str) -> bool {
STALE_MARKERS.iter().any(|marker| active.contains(marker))
}
fn grouped_json_has_exact(content: &str, command: &str) -> bool {
let Ok(root) = serde_json::from_str::<Value>(content) else {
return false;
};
root.get("hooks")
.and_then(|hooks| hooks.get(SESSION_START_EVENT))
.and_then(Value::as_array)
.is_some_and(|entries| {
entries.iter().any(|entry| {
entry
.get("hooks")
.and_then(Value::as_array)
.is_some_and(|members| {
members.iter().any(|member| {
member.get("command").and_then(Value::as_str) == Some(command)
})
})
})
})
}
fn copilot_has_exact(content: &str, command: &str) -> bool {
#[cfg(windows)]
let command_key = COPILOT_POWERSHELL_KEY;
#[cfg(not(windows))]
let command_key = COPILOT_BASH_KEY;
let Ok(root) = serde_json::from_str::<Value>(content) else {
return false;
};
root.get("hooks")
.and_then(|hooks| hooks.get(COPILOT_SESSION_START_EVENT))
.and_then(Value::as_array)
.is_some_and(|members| {
members
.iter()
.any(|member| member.get(command_key).and_then(Value::as_str) == Some(command))
})
}
fn kimi_has_exact(content: &str, command: &str) -> bool {
let Ok(document) = content.parse::<DocumentMut>() else {
return false;
};
let matches_hook = |event: Option<&str>, matcher: Option<&str>, cmd: Option<&str>| {
event == Some(SESSION_START_EVENT)
&& matcher == Some(KIMI_SESSION_MATCHER)
&& cmd == Some(command)
};
let standard = document
.get("hooks")
.and_then(Item::as_array_of_tables)
.is_some_and(|hooks| {
hooks.iter().any(|hook| {
matches_hook(
hook.get("event").and_then(Item::as_str),
hook.get("matcher").and_then(Item::as_str),
hook.get("command").and_then(Item::as_str),
)
})
});
let inline = document
.get("hooks")
.and_then(Item::as_array)
.is_some_and(|hooks| {
hooks.iter().any(|hook| {
hook.as_inline_table().is_some_and(|table| {
matches_hook(
table.get("event").and_then(toml_edit::Value::as_str),
table.get("matcher").and_then(toml_edit::Value::as_str),
table.get("command").and_then(toml_edit::Value::as_str),
)
})
})
});
standard || inline
}
#[cfg(test)]
fn setup_in(
executable: &Path,
home: &Path,
config: &Path,
xdg_config: &Path,
) -> Result<Vec<PathBuf>, HookError> {
Self::setup_in_with_codex(
executable,
home,
config,
xdg_config,
&home.join(CODEX_CONFIG_DIR),
)
}
fn setup_in_with_codex(
executable: &Path,
home: &Path,
config: &Path,
xdg_config: &Path,
codex_home: &Path,
) -> Result<Vec<PathBuf>, HookError> {
let executable = executable.to_str().ok_or(HookError::InvalidExecutable)?;
#[cfg(windows)]
let kimi_command = Self::powershell_hook_command(executable, AgentTool::Kimi);
#[cfg(not(windows))]
let kimi_command = Self::posix_hook_command(executable, AgentTool::Kimi)?;
#[cfg(windows)]
let copilot_command = Self::powershell_hook_command(executable, AgentTool::Copilot);
#[cfg(not(windows))]
let copilot_command = Self::posix_hook_command(executable, AgentTool::Copilot)?;
let pairs = Self::provider_paths(home, config, xdg_config, codex_home);
let paths: Vec<PathBuf> = pairs.iter().map(|(_, path)| path.clone()).collect();
#[cfg(windows)]
for (provider, path) in pairs[..3].iter().map(|(p, path)| (*p, path)) {
let command = Self::powershell_hook_command(executable, provider);
Self::install_grouped_json(path, provider, &command)?;
}
#[cfg(not(windows))]
for (provider, path) in pairs[..3].iter().map(|(p, path)| (*p, path)) {
let command = Self::posix_hook_command(executable, provider)?;
Self::install_grouped_json(path, provider, &command)?;
}
Self::install_copilot(&paths[3], &copilot_command)?;
Self::install_kimi(&paths[4], AgentTool::Kimi, &kimi_command)?;
Self::write_text(&paths[5], &Self::amp_plugin(executable)?)?;
Self::write_text(&paths[6], &Self::opencode_plugin(executable)?)?;
Ok(paths)
}
fn capture_arguments(provider: AgentTool) -> [String; 4] {
[
HOOK_SUBCOMMAND.to_string(),
CAPTURE_SUBCOMMAND.to_string(),
CAPTURE_PROVIDER_ARGUMENT.to_string(),
provider.protocol_token().to_string(),
]
}
fn posix_hook_command(executable: &str, provider: AgentTool) -> Result<String, HookError> {
let executable = shlex::try_quote(executable).map_err(|_| HookError::InvalidExecutable)?;
Ok(format!(
"{executable} {} {CAPTURE_PROCESS_ID_ARGUMENT} \"$PPID\" {CAPTURE_PARENT_PROCESS_ID_ARGUMENT} \"$(ps -o ppid= -p \"$PPID\" | tr -d '[:space:]')\"",
Self::capture_arguments(provider).join(" "),
))
}
#[cfg(any(windows, test))]
fn powershell_hook_command(executable: &str, provider: AgentTool) -> String {
let executable = executable.replace('\'', "''");
format!(
"$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",
Self::capture_arguments(provider).join(" "),
)
}
fn install_grouped_json(
path: &Path,
provider: AgentTool,
command: &str,
) -> Result<(), HookError> {
let mut root = Self::read_json(path)?;
let object = root
.as_object_mut()
.ok_or_else(|| HookError::Schema(path.to_path_buf()))?;
let hooks = Self::object_entry(object, "hooks", path)?;
let entries = Self::array_entry(hooks, SESSION_START_EVENT, path)?;
let mut installed = false;
let mut changed = false;
for entry in entries.iter_mut() {
let hooks = entry
.as_object_mut()
.and_then(|entry| entry.get_mut("hooks"))
.and_then(Value::as_array_mut)
.ok_or_else(|| HookError::Schema(path.to_path_buf()))?;
hooks.retain_mut(|hook| {
let Some(existing) = hook.get("command").and_then(Value::as_str) else {
return true;
};
if !Self::is_provider_capture_command(existing, provider) {
return true;
}
if installed {
changed = true;
return false;
}
installed = true;
if existing != command {
if let Some(object) = hook.as_object_mut() {
object.insert("command".to_string(), Value::String(command.to_string()));
}
changed = true;
}
true
});
}
let entry_count = entries.len();
entries.retain(|entry| !Self::is_empty_owned_hook_group(entry));
changed |= entries.len() != entry_count;
if !installed {
entries.push(json!({
"hooks": [{ "type": "command", "command": command }]
}));
changed = true;
}
if changed {
Self::write_json(path, &root)?;
}
Ok(())
}
fn is_provider_capture_command(command: &str, provider: AgentTool) -> bool {
shlex::split(command).is_some_and(|arguments| {
arguments
.as_slice()
.windows(Self::capture_arguments(provider).len())
.any(|arguments| arguments == Self::capture_arguments(provider))
})
}
fn is_empty_owned_hook_group(entry: &Value) -> bool {
entry.as_object().is_some_and(|entry| {
entry.len() == 1
&& entry
.get("hooks")
.and_then(Value::as_array)
.is_some_and(Vec::is_empty)
})
}
fn install_copilot(path: &Path, command: &str) -> Result<(), HookError> {
#[cfg(windows)]
let command_key = COPILOT_POWERSHELL_KEY;
#[cfg(not(windows))]
let command_key = COPILOT_BASH_KEY;
let hook = json!({
"version": 1,
"hooks": {
COPILOT_SESSION_START_EVENT: [{
"type": "command",
command_key: command
}]
}
});
Self::write_json(path, &hook)
}
fn install_kimi(path: &Path, provider: AgentTool, command: &str) -> Result<(), HookError> {
let raw = Self::read_text(path)?;
let mut document = if raw.trim().is_empty() {
DocumentMut::new()
} else {
raw.parse::<DocumentMut>()
.map_err(|source| HookError::Toml {
path: path.to_path_buf(),
source,
})?
};
if document.get("hooks").is_none() {
document["hooks"] = Item::ArrayOfTables(ArrayOfTables::new());
}
if let Some(inline) = document["hooks"].as_array().cloned() {
let mut tables = ArrayOfTables::new();
for value in inline.iter() {
let table = value
.as_inline_table()
.cloned()
.map(toml_edit::InlineTable::into_table)
.ok_or_else(|| HookError::Schema(path.to_path_buf()))?;
tables.push(table);
}
document["hooks"] = Item::ArrayOfTables(tables);
}
let hooks = document["hooks"]
.as_array_of_tables_mut()
.ok_or_else(|| HookError::Schema(path.to_path_buf()))?;
let mut installed = false;
let mut changed = false;
let mut duplicates = Vec::new();
for (index, hook) in hooks.iter_mut().enumerate() {
let Some(existing) = hook.get("command").and_then(Item::as_str) else {
continue;
};
if hook.get("event").and_then(Item::as_str) != Some(SESSION_START_EVENT)
|| !Self::is_provider_capture_command(existing, provider)
{
continue;
}
if installed {
duplicates.push(index);
continue;
}
installed = true;
if existing != command {
hook["command"] = toml_edit::value(command);
changed = true;
}
if hook.get("matcher").and_then(Item::as_str) != Some(KIMI_SESSION_MATCHER) {
hook["matcher"] = toml_edit::value(KIMI_SESSION_MATCHER);
changed = true;
}
}
for index in duplicates.into_iter().rev() {
hooks.remove(index);
changed = true;
}
if !installed {
let mut hook = Table::new();
hook["event"] = toml_edit::value(SESSION_START_EVENT);
hook["matcher"] = toml_edit::value(KIMI_SESSION_MATCHER);
hook["command"] = toml_edit::value(command);
hooks.push(hook);
changed = true;
}
if changed {
Self::write_text(path, &document.to_string())?;
}
Ok(())
}
fn amp_plugin(executable: &str) -> Result<String, HookError> {
let executable = serde_json::to_string(executable).map_err(HookError::PluginEncoding)?;
let owner =
serde_json::to_string(MUSTER_AGENT_SESSION_ENV).map_err(HookError::PluginEncoding)?;
let arguments = serde_json::to_string(&Self::capture_arguments(AgentTool::Amp))
.map_err(HookError::PluginEncoding)?;
Ok(format!(
r#"import {{ spawn }} from "node:child_process"
import type {{ PluginAPI }} from "@ampcode/plugin"
const executable = {executable}
const active = Boolean(process.env[{owner}])
const capture = (sessionId: string) => {{
if (!active) return
const child = spawn(executable, [...{arguments}, "{CAPTURE_PROCESS_ID_ARGUMENT}", process.pid.toString(), "{CAPTURE_PARENT_PROCESS_ID_ARGUMENT}", process.ppid.toString()], {{ stdio: ["pipe", "ignore", "ignore"] }})
child.on("error", () => {{}})
child.stdin.on("error", () => {{}})
child.stdin.end(JSON.stringify({{ version: {AGENT_PROTOCOL_VERSION}, event: "session_started", session_id: sessionId }}))
}}
export default function musterSession(amp: PluginAPI) {{
amp.on("session.start", (event) => capture(event.thread.id))
}}
"#
))
}
fn opencode_plugin(executable: &str) -> Result<String, HookError> {
let executable = serde_json::to_string(executable).map_err(HookError::PluginEncoding)?;
let owner =
serde_json::to_string(MUSTER_AGENT_SESSION_ENV).map_err(HookError::PluginEncoding)?;
let arguments = serde_json::to_string(&Self::capture_arguments(AgentTool::Opencode))
.map_err(HookError::PluginEncoding)?;
Ok(format!(
r#"import {{ spawn }} from "node:child_process"
const executable = {executable}
const active = Boolean(process.env[{owner}])
const sessionParents = new Map()
let activeSessionId
let capturedSessionId
let pendingSessionId
let captureInFlight = false
const flush = () => {{
if (!active || captureInFlight || !pendingSessionId || pendingSessionId === capturedSessionId) return
const sessionId = pendingSessionId
pendingSessionId = undefined
captureInFlight = true
const child = spawn(executable, [...{arguments}, "{CAPTURE_PROCESS_ID_ARGUMENT}", process.pid.toString(), "{CAPTURE_PARENT_PROCESS_ID_ARGUMENT}", process.ppid.toString()], {{ stdio: ["pipe", "ignore", "ignore"] }})
let settled = false
const complete = (succeeded) => {{
if (settled) return
settled = true
if (succeeded) capturedSessionId = sessionId
captureInFlight = false
flush()
}}
child.on("error", () => complete(false))
child.on("exit", (code) => complete(code === 0))
child.stdin.on("error", () => complete(false))
child.stdin.end(JSON.stringify({{ version: {AGENT_PROTOCOL_VERSION}, event: "session_started", session_id: sessionId }}))
}}
const capture = (sessionId) => {{
if (!active || !sessionId || sessionId === capturedSessionId || sessionId === pendingSessionId) return
pendingSessionId = sessionId
flush()
}}
export const MusterSession = async ({{ client }}) => {{
const known = await client.session.list().catch(() => undefined)
for (const info of known?.data ?? []) sessionParents.set(info.id, info.parentID)
const select = (sessionId) => {{
if (!sessionParents.has(sessionId) || sessionParents.get(sessionId)) return
activeSessionId = sessionId
capture(sessionId)
}}
return {{
event: async ({{ event }}) => {{
if (event.type === "session.created" || event.type === "session.updated") {{
const info = event.properties?.info ?? event.properties?.session ?? event.properties
const sessionId = info?.id ?? info?.sessionID
if (sessionId) sessionParents.set(sessionId, info?.parentID)
if (sessionId === activeSessionId && !info?.parentID) capture(sessionId)
return
}}
if (event.type === "session.deleted") {{
const info = event.properties?.info ?? event.properties?.session ?? event.properties
const sessionId = info?.id ?? info?.sessionID
if (sessionId) sessionParents.delete(sessionId)
if (sessionId === activeSessionId) activeSessionId = undefined
return
}}
if (event.type === "tui.session.select") select(event.properties?.sessionID)
}},
"chat.message": async (input) => select(input.sessionID),
}}
}}
"#
))
}
fn native_id(payload: &Value) -> Result<NativeSessionId, HookError> {
if let Some(version) = payload.get("version") {
let version = version
.as_u64()
.ok_or(HookError::UnsupportedProtocolVersion(u64::MAX))?;
if version != u64::from(AGENT_PROTOCOL_VERSION) {
return Err(HookError::UnsupportedProtocolVersion(version));
}
let event = payload
.get("event")
.and_then(Value::as_str)
.ok_or(HookError::MissingProtocolEvent)?;
if event != PROTOCOL_SESSION_STARTED {
return Err(HookError::UnsupportedProtocolEvent(event.to_string()));
}
}
let native = payload
.get("session_id")
.or_else(|| payload.get("sessionId"))
.and_then(Value::as_str)
.ok_or(HookError::MissingSessionId)?;
NativeSessionId::try_new(native).map_err(|_| HookError::MissingSessionId)
}
fn read_json(path: &Path) -> Result<Value, HookError> {
let raw = Self::read_text(path)?;
if raw.trim().is_empty() {
return Ok(Value::Object(Map::new()));
}
serde_json::from_str(&raw).map_err(|source| HookError::Json {
path: path.to_path_buf(),
source,
})
}
fn read_text(path: &Path) -> Result<String, HookError> {
match fs::read_to_string(path) {
Ok(raw) => Ok(raw),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(source) => Err(HookError::Read {
path: path.to_path_buf(),
source,
}),
}
}
fn object_entry<'a>(
parent: &'a mut Map<String, Value>,
key: &str,
path: &Path,
) -> Result<&'a mut Map<String, Value>, HookError> {
parent
.entry(key)
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut()
.ok_or_else(|| HookError::Schema(path.to_path_buf()))
}
fn array_entry<'a>(
parent: &'a mut Map<String, Value>,
key: &str,
path: &Path,
) -> Result<&'a mut Vec<Value>, HookError> {
parent
.entry(key)
.or_insert_with(|| Value::Array(Vec::new()))
.as_array_mut()
.ok_or_else(|| HookError::Schema(path.to_path_buf()))
}
fn write_json(path: &Path, value: &Value) -> Result<(), HookError> {
let mut raw = serde_json::to_string_pretty(value).map_err(|source| HookError::Json {
path: path.to_path_buf(),
source,
})?;
raw.push('\n');
Self::write_text(path, &raw)
}
fn write_text(path: &Path, raw: &str) -> Result<(), HookError> {
let destination = Self::write_destination(path)?;
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent).map_err(|source| HookError::Write {
path: parent.to_path_buf(),
source,
})?;
}
if fs::read_to_string(&destination).is_ok_and(|current| current == raw) {
return Ok(());
}
let permissions = match fs::metadata(&destination) {
Ok(metadata) => Some(metadata.permissions()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(source) => {
return Err(HookError::Read {
path: destination,
source,
});
},
};
let mut file = AtomicWriteFile::open(&destination).map_err(|source| HookError::Write {
path: destination.clone(),
source,
})?;
file.write_all(raw.as_bytes())
.map_err(|source| HookError::Write {
path: destination.clone(),
source,
})?;
if let Some(permissions) = permissions {
file.set_permissions(permissions)
.map_err(|source| HookError::Write {
path: destination.clone(),
source,
})?;
}
file.commit().map_err(|source| HookError::Write {
path: destination,
source,
})
}
fn write_destination(path: &Path) -> Result<PathBuf, HookError> {
let mut destination = path.to_path_buf();
for depth in 0..=MAX_PROVIDER_CONFIG_SYMLINKS {
match fs::symlink_metadata(&destination) {
Ok(metadata) if metadata.file_type().is_symlink() => {
if depth == MAX_PROVIDER_CONFIG_SYMLINKS {
return Err(HookError::SymlinkDepth(destination));
}
let target = fs::read_link(&destination).map_err(|source| HookError::Read {
path: destination.clone(),
source,
})?;
destination = if target.is_absolute() {
target
} else {
match destination.parent() {
Some(parent) => parent.join(target),
None => target,
}
};
},
Ok(_) => return Ok(destination),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(destination);
},
Err(source) => {
return Err(HookError::Read {
path: destination,
source,
});
},
}
}
Err(HookError::SymlinkDepth(destination))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn setup_preserves_configs_without_duplicating_hooks() {
let root = std::env::temp_dir().join(format!("muster-hooks-{}", uuid::Uuid::new_v4()));
let home = root.join("home");
let config = root.join("config");
let xdg_config = root.join("xdg");
let claude = home.join(CLAUDE_SETTINGS);
let kimi = home.join(KIMI_CONFIG);
ProviderHooks::write_text(&claude, "{\"theme\":\"dark\"}").unwrap();
ProviderHooks::write_text(&kimi, "model = \"kimi\"\n").unwrap();
let paths =
ProviderHooks::setup_in(Path::new("/opt/muster"), &home, &config, &xdg_config).unwrap();
ProviderHooks::setup_in(Path::new("/opt/muster"), &home, &config, &xdg_config).unwrap();
let claude: Value = serde_json::from_str(&fs::read_to_string(claude).unwrap()).unwrap();
assert_eq!(claude["theme"], "dark");
assert_eq!(
claude["hooks"][SESSION_START_EVENT]
.as_array()
.unwrap()
.len(),
1
);
let kimi = fs::read_to_string(kimi).unwrap();
assert!(kimi.contains("model = \"kimi\""));
assert_eq!(kimi.matches("event = \"SessionStart\"").count(), 1);
assert_eq!(paths[6], xdg_config.join(OPENCODE_PLUGIN));
assert!(paths[6].is_file());
assert!(!config.join(OPENCODE_PLUGIN).exists());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn setup_reconciles_outdated_hook_commands() {
const OLD_EXECUTABLE: &str = "/old/muster";
const NEW_EXECUTABLE: &str = "/new/muster";
const UNRELATED_COMMAND: &str = "notify-session";
let root_path =
std::env::temp_dir().join(format!("muster-hook-upgrade-{}", uuid::Uuid::new_v4()));
let home = root_path.join("home");
let config = root_path.join("config");
let xdg_config = root_path.join("xdg");
let claude = home.join(CLAUDE_SETTINGS);
let kimi = home.join(KIMI_CONFIG);
let outdated =
ProviderHooks::posix_hook_command(OLD_EXECUTABLE, AgentTool::Claude).unwrap();
let current = ProviderHooks::posix_hook_command(NEW_EXECUTABLE, AgentTool::Claude).unwrap();
let outdated_kimi =
ProviderHooks::posix_hook_command(OLD_EXECUTABLE, AgentTool::Kimi).unwrap();
let current_kimi =
ProviderHooks::posix_hook_command(NEW_EXECUTABLE, AgentTool::Kimi).unwrap();
ProviderHooks::write_json(
&claude,
&json!({
"hooks": {
SESSION_START_EVENT: [
{
"hooks": [
{ "type": "command", "command": outdated },
{ "type": "command", "command": UNRELATED_COMMAND }
]
},
{
"hooks": [
{ "type": "command", "command": current }
]
}
]
}
}),
)
.unwrap();
let mut kimi_config = DocumentMut::new();
kimi_config["hooks"] = Item::ArrayOfTables(ArrayOfTables::new());
for matcher in ["startup", KIMI_SESSION_MATCHER] {
let mut hook = Table::new();
hook["event"] = toml_edit::value(SESSION_START_EVENT);
hook["matcher"] = toml_edit::value(matcher);
hook["command"] = toml_edit::value(&outdated_kimi);
kimi_config["hooks"]
.as_array_of_tables_mut()
.unwrap()
.push(hook);
}
ProviderHooks::write_text(&kimi, &kimi_config.to_string()).unwrap();
ProviderHooks::setup_in(Path::new(NEW_EXECUTABLE), &home, &config, &xdg_config).unwrap();
let config: Value = serde_json::from_str(&fs::read_to_string(&claude).unwrap()).unwrap();
let commands = config["hooks"][SESSION_START_EVENT]
.as_array()
.unwrap()
.iter()
.filter_map(|entry| entry.get("hooks").and_then(Value::as_array))
.flatten()
.filter_map(|hook| hook.get("command").and_then(Value::as_str))
.collect::<Vec<_>>();
assert_eq!(
commands
.iter()
.filter(|command| **command == current.as_str())
.count(),
1
);
assert_eq!(
commands
.iter()
.filter(|command| **command == UNRELATED_COMMAND)
.count(),
1
);
assert!(!commands.contains(&outdated.as_str()));
let kimi = fs::read_to_string(kimi).unwrap();
assert!(!kimi.contains(&outdated_kimi));
assert_eq!(kimi.matches(¤t_kimi).count(), 1);
assert_eq!(kimi.matches(KIMI_SESSION_MATCHER).count(), 1);
fs::remove_dir_all(root_path).unwrap();
}
#[test]
fn setup_rejects_a_malformed_nested_hook_group() {
let root =
std::env::temp_dir().join(format!("muster-hook-schema-{}", uuid::Uuid::new_v4()));
let path = root.join(CLAUDE_SETTINGS);
ProviderHooks::write_json(
&path,
&json!({
"hooks": {
SESSION_START_EVENT: [{ "hooks": "not-an-array" }]
}
}),
)
.unwrap();
let result = ProviderHooks::install_grouped_json(&path, AgentTool::Claude, "muster hook");
assert!(matches!(result, Err(HookError::Schema(error_path)) if error_path == path));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn setup_uses_the_configured_codex_home() {
let root = std::env::temp_dir().join(format!("muster-codex-home-{}", uuid::Uuid::new_v4()));
let home = root.join("home");
let config = root.join("config");
let xdg_config = root.join("xdg");
let codex_home = root.join("custom-codex");
let paths = ProviderHooks::setup_in_with_codex(
Path::new("/tmp/muster"),
&home,
&config,
&xdg_config,
&codex_home,
)
.unwrap();
assert_eq!(paths[1], codex_home.join("hooks.json"));
assert!(paths[1].is_file());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn decodes_protocol_and_compatibility_payloads() {
let protocol = json!({
"version": AGENT_PROTOCOL_VERSION,
"event": "session_started",
"session_id": "native-one"
});
let native = json!({ "sessionId": "native-two" });
assert_eq!(
ProviderHooks::native_id(&protocol).unwrap().as_ref(),
"native-one"
);
assert_eq!(
ProviderHooks::native_id(&native).unwrap().as_ref(),
"native-two"
);
}
#[test]
fn rejects_unknown_versioned_protocol_events() {
let event = json!({
"version": AGENT_PROTOCOL_VERSION,
"event": "session_closed",
"session_id": "native-one"
});
assert!(matches!(
ProviderHooks::native_id(&event),
Err(HookError::UnsupportedProtocolEvent(name)) if name == "session_closed"
));
}
#[test]
fn powershell_commands_invoke_quoted_executables() {
let command = ProviderHooks::powershell_hook_command(
r"C:\Program Files\Muster's\muster.exe",
AgentTool::Copilot,
);
assert_eq!(
command,
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"#
);
}
#[test]
fn generated_plugins_handle_capture_process_errors() {
let amp = ProviderHooks::amp_plugin("/missing/muster").unwrap();
let opencode = ProviderHooks::opencode_plugin("/missing/muster").unwrap();
assert!(amp.contains("if (!active) return"));
assert!(opencode.contains("let pendingSessionId"));
assert!(opencode.contains("let captureInFlight = false"));
assert!(opencode.contains("const sessionParents = new Map()"));
assert!(opencode.contains("activeSessionId = sessionId"));
assert!(opencode.contains("sessionId === activeSessionId && !info?.parentID"));
assert!(opencode.contains(r#""chat.message": async (input) => select(input.sessionID)"#));
assert!(opencode.contains("sessionParents.get(sessionId)"));
assert!(!opencode.contains("capture(info?.id"));
assert!(opencode.contains("pendingSessionId = sessionId"));
assert!(opencode.contains(r#"child.on("error", () => complete(false))"#));
assert!(opencode.contains(r#"child.stdin.on("error", () => complete(false))"#));
assert!(amp.contains(CAPTURE_PROVIDER_ARGUMENT));
assert!(amp.contains("amp"));
assert!(opencode.contains(CAPTURE_PROVIDER_ARGUMENT));
assert!(opencode.contains(AgentTool::Opencode.protocol_token()));
for plugin in [&, &opencode] {
assert!(plugin.contains(MUSTER_AGENT_SESSION_ENV));
}
assert!(amp.contains(r#"child.on("error", () => {})"#));
assert!(amp.contains(r#"child.stdin.on("error", () => {})"#));
}
#[test]
fn status_does_not_match_path_prefixes() {
let root = std::env::temp_dir().join(format!("muster-prefix-{}", uuid::Uuid::new_v4()));
let home = root.join("home");
let config = root.join("config");
let xdg = root.join("xdg");
let codex = home.join(CODEX_CONFIG_DIR);
let installed_exe = Path::new("/opt/muster-old/muster");
let prefix_exe = Path::new("/opt/muster-old/mus");
ProviderHooks::setup_in_with_codex(installed_exe, &home, &config, &xdg, &codex).unwrap();
let prefix_statuses =
ProviderHooks::status_in(prefix_exe, &home, &config, &xdg, &codex).unwrap();
for status in &prefix_statuses {
assert_ne!(
status.state(),
HookState::Installed,
"prefix path matched as Installed for provider {}",
status.provider()
);
}
let exact_statuses =
ProviderHooks::status_in(installed_exe, &home, &config, &xdg, &codex).unwrap();
for status in &exact_statuses {
assert_eq!(
status.state(),
HookState::Installed,
"exact path not Installed for provider {}",
status.provider()
);
}
fs::remove_dir_all(root).unwrap();
}
#[test]
fn status_ignores_unrelated_capture_text() {
let root = std::env::temp_dir().join(format!("muster-unrel-{}", uuid::Uuid::new_v4()));
let home = root.join("home");
fs::create_dir_all(home.join(".claude")).unwrap();
fs::write(
home.join(CLAUDE_SETTINGS),
"{\"permissions\":{\"allow\":[\"Bash(asciinema capture:*)\"]}}",
)
.unwrap();
let statuses = ProviderHooks::status_in(
Path::new("/opt/muster/muster"),
&home,
&root.join("config"),
&root.join("xdg"),
&home.join(CODEX_CONFIG_DIR),
)
.unwrap();
let claude_status = statuses
.iter()
.find(|status| status.provider() == AgentTool::Claude)
.expect("claude status present");
assert_eq!(
claude_status.state(),
HookState::Missing,
"an unrelated capture permission is not a stale muster hook"
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn kimi_inline_hook_arrays_install_and_verify() {
let root = std::env::temp_dir().join(format!("muster-inline-{}", uuid::Uuid::new_v4()));
let home = root.join("home");
let config = root.join("config");
let xdg = root.join("xdg");
let codex = home.join(CODEX_CONFIG_DIR);
fs::create_dir_all(home.join(".kimi-code")).unwrap();
fs::write(
home.join(KIMI_CONFIG),
"hooks = [{ event = \"Other\", command = \"true\" }]\n",
)
.unwrap();
let exe = Path::new("/opt/muster/muster");
ProviderHooks::setup_in_with_codex(exe, &home, &config, &xdg, &codex).unwrap();
let statuses = ProviderHooks::status_in(exe, &home, &config, &xdg, &codex).unwrap();
let kimi_status = statuses
.iter()
.find(|status| status.provider() == AgentTool::Kimi)
.expect("kimi status present");
assert_eq!(kimi_status.state(), HookState::Installed);
let rewritten = fs::read_to_string(home.join(KIMI_CONFIG)).unwrap();
assert!(
rewritten.contains("event = \"Other\""),
"the unrelated inline hook survives normalization"
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn status_rejects_a_tampered_plugin() {
let root = std::env::temp_dir().join(format!("muster-tamper-{}", uuid::Uuid::new_v4()));
let home = root.join("home");
let config = root.join("config");
let xdg = root.join("xdg");
let codex = home.join(CODEX_CONFIG_DIR);
fs::create_dir_all(&home).unwrap();
let exe = Path::new("/opt/muster/muster");
ProviderHooks::setup_in_with_codex(exe, &home, &config, &xdg, &codex).unwrap();
let plugin = config.join(AMP_PLUGIN);
let mut tampered = fs::read_to_string(&plugin).unwrap();
tampered.push_str("\nconsole.log(\"extra\")\n");
fs::write(&plugin, tampered).unwrap();
let statuses = ProviderHooks::status_in(exe, &home, &config, &xdg, &codex).unwrap();
let amp_status = statuses
.iter()
.find(|status| status.provider() == AgentTool::Amp)
.expect("amp status present");
assert_eq!(amp_status.state(), HookState::Stale);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn status_rejects_a_provider_token_prefix() {
let root = std::env::temp_dir().join(format!("muster-tokpre-{}", uuid::Uuid::new_v4()));
let home = root.join("home");
let config = root.join("config");
let xdg = root.join("xdg");
let codex = home.join(CODEX_CONFIG_DIR);
fs::create_dir_all(&home).unwrap();
let exe = Path::new("/opt/muster/muster");
ProviderHooks::setup_in_with_codex(exe, &home, &config, &xdg, &codex).unwrap();
let claude = home.join(CLAUDE_SETTINGS);
let installed = fs::read_to_string(&claude).unwrap();
for suffix in ["-old", ".old", "+x", ":x"] {
let broken = installed.replace(
&format!(
"{CAPTURE_PROVIDER_ARGUMENT} {}",
AgentTool::Claude.protocol_token()
),
&format!(
"{CAPTURE_PROVIDER_ARGUMENT} {}{suffix}",
AgentTool::Claude.protocol_token()
),
);
fs::write(&claude, broken).unwrap();
let statuses = ProviderHooks::status_in(exe, &home, &config, &xdg, &codex).unwrap();
let claude_status = statuses
.iter()
.find(|status| status.provider() == AgentTool::Claude)
.expect("claude status present");
assert_eq!(
claude_status.state(),
HookState::Stale,
"'{suffix}' must break the callback"
);
}
fs::write(&claude, installed).unwrap();
fs::remove_dir_all(root).unwrap();
}
#[test]
fn status_rejects_suffixes_behind_filename_punctuation() {
for prefix in ["/prefix:", "/pre fix"] {
let root = std::env::temp_dir().join(format!("muster-punct-{}", uuid::Uuid::new_v4()));
let home = root.join("home");
let config = root.join("config");
let xdg = root.join("xdg");
let codex = home.join(CODEX_CONFIG_DIR);
fs::create_dir_all(&home).unwrap();
let other = format!("{prefix}/bin/muster");
ProviderHooks::setup_in_with_codex(Path::new(&other), &home, &config, &xdg, &codex)
.unwrap();
let statuses =
ProviderHooks::status_in(Path::new("/bin/muster"), &home, &config, &xdg, &codex)
.unwrap();
assert!(
statuses
.iter()
.all(|status| status.state() == HookState::Stale),
"a suffix behind '{prefix}' must not read installed"
);
fs::remove_dir_all(root).unwrap();
}
}
#[test]
fn status_rejects_a_path_suffix_of_another_executable() {
let root = std::env::temp_dir().join(format!("muster-suffix-{}", uuid::Uuid::new_v4()));
let home = root.join("home");
let config = root.join("config");
let xdg = root.join("xdg");
let codex = home.join(CODEX_CONFIG_DIR);
fs::create_dir_all(&home).unwrap();
let installed = Path::new("/usr/bin/muster");
ProviderHooks::setup_in_with_codex(installed, &home, &config, &xdg, &codex).unwrap();
let statuses =
ProviderHooks::status_in(Path::new("/bin/muster"), &home, &config, &xdg, &codex)
.unwrap();
assert!(
statuses
.iter()
.all(|status| status.state() == HookState::Stale),
"a suffix of another executable's path is stale, not installed"
);
let exact = ProviderHooks::status_in(installed, &home, &config, &xdg, &codex).unwrap();
assert!(
exact
.iter()
.all(|status| status.state() == HookState::Installed),
"the exact executable still reads installed"
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn status_rejects_a_wrong_provider_callback() {
let root = std::env::temp_dir().join(format!("muster-wrongp-{}", uuid::Uuid::new_v4()));
let home = root.join("home");
let config = root.join("config");
let xdg = root.join("xdg");
let codex = home.join(CODEX_CONFIG_DIR);
fs::create_dir_all(&home).unwrap();
let exe = Path::new("/opt/muster/muster");
ProviderHooks::setup_in_with_codex(exe, &home, &config, &xdg, &codex).unwrap();
let claude = home.join(CLAUDE_SETTINGS);
let wrong = fs::read_to_string(&claude).unwrap().replace(
AgentTool::Claude.protocol_token(),
AgentTool::Gemini.protocol_token(),
);
fs::write(&claude, wrong).unwrap();
let statuses = ProviderHooks::status_in(exe, &home, &config, &xdg, &codex).unwrap();
let claude_status = statuses
.iter()
.find(|status| status.provider() == AgentTool::Claude)
.expect("claude status present");
assert_eq!(
claude_status.state(),
HookState::Stale,
"a wrong-provider callback needs hooks setup again"
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn status_ignores_commented_out_hooks() {
let root = std::env::temp_dir().join(format!("muster-comment-{}", uuid::Uuid::new_v4()));
let home = root.join("home");
let config = root.join("config");
let xdg = root.join("xdg");
let codex = home.join(CODEX_CONFIG_DIR);
fs::create_dir_all(&home).unwrap();
let exe = Path::new("/opt/muster/muster");
ProviderHooks::setup_in_with_codex(exe, &home, &config, &xdg, &codex).unwrap();
let kimi = home.join(KIMI_CONFIG);
let disabled: String = fs::read_to_string(&kimi)
.unwrap()
.lines()
.map(|line| format!("# {line}\n"))
.collect();
fs::write(&kimi, disabled).unwrap();
let statuses = ProviderHooks::status_in(exe, &home, &config, &xdg, &codex).unwrap();
let kimi_status = statuses
.iter()
.find(|status| status.provider() == AgentTool::Kimi)
.expect("kimi status present");
assert_eq!(
kimi_status.state(),
HookState::Missing,
"a fully commented hook is not active"
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn status_reports_missing_stale_and_installed() {
let root = std::env::temp_dir().join(format!("muster-status-{}", uuid::Uuid::new_v4()));
let home = root.join("home");
let config = root.join("config");
let xdg = root.join("xdg");
let codex = home.join(CODEX_CONFIG_DIR);
fs::create_dir_all(&home).unwrap();
let exe = Path::new("/opt/muster/muster");
let before = ProviderHooks::status_in(exe, &home, &config, &xdg, &codex).unwrap();
assert!(
before
.iter()
.all(|status| status.state() == HookState::Missing)
);
ProviderHooks::setup_in_with_codex(exe, &home, &config, &xdg, &codex).unwrap();
let after = ProviderHooks::status_in(exe, &home, &config, &xdg, &codex).unwrap();
assert!(
after
.iter()
.all(|status| status.state() == HookState::Installed)
);
let moved = Path::new("/elsewhere/muster");
let stale = ProviderHooks::status_in(moved, &home, &config, &xdg, &codex).unwrap();
assert!(
stale
.iter()
.all(|status| status.state() == HookState::Stale)
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn status_detects_installed_when_executable_has_special_chars() {
let root =
std::env::temp_dir().join(format!("muster-status-encoded-{}", uuid::Uuid::new_v4()));
let home = root.join("home");
let config = root.join("config");
let xdg = root.join("xdg");
let codex = home.join(CODEX_CONFIG_DIR);
let exe_path_str = format!("{}/mu\\ster/muster", root.display());
let exe_path = Path::new(&exe_path_str);
ProviderHooks::setup_in_with_codex(exe_path, &home, &config, &xdg, &codex).unwrap();
let statuses = ProviderHooks::status_in(exe_path, &home, &config, &xdg, &codex).unwrap();
for status in &statuses {
assert_eq!(
status.state(),
HookState::Installed,
"{} should be Installed",
status.provider()
);
}
fs::remove_dir_all(root).unwrap();
}
#[cfg(unix)]
#[test]
fn atomic_writes_preserve_existing_permissions() {
use std::os::unix::fs::PermissionsExt;
const PRIVATE_MODE: u32 = 0o600;
const PERMISSION_MASK: u32 = 0o777;
let root = std::env::temp_dir().join(format!("muster-hook-mode-{}", uuid::Uuid::new_v4()));
let path = root.join("settings.json");
ProviderHooks::write_text(&path, "old").unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(PRIVATE_MODE)).unwrap();
ProviderHooks::write_text(&path, "new").unwrap();
assert_eq!(
fs::metadata(&path).unwrap().permissions().mode() & PERMISSION_MASK,
PRIVATE_MODE
);
fs::remove_dir_all(root).unwrap();
}
#[cfg(unix)]
#[test]
fn atomic_writes_preserve_provider_config_symlinks() {
use std::os::unix::fs::symlink;
let root =
std::env::temp_dir().join(format!("muster-hook-symlink-{}", uuid::Uuid::new_v4()));
let target = root.join("managed/settings.json");
let link = root.join("settings.json");
ProviderHooks::write_text(&target, "old").unwrap();
symlink(&target, &link).unwrap();
ProviderHooks::write_text(&link, "new").unwrap();
assert!(
fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink()
);
assert_eq!(fs::read_to_string(target).unwrap(), "new");
fs::remove_dir_all(root).unwrap();
}
#[cfg(unix)]
#[test]
fn atomic_writes_preserve_dangling_provider_config_symlinks() {
use std::os::unix::fs::symlink;
const RELATIVE_TARGET: &str = "managed/settings.json";
let root =
std::env::temp_dir().join(format!("muster-hook-dangling-{}", uuid::Uuid::new_v4()));
let target = root.join(RELATIVE_TARGET);
let link = root.join("settings.json");
fs::create_dir_all(&root).unwrap();
symlink(RELATIVE_TARGET, &link).unwrap();
ProviderHooks::write_text(&link, "new").unwrap();
assert!(
fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink()
);
assert_eq!(fs::read_to_string(target).unwrap(), "new");
fs::remove_dir_all(root).unwrap();
}
}