The hook installer only ever wrote ~/.claude/settings.json, so a codex session reported no activity and never posted its first prompt — leaving it on the daemon's generated handle with no haiku name. Adds a Target enum (claude-code, codex) over the hook file's location and event set. One script and one marker serve both; codex swaps Notification for PermissionRequest and has no StopFailure, so it never reports Error. Both agents spell the prompt event UserPromptSubmit and put the text in the payload's `prompt`, so autoname needs no change. Codex will not run a hook it has not been told to trust; `maestro hooks install` says so, and the approval stays the operator's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
178 lines
6.5 KiB
Rust
178 lines
6.5 KiB
Rust
//! First-start hook auto-install (#106).
|
|
//!
|
|
//! The daemon runs [`auto_install`] once at startup: for every agent maestro
|
|
//! knows how to hook ([`maestro_hooks::Target`]), unless that agent's hook
|
|
//! file already carries exactly what this build ships (and `[hooks]
|
|
//! .auto_install` hasn't opted out — checked by the caller), the same
|
|
//! installer `maestro hooks install` uses writes it. That covers upgrades as
|
|
//! well as first starts: an install from a maestro that had fewer events —
|
|
//! #131 added `UserPromptSubmit` to a hook #106 had already put in everyone's
|
|
//! settings — is rewritten rather than left half-wired. Best-effort: a
|
|
//! failure is warned about and never blocks startup, and one target failing
|
|
//! does not stop the others. The same global hook serves every session on the
|
|
//! machine, so nothing here targets repositories or worktrees.
|
|
|
|
use std::path::Path;
|
|
|
|
use maestro_hooks::Target;
|
|
|
|
/// Install the activity hook for every [`Target`] under `home_dir`, skipping
|
|
/// any whose install is already current. Returns what happened per target —
|
|
/// an older install being brought up to date is
|
|
/// [`AutoInstallOutcome::Upgraded`], so the log can say so. An unresolvable
|
|
/// home dir (`""`) reports every target as [`AutoInstallOutcome::Skipped`].
|
|
pub fn auto_install(home_dir: &str) -> Vec<(Target, AutoInstallOutcome)> {
|
|
Target::ALL
|
|
.iter()
|
|
.map(|&target| (target, auto_install_target(target, home_dir)))
|
|
.collect()
|
|
}
|
|
|
|
fn auto_install_target(target: Target, home_dir: &str) -> AutoInstallOutcome {
|
|
if home_dir.is_empty() {
|
|
return AutoInstallOutcome::Skipped;
|
|
}
|
|
let home = Path::new(home_dir);
|
|
let settings = target.settings_path(home);
|
|
let script = maestro_hooks::script_path(home);
|
|
if maestro_hooks::is_current(target, &settings, &script) {
|
|
return AutoInstallOutcome::AlreadyPresent;
|
|
}
|
|
let stale = maestro_hooks::is_installed(&settings);
|
|
match maestro_hooks::install_target(target, home) {
|
|
Ok(_) if stale => AutoInstallOutcome::Upgraded,
|
|
Ok(_) => AutoInstallOutcome::Installed,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
target = target.label(),
|
|
error = %e,
|
|
"hooks auto-install failed (non-fatal)"
|
|
);
|
|
AutoInstallOutcome::Failed
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What [`auto_install`] did, so callers can log and tests can assert.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum AutoInstallOutcome {
|
|
Installed,
|
|
/// An older maestro's hook was there and has been rewritten.
|
|
Upgraded,
|
|
AlreadyPresent,
|
|
Failed,
|
|
Skipped,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The outcome recorded for one target.
|
|
fn outcome(outcomes: &[(Target, AutoInstallOutcome)], target: Target) -> AutoInstallOutcome {
|
|
outcomes
|
|
.iter()
|
|
.find(|(t, _)| *t == target)
|
|
.map(|(_, o)| *o)
|
|
.expect("every target is reported")
|
|
}
|
|
|
|
/// Everything happens under a temp `$HOME`; the developer's own
|
|
/// `~/.claude/settings.json` and `~/.codex/hooks.json` are never read or
|
|
/// written.
|
|
#[test]
|
|
fn installs_when_settings_carry_no_maestro_hook() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let outcomes = auto_install(dir.path().to_str().unwrap());
|
|
for target in Target::ALL {
|
|
assert_eq!(
|
|
outcome(&outcomes, target),
|
|
AutoInstallOutcome::Installed,
|
|
"{}",
|
|
target.label()
|
|
);
|
|
assert!(maestro_hooks::is_installed(
|
|
&target.settings_path(dir.path())
|
|
));
|
|
}
|
|
}
|
|
|
|
/// Every agent maestro knows about is hooked, not just Claude Code: a
|
|
/// codex session reports activity and its first prompt the same way.
|
|
#[test]
|
|
fn codex_gets_its_own_hook_file() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
auto_install(dir.path().to_str().unwrap());
|
|
let hooks = dir.path().join(".codex/hooks.json");
|
|
let root: serde_json::Value =
|
|
serde_json::from_str(&std::fs::read_to_string(&hooks).unwrap()).unwrap();
|
|
let events = root["hooks"].as_object().unwrap();
|
|
assert!(events.contains_key("PermissionRequest"));
|
|
assert!(
|
|
!events.contains_key("Notification"),
|
|
"codex has no Notification"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn second_start_is_a_noop() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let home = dir.path().to_str().unwrap();
|
|
auto_install(home);
|
|
// A current install is left exactly as it is.
|
|
let before: Vec<String> = Target::ALL
|
|
.iter()
|
|
.map(|t| std::fs::read_to_string(t.settings_path(dir.path())).unwrap())
|
|
.collect();
|
|
let outcomes = auto_install(home);
|
|
for (i, target) in Target::ALL.iter().enumerate() {
|
|
assert_eq!(
|
|
outcome(&outcomes, *target),
|
|
AutoInstallOutcome::AlreadyPresent
|
|
);
|
|
assert_eq!(
|
|
before[i],
|
|
std::fs::read_to_string(target.settings_path(dir.path())).unwrap(),
|
|
"already-installed settings must not be rewritten"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// An install from an older maestro — present, but missing an event this
|
|
/// build ships — is upgraded rather than left alone (#131 shipped a new
|
|
/// event after #106 had already installed itself).
|
|
#[test]
|
|
fn a_stale_install_is_upgraded() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let home = dir.path().to_str().unwrap();
|
|
auto_install(home);
|
|
let settings = Target::ClaudeCode.settings_path(dir.path());
|
|
let script = maestro_hooks::script_path(dir.path());
|
|
|
|
let mut root: serde_json::Value =
|
|
serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
|
|
root["hooks"].as_object_mut().unwrap().remove("Stop");
|
|
std::fs::write(&settings, serde_json::to_string_pretty(&root).unwrap()).unwrap();
|
|
std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
|
|
|
|
let outcomes = auto_install(home);
|
|
assert_eq!(
|
|
outcome(&outcomes, Target::ClaudeCode),
|
|
AutoInstallOutcome::Upgraded
|
|
);
|
|
assert!(maestro_hooks::is_current(
|
|
Target::ClaudeCode,
|
|
&settings,
|
|
&script
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn unresolvable_home_is_skipped() {
|
|
let outcomes = auto_install("");
|
|
for target in Target::ALL {
|
|
assert_eq!(outcome(&outcomes, target), AutoInstallOutcome::Skipped);
|
|
}
|
|
}
|
|
}
|