1use std::path::PathBuf;
12use std::time::Duration;
13
14use colored::Colorize;
15
16const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
17
18#[derive(Debug, Clone, Default)]
20pub struct ConnArgs {
21 pub server_url: Option<String>,
23 pub port: Option<u16>,
25 pub data_dir: Option<PathBuf>,
27}
28
29impl ConnArgs {
30 pub(crate) fn api_base(&self) -> String {
32 if let Some(url) = &self.server_url {
33 let url = url.trim_end_matches('/');
34 let url = if url.contains("://") {
36 url.to_string()
37 } else {
38 format!("http://{url}")
39 };
40 return format!("{url}/api/v1");
41 }
42 let config = bamboo_llm::Config::from_data_dir(self.data_dir.clone());
43 let port = self.port.unwrap_or(config.server.port);
44 let host = match config.server.bind.trim() {
45 "" | "0.0.0.0" | "::" | "[::]" => "127.0.0.1".to_string(),
47 h if h.contains(':') && !h.starts_with('[') => format!("[{h}]"),
49 h => h.to_string(),
50 };
51 format!("http://{host}:{port}/api/v1")
52 }
53}
54
55pub(crate) fn unreachable(base: &str, e: reqwest::Error) -> anyhow::Error {
56 anyhow::anyhow!("could not reach the server at {base} ({e}). Is `bamboo serve` running?")
57}
58
59pub(crate) fn guard_id_segment(kind: &str, id: &str) -> anyhow::Result<()> {
64 if id.is_empty()
65 || id == "."
66 || id == ".."
67 || id.contains(['/', '\\', '?', '#', '%'])
68 || id.chars().any(char::is_whitespace)
69 {
70 anyhow::bail!("invalid {kind}: '{id}'");
71 }
72 Ok(())
73}
74
75pub async fn health(conn: ConnArgs) -> anyhow::Result<()> {
78 let base = conn.api_base();
79 let url = format!("{base}/health");
80 let resp = reqwest::Client::new()
81 .get(&url)
82 .timeout(REQUEST_TIMEOUT)
83 .send()
84 .await
85 .map_err(|e| unreachable(&base, e))?;
86 if resp.status().is_success() {
87 println!("{} {base}", "● healthy".green().bold());
88 Ok(())
89 } else {
90 anyhow::bail!("unhealthy: HTTP {} from {url}", resp.status());
91 }
92}
93
94pub async fn status(conn: ConnArgs) -> anyhow::Result<()> {
96 let base = conn.api_base();
97 let server = base.trim_end_matches("/api/v1");
98 println!("{:<10}{server}", "server:".bold());
99
100 let client = reqwest::Client::new();
101 let health = client
102 .get(format!("{base}/health"))
103 .timeout(REQUEST_TIMEOUT)
104 .send()
105 .await;
106 match health {
107 Ok(r) if r.status().is_success() => println!("{:<10}{}", "health:".bold(), "ok".green()),
108 Ok(r) => {
109 println!(
110 "{:<10}{} (HTTP {})",
111 "health:".bold(),
112 "down".red(),
113 r.status()
114 );
115 return Ok(());
116 }
117 Err(e) => {
118 println!("{:<10}{} ({e})", "health:".bold(), "unreachable".red());
119 return Ok(());
120 }
121 }
122
123 if let Ok(r) = client
124 .get(format!("{base}/sessions"))
125 .timeout(REQUEST_TIMEOUT)
126 .send()
127 .await
128 {
129 if let Ok(v) = r.json::<serde_json::Value>().await {
130 let sessions = v.get("sessions").and_then(|s| s.as_array());
131 let total = sessions.map(|s| s.len()).unwrap_or(0);
132 let running = sessions.map(|s| count_running(s)).unwrap_or(0);
133 println!(
134 "{:<10}{total} total, {} running",
135 "sessions:".bold(),
136 running.to_string().cyan()
137 );
138 }
139 }
140 Ok(())
141}
142
143pub async fn sessions_list(conn: ConnArgs) -> anyhow::Result<()> {
145 let base = conn.api_base();
146 let url = format!("{base}/sessions");
147 let resp = reqwest::Client::new()
148 .get(&url)
149 .timeout(REQUEST_TIMEOUT)
150 .send()
151 .await
152 .map_err(|e| unreachable(&base, e))?;
153 if !resp.status().is_success() {
154 anyhow::bail!("GET {url} -> HTTP {}", resp.status());
155 }
156 let v: serde_json::Value = resp.json().await?;
157 let sessions = v.get("sessions").and_then(|s| s.as_array());
158 let sessions = match sessions {
159 Some(s) if !s.is_empty() => s,
160 _ => {
161 println!("(no sessions)");
162 return Ok(());
163 }
164 };
165
166 println!(
169 "{:<38} {:<5} {:<26} {:>5} TITLE",
170 "SESSION ID", "RUN", "MODEL", "MSGS"
171 );
172 for s in sessions {
173 let id = s.get("id").and_then(|x| x.as_str()).unwrap_or("?");
174 let running = s
175 .get("is_running")
176 .and_then(|b| b.as_bool())
177 .unwrap_or(false);
178 let model = s.get("model").and_then(|x| x.as_str()).unwrap_or("");
179 let msgs = s.get("message_count").and_then(|x| x.as_u64()).unwrap_or(0);
180 let title = s.get("title").and_then(|x| x.as_str()).unwrap_or("");
181 println!(
182 "{:<38} {:<5} {:<26} {:>5} {}",
183 id,
184 if running { "run" } else { "-" },
185 truncate(model, 26),
186 msgs,
187 truncate(title, 60)
188 );
189 }
190 let running = count_running(sessions);
191 println!(
192 "\n{running} running. Stop one with: {}",
193 "bamboo stop <session-id>".cyan()
194 );
195 Ok(())
196}
197
198pub async fn stop(conn: ConnArgs, session_id: &str) -> anyhow::Result<()> {
200 guard_id_segment("session id", session_id)?;
201 let base = conn.api_base();
202 let url = format!("{base}/stop/{session_id}");
203 let resp = reqwest::Client::new()
204 .post(&url)
205 .timeout(REQUEST_TIMEOUT)
206 .send()
207 .await
208 .map_err(|e| unreachable(&base, e))?;
209 let status = resp.status();
210 let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
211 let message = body
212 .get("message")
213 .and_then(|m| m.as_str())
214 .unwrap_or("")
215 .to_string();
216 if status.is_success() {
217 let msg = if message.is_empty() {
218 "stopped"
219 } else {
220 &message
221 };
222 println!("{} {msg}", "✓".green());
223 Ok(())
224 } else if status.as_u16() == 404 {
225 anyhow::bail!(
226 "no active run for session '{session_id}'{}",
227 if message.is_empty() {
228 String::new()
229 } else {
230 format!(" ({message})")
231 }
232 );
233 } else {
234 anyhow::bail!("stop failed: HTTP {status} {message}");
235 }
236}
237
238pub async fn history(conn: ConnArgs, session_id: &str) -> anyhow::Result<()> {
243 guard_id_segment("session id", session_id)?;
244 let base = conn.api_base();
245 let url = format!("{base}/history/{session_id}");
246 let resp = reqwest::Client::new()
247 .get(&url)
248 .timeout(REQUEST_TIMEOUT)
249 .send()
250 .await
251 .map_err(|e| unreachable(&base, e))?;
252 if resp.status().as_u16() == 404 {
253 anyhow::bail!("session '{session_id}' not found");
254 }
255 if !resp.status().is_success() {
256 anyhow::bail!("GET {url} -> HTTP {}", resp.status());
257 }
258 let v: serde_json::Value = resp.json().await?;
259 let messages = v.get("messages").and_then(|m| m.as_array());
260 let messages = match messages {
261 Some(m) if !m.is_empty() => m,
262 _ => {
263 println!("(no messages)");
264 return Ok(());
265 }
266 };
267 for m in messages {
268 let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("?");
269 let content = m.get("content").and_then(|c| c.as_str()).unwrap_or("");
270 let label = match role {
271 "user" => "user".cyan(),
272 "assistant" => "assistant".green(),
273 "system" => "system".dimmed(),
274 "tool" => "tool".yellow(),
275 other => other.normal(),
276 };
277 println!("{label}: {content}");
278 }
279 let total = v
283 .get("total_message_count")
284 .and_then(|x| x.as_u64())
285 .unwrap_or(messages.len() as u64);
286 let truncated = v
287 .get("truncated")
288 .and_then(|x| x.as_bool())
289 .unwrap_or(false);
290 println!(
291 "\n{}",
292 history_summary(session_id, messages.len(), total, truncated)
293 );
294 Ok(())
295}
296
297fn history_summary(session_id: &str, shown: usize, total: u64, truncated: bool) -> String {
301 if truncated {
302 format!(
303 "{total} message(s) in session {session_id} (showing the newest {shown}; older messages omitted by the server's history cap)."
304 )
305 } else {
306 format!("{total} message(s) in session {session_id}.")
307 }
308}
309
310pub async fn respond(conn: ConnArgs, session_id: &str, answer: &str) -> anyhow::Result<()> {
314 guard_id_segment("session id", session_id)?;
315 let base = conn.api_base();
316 let url = format!("{base}/respond/{session_id}");
317 let resp = reqwest::Client::new()
318 .post(&url)
319 .timeout(REQUEST_TIMEOUT)
320 .json(&serde_json::json!({ "response": answer }))
323 .send()
324 .await
325 .map_err(|e| unreachable(&base, e))?;
326 let status = resp.status();
327 let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
328 if status.is_success() {
329 let auto_resume = body
330 .get("auto_resume_status")
331 .and_then(|s| s.as_str())
332 .unwrap_or("unknown");
333 println!(
334 "{} response recorded; the run resumes server-side (auto-resume: {auto_resume}).",
335 "✓".green()
336 );
337 if let Some(run_id) = body.get("run_id").and_then(|r| r.as_str()) {
338 println!("run id: {run_id}");
339 }
340 return Ok(());
341 }
342 if status.as_u16() == 404 {
343 anyhow::bail!("session '{session_id}' not found");
344 }
345 let error = body.get("error").and_then(|e| e.as_str()).unwrap_or("");
346 if status.as_u16() == 400 && error.contains("No pending question") {
347 anyhow::bail!(
348 "session '{session_id}' has no pending question — nothing to answer \
349 (check with: bamboo respond {session_id} --pending)"
350 );
351 }
352 let detail = body.get("message").and_then(|m| m.as_str()).unwrap_or("");
353 anyhow::bail!(
354 "respond failed: HTTP {status}{}{}",
355 if error.is_empty() {
356 String::new()
357 } else {
358 format!(" {error}")
359 },
360 if detail.is_empty() {
361 String::new()
362 } else {
363 format!(" ({detail})")
364 }
365 );
366}
367
368pub async fn respond_pending(conn: ConnArgs, session_id: &str, json: bool) -> anyhow::Result<()> {
371 guard_id_segment("session id", session_id)?;
372 let base = conn.api_base();
373 let url = format!("{base}/respond/{session_id}/pending");
374 let resp = reqwest::Client::new()
375 .get(&url)
376 .timeout(REQUEST_TIMEOUT)
377 .send()
378 .await
379 .map_err(|e| unreachable(&base, e))?;
380 if resp.status().as_u16() == 404 {
381 anyhow::bail!("session '{session_id}' not found");
382 }
383 if !resp.status().is_success() {
384 anyhow::bail!("GET {url} -> HTTP {}", resp.status());
385 }
386 let v: serde_json::Value = resp.json().await?;
387 if json {
388 println!("{}", serde_json::to_string_pretty(&v)?);
389 return Ok(());
390 }
391 match format_pending_question(session_id, &v) {
392 Some(text) => println!("{text}"),
393 None => println!("no pending question for session '{session_id}'."),
394 }
395 Ok(())
396}
397
398fn format_pending_question(session_id: &str, v: &serde_json::Value) -> Option<String> {
401 if !v
402 .get("has_pending_question")
403 .and_then(|b| b.as_bool())
404 .unwrap_or(false)
405 {
406 return None;
407 }
408 let question = v.get("question").and_then(|q| q.as_str()).unwrap_or("");
409 let mut out = format!("session: {session_id}\nquestion: {question}\n");
410 if let Some(options) = v.get("options").and_then(|o| o.as_array()) {
411 if !options.is_empty() {
412 out.push_str("options:\n");
413 for (i, opt) in options.iter().enumerate() {
414 let opt = opt
415 .as_str()
416 .map(str::to_string)
417 .unwrap_or_else(|| opt.to_string());
418 out.push_str(&format!(" {}. {opt}\n", i + 1));
419 }
420 }
421 }
422 if v.get("allow_custom")
423 .and_then(|b| b.as_bool())
424 .unwrap_or(false)
425 {
426 out.push_str("(custom free-text answers are allowed)\n");
427 }
428 if let Some(tool) = v
429 .get("tool_name")
430 .and_then(|t| t.as_str())
431 .filter(|t| !t.is_empty())
432 {
433 out.push_str(&format!("tool: {tool}\n"));
434 }
435 out.push_str(&format!(
436 "\nAnswer with: bamboo respond {session_id} \"<answer>\" — answering resumes the run server-side."
437 ));
438 Some(out)
439}
440
441pub async fn session_show(conn: ConnArgs, session_id: &str, json: bool) -> anyhow::Result<()> {
444 guard_id_segment("session id", session_id)?;
445 let base = conn.api_base();
446 let url = format!("{base}/sessions/{session_id}");
447 let resp = reqwest::Client::new()
448 .get(&url)
449 .timeout(REQUEST_TIMEOUT)
450 .send()
451 .await
452 .map_err(|e| unreachable(&base, e))?;
453 if resp.status().as_u16() == 404 {
454 anyhow::bail!("session '{session_id}' not found");
455 }
456 if !resp.status().is_success() {
457 anyhow::bail!("GET {url} -> HTTP {}", resp.status());
458 }
459 let v: serde_json::Value = resp.json().await?;
460 if json {
461 println!("{}", serde_json::to_string_pretty(&v)?);
462 return Ok(());
463 }
464 let session = v.get("session").unwrap_or(&v);
465 println!("{}", format_session_detail(session));
466 Ok(())
467}
468
469fn format_session_detail(s: &serde_json::Value) -> String {
473 let str_field = |key: &str| s.get(key).and_then(|x| x.as_str()).unwrap_or("");
474 let mut lines: Vec<String> = Vec::new();
475 let mut push = |label: &str, value: String| {
476 let label = format!("{:<16}", format!("{label}:"));
479 lines.push(format!("{}{value}", label.bold()));
480 };
481
482 push("id", str_field("id").to_string());
483 push("title", str_field("title").to_string());
484 push("kind", str_field("kind").to_string());
485 let model = str_field("model").to_string();
486 let model = match s.get("provider").and_then(|p| p.as_str()) {
487 Some(provider) if !provider.is_empty() => format!("{provider}:{model}"),
488 _ => model,
489 };
490 push("model", model);
491 let running = s
492 .get("is_running")
493 .and_then(|b| b.as_bool())
494 .unwrap_or(false);
495 push(
496 "running",
497 if running {
498 "yes".green().to_string()
499 } else {
500 "no".to_string()
501 },
502 );
503 if let Some(status) = s.get("last_run_status").and_then(|x| x.as_str()) {
504 let mut line = status.to_string();
505 if let Some(err) = s.get("last_run_error").and_then(|x| x.as_str()) {
506 line.push_str(&format!(" ({err})"));
507 }
508 push("last run", line);
509 }
510 let pending = s
511 .get("has_pending_question")
512 .and_then(|b| b.as_bool())
513 .unwrap_or(false);
514 push(
515 "pending q",
516 if pending {
517 "yes (see: bamboo respond <id> --pending)"
518 .yellow()
519 .to_string()
520 } else {
521 "no".to_string()
522 },
523 );
524 push(
525 "messages",
526 s.get("message_count")
527 .and_then(|x| x.as_u64())
528 .unwrap_or(0)
529 .to_string(),
530 );
531 if s.get("pinned").and_then(|b| b.as_bool()).unwrap_or(false) {
532 push("pinned", "yes".to_string());
533 }
534 if let Some(parent) = s.get("parent_session_id").and_then(|x| x.as_str()) {
535 push("parent", parent.to_string());
536 }
537 let child_count = s
538 .get("running_child_count")
539 .and_then(|x| x.as_u64())
540 .unwrap_or(0);
541 if child_count > 0 {
542 push("children", format!("{child_count} running"));
543 }
544 push("created", str_field("created_at").to_string());
545 push("last activity", str_field("last_activity_at").to_string());
546 if let Some(placement) = s.get("placement") {
547 let kind = placement.get("kind").and_then(|x| x.as_str()).unwrap_or("");
548 let host = placement.get("host").and_then(|x| x.as_str()).unwrap_or("");
549 if !kind.is_empty() || !host.is_empty() {
550 push("placement", format!("{kind} @ {host}"));
551 }
552 }
553 lines.join("\n")
554}
555
556pub async fn session_delete(conn: ConnArgs, session_id: &str, yes: bool) -> anyhow::Result<()> {
560 guard_id_segment("session id", session_id)?;
561 if !yes && !confirm(&format!(
562 "Delete session '{session_id}'? This cancels any running execution and removes it permanently."
563 ))? {
564 println!("aborted (nothing deleted).");
565 return Ok(());
566 }
567 let base = conn.api_base();
568 let url = format!("{base}/sessions/{session_id}");
569 let resp = reqwest::Client::new()
570 .delete(&url)
571 .timeout(REQUEST_TIMEOUT)
572 .send()
573 .await
574 .map_err(|e| unreachable(&base, e))?;
575 let status = resp.status();
576 if status.is_success() {
577 println!("{} session '{session_id}' deleted", "✓".green());
578 return Ok(());
579 }
580 if status.as_u16() == 404 {
581 anyhow::bail!("session '{session_id}' not found");
582 }
583 let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
584 let error = body.get("error").and_then(|e| e.as_str()).unwrap_or("");
585 anyhow::bail!(
586 "delete failed: HTTP {status}{}",
587 if error.is_empty() {
588 String::new()
589 } else {
590 format!(" ({error})")
591 }
592 );
593}
594
595pub(crate) fn confirm(prompt: &str) -> anyhow::Result<bool> {
599 use std::io::Write as _;
600 print!("{prompt} [y/N] ");
601 std::io::stdout().flush()?;
602 let mut line = String::new();
603 std::io::stdin().read_line(&mut line)?;
604 let answer = line.trim().to_ascii_lowercase();
605 Ok(answer == "y" || answer == "yes")
606}
607
608#[derive(Debug, Clone, Default)]
618pub struct ScheduleCreateArgs {
619 pub name: Option<String>,
621 pub cron: Option<String>,
623 pub every: Option<u64>,
625 pub daily: Option<String>,
627 pub prompt: Option<String>,
629 pub model: Option<String>,
631 pub workspace: Option<String>,
633 pub timezone: Option<String>,
635 pub disabled: bool,
637 pub json: Option<String>,
640}
641
642pub async fn schedules_list(conn: ConnArgs, json: bool) -> anyhow::Result<()> {
644 let base = conn.api_base();
645 let body = get_json(&base, &format!("{base}/schedules")).await?;
646 if json {
647 println!("{}", serde_json::to_string_pretty(&body)?);
648 return Ok(());
649 }
650 let schedules = body.get("schedules").and_then(|s| s.as_array());
651 let schedules = match schedules {
652 Some(s) if !s.is_empty() => s,
653 _ => {
654 println!("(no schedules)");
655 return Ok(());
656 }
657 };
658
659 println!(
661 "{:<38} {:<4} {:<24} {:<20} {:<20} NAME",
662 "SCHEDULE ID", "ON", "TRIGGER", "NEXT RUN", "LAST RUN"
663 );
664 for s in schedules {
665 let id = s.get("id").and_then(|x| x.as_str()).unwrap_or("?");
666 let enabled = s.get("enabled").and_then(|b| b.as_bool()).unwrap_or(false);
667 let trigger = s.get("trigger").map(trigger_summary).unwrap_or_default();
668 let state = s.get("state");
669 let next = state.and_then(|st| st.get("next_fire_at"));
670 let last = state.and_then(|st| st.get("last_started_at"));
671 let name = s.get("name").and_then(|x| x.as_str()).unwrap_or("");
672 println!(
673 "{:<38} {:<4} {:<24} {:<20} {:<20} {}",
674 id,
675 if enabled { "on" } else { "off" },
676 truncate(&trigger, 24),
677 fmt_ts(next),
678 fmt_ts(last),
679 truncate(name, 40)
680 );
681 }
682 println!(
683 "\n{} schedule(s). Inspect one with: {}",
684 schedules.len(),
685 "bamboo schedules show <id>".cyan()
686 );
687 Ok(())
688}
689
690pub async fn schedules_show(conn: ConnArgs, schedule_id: &str, json: bool) -> anyhow::Result<()> {
693 guard_id_segment("schedule id", schedule_id)?;
694 let base = conn.api_base();
695 let schedule = find_schedule(&base, schedule_id).await?;
696 if json {
697 println!("{}", serde_json::to_string_pretty(&schedule)?);
698 return Ok(());
699 }
700
701 let str_of = |v: &serde_json::Value| v.as_str().map(str::to_string);
702 let field = |key: &str| schedule.get(key).and_then(str_of).unwrap_or_default();
703 let enabled = schedule
704 .get("enabled")
705 .and_then(|b| b.as_bool())
706 .unwrap_or(false);
707 println!("{:<16}{}", "id:".bold(), field("id"));
708 println!("{:<16}{}", "name:".bold(), field("name"));
709 println!(
710 "{:<16}{}",
711 "enabled:".bold(),
712 if enabled {
713 "true".green()
714 } else {
715 "false".red()
716 }
717 );
718 if let Some(trigger) = schedule.get("trigger") {
719 println!("{:<16}{}", "trigger:".bold(), trigger_summary(trigger));
720 }
721 for key in ["timezone", "start_at", "end_at"] {
722 if let Some(value) = schedule.get(key).and_then(|v| v.as_str()) {
723 println!("{:<16}{value}", format!("{key}:").bold());
724 }
725 }
726 for key in ["misfire_policy", "overlap_policy"] {
727 if let Some(value) = schedule.get(key) {
728 let rendered = value
729 .get("type")
730 .and_then(|t| t.as_str())
731 .map(str::to_string)
732 .or_else(|| str_of(value))
733 .unwrap_or_else(|| value.to_string());
734 println!("{:<16}{rendered}", format!("{key}:").bold());
735 }
736 }
737 if let Some(state) = schedule.get("state") {
738 println!(
739 "{:<16}{}",
740 "next fire:".bold(),
741 fmt_ts(state.get("next_fire_at"))
742 );
743 println!(
744 "{:<16}{}",
745 "last started:".bold(),
746 fmt_ts(state.get("last_started_at"))
747 );
748 println!(
749 "{:<16}{}",
750 "last success:".bold(),
751 fmt_ts(state.get("last_success_at"))
752 );
753 let count = |key: &str| state.get(key).and_then(|v| v.as_u64()).unwrap_or(0);
754 println!(
755 "{:<16}{} total, {} ok, {} failed, {} missed ({} queued, {} running now)",
756 "runs:".bold(),
757 count("total_run_count"),
758 count("total_success_count"),
759 count("total_failure_count"),
760 count("total_missed_count"),
761 count("queued_run_count"),
762 count("running_run_count"),
763 );
764 }
765 if let Some(rc) = schedule.get("run_config") {
766 let rc_str = |key: &str| rc.get(key).and_then(|v| v.as_str());
767 if let Some(task) = rc_str("task_message") {
768 println!("{:<16}{}", "prompt:".bold(), truncate(task, 120));
769 }
770 for (label, key) in [
771 ("model:", "model"),
772 ("workspace:", "workspace_path"),
773 ("reasoning:", "reasoning_effort"),
774 ] {
775 if let Some(value) = rc_str(key) {
776 println!("{:<16}{value}", label.bold());
777 }
778 }
779 let auto = rc
780 .get("auto_execute")
781 .and_then(|b| b.as_bool())
782 .unwrap_or(false);
783 println!("{:<16}{auto}", "auto-execute:".bold());
784 }
785 println!(
786 "{:<16}{} {:<10}{}",
787 "created:".bold(),
788 fmt_ts(schedule.get("created_at")),
789 "updated:".bold(),
790 fmt_ts(schedule.get("updated_at"))
791 );
792 Ok(())
793}
794
795pub async fn schedules_create(conn: ConnArgs, args: ScheduleCreateArgs) -> anyhow::Result<()> {
799 let payload = match &args.json {
800 Some(source) => read_json_payload(source)?,
801 None => build_create_payload(&args)?,
802 };
803
804 let base = conn.api_base();
805 let url = format!("{base}/schedules");
806 let resp = reqwest::Client::new()
807 .post(&url)
808 .timeout(REQUEST_TIMEOUT)
809 .json(&payload)
810 .send()
811 .await
812 .map_err(|e| unreachable(&base, e))?;
813 let status = resp.status();
814 let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
815 if !status.is_success() {
816 anyhow::bail!(
817 "create failed: HTTP {status} {}",
818 server_error_message(&body)
819 );
820 }
821 let id = body.get("id").and_then(|x| x.as_str()).unwrap_or("?");
822 let name = body.get("name").and_then(|x| x.as_str()).unwrap_or("");
823 let enabled = body
824 .get("enabled")
825 .and_then(|b| b.as_bool())
826 .unwrap_or(false);
827 let trigger = body.get("trigger").map(trigger_summary).unwrap_or_default();
828 println!(
829 "{} created schedule {id} ('{name}', {trigger}, {})",
830 "✓".green(),
831 if enabled { "enabled" } else { "disabled" }
832 );
833 if let Some(next) = body.get("state").and_then(|st| st.get("next_fire_at")) {
834 println!(" next fire: {}", fmt_ts(Some(next)));
835 }
836 Ok(())
837}
838
839pub async fn schedules_delete(conn: ConnArgs, schedule_id: &str, yes: bool) -> anyhow::Result<()> {
842 guard_id_segment("schedule id", schedule_id)?;
843 let base = conn.api_base();
844 if !yes {
845 let schedule = find_schedule(&base, schedule_id).await?;
848 let name = schedule.get("name").and_then(|x| x.as_str()).unwrap_or("?");
849 if !confirm(&format!("Delete schedule '{name}' ({schedule_id})?"))? {
850 println!("aborted (nothing deleted).");
851 return Ok(());
852 }
853 }
854 let url = format!("{base}/schedules/{schedule_id}");
855 let resp = reqwest::Client::new()
856 .delete(&url)
857 .timeout(REQUEST_TIMEOUT)
858 .send()
859 .await
860 .map_err(|e| unreachable(&base, e))?;
861 let status = resp.status();
862 let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
863 if status.as_u16() == 404 {
864 anyhow::bail!("schedule '{schedule_id}' not found");
865 }
866 if !status.is_success() {
867 anyhow::bail!(
868 "delete failed: HTTP {status} {}",
869 server_error_message(&body)
870 );
871 }
872 println!("{} deleted schedule {schedule_id}", "✓".green());
873 Ok(())
874}
875
876pub async fn schedules_run(conn: ConnArgs, schedule_id: &str) -> anyhow::Result<()> {
878 guard_id_segment("schedule id", schedule_id)?;
879 let base = conn.api_base();
880 let url = format!("{base}/schedules/{schedule_id}/run");
881 let resp = reqwest::Client::new()
882 .post(&url)
883 .timeout(REQUEST_TIMEOUT)
884 .send()
885 .await
886 .map_err(|e| unreachable(&base, e))?;
887 let status = resp.status();
888 let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
889 if status.as_u16() == 404 {
890 anyhow::bail!("schedule '{schedule_id}' not found");
891 }
892 if !status.is_success() {
893 anyhow::bail!("run failed: HTTP {status} {}", server_error_message(&body));
894 }
895 let run_id = body.get("run_id").and_then(|x| x.as_str()).unwrap_or("?");
896 println!(
897 "{} run {run_id} enqueued (watch it with: {})",
898 "✓".green(),
899 format!("bamboo schedules runs {schedule_id}").cyan()
900 );
901 Ok(())
902}
903
904pub async fn schedules_runs(conn: ConnArgs, schedule_id: &str, json: bool) -> anyhow::Result<()> {
906 guard_id_segment("schedule id", schedule_id)?;
907 let base = conn.api_base();
908 let body = get_json(&base, &format!("{base}/schedules/{schedule_id}/runs")).await?;
909 if json {
910 println!("{}", serde_json::to_string_pretty(&body)?);
911 return Ok(());
912 }
913 let runs = body.get("runs").and_then(|r| r.as_array());
914 let runs = match runs {
915 Some(r) if !r.is_empty() => r,
916 _ => {
917 println!("(no runs)");
918 return Ok(());
919 }
920 };
921 println!(
922 "{:<38} {:<9} {:<20} {:<20} {:>9} SESSION",
923 "RUN ID", "STATUS", "SCHEDULED FOR", "STARTED", "DURATION"
924 );
925 for r in runs {
926 let run_id = r.get("run_id").and_then(|x| x.as_str()).unwrap_or("?");
927 let run_status = r.get("status").and_then(|x| x.as_str()).unwrap_or("?");
928 let duration = r
929 .get("execution_duration_ms")
930 .and_then(|x| x.as_u64())
931 .map(|ms| format!("{ms}ms"))
932 .unwrap_or_else(|| "-".to_string());
933 let session = r.get("session_id").and_then(|x| x.as_str()).unwrap_or("-");
934 println!(
935 "{:<38} {:<9} {:<20} {:<20} {:>9} {}",
936 run_id,
937 run_status,
938 fmt_ts(r.get("scheduled_for")),
939 fmt_ts(r.get("started_at")),
940 duration,
941 session
942 );
943 }
944 println!("\n{} run(s) for schedule {schedule_id}.", runs.len());
945 Ok(())
946}
947
948async fn get_json(base: &str, url: &str) -> anyhow::Result<serde_json::Value> {
951 let resp = reqwest::Client::new()
952 .get(url)
953 .timeout(REQUEST_TIMEOUT)
954 .send()
955 .await
956 .map_err(|e| unreachable(base, e))?;
957 let status = resp.status();
958 let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
959 if !status.is_success() {
960 anyhow::bail!("GET {url} -> HTTP {status} {}", server_error_message(&body));
961 }
962 Ok(body)
963}
964
965async fn find_schedule(base: &str, schedule_id: &str) -> anyhow::Result<serde_json::Value> {
968 let body = get_json(base, &format!("{base}/schedules")).await?;
969 body.get("schedules")
970 .and_then(|s| s.as_array())
971 .and_then(|schedules| {
972 schedules
973 .iter()
974 .find(|s| s.get("id").and_then(|x| x.as_str()) == Some(schedule_id))
975 })
976 .cloned()
977 .ok_or_else(|| {
978 anyhow::anyhow!(
979 "schedule '{schedule_id}' not found (list them with: bamboo schedules list)"
980 )
981 })
982}
983
984fn build_create_payload(args: &ScheduleCreateArgs) -> anyhow::Result<serde_json::Value> {
986 let name = args
987 .name
988 .as_deref()
989 .ok_or_else(|| anyhow::anyhow!("--name is required (or pass --json)"))?;
990 let prompt = args
991 .prompt
992 .as_deref()
993 .ok_or_else(|| anyhow::anyhow!("--prompt is required (or pass --json)"))?;
994
995 let trigger = if let Some(expr) = &args.cron {
996 serde_json::json!({ "type": "cron", "expr": expr })
997 } else if let Some(every_seconds) = args.every {
998 serde_json::json!({ "type": "interval", "every_seconds": every_seconds })
999 } else if let Some(hms) = &args.daily {
1000 let (hour, minute, second) = parse_daily_time(hms)?;
1001 serde_json::json!({ "type": "daily", "hour": hour, "minute": minute, "second": second })
1002 } else {
1003 anyhow::bail!("a trigger is required: --cron <expr>, --every <seconds>, or --daily <HH:MM[:SS]> (or pass --json)");
1004 };
1005
1006 let mut run_config = serde_json::json!({
1009 "task_message": prompt,
1010 "auto_execute": true,
1011 });
1012 if let Some(model) = &args.model {
1013 run_config["model"] = serde_json::json!(model);
1014 }
1015 if let Some(workspace) = &args.workspace {
1016 run_config["workspace_path"] = serde_json::json!(workspace);
1017 }
1018
1019 let mut payload = serde_json::json!({
1020 "name": name,
1021 "trigger": trigger,
1022 "enabled": !args.disabled,
1023 "run_config": run_config,
1024 });
1025 if let Some(timezone) = &args.timezone {
1026 payload["timezone"] = serde_json::json!(timezone);
1027 }
1028 Ok(payload)
1029}
1030
1031fn parse_daily_time(value: &str) -> anyhow::Result<(u8, u8, u8)> {
1033 let bad = || anyhow::anyhow!("invalid --daily time '{value}' (expected HH:MM or HH:MM:SS)");
1034 let parts: Vec<&str> = value.split(':').collect();
1035 if parts.len() != 2 && parts.len() != 3 {
1036 return Err(bad());
1037 }
1038 let hour: u8 = parts[0].parse().map_err(|_| bad())?;
1039 let minute: u8 = parts[1].parse().map_err(|_| bad())?;
1040 let second: u8 = if parts.len() == 3 {
1041 parts[2].parse().map_err(|_| bad())?
1042 } else {
1043 0
1044 };
1045 if hour > 23 || minute > 59 || second > 59 {
1046 return Err(bad());
1047 }
1048 Ok((hour, minute, second))
1049}
1050
1051fn read_json_payload(source: &str) -> anyhow::Result<serde_json::Value> {
1053 let text = if source == "-" {
1054 use std::io::Read as _;
1055 let mut buf = String::new();
1056 std::io::stdin().read_to_string(&mut buf)?;
1057 buf
1058 } else {
1059 std::fs::read_to_string(source)
1060 .map_err(|e| anyhow::anyhow!("failed to read '{source}': {e}"))?
1061 };
1062 serde_json::from_str(text.trim()).map_err(|e| anyhow::anyhow!("payload is not valid JSON: {e}"))
1063}
1064
1065pub(crate) fn server_error_message(body: &serde_json::Value) -> String {
1067 body.get("error")
1068 .and_then(|e| e.as_str())
1069 .map(|e| format!("({e})"))
1070 .unwrap_or_default()
1071}
1072
1073fn trigger_summary(trigger: &serde_json::Value) -> String {
1075 let joined = |key: &str| {
1076 trigger
1077 .get(key)
1078 .and_then(|v| v.as_array())
1079 .map(|items| {
1080 items
1081 .iter()
1082 .map(|d| match d {
1083 serde_json::Value::String(s) => s.clone(),
1084 other => other.to_string(),
1085 })
1086 .collect::<Vec<_>>()
1087 .join(",")
1088 })
1089 .unwrap_or_default()
1090 };
1091 let hm = || {
1092 format!(
1093 "{:02}:{:02}",
1094 trigger.get("hour").and_then(|v| v.as_u64()).unwrap_or(0),
1095 trigger.get("minute").and_then(|v| v.as_u64()).unwrap_or(0)
1096 )
1097 };
1098 match trigger.get("type").and_then(|t| t.as_str()) {
1099 Some("interval") => format!(
1100 "every {}s",
1101 trigger
1102 .get("every_seconds")
1103 .and_then(|v| v.as_u64())
1104 .unwrap_or(0)
1105 ),
1106 Some("daily") => format!(
1107 "daily {}:{:02}",
1108 hm(),
1109 trigger.get("second").and_then(|v| v.as_u64()).unwrap_or(0)
1110 ),
1111 Some("weekly") => format!("weekly {} {}", joined("weekdays"), hm()),
1112 Some("monthly") => format!("monthly {} {}", joined("days"), hm()),
1113 Some("cron") => format!(
1114 "cron '{}'",
1115 trigger.get("expr").and_then(|v| v.as_str()).unwrap_or("?")
1116 ),
1117 _ => trigger.to_string(),
1118 }
1119}
1120
1121fn fmt_ts(value: Option<&serde_json::Value>) -> String {
1124 let Some(s) = value.and_then(|v| v.as_str()) else {
1125 return "-".to_string();
1126 };
1127 if s.len() >= 19 && s.is_char_boundary(19) && s.as_bytes().get(10) == Some(&b'T') {
1129 format!("{} {}", &s[..10], &s[11..19])
1130 } else {
1131 s.to_string()
1132 }
1133}
1134
1135const MCP_MUTATE_TIMEOUT: Duration = Duration::from_secs(60);
1145
1146pub async fn mcp_status(conn: ConnArgs, json: bool) -> anyhow::Result<()> {
1149 let base = conn.api_base();
1150 let url = format!("{base}/mcp/servers");
1151 let resp = reqwest::Client::new()
1152 .get(&url)
1153 .timeout(REQUEST_TIMEOUT)
1154 .send()
1155 .await
1156 .map_err(|e| unreachable(&base, e))?;
1157 if !resp.status().is_success() {
1158 anyhow::bail!("GET {url} -> HTTP {}", resp.status());
1159 }
1160 let v: serde_json::Value = resp.json().await?;
1161 if json {
1162 println!("{}", serde_json::to_string_pretty(&v)?);
1163 return Ok(());
1164 }
1165
1166 let servers = v.get("servers").and_then(|s| s.as_array());
1167 let servers = match servers {
1168 Some(s) if !s.is_empty() => s,
1169 _ => {
1170 println!("(no MCP servers configured)");
1171 return Ok(());
1172 }
1173 };
1174
1175 println!(
1177 "{:<24} {:<8} {:<14} {:>5} NAME",
1178 "ID", "ENABLED", "STATUS", "TOOLS"
1179 );
1180 for s in servers {
1181 let id = s.get("id").and_then(|x| x.as_str()).unwrap_or("?");
1182 let enabled = s.get("enabled").and_then(|b| b.as_bool()).unwrap_or(false);
1183 let status = s.get("status").and_then(|x| x.as_str()).unwrap_or("?");
1184 let tools = s.get("tool_count").and_then(|x| x.as_u64()).unwrap_or(0);
1185 let name = s.get("name").and_then(|x| x.as_str()).unwrap_or("");
1186 println!(
1187 "{:<24} {:<8} {:<14} {:>5} {}",
1188 truncate(id, 24),
1189 if enabled { "yes" } else { "no" },
1190 truncate(status, 14),
1191 tools,
1192 truncate(name, 40)
1193 );
1194 }
1195 for s in servers {
1196 if let Some(err) = s.get("last_error").and_then(|e| e.as_str()) {
1197 if !err.trim().is_empty() {
1198 let id = s.get("id").and_then(|x| x.as_str()).unwrap_or("?");
1199 println!("{} {id}: {}", "!".yellow(), truncate(err, 100));
1200 }
1201 }
1202 }
1203 let connected = servers
1204 .iter()
1205 .filter(|s| s.get("status").and_then(|x| x.as_str()) == Some("connected"))
1206 .count();
1207 println!("\n{connected} connected of {}.", servers.len());
1208 Ok(())
1209}
1210
1211pub async fn mcp_connect(conn: ConnArgs, server_id: &str) -> anyhow::Result<()> {
1214 guard_id_segment("MCP server id", server_id)?;
1215 let base = conn.api_base();
1216 let url = format!("{base}/mcp/servers/{server_id}/connect");
1217 let resp = reqwest::Client::new()
1218 .post(&url)
1219 .timeout(MCP_MUTATE_TIMEOUT)
1220 .send()
1221 .await
1222 .map_err(|e| unreachable(&base, e))?;
1223 let status = resp.status();
1224 let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
1225 if status.is_success() {
1226 println!("{} server '{server_id}' connected", "✓".green());
1227 Ok(())
1228 } else if status.as_u16() == 404 {
1229 anyhow::bail!("MCP server '{server_id}' not found (check `bamboo mcp status`)");
1230 } else {
1231 anyhow::bail!(
1232 "connect failed: HTTP {status} {}",
1233 server_error_message(&body)
1234 );
1235 }
1236}
1237
1238pub async fn mcp_disconnect(conn: ConnArgs, server_id: &str) -> anyhow::Result<()> {
1241 guard_id_segment("MCP server id", server_id)?;
1242 let base = conn.api_base();
1243 let url = format!("{base}/mcp/servers/{server_id}/disconnect");
1244 let resp = reqwest::Client::new()
1245 .post(&url)
1246 .timeout(MCP_MUTATE_TIMEOUT)
1247 .send()
1248 .await
1249 .map_err(|e| unreachable(&base, e))?;
1250 let status = resp.status();
1251 let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
1252 if status.is_success() {
1253 println!("{} server '{server_id}' disconnected", "✓".green());
1254 Ok(())
1255 } else if status.as_u16() == 404 {
1256 anyhow::bail!("MCP server '{server_id}' not found (check `bamboo mcp status`)");
1257 } else {
1258 anyhow::bail!(
1259 "disconnect failed: HTTP {status} {}",
1260 server_error_message(&body)
1261 );
1262 }
1263}
1264
1265pub async fn mcp_refresh(conn: ConnArgs, server_id: Option<&str>) -> anyhow::Result<()> {
1268 let base = conn.api_base();
1269 let client = reqwest::Client::new();
1270
1271 let targets: Vec<String> = match server_id {
1272 Some(id) => {
1273 guard_id_segment("MCP server id", id)?;
1274 vec![id.to_string()]
1275 }
1276 None => {
1277 let url = format!("{base}/mcp/servers");
1279 let resp = client
1280 .get(&url)
1281 .timeout(REQUEST_TIMEOUT)
1282 .send()
1283 .await
1284 .map_err(|e| unreachable(&base, e))?;
1285 if !resp.status().is_success() {
1286 anyhow::bail!("GET {url} -> HTTP {}", resp.status());
1287 }
1288 let v: serde_json::Value = resp.json().await?;
1289 let ids: Vec<String> = v
1290 .get("servers")
1291 .and_then(|s| s.as_array())
1292 .map(|servers| {
1293 servers
1294 .iter()
1295 .filter(|s| s.get("enabled").and_then(|b| b.as_bool()).unwrap_or(false))
1296 .filter_map(|s| s.get("id").and_then(|x| x.as_str()))
1297 .map(String::from)
1298 .collect()
1299 })
1300 .unwrap_or_default();
1301 if ids.is_empty() {
1302 println!("(no enabled MCP servers to refresh)");
1303 return Ok(());
1304 }
1305 ids
1306 }
1307 };
1308
1309 let mut failures = 0usize;
1310 for id in &targets {
1311 let url = format!("{base}/mcp/servers/{id}/refresh");
1312 let result = client.post(&url).timeout(MCP_MUTATE_TIMEOUT).send().await;
1313 match result {
1314 Ok(resp) if resp.status().is_success() => {
1315 let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
1316 let tools = body.get("tool_count").and_then(|t| t.as_u64()).unwrap_or(0);
1317 println!("{} {id}: {tools} tool(s)", "✓".green());
1318 }
1319 Ok(resp) => {
1320 let status = resp.status();
1321 let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
1322 println!(
1323 "{} {id}: HTTP {status} {}",
1324 "✗".red(),
1325 server_error_message(&body)
1326 );
1327 failures += 1;
1328 }
1329 Err(e) => {
1330 println!("{} {id}: {e}", "✗".red());
1331 failures += 1;
1332 }
1333 }
1334 }
1335 if failures > 0 {
1336 anyhow::bail!("{failures} of {} refresh(es) failed", targets.len());
1337 }
1338 Ok(())
1339}
1340
1341pub async fn mcp_tools(conn: ConnArgs, server_id: Option<&str>, json: bool) -> anyhow::Result<()> {
1345 let base = conn.api_base();
1346 let url = match server_id {
1347 Some(id) => {
1348 guard_id_segment("MCP server id", id)?;
1349 format!("{base}/mcp/servers/{id}/tools")
1350 }
1351 None => format!("{base}/mcp/tools"),
1352 };
1353 let resp = reqwest::Client::new()
1354 .get(&url)
1355 .timeout(REQUEST_TIMEOUT)
1356 .send()
1357 .await
1358 .map_err(|e| unreachable(&base, e))?;
1359 if resp.status().as_u16() == 404 {
1360 anyhow::bail!(
1361 "MCP server '{}' not found (check `bamboo mcp status`)",
1362 server_id.unwrap_or("?")
1363 );
1364 }
1365 if !resp.status().is_success() {
1366 anyhow::bail!("GET {url} -> HTTP {}", resp.status());
1367 }
1368 let v: serde_json::Value = resp.json().await?;
1369 if json {
1370 println!("{}", serde_json::to_string_pretty(&v)?);
1371 return Ok(());
1372 }
1373
1374 let tools = v.get("tools").and_then(|t| t.as_array());
1375 let tools = match tools {
1376 Some(t) if !t.is_empty() => t,
1377 _ => {
1378 println!("(no tools)");
1379 return Ok(());
1380 }
1381 };
1382 println!("{:<32} {:<20} DESCRIPTION", "ALIAS", "SERVER");
1383 for t in tools {
1384 let alias = t.get("alias").and_then(|x| x.as_str()).unwrap_or("?");
1385 let server = t.get("server_id").and_then(|x| x.as_str()).unwrap_or("");
1386 let desc = t.get("description").and_then(|x| x.as_str()).unwrap_or("");
1387 println!(
1388 "{:<32} {:<20} {}",
1389 truncate(alias, 32),
1390 truncate(server, 20),
1391 truncate(desc, 70)
1392 );
1393 }
1394 println!("\n{} tool(s).", tools.len());
1395 Ok(())
1396}
1397
1398pub async fn mcp_add(conn: ConnArgs, payload_source: &str) -> anyhow::Result<()> {
1403 let payload = read_json_payload(payload_source)?;
1405
1406 let base = conn.api_base();
1407 let url = format!("{base}/mcp/servers");
1408 let resp = reqwest::Client::new()
1409 .post(&url)
1410 .timeout(MCP_MUTATE_TIMEOUT)
1411 .json(&payload)
1412 .send()
1413 .await
1414 .map_err(|e| unreachable(&base, e))?;
1415 let status = resp.status();
1416 let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
1417 if status.is_success() {
1418 let id = body
1419 .get("server_id")
1420 .and_then(|s| s.as_str())
1421 .unwrap_or("?");
1422 println!("{} server '{id}' saved", "✓".green());
1423 Ok(())
1424 } else {
1425 anyhow::bail!("add failed: HTTP {status} {}", server_error_message(&body));
1426 }
1427}
1428
1429pub async fn mcp_remove(conn: ConnArgs, server_id: &str, yes: bool) -> anyhow::Result<()> {
1434 guard_id_segment("MCP server id", server_id)?;
1435 if !yes
1436 && !confirm(&format!(
1437 "Remove MCP server '{server_id}'? This stops it and deletes its stored config."
1438 ))?
1439 {
1440 println!("aborted (nothing removed).");
1441 return Ok(());
1442 }
1443 let base = conn.api_base();
1444 let url = format!("{base}/mcp/servers/{server_id}");
1445 let resp = reqwest::Client::new()
1446 .delete(&url)
1447 .timeout(MCP_MUTATE_TIMEOUT)
1448 .send()
1449 .await
1450 .map_err(|e| unreachable(&base, e))?;
1451 let status = resp.status();
1452 let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
1453 if status.is_success() {
1454 println!("{} server '{server_id}' removed", "✓".green());
1455 Ok(())
1456 } else {
1457 anyhow::bail!(
1458 "remove failed: HTTP {status} {}",
1459 server_error_message(&body)
1460 );
1461 }
1462}
1463
1464fn count_running(sessions: &[serde_json::Value]) -> usize {
1466 sessions
1467 .iter()
1468 .filter(|x| {
1469 x.get("is_running")
1470 .and_then(|b| b.as_bool())
1471 .unwrap_or(false)
1472 })
1473 .count()
1474}
1475
1476pub(crate) fn truncate(s: &str, max: usize) -> String {
1478 if s.chars().count() <= max {
1479 s.to_string()
1480 } else {
1481 let head: String = s.chars().take(max.saturating_sub(1)).collect();
1482 format!("{head}…")
1483 }
1484}
1485
1486#[cfg(test)]
1487mod tests {
1488 use super::{
1489 build_create_payload, fmt_ts, format_pending_question, format_session_detail,
1490 guard_id_segment, history_summary, parse_daily_time, server_error_message, trigger_summary,
1491 ScheduleCreateArgs,
1492 };
1493
1494 #[test]
1495 fn guard_id_segment_rejects_path_hazards() {
1496 for bad in [
1497 "", ".", "..", "a/b", "a\\b", "a?b", "a#b", "a%b", "a b", "a\tb",
1498 ] {
1499 assert!(
1500 guard_id_segment("session id", bad).is_err(),
1501 "{bad:?} must be rejected"
1502 );
1503 }
1504 assert!(guard_id_segment("session id", "0195fd1e-abc4-7def-8123-456789abcdef").is_ok());
1505 let err = guard_id_segment("schedule id", "a/b").unwrap_err();
1507 assert!(err.to_string().contains("invalid schedule id"));
1508 }
1509
1510 #[test]
1511 fn history_summary_reports_true_total_and_truncation() {
1512 assert_eq!(
1514 history_summary("s1", 3, 3, false),
1515 "3 message(s) in session s1."
1516 );
1517 let line = history_summary("s1", 2000, 5000, true);
1519 assert!(line.starts_with("5000 message(s) in session s1"));
1520 assert!(line.contains("newest 2000"));
1521 assert!(line.contains("history cap"));
1522 }
1523
1524 #[test]
1525 fn format_pending_question_pretty_prints_question_and_options() {
1526 colored::control::set_override(false);
1527 let v = serde_json::json!({
1528 "has_pending_question": true,
1529 "question": "Proceed with the deploy?",
1530 "options": ["Yes", "No"],
1531 "allow_custom": true,
1532 "tool_call_id": "tc-1",
1533 "tool_name": "conclusion_with_options",
1534 "source": "tool",
1535 });
1536 let text = format_pending_question("sess-1", &v).expect("pending question");
1537 assert!(text.contains("question: Proceed with the deploy?"));
1538 assert!(text.contains("1. Yes"));
1539 assert!(text.contains("2. No"));
1540 assert!(text.contains("custom free-text answers are allowed"));
1541 assert!(text.contains("tool: conclusion_with_options"));
1542 assert!(text.contains("bamboo respond sess-1"));
1543 assert!(text.contains("resumes the run server-side"));
1544 }
1545
1546 #[test]
1547 fn format_pending_question_none_when_no_question() {
1548 let v = serde_json::json!({ "has_pending_question": false });
1549 assert!(format_pending_question("sess-1", &v).is_none());
1550 assert!(format_pending_question("sess-1", &serde_json::json!({})).is_none());
1552 }
1553
1554 #[test]
1555 fn format_session_detail_shows_core_and_optional_fields() {
1556 colored::control::set_override(false);
1557 let s = serde_json::json!({
1558 "id": "sess-9",
1559 "title": "Fix the bug",
1560 "kind": "root",
1561 "model": "claude-sonnet-5",
1562 "provider": "anthropic",
1563 "is_running": true,
1564 "last_run_status": "failed",
1565 "last_run_error": "boom",
1566 "has_pending_question": true,
1567 "message_count": 12,
1568 "pinned": true,
1569 "running_child_count": 2,
1570 "created_at": "2026-07-10T00:00:00Z",
1571 "last_activity_at": "2026-07-10T01:00:00Z",
1572 "placement": { "kind": "local", "host": "mac.local" },
1573 });
1574 let text = format_session_detail(&s);
1575 assert!(text.contains("sess-9"));
1576 assert!(text.contains("Fix the bug"));
1577 assert!(text.contains("anthropic:claude-sonnet-5"));
1578 assert!(text.contains("failed (boom)"));
1579 assert!(text.contains("2 running"));
1580 assert!(text.contains("local @ mac.local"));
1581 assert!(text.contains("bamboo respond <id> --pending"));
1582
1583 let minimal = serde_json::json!({
1585 "id": "sess-min",
1586 "title": "",
1587 "kind": "root",
1588 "model": "m",
1589 "is_running": false,
1590 "message_count": 0,
1591 });
1592 let text = format_session_detail(&minimal);
1593 assert!(!text.contains("last run"));
1594 assert!(!text.contains("parent"));
1595 assert!(!text.contains("children"));
1596 assert!(!text.contains("placement"));
1597 }
1598 #[test]
1599 fn parse_daily_time_accepts_hm_and_hms() {
1600 assert_eq!(parse_daily_time("09:30").unwrap(), (9, 30, 0));
1601 assert_eq!(parse_daily_time("23:59:59").unwrap(), (23, 59, 59));
1602 }
1603
1604 #[test]
1605 fn parse_daily_time_rejects_malformed_and_out_of_range() {
1606 for bad in ["", "9", "24:00", "09:60", "09:30:60", "a:b", "09:30:15:00"] {
1607 assert!(parse_daily_time(bad).is_err(), "{bad:?}");
1608 }
1609 }
1610
1611 #[test]
1612 fn build_create_payload_maps_flags_to_request_shape() {
1613 let payload = build_create_payload(&ScheduleCreateArgs {
1614 name: Some("nightly".to_string()),
1615 cron: Some("0 0 2 * * *".to_string()),
1616 prompt: Some("run the suite".to_string()),
1617 model: Some("anthropic:claude-sonnet-4".to_string()),
1618 workspace: Some("/tmp/repo".to_string()),
1619 timezone: Some("Asia/Shanghai".to_string()),
1620 ..Default::default()
1621 })
1622 .unwrap();
1623
1624 assert_eq!(payload["name"], "nightly");
1625 assert_eq!(payload["enabled"], true);
1626 assert_eq!(payload["trigger"]["type"], "cron");
1627 assert_eq!(payload["trigger"]["expr"], "0 0 2 * * *");
1628 assert_eq!(payload["timezone"], "Asia/Shanghai");
1629 assert_eq!(payload["run_config"]["task_message"], "run the suite");
1630 assert_eq!(payload["run_config"]["auto_execute"], true);
1631 assert_eq!(payload["run_config"]["model"], "anthropic:claude-sonnet-4");
1632 assert_eq!(payload["run_config"]["workspace_path"], "/tmp/repo");
1633 }
1634
1635 #[test]
1636 fn build_create_payload_daily_and_disabled() {
1637 let payload = build_create_payload(&ScheduleCreateArgs {
1638 name: Some("standup".to_string()),
1639 daily: Some("09:30".to_string()),
1640 prompt: Some("summarize".to_string()),
1641 disabled: true,
1642 ..Default::default()
1643 })
1644 .unwrap();
1645
1646 assert_eq!(payload["enabled"], false);
1647 assert_eq!(payload["trigger"]["type"], "daily");
1648 assert_eq!(payload["trigger"]["hour"], 9);
1649 assert_eq!(payload["trigger"]["minute"], 30);
1650 assert_eq!(payload["trigger"]["second"], 0);
1651 assert!(payload.get("timezone").is_none());
1653 assert!(payload["run_config"].get("model").is_none());
1654 }
1655
1656 #[test]
1657 fn build_create_payload_interval_trigger() {
1658 let payload = build_create_payload(&ScheduleCreateArgs {
1659 name: Some("tick".to_string()),
1660 every: Some(3600),
1661 prompt: Some("check".to_string()),
1662 ..Default::default()
1663 })
1664 .unwrap();
1665 assert_eq!(payload["trigger"]["type"], "interval");
1666 assert_eq!(payload["trigger"]["every_seconds"], 3600);
1667 }
1668
1669 #[test]
1670 fn build_create_payload_requires_name_prompt_and_trigger() {
1671 assert!(build_create_payload(&ScheduleCreateArgs::default()).is_err());
1672 assert!(build_create_payload(&ScheduleCreateArgs {
1673 name: Some("x".to_string()),
1674 prompt: Some("y".to_string()),
1675 ..Default::default()
1676 })
1677 .is_err());
1678 }
1679
1680 #[test]
1681 fn trigger_summary_renders_each_kind() {
1682 let case = |json: serde_json::Value| trigger_summary(&json);
1683 assert_eq!(
1684 case(serde_json::json!({"type":"interval","every_seconds":60})),
1685 "every 60s"
1686 );
1687 assert_eq!(
1688 case(serde_json::json!({"type":"daily","hour":9,"minute":30,"second":0})),
1689 "daily 09:30:00"
1690 );
1691 assert_eq!(
1692 case(serde_json::json!({"type":"weekly","weekdays":["mon","fri"],"hour":9,"minute":0})),
1693 "weekly mon,fri 09:00"
1694 );
1695 assert_eq!(
1696 case(serde_json::json!({"type":"monthly","days":[1,15],"hour":8,"minute":5})),
1697 "monthly 1,15 08:05"
1698 );
1699 assert_eq!(
1700 case(serde_json::json!({"type":"cron","expr":"0 0 2 * * *"})),
1701 "cron '0 0 2 * * *'"
1702 );
1703 }
1704
1705 #[test]
1706 fn fmt_ts_shortens_rfc3339_and_defaults_to_dash() {
1707 let value = serde_json::json!("2026-07-10T12:34:56.789012Z");
1708 assert_eq!(fmt_ts(Some(&value)), "2026-07-10 12:34:56");
1709 assert_eq!(fmt_ts(None), "-");
1710 let null = serde_json::Value::Null;
1711 assert_eq!(fmt_ts(Some(&null)), "-");
1712 }
1713
1714 #[test]
1715 fn server_error_message_extracts_error_field() {
1716 assert_eq!(
1717 server_error_message(&serde_json::json!({"error":"name is required"})),
1718 "(name is required)"
1719 );
1720 assert_eq!(server_error_message(&serde_json::Value::Null), "");
1721 }
1722}