Skip to main content

bamboo_agent/
admin_cli.rs

1//! The `bamboo health | status | sessions | session | stop | respond |
2//! schedules` admin CLI.
3//!
4//! A thin HTTP client over a running `bamboo serve` instance. Each command wraps
5//! an endpoint the server already exposes — `/api/v1/health`,
6//! `/api/v1/sessions`, `/api/v1/stop/{id}`, `/api/v1/respond/{id}`,
7//! `/api/v1/schedules` — so an operator can probe and steer the backend without
8//! hand-writing `curl`. The server is the single source of truth; this module
9//! only resolves the base URL and pretty-prints responses.
10
11use std::path::PathBuf;
12use std::time::Duration;
13
14use colored::Colorize;
15
16const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
17
18/// Connection options shared by every admin subcommand.
19#[derive(Debug, Clone, Default)]
20pub struct ConnArgs {
21    /// Full base URL override (e.g. `http://127.0.0.1:9562`). Wins over the rest.
22    pub server_url: Option<String>,
23    /// Port override (else read from the resolved config).
24    pub port: Option<u16>,
25    /// Data dir holding `config.json` (else `~/.bamboo`).
26    pub data_dir: Option<PathBuf>,
27}
28
29impl ConnArgs {
30    /// Resolve the API base, e.g. `http://127.0.0.1:9562/api/v1`.
31    pub(crate) fn api_base(&self) -> String {
32        if let Some(url) = &self.server_url {
33            let url = url.trim_end_matches('/');
34            // Tolerate a scheme-less override like `localhost:9562`.
35            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            // Listen-on-all addresses: a client must dial a concrete host.
46            "" | "0.0.0.0" | "::" | "[::]" => "127.0.0.1".to_string(),
47            // Bracket a bare IPv6 literal so the URL is well-formed.
48            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
59/// Guard an id used as a URL path segment: real ids (session ids, schedule
60/// ids) are opaque tokens (UUIDs), so reject anything that could traverse or
61/// malform the URL rather than encode it. `kind` names the id in the error,
62/// e.g. "session id".
63pub(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
75/// `bamboo health` — liveness probe. Exits non-zero (via the returned `Err`)
76/// when the server is unreachable or reports an unhealthy status.
77pub 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
94/// `bamboo status` — one-screen overview: address, health, session counts.
95pub 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
143/// `bamboo sessions` — tabulate sessions on a running server.
144pub 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    // Plain (un-colored) cells so the column widths line up — ANSI escapes would
167    // otherwise be counted against the padding.
168    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
198/// `bamboo stop <id>` — cancel a running session's loop.
199pub 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
238/// `bamboo history <session-id>` — print a session's UI-visible message
239/// transcript (a thin read over `GET /api/v1/history/{id}`). Handy for reviewing
240/// what a headless `-p` run actually did, or an interactive session's log,
241/// without the web UI. Folded in from the retired `bamboo-cli history`.
242pub 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    // The server caps a cold (non-delta) history fetch and reports the pre-cap
280    // count in `total_message_count` (+ `truncated`), so report the TRUE total —
281    // `messages.len()` would silently under-count a capped session (#423).
282    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
297/// The trailing summary line for `bamboo history`: the true total (pre-cap
298/// `total_message_count`), plus a truncation note when the server dropped
299/// older messages to stay under its cold-fetch cap (#423).
300fn 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
310/// `bamboo respond <id> <answer>` — answer a session's pending question
311/// (permission gate / clarification) out-of-band via
312/// `POST /api/v1/respond/{id}`. Answering resumes the blocked run server-side.
313pub 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        // The same shape the frontends send; the server also accepts optional
321        // model/provider/reasoning_effort overrides which the CLI leaves unset.
322        .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
368/// `bamboo respond <id> --pending` — show the question a session is blocked on
369/// (`GET /api/v1/respond/{id}/pending`), pretty or as raw JSON.
370pub 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
398/// Pretty-print the `GET /respond/{id}/pending` payload, or `None` when the
399/// session has no pending question.
400fn 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
441/// `bamboo session show <id>` — one session's detail
442/// (`GET /api/v1/sessions/{id}`), pretty or as raw JSON.
443pub 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
469/// Pretty-print the `session` object of `GET /sessions/{id}` as label/value
470/// lines (same visual style as `bamboo status`). Optional fields are only
471/// printed when present so the output stays one screen.
472fn 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        // Pad BEFORE colorizing: ANSI escapes would otherwise count against the
477        // column width (same caveat as the `sessions` table above).
478        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
556/// `bamboo session delete <id>` — delete a session
557/// (`DELETE /api/v1/sessions/{id}`), cancelling any running execution.
558/// Prompts for confirmation unless `yes` is set.
559pub 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
595/// Ask a yes/no question on stdout and read the answer from stdin. Defaults to
596/// "no" on anything but an explicit y/yes — including EOF (non-TTY pipe), so a
597/// script that forgets `--yes` aborts instead of deleting.
598pub(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// ---------------------------------------------------------------------------
609// `bamboo schedules ...` — timed tasks on a running server (/api/v1/schedules).
610// The scheduler creates a fresh session per fire and (when `auto_execute` is
611// set with a task message) runs it unattended.
612// ---------------------------------------------------------------------------
613
614/// Flag-based inputs for `bamboo schedules create` (the common case). The
615/// clap layer guarantees exactly one trigger source (`cron` / `every` /
616/// `daily` / `json`) and that `name` + `prompt` accompany the flag form.
617#[derive(Debug, Clone, Default)]
618pub struct ScheduleCreateArgs {
619    /// Schedule name (required unless `json`).
620    pub name: Option<String>,
621    /// Cron trigger expression (seconds-first, as the trigger engine parses it).
622    pub cron: Option<String>,
623    /// Interval trigger: fire every N seconds.
624    pub every: Option<u64>,
625    /// Daily trigger at `HH:MM[:SS]`.
626    pub daily: Option<String>,
627    /// Task prompt: the fired session's user message, auto-executed.
628    pub prompt: Option<String>,
629    /// Model override `provider:model` for the fired session.
630    pub model: Option<String>,
631    /// Workspace directory for the fired session's file tools.
632    pub workspace: Option<String>,
633    /// IANA timezone for wall-clock triggers (daily/cron).
634    pub timezone: Option<String>,
635    /// Create the schedule disabled (it will not fire until enabled).
636    pub disabled: bool,
637    /// Raw `CreateScheduleRequest` JSON: a file path or `-` for stdin. Full
638    /// fidelity escape hatch — posted verbatim.
639    pub json: Option<String>,
640}
641
642/// `bamboo schedules list` — tabulate schedules on a running server.
643pub 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    // Plain (un-colored) cells so the column widths line up.
660    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
690/// `bamboo schedules show <id>` — one schedule in detail. The server exposes
691/// no single-schedule GET, so this filters `GET /api/v1/schedules` by id.
692pub 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
795/// `bamboo schedules create` — POST /api/v1/schedules. Either assembles the
796/// request from the common-case flags or posts a raw JSON payload verbatim
797/// (`--json <file|->`) for full schema fidelity.
798pub 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
839/// `bamboo schedules delete <id>` — DELETE /api/v1/schedules/{id}. Confirms
840/// interactively (after resolving the schedule's name) unless `yes` is set.
841pub 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        // Resolve the name first so the operator confirms the right thing
846        // (this also fails fast on an unknown id before prompting).
847        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
876/// `bamboo schedules run <id>` — POST /api/v1/schedules/{id}/run (trigger now).
877pub 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
904/// `bamboo schedules runs <id>` — run history (GET /api/v1/schedules/{id}/runs).
905pub 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
948/// GET `url` and parse the JSON body, with the shared unreachable/HTTP-status
949/// error surface.
950async 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
965/// Resolve one schedule by exact id from the list endpoint (the server has no
966/// single-schedule GET).
967async 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
984/// Assemble a `CreateScheduleRequest` body from the flag-based inputs.
985fn 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    // The fired session's task message auto-executes: a schedule created from
1007    // the CLI's flag form exists to run an agent, not just to park a session.
1008    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
1031/// Parse `HH:MM` / `HH:MM:SS` for the `--daily` trigger.
1032fn 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
1051/// Read a raw JSON payload from a file path or stdin (`-`).
1052fn 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
1065/// Pull the server's `{"error": "..."}` detail out of an error body, if any.
1066pub(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
1073/// One-line human summary of a `ScheduleTrigger` JSON value.
1074fn 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
1121/// Render an RFC3339 timestamp value as `YYYY-MM-DD HH:MM:SS` (UTC); `-` when
1122/// absent. Keeps table columns narrow without a chrono dependency here.
1123fn 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    // "2026-07-10T12:34:56.789Z" -> "2026-07-10 12:34:56"
1128    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
1135// ---------------------------------------------------------------------------
1136// `bamboo mcp status|connect|disconnect|refresh|tools|add|remove` — MCP server
1137// management on a running instance (/api/v1/mcp). The offline config view
1138// stays `bamboo mcp list` (read_cli).
1139// ---------------------------------------------------------------------------
1140
1141/// Startup/refresh of an MCP server can take a while (stdio child spawn +
1142/// initialize handshake, default startup timeout 20s), so the mutating MCP
1143/// verbs get a more generous budget than the plain reads.
1144const MCP_MUTATE_TIMEOUT: Duration = Duration::from_secs(60);
1145
1146/// `bamboo mcp status` — live view over `GET /api/v1/mcp/servers`: connection
1147/// state + tool counts per configured server. `--json` prints the raw response.
1148pub 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    // Plain (un-colored) cells so the column widths line up.
1176    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
1211/// `bamboo mcp connect <id>` — enable + (re)connect a configured server
1212/// (`POST /api/v1/mcp/servers/{id}/connect`).
1213pub 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
1238/// `bamboo mcp disconnect <id>` — disable + disconnect a server
1239/// (`POST /api/v1/mcp/servers/{id}/disconnect`).
1240pub 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
1265/// `bamboo mcp refresh [<id>]` — re-list tools from one server, or from every
1266/// enabled server when no id is given (`POST /api/v1/mcp/servers/{id}/refresh`).
1267pub 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            // Refresh every ENABLED server (a disabled one has nothing to list).
1278            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
1341/// `bamboo mcp tools [<id>]` — list the tools one server exposes
1342/// (`GET /api/v1/mcp/servers/{id}/tools`), or every server's tools
1343/// (`GET /api/v1/mcp/tools`) when no id is given. `--json` prints raw JSON.
1344pub 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
1398/// `bamboo mcp add --json <file|->` — add (or overwrite) a server from a raw
1399/// JSON payload passed through to `POST /api/v1/mcp/servers`. The payload may
1400/// be the internal shape or the mainstream flat shape (`command`/`url`), same
1401/// as the HTTP API.
1402pub async fn mcp_add(conn: ConnArgs, payload_source: &str) -> anyhow::Result<()> {
1403    // Fail fast on malformed JSON instead of bouncing off the server.
1404    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
1429/// `bamboo mcp remove <id>` — stop + delete a server
1430/// (`DELETE /api/v1/mcp/servers/{id}`). Destructive (the stored config is
1431/// gone), though a removed server can be re-added with `bamboo mcp add` — so
1432/// it confirms like `sessions delete` / `schedules delete` unless `--yes`.
1433pub 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
1464/// Count array entries whose `is_running` is true.
1465fn 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
1476/// Truncate to `max` chars with a trailing ellipsis.
1477pub(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        // The error names the id kind so schedule/session messages stay distinct.
1506        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        // Un-truncated: the plain total (#423 item 2 — the total, not page len).
1513        assert_eq!(
1514            history_summary("s1", 3, 3, false),
1515            "3 message(s) in session s1."
1516        );
1517        // Truncated cold fetch: true total + an explicit truncation note.
1518        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        // Defensive: an empty/odd body also reads as "no pending question".
1551        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        // Optional fields absent → their lines are omitted entirely.
1584        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        // Optional fields stay absent so server defaults apply.
1652        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}