Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/hermes_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,54 @@ pub fn apply_onboard_hermes_config<R: HermesRunner>(
Ok(())
}

use crate::onboard::{STATS_CRON_JOB_NAME, STATS_CRON_PROMPT};

pub fn setup_hermes_stats_cron<R: HermesRunner>(runner: &mut R) -> Result<(), Box<dyn Error>> {
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<R: HermesRunner>(runner: &mut R) -> Result<(), Box<dyn Error>> {
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::*;
Expand Down Expand Up @@ -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]);
}
}
24 changes: 24 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -1883,6 +1899,14 @@ fn cmd_uninstall(skip_confirm: bool) -> Result<(), Box<dyn std::error::Error>> {
}
}

// 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())),
Expand Down
3 changes: 2 additions & 1 deletion src/onboard/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
10 changes: 10 additions & 0 deletions src/onboard/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
62 changes: 62 additions & 0 deletions src/openclaw_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<R: OpenclawRunner>(runner: &mut R) -> Result<(), Box<dyn Error>> {
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<R: OpenclawRunner>(runner: &mut R) -> Result<(), Box<dyn Error>> {
run_openclaw_command(runner, &["cron", "remove", STATS_CRON_JOB_NAME])?;
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -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]);
}
}
Loading