Skip to main content

roboticus_cli/cli/
mod.rs

1#![allow(non_snake_case, unused_variables)]
2
3use std::sync::OnceLock;
4
5use reqwest::Client;
6use serde_json::Value;
7
8use roboticus_core::style::{Theme, spinner_frame};
9
10pub(crate) const CRT_DRAW_MS: u64 = 4;
11
12#[macro_export]
13macro_rules! println {
14    () => {{ use std::io::Write; std::io::stdout().write_all(b"\n").ok(); std::io::stdout().flush().ok(); }};
15    ($($arg:tt)*) => {{ let __text = format!($($arg)*); theme().typewrite_line_stdout(&__text, CRT_DRAW_MS); }};
16}
17
18#[macro_export]
19macro_rules! eprintln {
20    () => {{ use std::io::Write; std::io::stderr().write_all(b"\n").ok(); }};
21    ($($arg:tt)*) => {{ let __text = format!($($arg)*); theme().typewrite_line(&__text, CRT_DRAW_MS); }};
22}
23
24static THEME: OnceLock<Theme> = OnceLock::new();
25static API_KEY: OnceLock<Option<String>> = OnceLock::new();
26
27pub fn init_api_key(key: Option<String>) {
28    let _ = API_KEY.set(key);
29}
30
31fn api_key() -> Option<&'static str> {
32    API_KEY.get().and_then(|k| k.as_deref())
33}
34
35/// Returns a `reqwest::Client` pre-configured with the API key header (if set).
36/// Use this instead of bare `reqwest::get()` / `reqwest::Client::new()` for
37/// any request to the Roboticus server.
38pub fn http_client() -> Result<Client, Box<dyn std::error::Error>> {
39    let mut builder = Client::builder().timeout(std::time::Duration::from_secs(10));
40    if let Some(key) = api_key() {
41        let mut headers = reqwest::header::HeaderMap::new();
42        headers.insert(
43            "x-api-key",
44            reqwest::header::HeaderValue::from_str(key)
45                .map_err(|e| format!("invalid API key header value: {e}"))?,
46        );
47        builder = builder.default_headers(headers);
48    }
49    Ok(builder.build()?)
50}
51
52pub fn init_theme(color_flag: &str, theme_flag: &str, no_draw: bool, nerdmode: bool) {
53    let t = Theme::from_flags(color_flag, theme_flag);
54    let t = if nerdmode {
55        t.with_nerdmode(true)
56    } else if no_draw {
57        t.with_draw(false)
58    } else {
59        t
60    };
61    let _ = THEME.set(t);
62}
63
64pub fn theme() -> &'static Theme {
65    THEME.get_or_init(Theme::detect)
66}
67
68// ── CLI Spinner ────────────────────────────────────────────────
69
70use std::sync::Arc;
71use std::sync::atomic::{AtomicBool, Ordering};
72
73/// A braille spinner that renders on stderr while an async task executes.
74/// Uses the shared `SPINNER_FRAMES` from `roboticus_core::style` for
75/// cross-surface consistency with the TUI thinking indicator.
76pub struct CliSpinner {
77    stop: Arc<AtomicBool>,
78    handle: Option<std::thread::JoinHandle<()>>,
79}
80
81impl CliSpinner {
82    /// Start a spinner with the given label (e.g. "Scanning models").
83    /// The spinner runs on a background thread, writing to stderr.
84    pub fn start(label: &str) -> Self {
85        let stop = Arc::new(AtomicBool::new(false));
86        let stop_clone = stop.clone();
87        let label = label.to_string();
88        let t = theme().clone();
89        let handle = std::thread::spawn(move || {
90            use std::io::Write;
91            let mut tick: usize = 0;
92            let accent = t.accent();
93            let dim = t.dim();
94            let reset = t.reset();
95            while !stop_clone.load(Ordering::Relaxed) {
96                let frame = spinner_frame(tick);
97                eprint!("\r  {accent}{frame}{reset} {dim}{label}{reset}  ");
98                std::io::stderr().flush().ok();
99                tick = tick.wrapping_add(1);
100                std::thread::sleep(std::time::Duration::from_millis(80));
101            }
102            // Clear the spinner line
103            eprint!("\r{}\r", " ".repeat(label.len() + 10));
104            std::io::stderr().flush().ok();
105        });
106        Self {
107            stop,
108            handle: Some(handle),
109        }
110    }
111
112    /// Stop the spinner and wait for the background thread to finish.
113    pub fn stop(mut self) {
114        self.stop.store(true, Ordering::Relaxed);
115        if let Some(h) = self.handle.take() {
116            let _ = h.join();
117        }
118    }
119}
120
121impl Drop for CliSpinner {
122    fn drop(&mut self) {
123        self.stop.store(true, Ordering::Relaxed);
124        if let Some(h) = self.handle.take() {
125            let _ = h.join();
126        }
127    }
128}
129
130/// Run an async future while displaying a braille spinner with the given label.
131/// Returns the future's result after stopping the spinner.
132pub async fn spin_while<F, T>(label: &str, future: F) -> T
133where
134    F: std::future::Future<Output = T>,
135{
136    let spinner = CliSpinner::start(label);
137    let result = future.await;
138    spinner.stop();
139    result
140}
141
142#[allow(clippy::type_complexity)]
143pub(crate) fn colors() -> (
144    &'static str,
145    &'static str,
146    &'static str,
147    &'static str,
148    &'static str,
149    &'static str,
150    &'static str,
151    &'static str,
152    &'static str,
153) {
154    let t = theme();
155    (
156        t.dim(),
157        t.bold(),
158        t.accent(),
159        t.success(),
160        t.warn(),
161        t.error(),
162        t.info(),
163        t.reset(),
164        t.mono(),
165    )
166}
167
168pub(crate) fn icons() -> (
169    &'static str,
170    &'static str,
171    &'static str,
172    &'static str,
173    &'static str,
174) {
175    let t = theme();
176    (
177        t.icon_ok(),
178        t.icon_action(),
179        t.icon_warn(),
180        t.icon_detail(),
181        t.icon_error(),
182    )
183}
184
185pub struct RoboticusClient {
186    client: Client,
187    base_url: String,
188}
189
190impl RoboticusClient {
191    pub fn new(base_url: &str) -> Result<Self, Box<dyn std::error::Error>> {
192        let mut builder = Client::builder().timeout(std::time::Duration::from_secs(10));
193        if let Some(key) = api_key() {
194            let mut headers = reqwest::header::HeaderMap::new();
195            headers.insert(
196                "x-api-key",
197                reqwest::header::HeaderValue::from_str(key)
198                    .map_err(|e| format!("invalid API key header value: {e}"))?,
199            );
200            builder = builder.default_headers(headers);
201        }
202        Ok(Self {
203            client: builder.build()?,
204            base_url: base_url.trim_end_matches('/').to_string(),
205        })
206    }
207    pub(crate) async fn get(&self, path: &str) -> Result<Value, Box<dyn std::error::Error>> {
208        let url = format!("{}{}", self.base_url, path);
209        let resp = self.client.get(&url).send().await?;
210        if !resp.status().is_success() {
211            let status = resp.status();
212            let body = resp.text().await.unwrap_or_default();
213            return Err(format!("HTTP {status}: {body}").into());
214        }
215        Ok(resp.json().await?)
216    }
217    async fn post(&self, path: &str, body: Value) -> Result<Value, Box<dyn std::error::Error>> {
218        let url = format!("{}{}", self.base_url, path);
219        let resp = self.client.post(&url).json(&body).send().await?;
220        if !resp.status().is_success() {
221            let status = resp.status();
222            let text = resp.text().await.unwrap_or_default();
223            return Err(format!("HTTP {status}: {text}").into());
224        }
225        Ok(resp.json().await?)
226    }
227    async fn put(&self, path: &str, body: Value) -> Result<Value, Box<dyn std::error::Error>> {
228        let url = format!("{}{}", self.base_url, path);
229        let resp = self.client.put(&url).json(&body).send().await?;
230        if !resp.status().is_success() {
231            let status = resp.status();
232            let text = resp.text().await.unwrap_or_default();
233            return Err(format!("HTTP {status}: {text}").into());
234        }
235        Ok(resp.json().await?)
236    }
237    fn check_connectivity_hint(e: &dyn std::error::Error) {
238        let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
239        let (OK, ACTION, WARN, DETAIL, ERR) = icons();
240        let msg = format!("{e:?}");
241        if msg.contains("Connection refused")
242            || msg.contains("ConnectionRefused")
243            || msg.contains("ConnectError")
244            || msg.contains("connect error")
245        {
246            eprintln!();
247            eprintln!(
248                "  {WARN} Is the Roboticus server running? Start it with: {BOLD}roboticus serve{RESET}"
249            );
250        }
251    }
252}
253
254pub(crate) fn heading(text: &str) {
255    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
256    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
257    eprintln!();
258    eprintln!("  {OK} {BOLD}{text}{RESET}");
259    eprintln!("  {DIM}{}{RESET}", "\u{2500}".repeat(60));
260}
261
262pub(crate) fn kv(key: &str, value: &str) {
263    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
264    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
265    eprintln!("    {DIM}{key:<20}{RESET} {value}");
266}
267
268pub(crate) fn kv_accent(key: &str, value: &str) {
269    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
270    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
271    eprintln!("    {DIM}{key:<20}{RESET} {ACCENT}{value}{RESET}");
272}
273
274pub(crate) fn kv_mono(key: &str, value: &str) {
275    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
276    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
277    eprintln!("    {DIM}{key:<20}{RESET} {MONO}{value}{RESET}");
278}
279
280pub(crate) fn badge(text: &str, color: &str) -> String {
281    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
282    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
283    format!("{color}\u{25cf} {text}{RESET}")
284}
285
286pub(crate) fn status_badge(status: &str) -> String {
287    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
288    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
289    match status {
290        "ok" | "running" | "success" => badge(status, GREEN),
291        "sleeping" | "pending" | "warning" => badge(status, YELLOW),
292        "dead" | "error" | "failed" => badge(status, RED),
293        _ => badge(status, DIM),
294    }
295}
296
297pub(crate) fn truncate_id(id: &str, len: usize) -> String {
298    if id.len() > len {
299        format!("{}...", &id[..len])
300    } else {
301        id.to_string()
302    }
303}
304
305pub(crate) fn table_separator(widths: &[usize]) {
306    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
307    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
308    let parts: Vec<String> = widths.iter().map(|w| "\u{2500}".repeat(*w)).collect();
309    eprintln!("    {DIM}\u{251c}{}\u{2524}{RESET}", parts.join("\u{253c}"));
310}
311
312pub(crate) fn table_header(headers: &[&str], widths: &[usize]) {
313    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
314    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
315    let cells: Vec<String> = headers
316        .iter()
317        .zip(widths)
318        .map(|(h, w)| format!("{BOLD}{h:<width$}{RESET}", width = w))
319        .collect();
320    eprintln!(
321        "    {DIM}\u{2502}{RESET}{}{DIM}\u{2502}{RESET}",
322        cells.join(&format!("{DIM}\u{2502}{RESET}"))
323    );
324    table_separator(widths);
325}
326
327pub(crate) fn table_row(cells: &[String], widths: &[usize]) {
328    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
329    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
330    let formatted: Vec<String> = cells
331        .iter()
332        .zip(widths)
333        .map(|(c, w)| {
334            let visible_len = strip_ansi_len(c);
335            if visible_len >= *w {
336                c.clone()
337            } else {
338                format!("{c}{}", " ".repeat(w - visible_len))
339            }
340        })
341        .collect();
342    eprintln!(
343        "    {DIM}\u{2502}{RESET}{}{DIM}\u{2502}{RESET}",
344        formatted.join(&format!("{DIM}\u{2502}{RESET}"))
345    );
346}
347
348pub(crate) fn strip_ansi_len(s: &str) -> usize {
349    let mut len = 0;
350    let mut in_escape = false;
351    for c in s.chars() {
352        if c == '\x1b' {
353            in_escape = true;
354        } else if in_escape {
355            if c == 'm' {
356                in_escape = false;
357            }
358        } else {
359            len += 1;
360        }
361    }
362    len
363}
364
365pub(crate) fn empty_state(msg: &str) {
366    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
367    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
368    eprintln!("    {DIM}\u{2500}\u{2500} {msg}{RESET}");
369}
370
371pub(crate) fn print_json_section(val: &Value, indent: usize) {
372    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
373    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
374    let pad = " ".repeat(indent);
375    match val {
376        Value::Object(map) => {
377            for (k, v) in map {
378                match v {
379                    Value::Object(_) => {
380                        eprintln!("{pad}{DIM}{k}:{RESET}");
381                        print_json_section(v, indent + 2);
382                    }
383                    Value::Array(arr) => {
384                        let items: Vec<String> =
385                            arr.iter().map(|i| format_json_val(i).to_string()).collect();
386                        eprintln!(
387                            "{pad}{DIM}{k:<22}{RESET} [{MONO}{}{RESET}]",
388                            items.join(", ")
389                        );
390                    }
391                    _ => eprintln!("{pad}{DIM}{k:<22}{RESET} {}", format_json_val(v)),
392                }
393            }
394        }
395        _ => eprintln!("{pad}{}", format_json_val(val)),
396    }
397}
398
399pub(crate) fn format_json_val(v: &Value) -> String {
400    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
401    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
402    match v {
403        Value::String(s) => format!("{MONO}{s}{RESET}"),
404        Value::Number(n) => format!("{ACCENT}{n}{RESET}"),
405        Value::Bool(b) => {
406            if *b {
407                format!("{GREEN}{b}{RESET}")
408            } else {
409                format!("{YELLOW}{b}{RESET}")
410            }
411        }
412        Value::Null => format!("{DIM}null{RESET}"),
413        _ => v.to_string(),
414    }
415}
416
417pub(crate) fn urlencoding(s: &str) -> String {
418    s.replace(' ', "%20")
419        .replace('&', "%26")
420        .replace('=', "%3D")
421        .replace('#', "%23")
422}
423
424pub(crate) fn which_binary_in_path(name: &str, path_var: &std::ffi::OsStr) -> Option<String> {
425    let candidates: Vec<String> = {
426        #[cfg(windows)]
427        {
428            let mut c = vec![name.to_string()];
429            let pathext = std::env::var("PATHEXT")
430                .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string())
431                .to_ascii_lowercase();
432            let has_ext = std::path::Path::new(name).extension().is_some();
433            if !has_ext {
434                for ext in pathext.split(';').filter(|e| !e.is_empty()) {
435                    c.push(format!("{name}{ext}"));
436                }
437            }
438            c
439        }
440        #[cfg(not(windows))]
441        {
442            vec![name.to_string()]
443        }
444    };
445
446    for dir in std::env::split_paths(path_var) {
447        #[cfg(windows)]
448        let dir = {
449            // Some Windows PATH entries are quoted; normalize before probing.
450            let raw = dir.to_string_lossy();
451            std::path::PathBuf::from(raw.trim().trim_matches('"'))
452        };
453
454        for candidate in &candidates {
455            let p = dir.join(candidate);
456            if p.is_file() {
457                return Some(p.display().to_string());
458            }
459        }
460    }
461
462    None
463}
464
465pub(crate) fn which_binary(name: &str) -> Option<String> {
466    let path_var = std::env::var_os("PATH")?;
467    if let Some(found) = which_binary_in_path(name, &path_var) {
468        return Some(found);
469    }
470
471    #[cfg(windows)]
472    {
473        // Fall back to Windows command resolution semantics.
474        let output = std::process::Command::new("where")
475            .arg(name)
476            .output()
477            .ok()?;
478        if output.status.success()
479            && let Some(first) = String::from_utf8_lossy(&output.stdout)
480                .lines()
481                .map(str::trim)
482                .find(|line| !line.is_empty())
483        {
484            return Some(first.to_string());
485        }
486    }
487
488    None
489}
490
491mod admin;
492pub mod defrag;
493mod memory;
494mod schedule;
495mod sessions;
496mod status;
497mod update;
498mod wallet;
499
500pub use admin::*;
501pub use defrag::*;
502pub use memory::*;
503pub use schedule::*;
504pub use sessions::*;
505pub use status::*;
506pub use update::*;
507pub use wallet::*;
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    #[test]
513    fn client_construction() {
514        let c = RoboticusClient::new("http://localhost:18789").unwrap();
515        assert_eq!(c.base_url, "http://localhost:18789");
516    }
517    #[test]
518    fn client_strips_trailing_slash() {
519        let c = RoboticusClient::new("http://localhost:18789/").unwrap();
520        assert_eq!(c.base_url, "http://localhost:18789");
521    }
522    #[test]
523    fn truncate_id_short() {
524        assert_eq!(truncate_id("abc", 10), "abc");
525    }
526    #[test]
527    fn truncate_id_long() {
528        assert_eq!(truncate_id("abcdefghijklmnop", 8), "abcdefgh...");
529    }
530    #[test]
531    fn status_badges() {
532        assert!(status_badge("ok").contains("ok"));
533        assert!(status_badge("dead").contains("dead"));
534        assert!(status_badge("foo").contains("foo"));
535    }
536    #[test]
537    fn strip_ansi_len_works() {
538        assert_eq!(strip_ansi_len("hello"), 5);
539        assert_eq!(strip_ansi_len("\x1b[32mhello\x1b[0m"), 5);
540    }
541    #[test]
542    fn urlencoding_encodes() {
543        assert_eq!(urlencoding("hello world"), "hello%20world");
544        assert_eq!(urlencoding("a&b=c#d"), "a%26b%3Dc%23d");
545    }
546    #[test]
547    fn format_json_val_types() {
548        assert!(format_json_val(&Value::String("test".into())).contains("test"));
549        assert!(format_json_val(&serde_json::json!(42)).contains("42"));
550        assert!(format_json_val(&Value::Null).contains("null"));
551    }
552    #[test]
553    fn which_binary_finds_sh() {
554        let path = std::env::var_os("PATH").expect("PATH must be set");
555        assert!(which_binary_in_path("sh", &path).is_some());
556    }
557    #[test]
558    fn which_binary_returns_none_for_nonsense() {
559        let path = std::env::var_os("PATH").expect("PATH must be set");
560        assert!(which_binary_in_path("__roboticus_nonexistent_binary_98765__", &path).is_none());
561    }
562
563    #[cfg(windows)]
564    #[test]
565    fn which_binary_handles_quoted_windows_path_segment() {
566        use std::ffi::OsString;
567        use std::path::PathBuf;
568
569        let test_dir = std::env::temp_dir().join(format!(
570            "roboticus-quoted-path-test-{}-{}",
571            std::process::id(),
572            "go"
573        ));
574        let _ = std::fs::remove_dir_all(&test_dir);
575        std::fs::create_dir_all(&test_dir).unwrap();
576
577        let go_exe = test_dir.join("go.exe");
578        std::fs::write(&go_exe, b"").unwrap();
579
580        let quoted_path = OsString::from(format!("\"{}\"", test_dir.display()));
581        let found = which_binary_in_path("go", &quoted_path).map(PathBuf::from);
582        assert_eq!(found, Some(go_exe.clone()));
583
584        let _ = std::fs::remove_file(go_exe);
585        let _ = std::fs::remove_dir_all(test_dir);
586    }
587
588    #[test]
589    fn format_json_val_bool_true() {
590        let result = format_json_val(&serde_json::json!(true));
591        assert!(result.contains("true"));
592    }
593
594    #[test]
595    fn format_json_val_bool_false() {
596        let result = format_json_val(&serde_json::json!(false));
597        assert!(result.contains("false"));
598    }
599
600    #[test]
601    fn format_json_val_array_uses_to_string() {
602        let result = format_json_val(&serde_json::json!([1, 2, 3]));
603        assert!(result.contains("1"));
604    }
605
606    #[test]
607    fn strip_ansi_len_empty() {
608        assert_eq!(strip_ansi_len(""), 0);
609    }
610
611    #[test]
612    fn strip_ansi_len_only_ansi() {
613        assert_eq!(strip_ansi_len("\x1b[32m\x1b[0m"), 0);
614    }
615
616    #[test]
617    fn status_badge_sleeping() {
618        assert!(status_badge("sleeping").contains("sleeping"));
619    }
620
621    #[test]
622    fn status_badge_pending() {
623        assert!(status_badge("pending").contains("pending"));
624    }
625
626    #[test]
627    fn status_badge_running() {
628        assert!(status_badge("running").contains("running"));
629    }
630
631    #[test]
632    fn badge_contains_text_and_bullet() {
633        let b = badge("running", "\x1b[32m");
634        assert!(b.contains("running"));
635        assert!(b.contains("\u{25cf}"));
636    }
637
638    #[test]
639    fn truncate_id_exact_length() {
640        assert_eq!(truncate_id("abc", 3), "abc");
641    }
642
643    // ── wiremock-based CLI command tests ─────────────────────────
644
645    use wiremock::matchers::{method, path};
646    use wiremock::{Mock, MockServer, ResponseTemplate};
647
648    async fn mock_get(server: &MockServer, p: &str, body: serde_json::Value) {
649        Mock::given(method("GET"))
650            .and(path(p))
651            .respond_with(ResponseTemplate::new(200).set_body_json(body))
652            .mount(server)
653            .await;
654    }
655
656    async fn mock_post(server: &MockServer, p: &str, body: serde_json::Value) {
657        Mock::given(method("POST"))
658            .and(path(p))
659            .respond_with(ResponseTemplate::new(200).set_body_json(body))
660            .mount(server)
661            .await;
662    }
663
664    async fn mock_put(server: &MockServer, p: &str, body: serde_json::Value) {
665        Mock::given(method("PUT"))
666            .and(path(p))
667            .respond_with(ResponseTemplate::new(200).set_body_json(body))
668            .mount(server)
669            .await;
670    }
671
672    // ── Skills ────────────────────────────────────────────────
673
674    #[tokio::test]
675    async fn cmd_skills_list_with_skills() {
676        let s = MockServer::start().await;
677        mock_get(&s, "/api/skills", serde_json::json!({
678            "skills": [
679                {"name": "greet", "kind": "builtin", "description": "Says hello", "enabled": true},
680                {"name": "calc", "kind": "gosh", "description": "Math stuff", "enabled": false}
681            ]
682        })).await;
683        super::cmd_skills_list(&s.uri(), false).await.unwrap();
684    }
685
686    #[tokio::test]
687    async fn cmd_skills_list_empty() {
688        let s = MockServer::start().await;
689        mock_get(&s, "/api/skills", serde_json::json!({"skills": []})).await;
690        super::cmd_skills_list(&s.uri(), false).await.unwrap();
691    }
692
693    #[tokio::test]
694    async fn cmd_skills_list_null_skills() {
695        let s = MockServer::start().await;
696        mock_get(&s, "/api/skills", serde_json::json!({})).await;
697        super::cmd_skills_list(&s.uri(), false).await.unwrap();
698    }
699
700    #[tokio::test]
701    async fn cmd_skill_detail_enabled_with_triggers() {
702        let s = MockServer::start().await;
703        mock_get(
704            &s,
705            "/api/skills/greet",
706            serde_json::json!({
707                "id": "greet-001", "name": "greet", "kind": "builtin",
708                "description": "Says hello", "source_path": "/skills/greet.gosh",
709                "content_hash": "abc123", "enabled": true,
710                "triggers_json": "[\"on_start\"]", "script_path": "/scripts/greet.gosh"
711            }),
712        )
713        .await;
714        super::cmd_skill_detail(&s.uri(), "greet", false)
715            .await
716            .unwrap();
717    }
718
719    #[tokio::test]
720    async fn cmd_skill_detail_disabled_no_triggers() {
721        let s = MockServer::start().await;
722        mock_get(
723            &s,
724            "/api/skills/calc",
725            serde_json::json!({
726                "id": "calc-001", "name": "calc", "kind": "gosh",
727                "description": "Math", "source_path": "", "content_hash": "",
728                "enabled": false, "triggers_json": "null", "script_path": "null"
729            }),
730        )
731        .await;
732        super::cmd_skill_detail(&s.uri(), "calc", false)
733            .await
734            .unwrap();
735    }
736
737    #[tokio::test]
738    async fn cmd_skill_detail_enabled_as_int() {
739        let s = MockServer::start().await;
740        mock_get(
741            &s,
742            "/api/skills/x",
743            serde_json::json!({
744                "id": "x", "name": "x", "kind": "builtin",
745                "description": "", "source_path": "", "content_hash": "",
746                "enabled": 1
747            }),
748        )
749        .await;
750        super::cmd_skill_detail(&s.uri(), "x", false).await.unwrap();
751    }
752
753    #[tokio::test]
754    async fn cmd_skills_reload_ok() {
755        let s = MockServer::start().await;
756        mock_post(&s, "/api/skills/reload", serde_json::json!({"ok": true})).await;
757        super::cmd_skills_reload(&s.uri()).await.unwrap();
758    }
759
760    #[tokio::test]
761    async fn cmd_skills_catalog_list_ok() {
762        let s = MockServer::start().await;
763        mock_get(
764            &s,
765            "/api/skills/catalog",
766            serde_json::json!({"items":[{"name":"foo","kind":"instruction","source":"registry"}]}),
767        )
768        .await;
769        super::cmd_skills_catalog_list(&s.uri(), None, false)
770            .await
771            .unwrap();
772    }
773
774    #[tokio::test]
775    async fn cmd_skills_catalog_install_ok() {
776        let s = MockServer::start().await;
777        mock_post(
778            &s,
779            "/api/skills/catalog/install",
780            serde_json::json!({"ok":true,"installed":["foo.md"],"activated":true}),
781        )
782        .await;
783        super::cmd_skills_catalog_install(&s.uri(), &["foo".to_string()], true)
784            .await
785            .unwrap();
786    }
787
788    // ── Wallet ────────────────────────────────────────────────
789
790    #[tokio::test]
791    async fn cmd_wallet_full() {
792        let s = MockServer::start().await;
793        mock_get(
794            &s,
795            "/api/wallet/balance",
796            serde_json::json!({
797                "balance": "42.50", "currency": "USDC", "note": "Testnet balance"
798            }),
799        )
800        .await;
801        mock_get(
802            &s,
803            "/api/wallet/address",
804            serde_json::json!({
805                "address": "0xdeadbeef"
806            }),
807        )
808        .await;
809        super::cmd_wallet(&s.uri(), false).await.unwrap();
810    }
811
812    #[tokio::test]
813    async fn cmd_wallet_no_note() {
814        let s = MockServer::start().await;
815        mock_get(
816            &s,
817            "/api/wallet/balance",
818            serde_json::json!({
819                "balance": "0.00", "currency": "USDC"
820            }),
821        )
822        .await;
823        mock_get(
824            &s,
825            "/api/wallet/address",
826            serde_json::json!({
827                "address": "0xabc"
828            }),
829        )
830        .await;
831        super::cmd_wallet(&s.uri(), false).await.unwrap();
832    }
833
834    #[tokio::test]
835    async fn cmd_wallet_address_ok() {
836        let s = MockServer::start().await;
837        mock_get(
838            &s,
839            "/api/wallet/address",
840            serde_json::json!({
841                "address": "0x1234"
842            }),
843        )
844        .await;
845        super::cmd_wallet_address(&s.uri(), false).await.unwrap();
846    }
847
848    #[tokio::test]
849    async fn cmd_wallet_balance_ok() {
850        let s = MockServer::start().await;
851        mock_get(
852            &s,
853            "/api/wallet/balance",
854            serde_json::json!({
855                "balance": "100.00", "currency": "ETH"
856            }),
857        )
858        .await;
859        super::cmd_wallet_balance(&s.uri(), false).await.unwrap();
860    }
861
862    // ── Schedule ──────────────────────────────────────────────
863
864    #[tokio::test]
865    async fn cmd_schedule_list_with_jobs() {
866        let s = MockServer::start().await;
867        mock_get(
868            &s,
869            "/api/cron/jobs",
870            serde_json::json!({
871                "jobs": [
872                    {
873                        "name": "backup", "schedule_kind": "cron", "schedule_expr": "0 * * * *",
874                        "last_run_at": "2025-01-01T12:00:00.000Z", "last_status": "ok",
875                        "consecutive_errors": 0
876                    },
877                    {
878                        "name": "cleanup", "schedule_kind": "interval", "schedule_expr": "30m",
879                        "last_run_at": null, "last_status": "pending",
880                        "consecutive_errors": 3
881                    }
882                ]
883            }),
884        )
885        .await;
886        super::cmd_schedule_list(&s.uri(), false).await.unwrap();
887    }
888
889    #[tokio::test]
890    async fn cmd_schedule_list_empty() {
891        let s = MockServer::start().await;
892        mock_get(&s, "/api/cron/jobs", serde_json::json!({"jobs": []})).await;
893        super::cmd_schedule_list(&s.uri(), false).await.unwrap();
894    }
895
896    #[tokio::test]
897    async fn cmd_schedule_recover_all_enables_paused_jobs() {
898        let s = MockServer::start().await;
899        mock_get(
900            &s,
901            "/api/cron/jobs",
902            serde_json::json!({
903                "jobs": [
904                    {
905                        "id": "job-1",
906                        "name": "calendar-monitor",
907                        "enabled": false,
908                        "last_status": "paused_unknown_action",
909                        "last_run_at": "2026-02-25 12:00:00"
910                    },
911                    {
912                        "id": "job-2",
913                        "name": "healthy-job",
914                        "enabled": true,
915                        "last_status": "success",
916                        "last_run_at": "2026-02-25 12:01:00"
917                    }
918                ]
919            }),
920        )
921        .await;
922        mock_put(
923            &s,
924            "/api/cron/jobs/job-1",
925            serde_json::json!({"updated": true}),
926        )
927        .await;
928        super::cmd_schedule_recover(&s.uri(), &[], true, false, false)
929            .await
930            .unwrap();
931    }
932
933    #[tokio::test]
934    async fn cmd_schedule_recover_dry_run_does_not_put() {
935        let s = MockServer::start().await;
936        mock_get(
937            &s,
938            "/api/cron/jobs",
939            serde_json::json!({
940                "jobs": [
941                    {
942                        "id": "job-1",
943                        "name": "calendar-monitor",
944                        "enabled": false,
945                        "last_status": "paused_unknown_action",
946                        "last_run_at": "2026-02-25 12:00:00"
947                    }
948                ]
949            }),
950        )
951        .await;
952        super::cmd_schedule_recover(&s.uri(), &[], true, true, false)
953            .await
954            .unwrap();
955    }
956
957    #[tokio::test]
958    async fn cmd_schedule_recover_name_filter() {
959        let s = MockServer::start().await;
960        mock_get(
961            &s,
962            "/api/cron/jobs",
963            serde_json::json!({
964                "jobs": [
965                    {
966                        "id": "job-1",
967                        "name": "calendar-monitor",
968                        "enabled": false,
969                        "last_status": "paused_unknown_action",
970                        "last_run_at": "2026-02-25 12:00:00"
971                    },
972                    {
973                        "id": "job-2",
974                        "name": "revenue-check",
975                        "enabled": false,
976                        "last_status": "paused_unknown_action",
977                        "last_run_at": "2026-02-25 12:01:00"
978                    }
979                ]
980            }),
981        )
982        .await;
983        mock_put(
984            &s,
985            "/api/cron/jobs/job-2",
986            serde_json::json!({"updated": true}),
987        )
988        .await;
989        super::cmd_schedule_recover(
990            &s.uri(),
991            &["revenue-check".to_string()],
992            false,
993            false,
994            false,
995        )
996        .await
997        .unwrap();
998    }
999
1000    // ── Memory ────────────────────────────────────────────────
1001
1002    #[tokio::test]
1003    async fn cmd_memory_working_with_entries() {
1004        let s = MockServer::start().await;
1005        mock_get(&s, "/api/memory/working/sess-1", serde_json::json!({
1006            "entries": [
1007                {"id": "e1", "entry_type": "fact", "content": "The sky is blue", "importance": 5}
1008            ]
1009        })).await;
1010        super::cmd_memory(&s.uri(), "working", Some("sess-1"), None, None, false)
1011            .await
1012            .unwrap();
1013    }
1014
1015    #[tokio::test]
1016    async fn cmd_memory_working_empty() {
1017        let s = MockServer::start().await;
1018        mock_get(
1019            &s,
1020            "/api/memory/working/sess-2",
1021            serde_json::json!({"entries": []}),
1022        )
1023        .await;
1024        super::cmd_memory(&s.uri(), "working", Some("sess-2"), None, None, false)
1025            .await
1026            .unwrap();
1027    }
1028
1029    #[tokio::test]
1030    async fn cmd_memory_working_no_session_errors() {
1031        let result = super::cmd_memory("http://unused", "working", None, None, None, false).await;
1032        assert!(result.is_err());
1033    }
1034
1035    #[tokio::test]
1036    async fn cmd_memory_episodic_with_entries() {
1037        let s = MockServer::start().await;
1038        Mock::given(method("GET"))
1039            .and(path("/api/memory/episodic"))
1040            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1041                "entries": [
1042                    {"id": "ep1", "classification": "conversation", "content": "User asked about weather", "importance": 3}
1043                ]
1044            })))
1045            .mount(&s)
1046            .await;
1047        super::cmd_memory(&s.uri(), "episodic", None, None, Some(10), false)
1048            .await
1049            .unwrap();
1050    }
1051
1052    #[tokio::test]
1053    async fn cmd_memory_episodic_empty() {
1054        let s = MockServer::start().await;
1055        Mock::given(method("GET"))
1056            .and(path("/api/memory/episodic"))
1057            .respond_with(
1058                ResponseTemplate::new(200).set_body_json(serde_json::json!({"entries": []})),
1059            )
1060            .mount(&s)
1061            .await;
1062        super::cmd_memory(&s.uri(), "episodic", None, None, None, false)
1063            .await
1064            .unwrap();
1065    }
1066
1067    #[tokio::test]
1068    async fn cmd_memory_semantic_with_entries() {
1069        let s = MockServer::start().await;
1070        mock_get(
1071            &s,
1072            "/api/memory/semantic/general",
1073            serde_json::json!({
1074                "entries": [
1075                    {"key": "favorite_color", "value": "blue", "confidence": 0.95}
1076                ]
1077            }),
1078        )
1079        .await;
1080        super::cmd_memory(&s.uri(), "semantic", None, None, None, false)
1081            .await
1082            .unwrap();
1083    }
1084
1085    #[tokio::test]
1086    async fn cmd_memory_semantic_custom_category() {
1087        let s = MockServer::start().await;
1088        mock_get(
1089            &s,
1090            "/api/memory/semantic/prefs",
1091            serde_json::json!({
1092                "entries": []
1093            }),
1094        )
1095        .await;
1096        super::cmd_memory(&s.uri(), "semantic", Some("prefs"), None, None, false)
1097            .await
1098            .unwrap();
1099    }
1100
1101    #[tokio::test]
1102    async fn cmd_memory_search_with_results() {
1103        let s = MockServer::start().await;
1104        Mock::given(method("GET"))
1105            .and(path("/api/memory/search"))
1106            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1107                "results": ["result one", "result two"]
1108            })))
1109            .mount(&s)
1110            .await;
1111        super::cmd_memory(&s.uri(), "search", None, Some("hello"), None, false)
1112            .await
1113            .unwrap();
1114    }
1115
1116    #[tokio::test]
1117    async fn cmd_memory_search_empty() {
1118        let s = MockServer::start().await;
1119        Mock::given(method("GET"))
1120            .and(path("/api/memory/search"))
1121            .respond_with(
1122                ResponseTemplate::new(200).set_body_json(serde_json::json!({"results": []})),
1123            )
1124            .mount(&s)
1125            .await;
1126        super::cmd_memory(&s.uri(), "search", None, Some("nope"), None, false)
1127            .await
1128            .unwrap();
1129    }
1130
1131    #[tokio::test]
1132    async fn cmd_memory_search_no_query_errors() {
1133        let result = super::cmd_memory("http://unused", "search", None, None, None, false).await;
1134        assert!(result.is_err());
1135    }
1136
1137    #[tokio::test]
1138    async fn cmd_memory_unknown_tier_errors() {
1139        let result = super::cmd_memory("http://unused", "bogus", None, None, None, false).await;
1140        assert!(result.is_err());
1141    }
1142
1143    // ── Sessions ──────────────────────────────────────────────
1144
1145    #[tokio::test]
1146    async fn cmd_sessions_list_with_sessions() {
1147        let s = MockServer::start().await;
1148        mock_get(&s, "/api/sessions", serde_json::json!({
1149            "sessions": [
1150                {"id": "s-001", "agent_id": "roboticus", "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T01:00:00Z"},
1151                {"id": "s-002", "agent_id": "duncan", "created_at": "2025-01-02T00:00:00Z", "updated_at": "2025-01-02T01:00:00Z"}
1152            ]
1153        })).await;
1154        super::cmd_sessions_list(&s.uri(), false).await.unwrap();
1155    }
1156
1157    #[tokio::test]
1158    async fn cmd_sessions_list_empty() {
1159        let s = MockServer::start().await;
1160        mock_get(&s, "/api/sessions", serde_json::json!({"sessions": []})).await;
1161        super::cmd_sessions_list(&s.uri(), false).await.unwrap();
1162    }
1163
1164    #[tokio::test]
1165    async fn cmd_session_detail_with_messages() {
1166        let s = MockServer::start().await;
1167        mock_get(
1168            &s,
1169            "/api/sessions/s-001",
1170            serde_json::json!({
1171                "id": "s-001", "agent_id": "roboticus",
1172                "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T01:00:00Z"
1173            }),
1174        )
1175        .await;
1176        mock_get(&s, "/api/sessions/s-001/messages", serde_json::json!({
1177            "messages": [
1178                {"role": "user", "content": "Hello!", "created_at": "2025-01-01T00:00:05.123Z"},
1179                {"role": "assistant", "content": "Hi there!", "created_at": "2025-01-01T00:00:06.456Z"},
1180                {"role": "system", "content": "Init", "created_at": "2025-01-01T00:00:00Z"},
1181                {"role": "tool", "content": "Result", "created_at": "2025-01-01T00:00:07Z"}
1182            ]
1183        })).await;
1184        super::cmd_session_detail(&s.uri(), "s-001", false)
1185            .await
1186            .unwrap();
1187    }
1188
1189    #[tokio::test]
1190    async fn cmd_session_detail_no_messages() {
1191        let s = MockServer::start().await;
1192        mock_get(
1193            &s,
1194            "/api/sessions/s-002",
1195            serde_json::json!({
1196                "id": "s-002", "agent_id": "roboticus",
1197                "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T01:00:00Z"
1198            }),
1199        )
1200        .await;
1201        mock_get(
1202            &s,
1203            "/api/sessions/s-002/messages",
1204            serde_json::json!({"messages": []}),
1205        )
1206        .await;
1207        super::cmd_session_detail(&s.uri(), "s-002", false)
1208            .await
1209            .unwrap();
1210    }
1211
1212    #[tokio::test]
1213    async fn cmd_session_create_ok() {
1214        let s = MockServer::start().await;
1215        mock_post(
1216            &s,
1217            "/api/sessions",
1218            serde_json::json!({"session_id": "new-001"}),
1219        )
1220        .await;
1221        super::cmd_session_create(&s.uri(), "roboticus")
1222            .await
1223            .unwrap();
1224    }
1225
1226    #[tokio::test]
1227    async fn cmd_session_export_json() {
1228        let s = MockServer::start().await;
1229        mock_get(
1230            &s,
1231            "/api/sessions/s-001",
1232            serde_json::json!({
1233                "id": "s-001", "agent_id": "roboticus", "created_at": "2025-01-01T00:00:00Z"
1234            }),
1235        )
1236        .await;
1237        mock_get(&s, "/api/sessions/s-001/messages", serde_json::json!({
1238            "messages": [{"role": "user", "content": "Hi", "created_at": "2025-01-01T00:00:01Z"}]
1239        })).await;
1240        let dir = tempfile::tempdir().unwrap();
1241        let out = dir.path().join("export.json");
1242        super::cmd_session_export(&s.uri(), "s-001", "json", Some(out.to_str().unwrap()))
1243            .await
1244            .unwrap();
1245        assert!(out.exists());
1246        let content = std::fs::read_to_string(&out).unwrap();
1247        assert!(content.contains("s-001"));
1248    }
1249
1250    #[tokio::test]
1251    async fn cmd_session_export_markdown() {
1252        let s = MockServer::start().await;
1253        mock_get(
1254            &s,
1255            "/api/sessions/s-001",
1256            serde_json::json!({
1257                "id": "s-001", "agent_id": "roboticus", "created_at": "2025-01-01T00:00:00Z"
1258            }),
1259        )
1260        .await;
1261        mock_get(&s, "/api/sessions/s-001/messages", serde_json::json!({
1262            "messages": [{"role": "user", "content": "Hi", "created_at": "2025-01-01T00:00:01Z"}]
1263        })).await;
1264        let dir = tempfile::tempdir().unwrap();
1265        let out = dir.path().join("export.md");
1266        super::cmd_session_export(&s.uri(), "s-001", "markdown", Some(out.to_str().unwrap()))
1267            .await
1268            .unwrap();
1269        assert!(out.exists());
1270        let content = std::fs::read_to_string(&out).unwrap();
1271        assert!(content.contains("# Session"));
1272    }
1273
1274    #[tokio::test]
1275    async fn cmd_session_export_html() {
1276        let s = MockServer::start().await;
1277        mock_get(
1278            &s,
1279            "/api/sessions/s-001",
1280            serde_json::json!({
1281                "id": "s-001", "agent_id": "roboticus", "created_at": "2025-01-01T00:00:00Z"
1282            }),
1283        )
1284        .await;
1285        mock_get(&s, "/api/sessions/s-001/messages", serde_json::json!({
1286            "messages": [
1287                {"role": "user", "content": "Hello <world> & \"friends\"", "created_at": "2025-01-01T00:00:01Z"},
1288                {"role": "assistant", "content": "Hi", "created_at": "2025-01-01T00:00:02Z"},
1289                {"role": "system", "content": "Sys", "created_at": "2025-01-01T00:00:00Z"},
1290                {"role": "tool", "content": "Tool output", "created_at": "2025-01-01T00:00:03Z"}
1291            ]
1292        })).await;
1293        let dir = tempfile::tempdir().unwrap();
1294        let out = dir.path().join("export.html");
1295        super::cmd_session_export(&s.uri(), "s-001", "html", Some(out.to_str().unwrap()))
1296            .await
1297            .unwrap();
1298        let content = std::fs::read_to_string(&out).unwrap();
1299        assert!(content.contains("<!DOCTYPE html>"));
1300        assert!(content.contains("&amp;"));
1301        assert!(content.contains("&lt;"));
1302    }
1303
1304    #[tokio::test]
1305    async fn cmd_session_export_to_stdout() {
1306        let s = MockServer::start().await;
1307        mock_get(
1308            &s,
1309            "/api/sessions/s-001",
1310            serde_json::json!({
1311                "id": "s-001", "agent_id": "roboticus", "created_at": "2025-01-01T00:00:00Z"
1312            }),
1313        )
1314        .await;
1315        mock_get(
1316            &s,
1317            "/api/sessions/s-001/messages",
1318            serde_json::json!({"messages": []}),
1319        )
1320        .await;
1321        super::cmd_session_export(&s.uri(), "s-001", "json", None)
1322            .await
1323            .unwrap();
1324    }
1325
1326    #[tokio::test]
1327    async fn cmd_session_export_unknown_format() {
1328        let s = MockServer::start().await;
1329        mock_get(
1330            &s,
1331            "/api/sessions/s-001",
1332            serde_json::json!({
1333                "id": "s-001", "agent_id": "roboticus", "created_at": "2025-01-01T00:00:00Z"
1334            }),
1335        )
1336        .await;
1337        mock_get(
1338            &s,
1339            "/api/sessions/s-001/messages",
1340            serde_json::json!({"messages": []}),
1341        )
1342        .await;
1343        super::cmd_session_export(&s.uri(), "s-001", "csv", None)
1344            .await
1345            .unwrap();
1346    }
1347
1348    #[tokio::test]
1349    async fn cmd_session_export_not_found() {
1350        let s = MockServer::start().await;
1351        Mock::given(method("GET"))
1352            .and(path("/api/sessions/missing"))
1353            .respond_with(ResponseTemplate::new(404))
1354            .mount(&s)
1355            .await;
1356        super::cmd_session_export(&s.uri(), "missing", "json", None)
1357            .await
1358            .unwrap();
1359    }
1360
1361    // ── Circuit breaker ───────────────────────────────────────
1362
1363    #[tokio::test]
1364    async fn cmd_circuit_status_with_providers() {
1365        let s = MockServer::start().await;
1366        mock_get(
1367            &s,
1368            "/api/breaker/status",
1369            serde_json::json!({
1370                "providers": {
1371                    "ollama": {"state": "closed"},
1372                    "openai": {"state": "open"}
1373                },
1374                "note": "All good"
1375            }),
1376        )
1377        .await;
1378        super::cmd_circuit_status(&s.uri(), false).await.unwrap();
1379    }
1380
1381    #[tokio::test]
1382    async fn cmd_circuit_status_empty_providers() {
1383        let s = MockServer::start().await;
1384        mock_get(
1385            &s,
1386            "/api/breaker/status",
1387            serde_json::json!({"providers": {}}),
1388        )
1389        .await;
1390        super::cmd_circuit_status(&s.uri(), false).await.unwrap();
1391    }
1392
1393    #[tokio::test]
1394    async fn cmd_circuit_status_no_providers_key() {
1395        let s = MockServer::start().await;
1396        mock_get(&s, "/api/breaker/status", serde_json::json!({})).await;
1397        super::cmd_circuit_status(&s.uri(), false).await.unwrap();
1398    }
1399
1400    #[tokio::test]
1401    async fn cmd_circuit_reset_success() {
1402        let s = MockServer::start().await;
1403        mock_get(
1404            &s,
1405            "/api/breaker/status",
1406            serde_json::json!({
1407                "providers": {
1408                    "ollama": {"state": "open"},
1409                    "moonshot": {"state": "open"}
1410                }
1411            }),
1412        )
1413        .await;
1414        mock_post(
1415            &s,
1416            "/api/breaker/reset/ollama",
1417            serde_json::json!({"ok": true}),
1418        )
1419        .await;
1420        mock_post(
1421            &s,
1422            "/api/breaker/reset/moonshot",
1423            serde_json::json!({"ok": true}),
1424        )
1425        .await;
1426        super::cmd_circuit_reset(&s.uri(), None).await.unwrap();
1427    }
1428
1429    #[tokio::test]
1430    async fn cmd_circuit_reset_server_error() {
1431        let s = MockServer::start().await;
1432        Mock::given(method("GET"))
1433            .and(path("/api/breaker/status"))
1434            .respond_with(ResponseTemplate::new(500))
1435            .mount(&s)
1436            .await;
1437        super::cmd_circuit_reset(&s.uri(), None).await.unwrap();
1438    }
1439
1440    #[tokio::test]
1441    async fn cmd_circuit_reset_single_provider() {
1442        let s = MockServer::start().await;
1443        mock_post(
1444            &s,
1445            "/api/breaker/reset/openai",
1446            serde_json::json!({"ok": true}),
1447        )
1448        .await;
1449        super::cmd_circuit_reset(&s.uri(), Some("openai"))
1450            .await
1451            .unwrap();
1452    }
1453
1454    // ── Agents ────────────────────────────────────────────────
1455
1456    #[tokio::test]
1457    async fn cmd_agents_list_with_agents() {
1458        let s = MockServer::start().await;
1459        mock_get(
1460            &s,
1461            "/api/agents",
1462            serde_json::json!({
1463                "agents": [
1464                    {"id": "roboticus", "name": "Roboticus", "state": "running", "model": "qwen3:8b"},
1465                    {"id": "duncan", "name": "Duncan", "state": "sleeping", "model": "gpt-4o"}
1466                ]
1467            }),
1468        )
1469        .await;
1470        super::cmd_agents_list(&s.uri(), false).await.unwrap();
1471    }
1472
1473    #[tokio::test]
1474    async fn cmd_agents_list_empty() {
1475        let s = MockServer::start().await;
1476        mock_get(&s, "/api/agents", serde_json::json!({"agents": []})).await;
1477        super::cmd_agents_list(&s.uri(), false).await.unwrap();
1478    }
1479
1480    // ── Channels ──────────────────────────────────────────────
1481
1482    #[tokio::test]
1483    async fn cmd_channels_status_with_channels() {
1484        let s = MockServer::start().await;
1485        Mock::given(method("GET"))
1486            .and(path("/api/channels/status"))
1487            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
1488                {"name": "telegram", "connected": true, "messages_received": 100, "messages_sent": 50},
1489                {"name": "whatsapp", "connected": false, "messages_received": 0, "messages_sent": 0}
1490            ])))
1491            .mount(&s)
1492            .await;
1493        super::cmd_channels_status(&s.uri(), false).await.unwrap();
1494    }
1495
1496    #[tokio::test]
1497    async fn cmd_channels_status_empty() {
1498        let s = MockServer::start().await;
1499        Mock::given(method("GET"))
1500            .and(path("/api/channels/status"))
1501            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1502            .mount(&s)
1503            .await;
1504        super::cmd_channels_status(&s.uri(), false).await.unwrap();
1505    }
1506
1507    #[tokio::test]
1508    async fn cmd_channels_dead_letter_with_items() {
1509        let s = MockServer::start().await;
1510        Mock::given(method("GET"))
1511            .and(path("/api/channels/dead-letter"))
1512            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1513                "items": [
1514                    {"id": "dl-1", "channel": "telegram", "attempts": 5, "max_attempts": 5, "last_error": "blocked"}
1515                ]
1516            })))
1517            .mount(&s)
1518            .await;
1519        super::cmd_channels_dead_letter(&s.uri(), 10, false)
1520            .await
1521            .unwrap();
1522    }
1523
1524    #[tokio::test]
1525    async fn cmd_channels_replay_ok() {
1526        let s = MockServer::start().await;
1527        Mock::given(method("POST"))
1528            .and(path("/api/channels/dead-letter/dl-1/replay"))
1529            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})))
1530            .mount(&s)
1531            .await;
1532        super::cmd_channels_replay(&s.uri(), "dl-1").await.unwrap();
1533    }
1534
1535    // ── Plugins ───────────────────────────────────────────────
1536
1537    #[tokio::test]
1538    async fn cmd_plugins_list_with_plugins() {
1539        let s = MockServer::start().await;
1540        mock_get(&s, "/api/plugins", serde_json::json!({
1541            "plugins": [
1542                {"name": "weather", "version": "1.0", "status": "active", "tools": [{"name": "get_weather"}]},
1543                {"name": "empty", "version": "0.1", "status": "inactive", "tools": []}
1544            ]
1545        })).await;
1546        super::cmd_plugins_list(&s.uri(), false).await.unwrap();
1547    }
1548
1549    #[tokio::test]
1550    async fn cmd_plugins_list_empty() {
1551        let s = MockServer::start().await;
1552        mock_get(&s, "/api/plugins", serde_json::json!({"plugins": []})).await;
1553        super::cmd_plugins_list(&s.uri(), false).await.unwrap();
1554    }
1555
1556    #[tokio::test]
1557    async fn cmd_plugin_info_found() {
1558        let s = MockServer::start().await;
1559        mock_get(
1560            &s,
1561            "/api/plugins",
1562            serde_json::json!({
1563                "plugins": [
1564                    {
1565                        "name": "weather", "version": "1.0", "description": "Weather plugin",
1566                        "enabled": true, "manifest_path": "/plugins/weather/plugin.toml",
1567                        "tools": [{"name": "get_weather"}, {"name": "get_forecast"}]
1568                    }
1569                ]
1570            }),
1571        )
1572        .await;
1573        super::cmd_plugin_info(&s.uri(), "weather", false)
1574            .await
1575            .unwrap();
1576    }
1577
1578    #[tokio::test]
1579    async fn cmd_plugin_info_disabled() {
1580        let s = MockServer::start().await;
1581        mock_get(
1582            &s,
1583            "/api/plugins",
1584            serde_json::json!({
1585                "plugins": [{"name": "old", "version": "0.1", "enabled": false}]
1586            }),
1587        )
1588        .await;
1589        super::cmd_plugin_info(&s.uri(), "old", false)
1590            .await
1591            .unwrap();
1592    }
1593
1594    #[tokio::test]
1595    async fn cmd_plugin_info_not_found() {
1596        let s = MockServer::start().await;
1597        mock_get(&s, "/api/plugins", serde_json::json!({"plugins": []})).await;
1598        let result = super::cmd_plugin_info(&s.uri(), "nonexistent", false).await;
1599        assert!(result.is_err());
1600    }
1601
1602    #[tokio::test]
1603    async fn cmd_plugin_toggle_enable() {
1604        let s = MockServer::start().await;
1605        Mock::given(method("PUT"))
1606            .and(path("/api/plugins/weather/toggle"))
1607            .respond_with(ResponseTemplate::new(200))
1608            .mount(&s)
1609            .await;
1610        super::cmd_plugin_toggle(&s.uri(), "weather", true)
1611            .await
1612            .unwrap();
1613    }
1614
1615    #[tokio::test]
1616    async fn cmd_plugin_toggle_disable_fails() {
1617        let s = MockServer::start().await;
1618        Mock::given(method("PUT"))
1619            .and(path("/api/plugins/weather/toggle"))
1620            .respond_with(ResponseTemplate::new(404))
1621            .mount(&s)
1622            .await;
1623        let result = super::cmd_plugin_toggle(&s.uri(), "weather", false).await;
1624        assert!(result.is_err());
1625    }
1626
1627    #[tokio::test]
1628    async fn cmd_plugin_install_missing_source() {
1629        let result = super::cmd_plugin_install("/tmp/roboticus_test_nonexistent_plugin_dir").await;
1630        assert!(result.is_err());
1631    }
1632
1633    #[tokio::test]
1634    async fn cmd_plugin_install_no_manifest() {
1635        let dir = tempfile::tempdir().unwrap();
1636        let result = super::cmd_plugin_install(dir.path().to_str().unwrap()).await;
1637        assert!(result.is_err());
1638    }
1639
1640    #[serial_test::serial]
1641    #[tokio::test]
1642    async fn cmd_plugin_install_valid() {
1643        // Use separate dirs for the plugin source and HOME to avoid
1644        // copy_dir_recursive copying the source into a subdirectory of itself
1645        // (which causes infinite recursion / stack overflow).
1646        let src_dir = tempfile::tempdir().unwrap();
1647        let home_dir = tempfile::tempdir().unwrap();
1648        let manifest = src_dir.path().join("plugin.toml");
1649        std::fs::write(&manifest, "name = \"test-plugin\"\nversion = \"0.1\"").unwrap();
1650        std::fs::write(src_dir.path().join("main.gosh"), "print(\"hi\")").unwrap();
1651
1652        let sub = src_dir.path().join("sub");
1653        std::fs::create_dir(&sub).unwrap();
1654        std::fs::write(sub.join("helper.gosh"), "// helper").unwrap();
1655
1656        let _home_guard =
1657            crate::test_support::EnvGuard::set("HOME", home_dir.path().to_str().unwrap());
1658        let _ = super::cmd_plugin_install(src_dir.path().to_str().unwrap()).await;
1659    }
1660
1661    #[serial_test::serial]
1662    #[test]
1663    fn cmd_plugin_uninstall_not_found() {
1664        let _home_guard =
1665            crate::test_support::EnvGuard::set("HOME", "/tmp/roboticus_test_uninstall_home");
1666        let result = super::cmd_plugin_uninstall("nonexistent");
1667        assert!(
1668            result.is_err(),
1669            "uninstall of nonexistent plugin should fail"
1670        );
1671    }
1672
1673    #[serial_test::serial]
1674    #[test]
1675    fn cmd_plugin_uninstall_exists() {
1676        let dir = tempfile::tempdir().unwrap();
1677        let plugins_dir = dir
1678            .path()
1679            .join(".roboticus")
1680            .join("plugins")
1681            .join("myplugin");
1682        std::fs::create_dir_all(&plugins_dir).unwrap();
1683        std::fs::write(plugins_dir.join("plugin.toml"), "name = \"myplugin\"").unwrap();
1684        let _home_guard = crate::test_support::EnvGuard::set("HOME", dir.path().to_str().unwrap());
1685        super::cmd_plugin_uninstall("myplugin").unwrap();
1686        assert!(!plugins_dir.exists());
1687    }
1688
1689    // ── Models ────────────────────────────────────────────────
1690
1691    #[tokio::test]
1692    async fn cmd_models_list_full_config() {
1693        let s = MockServer::start().await;
1694        mock_get(&s, "/api/config", serde_json::json!({
1695            "models": {
1696                "primary": "qwen3:8b",
1697                "fallbacks": ["gpt-4o", "claude-3"],
1698                "routing": { "mode": "adaptive", "confidence_threshold": 0.85, "local_first": false }
1699            }
1700        })).await;
1701        super::cmd_models_list(&s.uri(), false).await.unwrap();
1702    }
1703
1704    #[tokio::test]
1705    async fn cmd_models_list_minimal_config() {
1706        let s = MockServer::start().await;
1707        mock_get(&s, "/api/config", serde_json::json!({})).await;
1708        super::cmd_models_list(&s.uri(), false).await.unwrap();
1709    }
1710
1711    #[tokio::test]
1712    async fn cmd_models_scan_no_providers() {
1713        let s = MockServer::start().await;
1714        mock_get(&s, "/api/config", serde_json::json!({"providers": {}})).await;
1715        super::cmd_models_scan(&s.uri(), None).await.unwrap();
1716    }
1717
1718    #[tokio::test]
1719    async fn cmd_models_scan_with_local_provider() {
1720        let s = MockServer::start().await;
1721        mock_get(
1722            &s,
1723            "/api/config",
1724            serde_json::json!({
1725                "providers": {
1726                    "ollama": {"url": &format!("{}/ollama", s.uri())}
1727                }
1728            }),
1729        )
1730        .await;
1731        Mock::given(method("GET"))
1732            .and(path("/ollama/v1/models"))
1733            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1734                "data": [{"id": "qwen3:8b"}, {"id": "llama3:70b"}]
1735            })))
1736            .mount(&s)
1737            .await;
1738        super::cmd_models_scan(&s.uri(), None).await.unwrap();
1739    }
1740
1741    #[tokio::test]
1742    async fn cmd_models_scan_local_ollama() {
1743        let s = MockServer::start().await;
1744        let _ollama_url = s.uri().to_string().replace("http://", "http://localhost:");
1745        mock_get(
1746            &s,
1747            "/api/config",
1748            serde_json::json!({
1749                "providers": {
1750                    "ollama": {"url": &s.uri()}
1751                }
1752            }),
1753        )
1754        .await;
1755        Mock::given(method("GET"))
1756            .and(path("/api/tags"))
1757            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1758                "models": [{"name": "qwen3:8b"}, {"model": "llama3"}]
1759            })))
1760            .mount(&s)
1761            .await;
1762        super::cmd_models_scan(&s.uri(), Some("ollama"))
1763            .await
1764            .unwrap();
1765    }
1766
1767    #[tokio::test]
1768    async fn cmd_models_scan_provider_filter_skips_others() {
1769        let s = MockServer::start().await;
1770        mock_get(
1771            &s,
1772            "/api/config",
1773            serde_json::json!({
1774                "providers": {
1775                    "ollama": {"url": "http://localhost:11434"},
1776                    "openai": {"url": "https://api.openai.com"}
1777                }
1778            }),
1779        )
1780        .await;
1781        super::cmd_models_scan(&s.uri(), Some("openai"))
1782            .await
1783            .unwrap();
1784    }
1785
1786    #[tokio::test]
1787    async fn cmd_models_scan_empty_url() {
1788        let s = MockServer::start().await;
1789        mock_get(
1790            &s,
1791            "/api/config",
1792            serde_json::json!({
1793                "providers": { "test": {"url": ""} }
1794            }),
1795        )
1796        .await;
1797        super::cmd_models_scan(&s.uri(), None).await.unwrap();
1798    }
1799
1800    #[tokio::test]
1801    async fn cmd_models_scan_error_response() {
1802        let s = MockServer::start().await;
1803        mock_get(
1804            &s,
1805            "/api/config",
1806            serde_json::json!({
1807                "providers": {
1808                    "bad": {"url": &s.uri()}
1809                }
1810            }),
1811        )
1812        .await;
1813        Mock::given(method("GET"))
1814            .and(path("/v1/models"))
1815            .respond_with(ResponseTemplate::new(500))
1816            .mount(&s)
1817            .await;
1818        super::cmd_models_scan(&s.uri(), None).await.unwrap();
1819    }
1820
1821    #[tokio::test]
1822    async fn cmd_models_scan_no_models_found() {
1823        let s = MockServer::start().await;
1824        mock_get(
1825            &s,
1826            "/api/config",
1827            serde_json::json!({
1828                "providers": {
1829                    "empty": {"url": &s.uri()}
1830                }
1831            }),
1832        )
1833        .await;
1834        Mock::given(method("GET"))
1835            .and(path("/v1/models"))
1836            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1837            .mount(&s)
1838            .await;
1839        super::cmd_models_scan(&s.uri(), None).await.unwrap();
1840    }
1841
1842    // ── Metrics ───────────────────────────────────────────────
1843
1844    #[tokio::test]
1845    async fn cmd_metrics_costs_with_data() {
1846        let s = MockServer::start().await;
1847        mock_get(&s, "/api/stats/costs", serde_json::json!({
1848            "costs": [
1849                {"model": "qwen3:8b", "provider": "ollama", "tokens_in": 100, "tokens_out": 50, "cost": 0.001, "cached": false},
1850                {"model": "gpt-4o", "provider": "openai", "tokens_in": 200, "tokens_out": 100, "cost": 0.01, "cached": true}
1851            ]
1852        })).await;
1853        super::cmd_metrics(&s.uri(), "costs", None, false)
1854            .await
1855            .unwrap();
1856    }
1857
1858    #[tokio::test]
1859    async fn cmd_metrics_costs_empty() {
1860        let s = MockServer::start().await;
1861        mock_get(&s, "/api/stats/costs", serde_json::json!({"costs": []})).await;
1862        super::cmd_metrics(&s.uri(), "costs", None, false)
1863            .await
1864            .unwrap();
1865    }
1866
1867    #[tokio::test]
1868    async fn cmd_metrics_transactions_with_data() {
1869        let s = MockServer::start().await;
1870        Mock::given(method("GET"))
1871            .and(path("/api/stats/transactions"))
1872            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1873                "transactions": [
1874                    {"id": "tx-001", "tx_type": "inference", "amount": 0.01, "currency": "USD",
1875                     "counterparty": "openai", "created_at": "2025-01-01T12:00:00.000Z"},
1876                    {"id": "tx-002", "tx_type": "transfer", "amount": 5.00, "currency": "USDC",
1877                     "counterparty": "user", "created_at": "2025-01-01T13:00:00Z"}
1878                ]
1879            })))
1880            .mount(&s)
1881            .await;
1882        super::cmd_metrics(&s.uri(), "transactions", Some(48), false)
1883            .await
1884            .unwrap();
1885    }
1886
1887    #[tokio::test]
1888    async fn cmd_metrics_transactions_empty() {
1889        let s = MockServer::start().await;
1890        Mock::given(method("GET"))
1891            .and(path("/api/stats/transactions"))
1892            .respond_with(
1893                ResponseTemplate::new(200).set_body_json(serde_json::json!({"transactions": []})),
1894            )
1895            .mount(&s)
1896            .await;
1897        super::cmd_metrics(&s.uri(), "transactions", None, false)
1898            .await
1899            .unwrap();
1900    }
1901
1902    #[tokio::test]
1903    async fn cmd_metrics_cache_stats() {
1904        let s = MockServer::start().await;
1905        mock_get(
1906            &s,
1907            "/api/stats/cache",
1908            serde_json::json!({
1909                "hits": 42, "misses": 8, "entries": 100, "hit_rate": 84.0
1910            }),
1911        )
1912        .await;
1913        super::cmd_metrics(&s.uri(), "cache", None, false)
1914            .await
1915            .unwrap();
1916    }
1917
1918    #[tokio::test]
1919    async fn cmd_metrics_unknown_kind() {
1920        let s = MockServer::start().await;
1921        let result = super::cmd_metrics(&s.uri(), "bogus", None, false).await;
1922        assert!(result.is_err());
1923    }
1924
1925    // ── Completion ────────────────────────────────────────────
1926
1927    #[test]
1928    fn cmd_completion_bash() {
1929        super::cmd_completion("bash").unwrap();
1930    }
1931
1932    #[test]
1933    fn cmd_completion_zsh() {
1934        super::cmd_completion("zsh").unwrap();
1935    }
1936
1937    #[test]
1938    fn cmd_completion_fish() {
1939        super::cmd_completion("fish").unwrap();
1940    }
1941
1942    #[test]
1943    fn cmd_completion_unknown() {
1944        super::cmd_completion("powershell").unwrap();
1945    }
1946
1947    // ── Logs ──────────────────────────────────────────────────
1948
1949    #[tokio::test]
1950    async fn cmd_logs_static_with_entries() {
1951        let s = MockServer::start().await;
1952        Mock::given(method("GET"))
1953            .and(path("/api/logs"))
1954            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1955                "entries": [
1956                    {"timestamp": "2025-01-01T00:00:00Z", "level": "INFO", "message": "Started", "target": "roboticus"},
1957                    {"timestamp": "2025-01-01T00:00:01Z", "level": "WARN", "message": "Low memory", "target": "system"},
1958                    {"timestamp": "2025-01-01T00:00:02Z", "level": "ERROR", "message": "Failed", "target": "api"},
1959                    {"timestamp": "2025-01-01T00:00:03Z", "level": "DEBUG", "message": "Trace", "target": "db"},
1960                    {"timestamp": "2025-01-01T00:00:04Z", "level": "TRACE", "message": "Deep", "target": "core"}
1961                ]
1962            })))
1963            .mount(&s)
1964            .await;
1965        super::cmd_logs(&s.uri(), 50, false, "info", false)
1966            .await
1967            .unwrap();
1968    }
1969
1970    #[tokio::test]
1971    async fn cmd_logs_static_empty() {
1972        let s = MockServer::start().await;
1973        Mock::given(method("GET"))
1974            .and(path("/api/logs"))
1975            .respond_with(
1976                ResponseTemplate::new(200).set_body_json(serde_json::json!({"entries": []})),
1977            )
1978            .mount(&s)
1979            .await;
1980        super::cmd_logs(&s.uri(), 10, false, "info", false)
1981            .await
1982            .unwrap();
1983    }
1984
1985    #[tokio::test]
1986    async fn cmd_logs_static_no_entries_key() {
1987        let s = MockServer::start().await;
1988        Mock::given(method("GET"))
1989            .and(path("/api/logs"))
1990            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1991            .mount(&s)
1992            .await;
1993        super::cmd_logs(&s.uri(), 10, false, "info", false)
1994            .await
1995            .unwrap();
1996    }
1997
1998    #[tokio::test]
1999    async fn cmd_logs_server_error_falls_back() {
2000        let s = MockServer::start().await;
2001        Mock::given(method("GET"))
2002            .and(path("/api/logs"))
2003            .respond_with(ResponseTemplate::new(500))
2004            .mount(&s)
2005            .await;
2006        super::cmd_logs(&s.uri(), 10, false, "info", false)
2007            .await
2008            .unwrap();
2009    }
2010
2011    // ── Security audit (filesystem) ──────────────────────────
2012
2013    #[test]
2014    fn cmd_security_audit_missing_config() {
2015        super::cmd_security_audit("/tmp/roboticus_test_nonexistent_config.toml", false).unwrap();
2016    }
2017
2018    #[test]
2019    fn cmd_security_audit_clean_config() {
2020        let dir = tempfile::tempdir().unwrap();
2021        let config = dir.path().join("roboticus.toml");
2022        std::fs::write(&config, "[server]\nbind = \"localhost\"\nport = 18789\n").unwrap();
2023        #[cfg(unix)]
2024        {
2025            use std::os::unix::fs::PermissionsExt;
2026            std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o600)).unwrap();
2027        }
2028        super::cmd_security_audit(config.to_str().unwrap(), false).unwrap();
2029    }
2030
2031    #[test]
2032    fn cmd_security_audit_plaintext_keys() {
2033        let dir = tempfile::tempdir().unwrap();
2034        let config = dir.path().join("roboticus.toml");
2035        std::fs::write(&config, "[providers.openai]\napi_key = \"sk-secret123\"\n").unwrap();
2036        #[cfg(unix)]
2037        {
2038            use std::os::unix::fs::PermissionsExt;
2039            std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o600)).unwrap();
2040        }
2041        super::cmd_security_audit(config.to_str().unwrap(), false).unwrap();
2042    }
2043
2044    #[test]
2045    fn cmd_security_audit_env_var_keys() {
2046        let dir = tempfile::tempdir().unwrap();
2047        let config = dir.path().join("roboticus.toml");
2048        std::fs::write(&config, "[providers.openai]\napi_key = \"${OPENAI_KEY}\"\n").unwrap();
2049        super::cmd_security_audit(config.to_str().unwrap(), false).unwrap();
2050    }
2051
2052    #[test]
2053    fn cmd_security_audit_wildcard_cors() {
2054        let dir = tempfile::tempdir().unwrap();
2055        let config = dir.path().join("roboticus.toml");
2056        std::fs::write(
2057            &config,
2058            "[server]\nbind = \"0.0.0.0\"\n\n[cors]\norigins = \"*\"\n",
2059        )
2060        .unwrap();
2061        super::cmd_security_audit(config.to_str().unwrap(), false).unwrap();
2062    }
2063
2064    #[cfg(unix)]
2065    #[test]
2066    fn cmd_security_audit_loose_config_permissions() {
2067        let dir = tempfile::tempdir().unwrap();
2068        let config = dir.path().join("roboticus.toml");
2069        std::fs::write(&config, "[server]\nport = 18789\n").unwrap();
2070        use std::os::unix::fs::PermissionsExt;
2071        std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o644)).unwrap();
2072        super::cmd_security_audit(config.to_str().unwrap(), false).unwrap();
2073    }
2074
2075    // ── Reset (with --yes to skip stdin) ─────────────────────
2076
2077    #[serial_test::serial]
2078    #[test]
2079    fn cmd_reset_yes_no_db() {
2080        let dir = tempfile::tempdir().unwrap();
2081        let roboticus_dir = dir.path().join(".roboticus");
2082        std::fs::create_dir_all(&roboticus_dir).unwrap();
2083        let _home_guard = crate::test_support::EnvGuard::set("HOME", dir.path().to_str().unwrap());
2084        super::cmd_reset(true).unwrap();
2085    }
2086
2087    #[serial_test::serial]
2088    #[test]
2089    fn cmd_reset_yes_with_db_and_config() {
2090        let dir = tempfile::tempdir().unwrap();
2091        let roboticus_dir = dir.path().join(".roboticus");
2092        std::fs::create_dir_all(&roboticus_dir).unwrap();
2093        std::fs::write(roboticus_dir.join("state.db"), "fake db").unwrap();
2094        std::fs::write(roboticus_dir.join("state.db-wal"), "wal").unwrap();
2095        std::fs::write(roboticus_dir.join("state.db-shm"), "shm").unwrap();
2096        std::fs::write(roboticus_dir.join("roboticus.toml"), "[server]").unwrap();
2097        std::fs::create_dir_all(roboticus_dir.join("logs")).unwrap();
2098        std::fs::write(roboticus_dir.join("wallet.json"), "{}").unwrap();
2099        let _home_guard = crate::test_support::EnvGuard::set("HOME", dir.path().to_str().unwrap());
2100        super::cmd_reset(true).unwrap();
2101        assert!(!roboticus_dir.join("state.db").exists());
2102        assert!(!roboticus_dir.join("roboticus.toml").exists());
2103        assert!(!roboticus_dir.join("logs").exists());
2104        assert!(roboticus_dir.join("wallet.json").exists());
2105    }
2106
2107    // ── Mechanic ──────────────────────────────────────────────
2108
2109    #[serial_test::serial]
2110    #[tokio::test]
2111    async fn cmd_mechanic_gateway_up() {
2112        let s = MockServer::start().await;
2113        mock_get(&s, "/api/health", serde_json::json!({"status": "ok"})).await;
2114        mock_get(&s, "/api/config", serde_json::json!({"models": {}})).await;
2115        mock_get(
2116            &s,
2117            "/api/skills",
2118            serde_json::json!({"skills": [{"id": "s1"}]}),
2119        )
2120        .await;
2121        mock_get(
2122            &s,
2123            "/api/wallet/balance",
2124            serde_json::json!({"balance": "1.00"}),
2125        )
2126        .await;
2127        Mock::given(method("GET"))
2128            .and(path("/api/channels/status"))
2129            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
2130                {"connected": true}, {"connected": false}
2131            ])))
2132            .mount(&s)
2133            .await;
2134        let dir = tempfile::tempdir().unwrap();
2135        let roboticus_dir = dir.path().join(".roboticus");
2136        for sub in &["workspace", "skills", "plugins", "logs"] {
2137            std::fs::create_dir_all(roboticus_dir.join(sub)).unwrap();
2138        }
2139        std::fs::write(roboticus_dir.join("roboticus.toml"), "[server]").unwrap();
2140        let _home_guard = crate::test_support::EnvGuard::set("HOME", dir.path().to_str().unwrap());
2141        let _ = super::cmd_mechanic(&s.uri(), false, false, &[]).await;
2142    }
2143
2144    #[serial_test::serial]
2145    #[tokio::test]
2146    async fn cmd_mechanic_gateway_down() {
2147        let s = MockServer::start().await;
2148        Mock::given(method("GET"))
2149            .and(path("/api/health"))
2150            .respond_with(ResponseTemplate::new(503))
2151            .mount(&s)
2152            .await;
2153        let dir = tempfile::tempdir().unwrap();
2154        let _home_guard = crate::test_support::EnvGuard::set("HOME", dir.path().to_str().unwrap());
2155        let _ = super::cmd_mechanic(&s.uri(), false, false, &[]).await;
2156    }
2157
2158    #[serial_test::serial]
2159    #[tokio::test]
2160    #[ignore = "sets HOME globally, racy with parallel tests — run with --ignored"]
2161    async fn cmd_mechanic_repair_creates_dirs() {
2162        let s = MockServer::start().await;
2163        Mock::given(method("GET"))
2164            .and(path("/api/health"))
2165            .respond_with(
2166                ResponseTemplate::new(200).set_body_json(serde_json::json!({"status": "ok"})),
2167            )
2168            .mount(&s)
2169            .await;
2170        mock_get(&s, "/api/config", serde_json::json!({})).await;
2171        mock_get(&s, "/api/skills", serde_json::json!({"skills": []})).await;
2172        mock_get(&s, "/api/wallet/balance", serde_json::json!({})).await;
2173        Mock::given(method("GET"))
2174            .and(path("/api/channels/status"))
2175            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
2176            .mount(&s)
2177            .await;
2178        let dir = tempfile::tempdir().unwrap();
2179        let _home_guard = crate::test_support::EnvGuard::set("HOME", dir.path().to_str().unwrap());
2180        let _ = super::cmd_mechanic(&s.uri(), true, false, &[]).await;
2181        assert!(dir.path().join(".roboticus").join("workspace").exists());
2182    }
2183}