From 3c5bb51e884e64e4eaf7e83deee83a9510ebf953 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 22:22:49 +0100 Subject: [PATCH] feat(maestro): add `maestro hooks install` subcommand --- Cargo.lock | 1 + crates/maestro-cli/Cargo.toml | 1 + crates/maestro-cli/src/cli.rs | 34 ++++++++++++++++++++++++++++++ crates/maestro-cli/src/dispatch.rs | 33 +++++++++++++++++++++++++++++ crates/maestro/src/main.rs | 18 +++++++++++++++- 5 files changed, 86 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 5db5452..76adbd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1065,6 +1065,7 @@ dependencies = [ "futures-util", "libc", "maestro-config", + "maestro-hooks", "maestro-protocol", "maestro-testing", "nix", diff --git a/crates/maestro-cli/Cargo.toml b/crates/maestro-cli/Cargo.toml index 9dc2320..81720da 100644 --- a/crates/maestro-cli/Cargo.toml +++ b/crates/maestro-cli/Cargo.toml @@ -17,6 +17,7 @@ dirs.workspace = true futures-util.workspace = true libc.workspace = true maestro-config.workspace = true +maestro-hooks.workspace = true maestro-protocol = { workspace = true, features = ["client"] } nix.workspace = true serde_json.workspace = true diff --git a/crates/maestro-cli/src/cli.rs b/crates/maestro-cli/src/cli.rs index b1c7ac2..fbb7ee7 100644 --- a/crates/maestro-cli/src/cli.rs +++ b/crates/maestro-cli/src/cli.rs @@ -43,6 +43,23 @@ pub enum Command { Kill(KillArgs), /// List retained worktrees, or reclaim one by ID (#86). Worktrees(WorktreesArgs), + /// Install or update the agent activity hook (#106). + Hooks(HooksArgs), +} + +/// `maestro hooks ` +#[derive(Debug, clap::Args)] +pub struct HooksArgs { + #[command(subcommand)] + pub command: HooksCommand, +} + +#[derive(Debug, Subcommand)] +pub enum HooksCommand { + /// Write the Claude Code activity hook into ~/.claude/settings.json. + /// Idempotent: an older maestro hook is updated in place, every other + /// hook is left untouched. + Install, } /// `maestro worktrees ` @@ -275,6 +292,23 @@ mod tests { assert!(err.to_string().contains("KEY=VALUE")); } + #[test] + fn parses_hooks_install() { + let cli = Cli::try_parse_from(["maestro", "hooks", "install"]).unwrap(); + match cli.command { + Command::Hooks(h) => { + assert!(matches!(h.command, HooksCommand::Install)); + } + _ => panic!("wrong subcommand"), + } + // No subcommand → help, like `worktrees`. + let err = Cli::try_parse_from(["maestro", "hooks"]).unwrap_err(); + assert!(matches!( + err.kind(), + clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand + )); + } + #[test] fn parses_worktrees_list() { let cli = Cli::try_parse_from(["maestro", "worktrees", "list", "--json"]).unwrap(); diff --git a/crates/maestro-cli/src/dispatch.rs b/crates/maestro-cli/src/dispatch.rs index 3e2b36a..1febc52 100644 --- a/crates/maestro-cli/src/dispatch.rs +++ b/crates/maestro-cli/src/dispatch.rs @@ -24,6 +24,10 @@ pub enum CliError { #[error("{0}")] Attach(#[from] crate::attach::AttachError), #[error("{0}")] + Hooks(#[from] maestro_hooks::HooksError), + #[error("could not determine the home directory ($HOME unset?)")] + NoHome, + #[error("{0}")] Io(#[from] std::io::Error), } @@ -40,6 +44,15 @@ pub async fn dispatch( None => default_config_path()?, }; + // `hooks install` is purely local: it must work with the daemon down, so + // it never touches the client. + if let Command::Hooks(args) = &command { + match args.command { + crate::cli::HooksCommand::Install => run_hooks_install()?, + } + return Ok(()); + } + let daemon = client(&config_path, daemon_url_override)?; match command { @@ -77,6 +90,7 @@ pub async fn dispatch( ); } } + Command::Hooks(_) => unreachable!("hooks handled before the daemon client exists"), Command::Worktrees(args) => match args.command { WorktreesCommand::List(list_args) => { let mut stdout = std::io::stdout().lock(); @@ -101,6 +115,25 @@ pub async fn dispatch( Ok(()) } +/// `maestro hooks install` (#106): write the shipped hook script under the +/// user's data dir and register it in their Claude Code settings. Idempotent +/// and foreign-hook-preserving — the guarantees live in [`maestro_hooks`]. +#[allow(clippy::result_large_err)] +fn run_hooks_install() -> Result<(), CliError> { + let home = dirs::home_dir().ok_or(CliError::NoHome)?; + let changed = maestro_hooks::install_for_home(&home)?; + if changed { + println!( + "installed claude-code activity hook: {} → {}", + maestro_hooks::script_path(&home).display(), + maestro_hooks::settings_path(&home).display() + ); + } else { + println!("claude-code activity hook already up to date"); + } + Ok(()) +} + /// Default detach byte, matching `maestro attach --detach-key`'s `^q`. const CTRL_Q: u8 = 0x11; diff --git a/crates/maestro/src/main.rs b/crates/maestro/src/main.rs index 73eafe4..d571636 100644 --- a/crates/maestro/src/main.rs +++ b/crates/maestro/src/main.rs @@ -55,6 +55,9 @@ enum Command { /// Kill a session by ID. #[cfg(feature = "cli")] Kill(maestro_cli::cli::KillArgs), + /// Install or update the agent activity hook (#106). + #[cfg(feature = "cli")] + Hooks(maestro_cli::cli::HooksArgs), /// Per-host daemon: tmux wrapper + session API. #[cfg(feature = "daemon")] Daemon { @@ -123,6 +126,15 @@ async fn dispatch(cli: Cli) -> Result<(), Box> { ) .await? } + #[cfg(feature = "cli")] + Command::Hooks(args) => { + maestro_cli::dispatch( + maestro_cli::cli::Command::Hooks(args), + cli.config, + cli.daemon_url, + ) + .await? + } #[cfg(feature = "daemon")] Command::Daemon { cmd } => maestro_daemon::cli::dispatch(cli.config, cmd).await?, #[cfg(feature = "tui")] @@ -157,7 +169,11 @@ fn init_tracing(cli: &Cli) { fn is_cli_subcommand(command: &Command) -> bool { matches!( command, - Command::List(_) | Command::Spawn(_) | Command::Attach(_) | Command::Kill(_) + Command::List(_) + | Command::Spawn(_) + | Command::Attach(_) + | Command::Kill(_) + | Command::Hooks(_) ) }