1pub use zeroclaw_runtime::cron::*;
2
3use crate::config::Config;
4use anyhow::{Result, bail};
5use zeroclaw_runtime::i18n::{get_required_cli_string, get_required_cli_string_with_args};
6
7fn require_configured_agent(config: &Config, agent_alias: &str) -> Result<()> {
9 if config.agent(agent_alias).is_none() {
10 ::zeroclaw_log::record!(
11 WARN,
12 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
13 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
14 .with_attrs(::serde_json::json!({"agent_alias": agent_alias})),
15 "cron CLI rejected: unknown agent alias"
16 );
17 anyhow::bail!("Unknown agent {agent_alias:?} (no [agents.{agent_alias}] entry configured)");
18 }
19 Ok(())
20}
21
22fn parse_explicit_rfc3339_utc(raw: &str) -> Result<chrono::DateTime<chrono::Utc>> {
23 chrono::DateTime::parse_from_rfc3339(raw)
24 .map(|timestamp| timestamp.with_timezone(&chrono::Utc))
25 .map_err(|err| {
26 ::zeroclaw_log::record!(
27 WARN,
28 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
29 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
30 .with_attrs(::serde_json::json!({
31 "raw": raw,
32 "error": format!("{}", err),
33 })),
34 "cron --at rejected: timestamp lacks explicit Z/offset or is malformed"
35 );
36 anyhow::Error::msg(format!(
37 "Invalid RFC3339 timestamp for --at: expected RFC3339 timestamp with explicit Z or offset, e.g. 2026-05-18T09:00:00Z or 2026-05-18T09:00:00-04:00; got '{raw}': {err}"
38 ))
39 })
40}
41
42pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<()> {
43 match command {
44 crate::CronCommands::List => {
45 let jobs = list_jobs(config)?;
46 if jobs.is_empty() {
47 println!("{}", get_required_cli_string("cli-cron-none"));
48 println!("\n{}", get_required_cli_string("cli-cron-usage"));
49 println!(" zeroclaw cron add '0 9 * * *' 'agent -m \"Good morning!\"'"); return Ok(());
51 }
52
53 println!(
54 "{}",
55 get_required_cli_string_with_args(
56 "cli-cron-jobs-header",
57 &[("count", &jobs.len().to_string())]
58 )
59 );
60 for job in jobs {
61 let last_run = job
62 .last_run
63 .map_or_else(|| "never".into(), |d| d.to_rfc3339());
64 let last_status = job.last_status.unwrap_or_else(|| "n/a".into());
65 println!(
66 "- {} | {:?} | next={} | last={} ({})",
67 job.id,
68 job.schedule,
69 job.next_run.to_rfc3339(),
70 last_run,
71 last_status,
72 );
73 if !job.command.is_empty() {
74 println!(
75 "{}",
76 get_required_cli_string_with_args(
77 "cli-cron-list-cmd",
78 &[("cmd", &job.command)]
79 )
80 );
81 }
82 if let Some(prompt) = &job.prompt {
83 println!(
84 "{}",
85 get_required_cli_string_with_args(
86 "cli-cron-list-prompt",
87 &[("prompt", prompt)]
88 )
89 );
90 }
91 }
92 Ok(())
93 }
94 crate::CronCommands::Add {
95 expression,
96 agent_alias,
97 tz,
98 prompt,
99 allowed_tools,
100 command,
101 } => {
102 require_configured_agent(config, &agent_alias)?;
103 let schedule = Schedule::Cron {
104 expr: expression,
105 tz,
106 };
107 if prompt {
108 let job = add_agent_job(
109 config,
110 &agent_alias,
111 None,
112 schedule,
113 &command,
114 SessionTarget::Isolated,
115 None,
116 None,
117 false,
118 if allowed_tools.is_empty() {
119 None
120 } else {
121 Some(allowed_tools)
122 },
123 )?;
124 println!(
125 "{}",
126 get_required_cli_string_with_args("cli-cron-added-agent", &[("id", &job.id)])
127 );
128 println!(
129 "{}",
130 get_required_cli_string_with_args("cli-cron-expr", &[("v", &job.expression)])
131 );
132 println!(
133 "{}",
134 get_required_cli_string_with_args(
135 "cli-cron-next",
136 &[("v", &job.next_run.to_rfc3339())]
137 )
138 );
139 println!(
140 "{}",
141 get_required_cli_string_with_args(
142 "cli-cron-prompt",
143 &[("v", job.prompt.as_deref().unwrap_or_default())]
144 )
145 );
146 } else {
147 if !allowed_tools.is_empty() {
148 bail!("--allowed-tool is only supported with --prompt cron jobs");
149 }
150 let job = add_shell_job(config, &agent_alias, None, schedule, &command)?;
151 println!(
152 "{}",
153 get_required_cli_string_with_args("cli-cron-added", &[("id", &job.id)])
154 );
155 println!(
156 "{}",
157 get_required_cli_string_with_args("cli-cron-expr2", &[("v", &job.expression)])
158 );
159 println!(
160 "{}",
161 get_required_cli_string_with_args(
162 "cli-cron-next2",
163 &[("v", &job.next_run.to_rfc3339())]
164 )
165 );
166 println!(
167 "{}",
168 get_required_cli_string_with_args("cli-cron-cmd", &[("v", &job.command)])
169 );
170 }
171 Ok(())
172 }
173 crate::CronCommands::AddAt {
174 at,
175 agent_alias,
176 prompt,
177 allowed_tools,
178 command,
179 } => {
180 require_configured_agent(config, &agent_alias)?;
181 let at = parse_explicit_rfc3339_utc(&at)?;
182 let schedule = Schedule::At { at };
183 if prompt {
184 let job = add_agent_job(
185 config,
186 &agent_alias,
187 None,
188 schedule,
189 &command,
190 SessionTarget::Isolated,
191 None,
192 None,
193 true,
194 if allowed_tools.is_empty() {
195 None
196 } else {
197 Some(allowed_tools)
198 },
199 )?;
200 println!(
201 "{}",
202 get_required_cli_string_with_args(
203 "cli-cron-added-oneshot-agent",
204 &[("id", &job.id)]
205 )
206 );
207 println!(
208 "{}",
209 get_required_cli_string_with_args(
210 "cli-cron-at",
211 &[("v", &job.next_run.to_rfc3339())]
212 )
213 );
214 println!(
215 "{}",
216 get_required_cli_string_with_args(
217 "cli-cron-prompt",
218 &[("v", job.prompt.as_deref().unwrap_or_default())]
219 )
220 );
221 } else {
222 if !allowed_tools.is_empty() {
223 bail!("--allowed-tool is only supported with --prompt cron jobs");
224 }
225 let job = add_shell_job(config, &agent_alias, None, schedule, &command)?;
226 println!(
227 "{}",
228 get_required_cli_string_with_args("cli-cron-added-oneshot", &[("id", &job.id)])
229 );
230 println!(
231 "{}",
232 get_required_cli_string_with_args(
233 "cli-cron-at2",
234 &[("v", &job.next_run.to_rfc3339())]
235 )
236 );
237 println!(
238 "{}",
239 get_required_cli_string_with_args("cli-cron-cmd", &[("v", &job.command)])
240 );
241 }
242 Ok(())
243 }
244 crate::CronCommands::AddEvery {
245 every_ms,
246 agent_alias,
247 prompt,
248 allowed_tools,
249 command,
250 } => {
251 require_configured_agent(config, &agent_alias)?;
252 let schedule = Schedule::Every { every_ms };
253 if prompt {
254 let job = add_agent_job(
255 config,
256 &agent_alias,
257 None,
258 schedule,
259 &command,
260 SessionTarget::Isolated,
261 None,
262 None,
263 false,
264 if allowed_tools.is_empty() {
265 None
266 } else {
267 Some(allowed_tools)
268 },
269 )?;
270 println!(
271 "{}",
272 get_required_cli_string_with_args(
273 "cli-cron-added-interval-agent",
274 &[("id", &job.id)]
275 )
276 );
277 println!(
278 "{}",
279 get_required_cli_string_with_args(
280 "cli-cron-every",
281 &[("v", &every_ms.to_string())]
282 )
283 );
284 println!(
285 "{}",
286 get_required_cli_string_with_args(
287 "cli-cron-next3",
288 &[("v", &job.next_run.to_rfc3339())]
289 )
290 );
291 println!(
292 "{}",
293 get_required_cli_string_with_args(
294 "cli-cron-prompt3",
295 &[("v", job.prompt.as_deref().unwrap_or_default())]
296 )
297 );
298 } else {
299 if !allowed_tools.is_empty() {
300 bail!("--allowed-tool is only supported with --prompt cron jobs");
301 }
302 let job = add_shell_job(config, &agent_alias, None, schedule, &command)?;
303 println!(
304 "{}",
305 get_required_cli_string_with_args(
306 "cli-cron-added-interval",
307 &[("id", &job.id)]
308 )
309 );
310 println!(
311 "{}",
312 get_required_cli_string_with_args(
313 "cli-cron-every",
314 &[("v", &every_ms.to_string())]
315 )
316 );
317 println!(
318 "{}",
319 get_required_cli_string_with_args(
320 "cli-cron-next3",
321 &[("v", &job.next_run.to_rfc3339())]
322 )
323 );
324 println!(
325 "{}",
326 get_required_cli_string_with_args("cli-cron-cmd3", &[("v", &job.command)])
327 );
328 }
329 Ok(())
330 }
331 crate::CronCommands::Once {
332 delay,
333 agent_alias,
334 prompt,
335 allowed_tools,
336 command,
337 } => {
338 require_configured_agent(config, &agent_alias)?;
339 if prompt {
340 let duration = parse_delay(&delay)?;
341 let at = chrono::Utc::now() + duration;
342 let schedule = Schedule::At { at };
343 let job = add_agent_job(
344 config,
345 &agent_alias,
346 None,
347 schedule,
348 &command,
349 SessionTarget::Isolated,
350 None,
351 None,
352 true,
353 if allowed_tools.is_empty() {
354 None
355 } else {
356 Some(allowed_tools)
357 },
358 )?;
359 println!(
360 "{}",
361 get_required_cli_string_with_args(
362 "cli-cron-added-oneshot-agent",
363 &[("id", &job.id)]
364 )
365 );
366 println!(
367 "{}",
368 get_required_cli_string_with_args(
369 "cli-cron-at",
370 &[("v", &job.next_run.to_rfc3339())]
371 )
372 );
373 println!(
374 "{}",
375 get_required_cli_string_with_args(
376 "cli-cron-prompt",
377 &[("v", job.prompt.as_deref().unwrap_or_default())]
378 )
379 );
380 } else {
381 if !allowed_tools.is_empty() {
382 bail!("--allowed-tool is only supported with --prompt cron jobs");
383 }
384 let job = add_once(config, &agent_alias, &delay, &command)?;
385 println!(
386 "{}",
387 get_required_cli_string_with_args("cli-cron-added-oneshot", &[("id", &job.id)])
388 );
389 println!(
390 "{}",
391 get_required_cli_string_with_args(
392 "cli-cron-at2",
393 &[("v", &job.next_run.to_rfc3339())]
394 )
395 );
396 println!(
397 "{}",
398 get_required_cli_string_with_args("cli-cron-cmd", &[("v", &job.command)])
399 );
400 }
401 Ok(())
402 }
403 crate::CronCommands::Update {
404 id,
405 agent_alias,
406 expression,
407 tz,
408 command,
409 name,
410 allowed_tools,
411 } => {
412 require_configured_agent(config, &agent_alias)?;
413 if expression.is_none()
414 && tz.is_none()
415 && command.is_none()
416 && name.is_none()
417 && allowed_tools.is_empty()
418 {
419 bail!(
420 "At least one of --expression, --tz, --command, --name, or --allowed-tool must be provided"
421 );
422 }
423
424 let existing = if expression.is_some() || tz.is_some() || !allowed_tools.is_empty() {
425 Some(get_job(config, &id)?)
426 } else {
427 None
428 };
429
430 let schedule = if expression.is_some() || tz.is_some() {
434 let existing = existing
435 .as_ref()
436 .expect("existing job must be loaded when updating schedule");
437 let (existing_expr, existing_tz) = match &existing.schedule {
438 Schedule::Cron {
439 expr,
440 tz: existing_tz,
441 } => (expr.clone(), existing_tz.clone()),
442 _ => bail!("Cannot update expression/tz on a non-cron schedule"),
443 };
444 Some(Schedule::Cron {
445 expr: expression.unwrap_or(existing_expr),
446 tz: tz.or(existing_tz),
447 })
448 } else {
449 None
450 };
451
452 if !allowed_tools.is_empty() {
453 let existing = existing
454 .as_ref()
455 .expect("existing job must be loaded when updating allowed tools");
456 if existing.job_type != JobType::Agent {
457 bail!("--allowed-tool is only supported for agent cron jobs");
458 }
459 }
460
461 let patch = CronJobPatch {
462 schedule,
463 command,
464 name,
465 allowed_tools: if allowed_tools.is_empty() {
466 None
467 } else {
468 Some(allowed_tools)
469 },
470 ..CronJobPatch::default()
471 };
472
473 let job = update_shell_job_with_approval(config, &agent_alias, &id, patch, false)?;
474 println!(
475 "{}",
476 get_required_cli_string_with_args("cli-cron-updated", &[("id", &job.id)])
477 );
478 println!(
479 "{}",
480 get_required_cli_string_with_args("cli-cron-expr2", &[("v", &job.expression)])
481 );
482 println!(
483 "{}",
484 get_required_cli_string_with_args(
485 "cli-cron-next2",
486 &[("v", &job.next_run.to_rfc3339())]
487 )
488 );
489 println!(
490 "{}",
491 get_required_cli_string_with_args("cli-cron-cmd", &[("v", &job.command)])
492 );
493 Ok(())
494 }
495 crate::CronCommands::Remove { id } => remove_job(config, &id),
496 crate::CronCommands::Pause { id } => {
497 pause_job(config, &id)?;
498 println!(
499 "{}",
500 get_required_cli_string_with_args("cli-cron-paused", &[("id", &id)])
501 );
502 Ok(())
503 }
504 crate::CronCommands::Resume { id } => {
505 resume_job(config, &id)?;
506 println!(
507 "{}",
508 get_required_cli_string_with_args("cli-cron-resumed", &[("id", &id)])
509 );
510 Ok(())
511 }
512 }
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518 use tempfile::TempDir;
519
520 fn test_config(tmp: &TempDir) -> Config {
521 let mut config = Config {
522 data_dir: tmp.path().join("workspace"),
523 config_path: tmp.path().join("config.toml"),
524 ..Config::default()
525 };
526 std::fs::create_dir_all(&config.data_dir).unwrap();
527 config
528 .risk_profiles
529 .entry("test-agent".to_string())
530 .or_default();
531 config
532 .runtime_profiles
533 .entry("test-agent".to_string())
534 .or_default();
535 config
536 .providers
537 .models
538 .ensure("openrouter", "test-agent")
539 .expect("known family");
540 config.agents.entry("test-agent".to_string()).or_insert(
541 zeroclaw_config::schema::AliasedAgentConfig {
542 model_provider: "openrouter.test-agent".into(),
543 risk_profile: "test-agent".to_string(),
544 runtime_profile: "test-agent".to_string(),
545 ..Default::default()
546 },
547 );
548 config
549 }
550
551 #[test]
552 fn cli_add_at_rejects_timestamp_without_explicit_offset_with_actionable_error() {
553 let tmp = TempDir::new().unwrap();
554 let config = test_config(&tmp);
555
556 let result = handle_command(
557 crate::CronCommands::AddAt {
558 at: "2026-05-18T09:00:00".into(),
559 agent_alias: "test-agent".into(),
560 prompt: false,
561 allowed_tools: vec![],
562 command: "echo at".into(),
563 },
564 &config,
565 );
566
567 let error = result.expect_err("bare local timestamp must be rejected");
568 let message = error.to_string();
569 assert!(
570 message.contains("RFC3339 timestamp with explicit Z or offset"),
571 "error should explain the explicit offset requirement: {message}"
572 );
573 assert!(message.contains("2026-05-18T09:00:00Z"));
574 assert!(message.contains("2026-05-18T09:00:00-04:00"));
575 }
576}