diff --git a/src/hermes_cli.rs b/src/hermes_cli.rs index 1ae7aad..41837fe 100644 --- a/src/hermes_cli.rs +++ b/src/hermes_cli.rs @@ -157,6 +157,54 @@ pub fn apply_onboard_hermes_config( Ok(()) } +use crate::onboard::{STATS_CRON_JOB_NAME, STATS_CRON_PROMPT}; + +pub fn setup_hermes_stats_cron(runner: &mut R) -> Result<(), Box> { + let output = runner + .run(&[ + "cron".into(), + "create".into(), + "0 9 * * 1".into(), + STATS_CRON_PROMPT.into(), + "--skill".into(), + "get-clawshell-stats".into(), + "--name".into(), + STATS_CRON_JOB_NAME.into(), + ]) + .map_err(|e| format!("failed to run `hermes cron create`: {e}"))?; + if !output.success { + let status = output + .status_code + .map(|c| c.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + return Err(format!( + "`hermes cron create` exited with status {status}: {}", + output.stderr.trim() + ) + .into()); + } + Ok(()) +} + +#[allow(dead_code)] +pub fn remove_hermes_stats_cron(runner: &mut R) -> Result<(), Box> { + let output = runner + .run(&["cron".into(), "remove".into(), STATS_CRON_JOB_NAME.into()]) + .map_err(|e| format!("failed to run `hermes cron remove`: {e}"))?; + if !output.success { + let status = output + .status_code + .map(|c| c.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + return Err(format!( + "`hermes cron remove` exited with status {status}: {}", + output.stderr.trim() + ) + .into()); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -263,4 +311,29 @@ mod tests { assert!(msg.contains("failed to run"), "msg: {msg}"); assert!(msg.contains("no such binary"), "msg: {msg}"); } + + #[test] + fn test_setup_hermes_stats_cron_sends_correct_args() { + let mut runner = FakeHermesRunner::default(); + setup_hermes_stats_cron(&mut runner).unwrap(); + assert_eq!(runner.calls.len(), 1); + let args = &runner.calls[0]; + assert_eq!(args[0], "cron"); + assert_eq!(args[1], "create"); + assert_eq!(args[2], "0 9 * * 1"); + assert!(args[3].contains("get-clawshell-stats")); + assert!(args[3].contains("/admin/stats")); + assert!(args.contains(&"--skill".to_string())); + assert!(args.contains(&"get-clawshell-stats".to_string())); + assert!(args.contains(&"--name".to_string())); + assert!(args.contains(&STATS_CRON_JOB_NAME.to_string())); + } + + #[test] + fn test_remove_hermes_stats_cron_sends_correct_args() { + let mut runner = FakeHermesRunner::default(); + remove_hermes_stats_cron(&mut runner).unwrap(); + assert_eq!(runner.calls.len(), 1); + assert_eq!(runner.calls[0], vec!["cron", "remove", STATS_CRON_JOB_NAME]); + } } diff --git a/src/main.rs b/src/main.rs index 6376362..0859aee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1168,6 +1168,14 @@ fn apply_openclaw_onboarding_steps( onboard::upsert_managed_skill_manifest_entry(config_file, &skill.manifest_entry)?; tui::print_info("OpenClaw skill", &skill.path.display().to_string()); } + // Set up the weekly stats cron job. + let mut openclaw_runner = openclaw_cli::RealOpenclawRunner; + match openclaw_cli::setup_openclaw_stats_cron(&mut openclaw_runner) { + Ok(()) => tui::print_info("Cron job", "clawshell-weekly-stats (Mon 09:00)"), + Err(err) => { + tui::print_warning(&format!("Failed to set up weekly stats cron job: {err}")) + } + } tui::print_step_done(6, TOTAL_STEPS, "OpenClaw skills written"); written } else { @@ -1326,6 +1334,14 @@ fn apply_hermes_onboarding_steps( for path in &paths { tui::print_info("Hermes skill", &path.display().to_string()); } + // Set up the weekly stats cron job. + let mut hermes_runner = hermes_cli::RealHermesRunner; + match hermes_cli::setup_hermes_stats_cron(&mut hermes_runner) { + Ok(()) => tui::print_info("Cron job", "clawshell-weekly-stats (Mon 09:00)"), + Err(err) => { + tui::print_warning(&format!("Failed to set up weekly stats cron job: {err}")) + } + } tui::print_step_done(6, TOTAL_STEPS, "Hermes skills written"); } Err(error) => { @@ -1883,6 +1899,14 @@ fn cmd_uninstall(skip_confirm: bool) -> Result<(), Box> { } } + // 0a. Remove the stats cron job (best-effort). + if openclaw_path.as_ref().is_some_and(|p| p.exists()) { + let mut runner = openclaw_cli::RealOpenclawRunner; + if let Err(err) = openclaw_cli::remove_openclaw_stats_cron(&mut runner) { + warn!(error = %err, "Failed to remove stats cron job during uninstall"); + } + } + // 0b. Remove ClawShell-managed OpenClaw skills if present. let remove_skill_dir = |path: &Path| match std::fs::remove_dir_all(path) { Ok(()) => tui::print_success(&format!("OpenClaw skill removed: {}", path.display())), diff --git a/src/onboard/mod.rs b/src/onboard/mod.rs index 72a2d53..ab51c2f 100644 --- a/src/onboard/mod.rs +++ b/src/onboard/mod.rs @@ -33,5 +33,6 @@ pub use openclaw_json::{patch_openclaw_config_for_clawshell, remove_clawshell_op pub use skills::{render_admin_stats_skill, render_email_messages_skill}; pub use types::{ ADMIN_STATS_SKILL_NAME, EMAIL_MESSAGES_SKILL_NAME, OnboardAuthMethod, OnboardConfig, - OnboardSkillBundle, OnboardTarget, OpenclawFileRemovalPreview, + OnboardSkillBundle, OnboardTarget, OpenclawFileRemovalPreview, STATS_CRON_JOB_NAME, + STATS_CRON_PROMPT, }; diff --git a/src/onboard/types.rs b/src/onboard/types.rs index bb1d37f..a0949b3 100644 --- a/src/onboard/types.rs +++ b/src/onboard/types.rs @@ -121,6 +121,16 @@ pub struct OnboardSkillBundle { pub const EMAIL_MESSAGES_SKILL_NAME: &str = "get-email-messages"; pub const ADMIN_STATS_SKILL_NAME: &str = "get-clawshell-stats"; +pub const STATS_CRON_JOB_NAME: &str = "clawshell-weekly-stats"; + +pub const STATS_CRON_PROMPT: &str = "\ +Use the get-clawshell-stats skill to fetch ClawShell runtime statistics \ +from the /admin/stats endpoint, then present a short summary: total \ +requests served, token usage (prompt, completion, total), and \ +email-filter activity (total filtered count plus the top filtered \ +senders). If the endpoint returns an error or is unreachable, report \ +that instead."; + /// Sender filtering mode for the Email endpoint. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OnboardEmailMode { diff --git a/src/openclaw_cli.rs b/src/openclaw_cli.rs index 0196b4c..9304d4d 100644 --- a/src/openclaw_cli.rs +++ b/src/openclaw_cli.rs @@ -645,6 +645,33 @@ fn nested_value_or_empty_object(json: &Value, path: &[&str]) -> Value { current.clone() } +use crate::onboard::{STATS_CRON_JOB_NAME, STATS_CRON_PROMPT}; + +pub fn setup_openclaw_stats_cron(runner: &mut R) -> Result<(), Box> { + run_openclaw_command( + runner, + &[ + "cron", + "add", + "--name", + STATS_CRON_JOB_NAME, + "--cron", + "0 9 * * 1", + "--session", + "isolated", + "--message", + STATS_CRON_PROMPT, + "--no-deliver", + ], + )?; + Ok(()) +} + +pub fn remove_openclaw_stats_cron(runner: &mut R) -> Result<(), Box> { + run_openclaw_command(runner, &["cron", "remove", STATS_CRON_JOB_NAME])?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -1180,4 +1207,39 @@ mod tests { assert!(error.contains("failed to resolve non-root target account metadata")); assert!(error.contains("uid 1000")); } + + #[test] + fn test_setup_openclaw_stats_cron_sends_correct_args() { + let mut runner = FakeOpenclawRunner { + responses: VecDeque::from([ok_output("")]), + ..Default::default() + }; + setup_openclaw_stats_cron(&mut runner).unwrap(); + assert_eq!(runner.calls.len(), 1); + let args = &runner.calls[0]; + assert_eq!(args[0], "cron"); + assert_eq!(args[1], "add"); + assert!(args.contains(&"--name".to_string())); + assert!(args.contains(&STATS_CRON_JOB_NAME.to_string())); + assert!(args.contains(&"--cron".to_string())); + assert!(args.contains(&"0 9 * * 1".to_string())); + assert!(args.contains(&"--session".to_string())); + assert!(args.contains(&"isolated".to_string())); + assert!(args.contains(&"--no-deliver".to_string())); + let message_idx = args.iter().position(|a| a == "--message").unwrap(); + let message = &args[message_idx + 1]; + assert!(message.contains("get-clawshell-stats")); + assert!(message.contains("/admin/stats")); + } + + #[test] + fn test_remove_openclaw_stats_cron_sends_correct_args() { + let mut runner = FakeOpenclawRunner { + responses: VecDeque::from([ok_output("")]), + ..Default::default() + }; + remove_openclaw_stats_cron(&mut runner).unwrap(); + assert_eq!(runner.calls.len(), 1); + assert_eq!(runner.calls[0], vec!["cron", "remove", STATS_CRON_JOB_NAME]); + } }