From 18784ba9f721767577b4f71df7f718d1ccdae1a1 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 17 Aug 2026 08:55:35 -0400 Subject: [PATCH] hooks : on_install/on_start/on_stop/on_update globaux (settings.hooks) et par projet (issue #34) - echec non bloquant, dry-run respecte, ordre global puis projet --- src/commands/install_cmd.rs | 5 ++ src/commands/run_cmd.rs | 6 ++ src/commands/update_cmd.rs | 5 ++ src/config.rs | 7 +++ src/hooks.rs | 120 ++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 6 files changed, 144 insertions(+) create mode 100644 src/hooks.rs diff --git a/src/commands/install_cmd.rs b/src/commands/install_cmd.rs index 10e52ee..b9021c0 100644 --- a/src/commands/install_cmd.rs +++ b/src/commands/install_cmd.rs @@ -143,6 +143,11 @@ pub fn run(app: &App, agent_name: &str, method_sel: Option<&str>, force: bool) - .with_agent(agent.name.clone()) .with_args(vec![format!("method={}", method.kind)]), ); + crate::hooks::run_hooks( + app, + "on_install", + &std::env::current_dir().unwrap_or_default(), + ); app.log.success(&format!( "installed {} (v{version}) in {}", agent.title(), diff --git a/src/commands/run_cmd.rs b/src/commands/run_cmd.rs index cd022f3..ac01fe9 100644 --- a/src/commands/run_cmd.rs +++ b/src/commands/run_cmd.rs @@ -61,6 +61,8 @@ fn start_one( let bin = PathBuf::from(&exec.program); let is_managed = app.state.get(&agent.name).ok().flatten().is_some(); let ctx = crate::context::detect(&std::env::current_dir().unwrap_or_default()); + let current = std::env::current_dir().unwrap_or_default(); + crate::hooks::run_hooks(app, "on_start", cwd.unwrap_or(¤t)); if background { let pid = process::spawn_background(app, &agent.name, &bin, &exec.args, &exec.env, cwd)?; if app.dry_run() { @@ -194,6 +196,8 @@ fn stop_one(app: &App, agent: &AgentDef, force: bool, timeout: u64) -> Result Result Result { .with_agent(agent.name.clone()) .with_args(vec![format!("method={}", method.kind)]), ); + crate::hooks::run_hooks( + app, + "on_update", + &std::env::current_dir().unwrap_or_default(), + ); app.log.success(&format!( "updated {} to v{version}", agent.title() diff --git a/src/config.rs b/src/config.rs index 50aff7c..0a086b8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -73,6 +73,10 @@ pub struct Settings { /// --theme flag overrides it). #[serde(default)] pub theme: Option, + /// Lifecycle hooks: on_install / on_start / on_stop / on_update command + /// lines (global; project profiles can add their own). + #[serde(default)] + pub hooks: BTreeMap>, } /// A project profile: everything am needs to know about one project. @@ -497,6 +501,9 @@ pub fn merge(base: &mut Config, overlay: Config) { if o.self_update_repo.is_some() { s.self_update_repo = o.self_update_repo; } + for (k, v) in o.hooks { + s.hooks.insert(k, v); + } for (k, v) in overlay.aliases { base.aliases.insert(k, v); } diff --git a/src/hooks.rs b/src/hooks.rs new file mode 100644 index 0000000..3eff22c --- /dev/null +++ b/src/hooks.rs @@ -0,0 +1,120 @@ +//! Hooks: user-defined commands run around agent lifecycle events +//! (on_install, on_start, on_stop, on_update). Global settings.hooks run +//! first, then the hooks of the project profile containing the cwd. + +use crate::app::App; +use crate::runner::SystemRunner; +use std::collections::BTreeMap; +use std::path::Path; + +/// Run every hook registered for an event, in order. Failures are warnings +/// and never break the main command; --dry-run announces them without +/// executing anything. +pub fn run_hooks(app: &App, hook: &str, cwd: &Path) { + let mut cmds: Vec = Vec::new(); + if let Some(global) = app.config.settings.hooks.get(hook) { + cmds.extend(global.iter().cloned()); + } + if let Some((_, profile)) = crate::projects::profile_for(app, cwd) { + if let Some(list) = profile.hooks.get(hook) { + cmds.extend(list.iter().cloned()); + } + } + if cmds.is_empty() { + return; + } + let runner = SystemRunner::new(app.dry_run(), app.cli.verbose, &app.log); + for cmd in &cmds { + let tokens = shell_words::split(cmd).unwrap_or_else(|_| vec![cmd.clone()]); + if tokens.is_empty() { + continue; + } + let prog = tokens[0].clone(); + let args: Vec = tokens[1..].to_vec(); + app.log.info(&format!("hook {hook}: {cmd}")); + if let Err(e) = crate::installers::sh(&runner, &prog, &args, &BTreeMap::new(), Some(cwd)) { + app.log.warn(&format!("hook {hook} failed: {e:#}")); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + fn app_with(dir: &std::path::Path) -> App { + let cfg = dir.join("config.yaml"); + std::fs::write( + &cfg, + concat!( + "version: \"1.0\"\n", + "settings:\n", + " auto_install_deps: false\n", + "agents: []\n", + ), + ) + .unwrap(); + let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]); + let mut app = App::from_cli(cli).unwrap(); + let mut p = app.paths.clone(); + p.install_dir = dir.join("agents"); + p.bin_dir = dir.join("agents").join("bin"); + p.log_dir = dir.join("logs"); + p.state_file = dir.join("state.json"); + p.probe_cache_file = dir.join("probe-cache.json"); + p.config_dir = Some(dir.join("config")); + app.paths = p; + app + } + + #[test] + fn hook_runs_and_writes_marker() { + let guard = tempfile::tempdir().unwrap(); + let dir = guard.path().to_path_buf(); + std::mem::forget(guard); + let marker = dir.join("marker.txt"); + let mut app = app_with(&dir); + let cmd = format!("sh -c \"echo ran > {}\"", marker.display().to_string().replace("\\", "/")); + app.config.settings.hooks.insert("on_start".to_string(), vec![cmd]); + run_hooks(&app, "on_start", &dir); + assert!(marker.exists(), "hook a ecrit le marqueur"); + } + + #[test] + fn failing_hook_warns_without_panicking() { + let guard = tempfile::tempdir().unwrap(); + let dir = guard.path().to_path_buf(); + std::mem::forget(guard); + let mut app = app_with(&dir); + app.config.settings.hooks.insert("on_start".to_string(), vec!["sh -c exit 3".to_string()]); + run_hooks(&app, "on_start", &dir); // ne panique pas + run_hooks(&app, "on_update", &dir); // aucun hook : no-op + } + + #[test] + fn dry_run_announces_without_executing() { + let guard = tempfile::tempdir().unwrap(); + let dir = guard.path().to_path_buf(); + std::mem::forget(guard); + let marker = dir.join("marker2.txt"); + let mut app = app_with(&dir); + let cmd = format!("sh -c \"echo ran > {}\"", marker.display().to_string().replace("\\", "/")); + app.config.settings.hooks.insert("on_start".to_string(), vec![cmd]); + app.cli.dry_run = true; + run_hooks(&app, "on_start", &dir); + assert!(!marker.exists(), "dry-run ne doit rien executer"); + } + + #[test] + fn settings_hooks_parse_from_yaml() { + let cfg: crate::config::Config = serde_yaml::from_str( + "version: \"1.0\"\nsettings:\n hooks:\n on_start: [\"sh -c echo hi\"]\nagents: []\n", + ) + .unwrap(); + assert_eq!( + cfg.settings.hooks["on_start"], + vec!["sh -c echo hi".to_string()] + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index a8e59ce..ff93485 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,6 +23,7 @@ pub mod download; pub mod events; pub mod help; pub mod history; +pub mod hooks; pub mod installers; pub mod nav; pub mod output; -- 2.54.0