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;
492mod apps;
493pub mod defrag;
494pub mod mcp;
495mod memory;
496mod profiles;
497mod schedule;
498mod sessions;
499mod status;
500mod update;
501mod wallet;
502
503pub use admin::*;
504pub use apps::*;
505pub use defrag::*;
506pub use mcp::*;
507pub use memory::*;
508pub use profiles::*;
509pub use schedule::*;
510pub use sessions::*;
511pub use status::*;
512pub use update::*;
513pub use wallet::*;
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    #[test]
519    fn client_construction() {
520        let c = RoboticusClient::new("http://localhost:18789").unwrap();
521        assert_eq!(c.base_url, "http://localhost:18789");
522    }
523    #[test]
524    fn client_strips_trailing_slash() {
525        let c = RoboticusClient::new("http://localhost:18789/").unwrap();
526        assert_eq!(c.base_url, "http://localhost:18789");
527    }
528    #[test]
529    fn truncate_id_short() {
530        assert_eq!(truncate_id("abc", 10), "abc");
531    }
532    #[test]
533    fn truncate_id_long() {
534        assert_eq!(truncate_id("abcdefghijklmnop", 8), "abcdefgh...");
535    }
536    #[test]
537    fn status_badges() {
538        assert!(status_badge("ok").contains("ok"));
539        assert!(status_badge("dead").contains("dead"));
540        assert!(status_badge("foo").contains("foo"));
541    }
542    #[test]
543    fn strip_ansi_len_works() {
544        assert_eq!(strip_ansi_len("hello"), 5);
545        assert_eq!(strip_ansi_len("\x1b[32mhello\x1b[0m"), 5);
546    }
547    #[test]
548    fn urlencoding_encodes() {
549        assert_eq!(urlencoding("hello world"), "hello%20world");
550        assert_eq!(urlencoding("a&b=c#d"), "a%26b%3Dc%23d");
551    }
552    #[test]
553    fn format_json_val_types() {
554        assert!(format_json_val(&Value::String("test".into())).contains("test"));
555        assert!(format_json_val(&serde_json::json!(42)).contains("42"));
556        assert!(format_json_val(&Value::Null).contains("null"));
557    }
558    #[test]
559    fn which_binary_finds_sh() {
560        let path = std::env::var_os("PATH").expect("PATH must be set");
561        assert!(which_binary_in_path("sh", &path).is_some());
562    }
563    #[test]
564    fn which_binary_returns_none_for_nonsense() {
565        let path = std::env::var_os("PATH").expect("PATH must be set");
566        assert!(which_binary_in_path("__roboticus_nonexistent_binary_98765__", &path).is_none());
567    }
568
569    #[cfg(windows)]
570    #[test]
571    fn which_binary_handles_quoted_windows_path_segment() {
572        use std::ffi::OsString;
573        use std::path::PathBuf;
574
575        let test_dir = std::env::temp_dir().join(format!(
576            "roboticus-quoted-path-test-{}-{}",
577            std::process::id(),
578            "go"
579        ));
580        let _ = std::fs::remove_dir_all(&test_dir);
581        std::fs::create_dir_all(&test_dir).unwrap();
582
583        let go_exe = test_dir.join("go.exe");
584        std::fs::write(&go_exe, b"").unwrap();
585
586        let quoted_path = OsString::from(format!("\"{}\"", test_dir.display()));
587        let found = which_binary_in_path("go", &quoted_path).map(PathBuf::from);
588        assert_eq!(found, Some(go_exe.clone()));
589
590        let _ = std::fs::remove_file(go_exe);
591        let _ = std::fs::remove_dir_all(test_dir);
592    }
593
594    #[test]
595    fn format_json_val_bool_true() {
596        let result = format_json_val(&serde_json::json!(true));
597        assert!(result.contains("true"));
598    }
599
600    #[test]
601    fn format_json_val_bool_false() {
602        let result = format_json_val(&serde_json::json!(false));
603        assert!(result.contains("false"));
604    }
605
606    #[test]
607    fn format_json_val_array_uses_to_string() {
608        let result = format_json_val(&serde_json::json!([1, 2, 3]));
609        assert!(result.contains("1"));
610    }
611
612    #[test]
613    fn strip_ansi_len_empty() {
614        assert_eq!(strip_ansi_len(""), 0);
615    }
616
617    #[test]
618    fn strip_ansi_len_only_ansi() {
619        assert_eq!(strip_ansi_len("\x1b[32m\x1b[0m"), 0);
620    }
621
622    #[test]
623    fn status_badge_sleeping() {
624        assert!(status_badge("sleeping").contains("sleeping"));
625    }
626
627    #[test]
628    fn status_badge_pending() {
629        assert!(status_badge("pending").contains("pending"));
630    }
631
632    #[test]
633    fn status_badge_running() {
634        assert!(status_badge("running").contains("running"));
635    }
636
637    #[test]
638    fn badge_contains_text_and_bullet() {
639        let b = badge("running", "\x1b[32m");
640        assert!(b.contains("running"));
641        assert!(b.contains("\u{25cf}"));
642    }
643
644    #[test]
645    fn truncate_id_exact_length() {
646        assert_eq!(truncate_id("abc", 3), "abc");
647    }
648
649    // ── wiremock-based CLI command tests ─────────────────────────
650
651    use wiremock::matchers::{method, path};
652    use wiremock::{Mock, MockServer, ResponseTemplate};
653
654    async fn mock_get(server: &MockServer, p: &str, body: serde_json::Value) {
655        Mock::given(method("GET"))
656            .and(path(p))
657            .respond_with(ResponseTemplate::new(200).set_body_json(body))
658            .mount(server)
659            .await;
660    }
661
662    async fn mock_post(server: &MockServer, p: &str, body: serde_json::Value) {
663        Mock::given(method("POST"))
664            .and(path(p))
665            .respond_with(ResponseTemplate::new(200).set_body_json(body))
666            .mount(server)
667            .await;
668    }
669
670    async fn mock_put(server: &MockServer, p: &str, body: serde_json::Value) {
671        Mock::given(method("PUT"))
672            .and(path(p))
673            .respond_with(ResponseTemplate::new(200).set_body_json(body))
674            .mount(server)
675            .await;
676    }
677
678    // ── Skills ────────────────────────────────────────────────
679
680    #[tokio::test]
681    async fn cmd_skills_list_with_skills() {
682        let s = MockServer::start().await;
683        mock_get(&s, "/api/skills", serde_json::json!({
684            "skills": [
685                {"name": "greet", "kind": "builtin", "description": "Says hello", "enabled": true},
686                {"name": "calc", "kind": "gosh", "description": "Math stuff", "enabled": false}
687            ]
688        })).await;
689        super::cmd_skills_list(&s.uri(), false).await.unwrap();
690    }
691
692    #[tokio::test]
693    async fn cmd_skills_list_empty() {
694        let s = MockServer::start().await;
695        mock_get(&s, "/api/skills", serde_json::json!({"skills": []})).await;
696        super::cmd_skills_list(&s.uri(), false).await.unwrap();
697    }
698
699    #[tokio::test]
700    async fn cmd_skills_list_null_skills() {
701        let s = MockServer::start().await;
702        mock_get(&s, "/api/skills", serde_json::json!({})).await;
703        super::cmd_skills_list(&s.uri(), false).await.unwrap();
704    }
705
706    #[tokio::test]
707    async fn cmd_skill_detail_enabled_with_triggers() {
708        let s = MockServer::start().await;
709        mock_get(
710            &s,
711            "/api/skills/greet",
712            serde_json::json!({
713                "id": "greet-001", "name": "greet", "kind": "builtin",
714                "description": "Says hello", "source_path": "/skills/greet.gosh",
715                "content_hash": "abc123", "enabled": true,
716                "triggers_json": "[\"on_start\"]", "script_path": "/scripts/greet.gosh"
717            }),
718        )
719        .await;
720        super::cmd_skill_detail(&s.uri(), "greet", false)
721            .await
722            .unwrap();
723    }
724
725    #[tokio::test]
726    async fn cmd_skill_detail_disabled_no_triggers() {
727        let s = MockServer::start().await;
728        mock_get(
729            &s,
730            "/api/skills/calc",
731            serde_json::json!({
732                "id": "calc-001", "name": "calc", "kind": "gosh",
733                "description": "Math", "source_path": "", "content_hash": "",
734                "enabled": false, "triggers_json": "null", "script_path": "null"
735            }),
736        )
737        .await;
738        super::cmd_skill_detail(&s.uri(), "calc", false)
739            .await
740            .unwrap();
741    }
742
743    #[tokio::test]
744    async fn cmd_skill_detail_enabled_as_int() {
745        let s = MockServer::start().await;
746        mock_get(
747            &s,
748            "/api/skills/x",
749            serde_json::json!({
750                "id": "x", "name": "x", "kind": "builtin",
751                "description": "", "source_path": "", "content_hash": "",
752                "enabled": 1
753            }),
754        )
755        .await;
756        super::cmd_skill_detail(&s.uri(), "x", false).await.unwrap();
757    }
758
759    #[tokio::test]
760    async fn cmd_skills_reload_ok() {
761        let s = MockServer::start().await;
762        mock_post(&s, "/api/skills/reload", serde_json::json!({"ok": true})).await;
763        super::cmd_skills_reload(&s.uri()).await.unwrap();
764    }
765
766    #[tokio::test]
767    async fn cmd_skills_catalog_list_ok() {
768        let s = MockServer::start().await;
769        mock_get(
770            &s,
771            "/api/skills/catalog",
772            serde_json::json!({"items":[{"name":"foo","kind":"instruction","source":"registry"}]}),
773        )
774        .await;
775        super::cmd_skills_catalog_list(&s.uri(), None, false)
776            .await
777            .unwrap();
778    }
779
780    #[tokio::test]
781    async fn cmd_skills_catalog_install_ok() {
782        let s = MockServer::start().await;
783        mock_post(
784            &s,
785            "/api/skills/catalog/install",
786            serde_json::json!({"ok":true,"installed":["foo.md"],"activated":true}),
787        )
788        .await;
789        super::cmd_skills_catalog_install(&s.uri(), &["foo".to_string()], true)
790            .await
791            .unwrap();
792    }
793
794    // ── Wallet ────────────────────────────────────────────────
795
796    #[tokio::test]
797    async fn cmd_wallet_full() {
798        let s = MockServer::start().await;
799        mock_get(
800            &s,
801            "/api/wallet/balance",
802            serde_json::json!({
803                "balance": "42.50", "currency": "USDC", "note": "Testnet balance"
804            }),
805        )
806        .await;
807        mock_get(
808            &s,
809            "/api/wallet/address",
810            serde_json::json!({
811                "address": "0xdeadbeef"
812            }),
813        )
814        .await;
815        super::cmd_wallet(&s.uri(), false).await.unwrap();
816    }
817
818    #[tokio::test]
819    async fn cmd_wallet_no_note() {
820        let s = MockServer::start().await;
821        mock_get(
822            &s,
823            "/api/wallet/balance",
824            serde_json::json!({
825                "balance": "0.00", "currency": "USDC"
826            }),
827        )
828        .await;
829        mock_get(
830            &s,
831            "/api/wallet/address",
832            serde_json::json!({
833                "address": "0xabc"
834            }),
835        )
836        .await;
837        super::cmd_wallet(&s.uri(), false).await.unwrap();
838    }
839
840    #[tokio::test]
841    async fn cmd_wallet_address_ok() {
842        let s = MockServer::start().await;
843        mock_get(
844            &s,
845            "/api/wallet/address",
846            serde_json::json!({
847                "address": "0x1234"
848            }),
849        )
850        .await;
851        super::cmd_wallet_address(&s.uri(), false).await.unwrap();
852    }
853
854    #[tokio::test]
855    async fn cmd_wallet_balance_ok() {
856        let s = MockServer::start().await;
857        mock_get(
858            &s,
859            "/api/wallet/balance",
860            serde_json::json!({
861                "balance": "100.00", "currency": "ETH"
862            }),
863        )
864        .await;
865        super::cmd_wallet_balance(&s.uri(), false).await.unwrap();
866    }
867
868    // ── Schedule ──────────────────────────────────────────────
869
870    #[tokio::test]
871    async fn cmd_schedule_list_with_jobs() {
872        let s = MockServer::start().await;
873        mock_get(
874            &s,
875            "/api/cron/jobs",
876            serde_json::json!({
877                "jobs": [
878                    {
879                        "name": "backup", "schedule_kind": "cron", "schedule_expr": "0 * * * *",
880                        "last_run_at": "2025-01-01T12:00:00.000Z", "last_status": "ok",
881                        "consecutive_errors": 0
882                    },
883                    {
884                        "name": "cleanup", "schedule_kind": "interval", "schedule_expr": "30m",
885                        "last_run_at": null, "last_status": "pending",
886                        "consecutive_errors": 3
887                    }
888                ]
889            }),
890        )
891        .await;
892        super::cmd_schedule_list(&s.uri(), false).await.unwrap();
893    }
894
895    #[tokio::test]
896    async fn cmd_schedule_list_empty() {
897        let s = MockServer::start().await;
898        mock_get(&s, "/api/cron/jobs", serde_json::json!({"jobs": []})).await;
899        super::cmd_schedule_list(&s.uri(), false).await.unwrap();
900    }
901
902    #[tokio::test]
903    async fn cmd_schedule_recover_all_enables_paused_jobs() {
904        let s = MockServer::start().await;
905        mock_get(
906            &s,
907            "/api/cron/jobs",
908            serde_json::json!({
909                "jobs": [
910                    {
911                        "id": "job-1",
912                        "name": "calendar-monitor",
913                        "enabled": false,
914                        "last_status": "paused_unknown_action",
915                        "last_run_at": "2026-02-25 12:00:00"
916                    },
917                    {
918                        "id": "job-2",
919                        "name": "healthy-job",
920                        "enabled": true,
921                        "last_status": "success",
922                        "last_run_at": "2026-02-25 12:01:00"
923                    }
924                ]
925            }),
926        )
927        .await;
928        mock_put(
929            &s,
930            "/api/cron/jobs/job-1",
931            serde_json::json!({"updated": true}),
932        )
933        .await;
934        super::cmd_schedule_recover(&s.uri(), &[], true, false, false)
935            .await
936            .unwrap();
937    }
938
939    #[tokio::test]
940    async fn cmd_schedule_recover_dry_run_does_not_put() {
941        let s = MockServer::start().await;
942        mock_get(
943            &s,
944            "/api/cron/jobs",
945            serde_json::json!({
946                "jobs": [
947                    {
948                        "id": "job-1",
949                        "name": "calendar-monitor",
950                        "enabled": false,
951                        "last_status": "paused_unknown_action",
952                        "last_run_at": "2026-02-25 12:00:00"
953                    }
954                ]
955            }),
956        )
957        .await;
958        super::cmd_schedule_recover(&s.uri(), &[], true, true, false)
959            .await
960            .unwrap();
961    }
962
963    #[tokio::test]
964    async fn cmd_schedule_recover_name_filter() {
965        let s = MockServer::start().await;
966        mock_get(
967            &s,
968            "/api/cron/jobs",
969            serde_json::json!({
970                "jobs": [
971                    {
972                        "id": "job-1",
973                        "name": "calendar-monitor",
974                        "enabled": false,
975                        "last_status": "paused_unknown_action",
976                        "last_run_at": "2026-02-25 12:00:00"
977                    },
978                    {
979                        "id": "job-2",
980                        "name": "revenue-check",
981                        "enabled": false,
982                        "last_status": "paused_unknown_action",
983                        "last_run_at": "2026-02-25 12:01:00"
984                    }
985                ]
986            }),
987        )
988        .await;
989        mock_put(
990            &s,
991            "/api/cron/jobs/job-2",
992            serde_json::json!({"updated": true}),
993        )
994        .await;
995        super::cmd_schedule_recover(
996            &s.uri(),
997            &["revenue-check".to_string()],
998            false,
999            false,
1000            false,
1001        )
1002        .await
1003        .unwrap();
1004    }
1005
1006    // ── Memory ────────────────────────────────────────────────
1007
1008    #[tokio::test]
1009    async fn cmd_memory_working_with_entries() {
1010        let s = MockServer::start().await;
1011        mock_get(&s, "/api/memory/working/sess-1", serde_json::json!({
1012            "entries": [
1013                {"id": "e1", "entry_type": "fact", "content": "The sky is blue", "importance": 5}
1014            ]
1015        })).await;
1016        super::cmd_memory(&s.uri(), "working", Some("sess-1"), None, None, false)
1017            .await
1018            .unwrap();
1019    }
1020
1021    #[tokio::test]
1022    async fn cmd_memory_working_empty() {
1023        let s = MockServer::start().await;
1024        mock_get(
1025            &s,
1026            "/api/memory/working/sess-2",
1027            serde_json::json!({"entries": []}),
1028        )
1029        .await;
1030        super::cmd_memory(&s.uri(), "working", Some("sess-2"), None, None, false)
1031            .await
1032            .unwrap();
1033    }
1034
1035    #[tokio::test]
1036    async fn cmd_memory_working_no_session_errors() {
1037        let result = super::cmd_memory("http://unused", "working", None, None, None, false).await;
1038        assert!(result.is_err());
1039    }
1040
1041    #[tokio::test]
1042    async fn cmd_memory_episodic_with_entries() {
1043        let s = MockServer::start().await;
1044        Mock::given(method("GET"))
1045            .and(path("/api/memory/episodic"))
1046            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1047                "entries": [
1048                    {"id": "ep1", "classification": "conversation", "content": "User asked about weather", "importance": 3}
1049                ]
1050            })))
1051            .mount(&s)
1052            .await;
1053        super::cmd_memory(&s.uri(), "episodic", None, None, Some(10), false)
1054            .await
1055            .unwrap();
1056    }
1057
1058    #[tokio::test]
1059    async fn cmd_memory_episodic_empty() {
1060        let s = MockServer::start().await;
1061        Mock::given(method("GET"))
1062            .and(path("/api/memory/episodic"))
1063            .respond_with(
1064                ResponseTemplate::new(200).set_body_json(serde_json::json!({"entries": []})),
1065            )
1066            .mount(&s)
1067            .await;
1068        super::cmd_memory(&s.uri(), "episodic", None, None, None, false)
1069            .await
1070            .unwrap();
1071    }
1072
1073    #[tokio::test]
1074    async fn cmd_memory_semantic_with_entries() {
1075        let s = MockServer::start().await;
1076        mock_get(
1077            &s,
1078            "/api/memory/semantic/general",
1079            serde_json::json!({
1080                "entries": [
1081                    {"key": "favorite_color", "value": "blue", "confidence": 0.95}
1082                ]
1083            }),
1084        )
1085        .await;
1086        super::cmd_memory(&s.uri(), "semantic", None, None, None, false)
1087            .await
1088            .unwrap();
1089    }
1090
1091    #[tokio::test]
1092    async fn cmd_memory_semantic_custom_category() {
1093        let s = MockServer::start().await;
1094        mock_get(
1095            &s,
1096            "/api/memory/semantic/prefs",
1097            serde_json::json!({
1098                "entries": []
1099            }),
1100        )
1101        .await;
1102        super::cmd_memory(&s.uri(), "semantic", Some("prefs"), None, None, false)
1103            .await
1104            .unwrap();
1105    }
1106
1107    #[tokio::test]
1108    async fn cmd_memory_search_with_results() {
1109        let s = MockServer::start().await;
1110        Mock::given(method("GET"))
1111            .and(path("/api/memory/search"))
1112            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1113                "results": ["result one", "result two"]
1114            })))
1115            .mount(&s)
1116            .await;
1117        super::cmd_memory(&s.uri(), "search", None, Some("hello"), None, false)
1118            .await
1119            .unwrap();
1120    }
1121
1122    #[tokio::test]
1123    async fn cmd_memory_search_empty() {
1124        let s = MockServer::start().await;
1125        Mock::given(method("GET"))
1126            .and(path("/api/memory/search"))
1127            .respond_with(
1128                ResponseTemplate::new(200).set_body_json(serde_json::json!({"results": []})),
1129            )
1130            .mount(&s)
1131            .await;
1132        super::cmd_memory(&s.uri(), "search", None, Some("nope"), None, false)
1133            .await
1134            .unwrap();
1135    }
1136
1137    #[tokio::test]
1138    async fn cmd_memory_search_no_query_errors() {
1139        let result = super::cmd_memory("http://unused", "search", None, None, None, false).await;
1140        assert!(result.is_err());
1141    }
1142
1143    #[tokio::test]
1144    async fn cmd_memory_unknown_tier_errors() {
1145        let result = super::cmd_memory("http://unused", "bogus", None, None, None, false).await;
1146        assert!(result.is_err());
1147    }
1148
1149    // ── Sessions ──────────────────────────────────────────────
1150
1151    #[tokio::test]
1152    async fn cmd_sessions_list_with_sessions() {
1153        let s = MockServer::start().await;
1154        mock_get(&s, "/api/sessions", serde_json::json!({
1155            "sessions": [
1156                {"id": "s-001", "agent_id": "roboticus", "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T01:00:00Z"},
1157                {"id": "s-002", "agent_id": "duncan", "created_at": "2025-01-02T00:00:00Z", "updated_at": "2025-01-02T01:00:00Z"}
1158            ]
1159        })).await;
1160        super::cmd_sessions_list(&s.uri(), false).await.unwrap();
1161    }
1162
1163    #[tokio::test]
1164    async fn cmd_sessions_list_empty() {
1165        let s = MockServer::start().await;
1166        mock_get(&s, "/api/sessions", serde_json::json!({"sessions": []})).await;
1167        super::cmd_sessions_list(&s.uri(), false).await.unwrap();
1168    }
1169
1170    #[tokio::test]
1171    async fn cmd_session_detail_with_messages() {
1172        let s = MockServer::start().await;
1173        mock_get(
1174            &s,
1175            "/api/sessions/s-001",
1176            serde_json::json!({
1177                "id": "s-001", "agent_id": "roboticus",
1178                "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T01:00:00Z"
1179            }),
1180        )
1181        .await;
1182        mock_get(&s, "/api/sessions/s-001/messages", serde_json::json!({
1183            "messages": [
1184                {"role": "user", "content": "Hello!", "created_at": "2025-01-01T00:00:05.123Z"},
1185                {"role": "assistant", "content": "Hi there!", "created_at": "2025-01-01T00:00:06.456Z"},
1186                {"role": "system", "content": "Init", "created_at": "2025-01-01T00:00:00Z"},
1187                {"role": "tool", "content": "Result", "created_at": "2025-01-01T00:00:07Z"}
1188            ]
1189        })).await;
1190        super::cmd_session_detail(&s.uri(), "s-001", false)
1191            .await
1192            .unwrap();
1193    }
1194
1195    #[tokio::test]
1196    async fn cmd_session_detail_no_messages() {
1197        let s = MockServer::start().await;
1198        mock_get(
1199            &s,
1200            "/api/sessions/s-002",
1201            serde_json::json!({
1202                "id": "s-002", "agent_id": "roboticus",
1203                "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T01:00:00Z"
1204            }),
1205        )
1206        .await;
1207        mock_get(
1208            &s,
1209            "/api/sessions/s-002/messages",
1210            serde_json::json!({"messages": []}),
1211        )
1212        .await;
1213        super::cmd_session_detail(&s.uri(), "s-002", false)
1214            .await
1215            .unwrap();
1216    }
1217
1218    #[tokio::test]
1219    async fn cmd_session_create_ok() {
1220        let s = MockServer::start().await;
1221        mock_post(
1222            &s,
1223            "/api/sessions",
1224            serde_json::json!({"session_id": "new-001"}),
1225        )
1226        .await;
1227        super::cmd_session_create(&s.uri(), "roboticus")
1228            .await
1229            .unwrap();
1230    }
1231
1232    #[tokio::test]
1233    async fn cmd_session_export_json() {
1234        let s = MockServer::start().await;
1235        mock_get(
1236            &s,
1237            "/api/sessions/s-001",
1238            serde_json::json!({
1239                "id": "s-001", "agent_id": "roboticus", "created_at": "2025-01-01T00:00:00Z"
1240            }),
1241        )
1242        .await;
1243        mock_get(&s, "/api/sessions/s-001/messages", serde_json::json!({
1244            "messages": [{"role": "user", "content": "Hi", "created_at": "2025-01-01T00:00:01Z"}]
1245        })).await;
1246        let dir = tempfile::tempdir().unwrap();
1247        let out = dir.path().join("export.json");
1248        super::cmd_session_export(&s.uri(), "s-001", "json", Some(out.to_str().unwrap()))
1249            .await
1250            .unwrap();
1251        assert!(out.exists());
1252        let content = std::fs::read_to_string(&out).unwrap();
1253        assert!(content.contains("s-001"));
1254    }
1255
1256    #[tokio::test]
1257    async fn cmd_session_export_markdown() {
1258        let s = MockServer::start().await;
1259        mock_get(
1260            &s,
1261            "/api/sessions/s-001",
1262            serde_json::json!({
1263                "id": "s-001", "agent_id": "roboticus", "created_at": "2025-01-01T00:00:00Z"
1264            }),
1265        )
1266        .await;
1267        mock_get(&s, "/api/sessions/s-001/messages", serde_json::json!({
1268            "messages": [{"role": "user", "content": "Hi", "created_at": "2025-01-01T00:00:01Z"}]
1269        })).await;
1270        let dir = tempfile::tempdir().unwrap();
1271        let out = dir.path().join("export.md");
1272        super::cmd_session_export(&s.uri(), "s-001", "markdown", Some(out.to_str().unwrap()))
1273            .await
1274            .unwrap();
1275        assert!(out.exists());
1276        let content = std::fs::read_to_string(&out).unwrap();
1277        assert!(content.contains("# Session"));
1278    }
1279
1280    #[tokio::test]
1281    async fn cmd_session_export_html() {
1282        let s = MockServer::start().await;
1283        mock_get(
1284            &s,
1285            "/api/sessions/s-001",
1286            serde_json::json!({
1287                "id": "s-001", "agent_id": "roboticus", "created_at": "2025-01-01T00:00:00Z"
1288            }),
1289        )
1290        .await;
1291        mock_get(&s, "/api/sessions/s-001/messages", serde_json::json!({
1292            "messages": [
1293                {"role": "user", "content": "Hello <world> & \"friends\"", "created_at": "2025-01-01T00:00:01Z"},
1294                {"role": "assistant", "content": "Hi", "created_at": "2025-01-01T00:00:02Z"},
1295                {"role": "system", "content": "Sys", "created_at": "2025-01-01T00:00:00Z"},
1296                {"role": "tool", "content": "Tool output", "created_at": "2025-01-01T00:00:03Z"}
1297            ]
1298        })).await;
1299        let dir = tempfile::tempdir().unwrap();
1300        let out = dir.path().join("export.html");
1301        super::cmd_session_export(&s.uri(), "s-001", "html", Some(out.to_str().unwrap()))
1302            .await
1303            .unwrap();
1304        let content = std::fs::read_to_string(&out).unwrap();
1305        assert!(content.contains("<!DOCTYPE html>"));
1306        assert!(content.contains("&amp;"));
1307        assert!(content.contains("&lt;"));
1308    }
1309
1310    #[tokio::test]
1311    async fn cmd_session_export_to_stdout() {
1312        let s = MockServer::start().await;
1313        mock_get(
1314            &s,
1315            "/api/sessions/s-001",
1316            serde_json::json!({
1317                "id": "s-001", "agent_id": "roboticus", "created_at": "2025-01-01T00:00:00Z"
1318            }),
1319        )
1320        .await;
1321        mock_get(
1322            &s,
1323            "/api/sessions/s-001/messages",
1324            serde_json::json!({"messages": []}),
1325        )
1326        .await;
1327        super::cmd_session_export(&s.uri(), "s-001", "json", None)
1328            .await
1329            .unwrap();
1330    }
1331
1332    #[tokio::test]
1333    async fn cmd_session_export_unknown_format() {
1334        let s = MockServer::start().await;
1335        mock_get(
1336            &s,
1337            "/api/sessions/s-001",
1338            serde_json::json!({
1339                "id": "s-001", "agent_id": "roboticus", "created_at": "2025-01-01T00:00:00Z"
1340            }),
1341        )
1342        .await;
1343        mock_get(
1344            &s,
1345            "/api/sessions/s-001/messages",
1346            serde_json::json!({"messages": []}),
1347        )
1348        .await;
1349        super::cmd_session_export(&s.uri(), "s-001", "csv", None)
1350            .await
1351            .unwrap();
1352    }
1353
1354    #[tokio::test]
1355    async fn cmd_session_export_not_found() {
1356        let s = MockServer::start().await;
1357        Mock::given(method("GET"))
1358            .and(path("/api/sessions/missing"))
1359            .respond_with(ResponseTemplate::new(404))
1360            .mount(&s)
1361            .await;
1362        super::cmd_session_export(&s.uri(), "missing", "json", None)
1363            .await
1364            .unwrap();
1365    }
1366
1367    // ── Circuit breaker ───────────────────────────────────────
1368
1369    #[tokio::test]
1370    async fn cmd_circuit_status_with_providers() {
1371        let s = MockServer::start().await;
1372        mock_get(
1373            &s,
1374            "/api/breaker/status",
1375            serde_json::json!({
1376                "providers": {
1377                    "ollama": {"state": "closed"},
1378                    "openai": {"state": "open"}
1379                },
1380                "note": "All good"
1381            }),
1382        )
1383        .await;
1384        super::cmd_circuit_status(&s.uri(), false).await.unwrap();
1385    }
1386
1387    #[tokio::test]
1388    async fn cmd_circuit_status_empty_providers() {
1389        let s = MockServer::start().await;
1390        mock_get(
1391            &s,
1392            "/api/breaker/status",
1393            serde_json::json!({"providers": {}}),
1394        )
1395        .await;
1396        super::cmd_circuit_status(&s.uri(), false).await.unwrap();
1397    }
1398
1399    #[tokio::test]
1400    async fn cmd_circuit_status_no_providers_key() {
1401        let s = MockServer::start().await;
1402        mock_get(&s, "/api/breaker/status", serde_json::json!({})).await;
1403        super::cmd_circuit_status(&s.uri(), false).await.unwrap();
1404    }
1405
1406    #[tokio::test]
1407    async fn cmd_circuit_reset_success() {
1408        let s = MockServer::start().await;
1409        mock_get(
1410            &s,
1411            "/api/breaker/status",
1412            serde_json::json!({
1413                "providers": {
1414                    "ollama": {"state": "open"},
1415                    "moonshot": {"state": "open"}
1416                }
1417            }),
1418        )
1419        .await;
1420        mock_post(
1421            &s,
1422            "/api/breaker/reset/ollama",
1423            serde_json::json!({"ok": true}),
1424        )
1425        .await;
1426        mock_post(
1427            &s,
1428            "/api/breaker/reset/moonshot",
1429            serde_json::json!({"ok": true}),
1430        )
1431        .await;
1432        super::cmd_circuit_reset(&s.uri(), None).await.unwrap();
1433    }
1434
1435    #[tokio::test]
1436    async fn cmd_circuit_reset_server_error() {
1437        let s = MockServer::start().await;
1438        Mock::given(method("GET"))
1439            .and(path("/api/breaker/status"))
1440            .respond_with(ResponseTemplate::new(500))
1441            .mount(&s)
1442            .await;
1443        super::cmd_circuit_reset(&s.uri(), None).await.unwrap();
1444    }
1445
1446    #[tokio::test]
1447    async fn cmd_circuit_reset_single_provider() {
1448        let s = MockServer::start().await;
1449        mock_post(
1450            &s,
1451            "/api/breaker/reset/openai",
1452            serde_json::json!({"ok": true}),
1453        )
1454        .await;
1455        super::cmd_circuit_reset(&s.uri(), Some("openai"))
1456            .await
1457            .unwrap();
1458    }
1459
1460    // ── Agents ────────────────────────────────────────────────
1461
1462    #[tokio::test]
1463    async fn cmd_agents_list_with_agents() {
1464        let s = MockServer::start().await;
1465        mock_get(
1466            &s,
1467            "/api/agents",
1468            serde_json::json!({
1469                "agents": [
1470                    {"id": "roboticus", "name": "Roboticus", "state": "running", "model": "qwen3:8b"},
1471                    {"id": "duncan", "name": "Duncan", "state": "sleeping", "model": "gpt-4o"}
1472                ]
1473            }),
1474        )
1475        .await;
1476        super::cmd_agents_list(&s.uri(), false).await.unwrap();
1477    }
1478
1479    #[tokio::test]
1480    async fn cmd_agents_list_empty() {
1481        let s = MockServer::start().await;
1482        mock_get(&s, "/api/agents", serde_json::json!({"agents": []})).await;
1483        super::cmd_agents_list(&s.uri(), false).await.unwrap();
1484    }
1485
1486    // ── Channels ──────────────────────────────────────────────
1487
1488    #[tokio::test]
1489    async fn cmd_channels_status_with_channels() {
1490        let s = MockServer::start().await;
1491        Mock::given(method("GET"))
1492            .and(path("/api/channels/status"))
1493            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
1494                {"name": "telegram", "connected": true, "messages_received": 100, "messages_sent": 50},
1495                {"name": "whatsapp", "connected": false, "messages_received": 0, "messages_sent": 0}
1496            ])))
1497            .mount(&s)
1498            .await;
1499        super::cmd_channels_status(&s.uri(), false).await.unwrap();
1500    }
1501
1502    #[tokio::test]
1503    async fn cmd_channels_status_empty() {
1504        let s = MockServer::start().await;
1505        Mock::given(method("GET"))
1506            .and(path("/api/channels/status"))
1507            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1508            .mount(&s)
1509            .await;
1510        super::cmd_channels_status(&s.uri(), false).await.unwrap();
1511    }
1512
1513    #[tokio::test]
1514    async fn cmd_channels_dead_letter_with_items() {
1515        let s = MockServer::start().await;
1516        Mock::given(method("GET"))
1517            .and(path("/api/channels/dead-letter"))
1518            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1519                "items": [
1520                    {"id": "dl-1", "channel": "telegram", "attempts": 5, "max_attempts": 5, "last_error": "blocked"}
1521                ]
1522            })))
1523            .mount(&s)
1524            .await;
1525        super::cmd_channels_dead_letter(&s.uri(), 10, false)
1526            .await
1527            .unwrap();
1528    }
1529
1530    #[tokio::test]
1531    async fn cmd_channels_replay_ok() {
1532        let s = MockServer::start().await;
1533        Mock::given(method("POST"))
1534            .and(path("/api/channels/dead-letter/dl-1/replay"))
1535            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})))
1536            .mount(&s)
1537            .await;
1538        super::cmd_channels_replay(&s.uri(), "dl-1").await.unwrap();
1539    }
1540
1541    // ── Plugins ───────────────────────────────────────────────
1542
1543    #[tokio::test]
1544    async fn cmd_plugins_list_with_plugins() {
1545        let s = MockServer::start().await;
1546        mock_get(&s, "/api/plugins", serde_json::json!({
1547            "plugins": [
1548                {"name": "weather", "version": "1.0", "status": "active", "tools": [{"name": "get_weather"}]},
1549                {"name": "empty", "version": "0.1", "status": "inactive", "tools": []}
1550            ]
1551        })).await;
1552        super::cmd_plugins_list(&s.uri(), false).await.unwrap();
1553    }
1554
1555    #[tokio::test]
1556    async fn cmd_plugins_list_empty() {
1557        let s = MockServer::start().await;
1558        mock_get(&s, "/api/plugins", serde_json::json!({"plugins": []})).await;
1559        super::cmd_plugins_list(&s.uri(), false).await.unwrap();
1560    }
1561
1562    #[tokio::test]
1563    async fn cmd_plugin_info_found() {
1564        let s = MockServer::start().await;
1565        mock_get(
1566            &s,
1567            "/api/plugins",
1568            serde_json::json!({
1569                "plugins": [
1570                    {
1571                        "name": "weather", "version": "1.0", "description": "Weather plugin",
1572                        "enabled": true, "manifest_path": "/plugins/weather/plugin.toml",
1573                        "tools": [{"name": "get_weather"}, {"name": "get_forecast"}]
1574                    }
1575                ]
1576            }),
1577        )
1578        .await;
1579        super::cmd_plugin_info(&s.uri(), "weather", false)
1580            .await
1581            .unwrap();
1582    }
1583
1584    #[tokio::test]
1585    async fn cmd_plugin_info_disabled() {
1586        let s = MockServer::start().await;
1587        mock_get(
1588            &s,
1589            "/api/plugins",
1590            serde_json::json!({
1591                "plugins": [{"name": "old", "version": "0.1", "enabled": false}]
1592            }),
1593        )
1594        .await;
1595        super::cmd_plugin_info(&s.uri(), "old", false)
1596            .await
1597            .unwrap();
1598    }
1599
1600    #[tokio::test]
1601    async fn cmd_plugin_info_not_found() {
1602        let s = MockServer::start().await;
1603        mock_get(&s, "/api/plugins", serde_json::json!({"plugins": []})).await;
1604        let result = super::cmd_plugin_info(&s.uri(), "nonexistent", false).await;
1605        assert!(result.is_err());
1606    }
1607
1608    #[tokio::test]
1609    async fn cmd_plugin_toggle_enable() {
1610        let s = MockServer::start().await;
1611        Mock::given(method("PUT"))
1612            .and(path("/api/plugins/weather/toggle"))
1613            .respond_with(ResponseTemplate::new(200))
1614            .mount(&s)
1615            .await;
1616        super::cmd_plugin_toggle(&s.uri(), "weather", true)
1617            .await
1618            .unwrap();
1619    }
1620
1621    #[tokio::test]
1622    async fn cmd_plugin_toggle_disable_fails() {
1623        let s = MockServer::start().await;
1624        Mock::given(method("PUT"))
1625            .and(path("/api/plugins/weather/toggle"))
1626            .respond_with(ResponseTemplate::new(404))
1627            .mount(&s)
1628            .await;
1629        let result = super::cmd_plugin_toggle(&s.uri(), "weather", false).await;
1630        assert!(result.is_err());
1631    }
1632
1633    #[tokio::test]
1634    async fn cmd_plugin_install_missing_source() {
1635        let result = super::cmd_plugin_install("/tmp/roboticus_test_nonexistent_plugin_dir").await;
1636        assert!(result.is_err());
1637    }
1638
1639    #[tokio::test]
1640    async fn cmd_plugin_install_no_manifest() {
1641        let dir = tempfile::tempdir().unwrap();
1642        let result = super::cmd_plugin_install(dir.path().to_str().unwrap()).await;
1643        assert!(result.is_err());
1644    }
1645
1646    #[serial_test::serial]
1647    #[tokio::test]
1648    async fn cmd_plugin_install_valid() {
1649        // Use separate dirs for the plugin source and HOME to avoid
1650        // copy_dir_recursive copying the source into a subdirectory of itself
1651        // (which causes infinite recursion / stack overflow).
1652        let src_dir = tempfile::tempdir().unwrap();
1653        let home_dir = tempfile::tempdir().unwrap();
1654        let manifest = src_dir.path().join("plugin.toml");
1655        std::fs::write(&manifest, "name = \"test-plugin\"\nversion = \"0.1\"").unwrap();
1656        std::fs::write(src_dir.path().join("main.gosh"), "print(\"hi\")").unwrap();
1657
1658        let sub = src_dir.path().join("sub");
1659        std::fs::create_dir(&sub).unwrap();
1660        std::fs::write(sub.join("helper.gosh"), "// helper").unwrap();
1661
1662        let _home_guard =
1663            crate::test_support::EnvGuard::set("HOME", home_dir.path().to_str().unwrap());
1664        let _ = super::cmd_plugin_install(src_dir.path().to_str().unwrap()).await;
1665    }
1666
1667    #[serial_test::serial]
1668    #[test]
1669    fn cmd_plugin_uninstall_not_found() {
1670        let _home_guard =
1671            crate::test_support::EnvGuard::set("HOME", "/tmp/roboticus_test_uninstall_home");
1672        let result = super::cmd_plugin_uninstall("nonexistent");
1673        assert!(
1674            result.is_err(),
1675            "uninstall of nonexistent plugin should fail"
1676        );
1677    }
1678
1679    #[serial_test::serial]
1680    #[test]
1681    fn cmd_plugin_uninstall_exists() {
1682        let dir = tempfile::tempdir().unwrap();
1683        let plugins_dir = dir
1684            .path()
1685            .join(".roboticus")
1686            .join("plugins")
1687            .join("myplugin");
1688        std::fs::create_dir_all(&plugins_dir).unwrap();
1689        std::fs::write(plugins_dir.join("plugin.toml"), "name = \"myplugin\"").unwrap();
1690        let _home_guard = crate::test_support::EnvGuard::set("HOME", dir.path().to_str().unwrap());
1691        super::cmd_plugin_uninstall("myplugin").unwrap();
1692        assert!(!plugins_dir.exists());
1693    }
1694
1695    // ── Models ────────────────────────────────────────────────
1696
1697    #[tokio::test]
1698    async fn cmd_models_list_full_config() {
1699        let s = MockServer::start().await;
1700        mock_get(&s, "/api/config", serde_json::json!({
1701            "models": {
1702                "primary": "qwen3:8b",
1703                "fallbacks": ["gpt-4o", "claude-3"],
1704                "routing": { "mode": "adaptive", "confidence_threshold": 0.85, "local_first": false }
1705            }
1706        })).await;
1707        super::cmd_models_list(&s.uri(), false).await.unwrap();
1708    }
1709
1710    #[tokio::test]
1711    async fn cmd_models_list_minimal_config() {
1712        let s = MockServer::start().await;
1713        mock_get(&s, "/api/config", serde_json::json!({})).await;
1714        super::cmd_models_list(&s.uri(), false).await.unwrap();
1715    }
1716
1717    #[tokio::test]
1718    async fn cmd_models_scan_no_providers() {
1719        let s = MockServer::start().await;
1720        mock_get(&s, "/api/config", serde_json::json!({"providers": {}})).await;
1721        super::cmd_models_scan(&s.uri(), None).await.unwrap();
1722    }
1723
1724    #[tokio::test]
1725    async fn cmd_models_scan_with_local_provider() {
1726        let s = MockServer::start().await;
1727        mock_get(
1728            &s,
1729            "/api/config",
1730            serde_json::json!({
1731                "providers": {
1732                    "ollama": {"url": &format!("{}/ollama", s.uri())}
1733                }
1734            }),
1735        )
1736        .await;
1737        Mock::given(method("GET"))
1738            .and(path("/ollama/v1/models"))
1739            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1740                "data": [{"id": "qwen3:8b"}, {"id": "llama3:70b"}]
1741            })))
1742            .mount(&s)
1743            .await;
1744        super::cmd_models_scan(&s.uri(), None).await.unwrap();
1745    }
1746
1747    #[tokio::test]
1748    async fn cmd_models_scan_local_ollama() {
1749        let s = MockServer::start().await;
1750        let _ollama_url = s.uri().to_string().replace("http://", "http://localhost:");
1751        mock_get(
1752            &s,
1753            "/api/config",
1754            serde_json::json!({
1755                "providers": {
1756                    "ollama": {"url": &s.uri()}
1757                }
1758            }),
1759        )
1760        .await;
1761        Mock::given(method("GET"))
1762            .and(path("/api/tags"))
1763            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1764                "models": [{"name": "qwen3:8b"}, {"model": "llama3"}]
1765            })))
1766            .mount(&s)
1767            .await;
1768        super::cmd_models_scan(&s.uri(), Some("ollama"))
1769            .await
1770            .unwrap();
1771    }
1772
1773    #[tokio::test]
1774    async fn cmd_models_scan_provider_filter_skips_others() {
1775        let s = MockServer::start().await;
1776        mock_get(
1777            &s,
1778            "/api/config",
1779            serde_json::json!({
1780                "providers": {
1781                    "ollama": {"url": "http://localhost:11434"},
1782                    "openai": {"url": "https://api.openai.com"}
1783                }
1784            }),
1785        )
1786        .await;
1787        super::cmd_models_scan(&s.uri(), Some("openai"))
1788            .await
1789            .unwrap();
1790    }
1791
1792    #[tokio::test]
1793    async fn cmd_models_scan_empty_url() {
1794        let s = MockServer::start().await;
1795        mock_get(
1796            &s,
1797            "/api/config",
1798            serde_json::json!({
1799                "providers": { "test": {"url": ""} }
1800            }),
1801        )
1802        .await;
1803        super::cmd_models_scan(&s.uri(), None).await.unwrap();
1804    }
1805
1806    #[tokio::test]
1807    async fn cmd_models_scan_error_response() {
1808        let s = MockServer::start().await;
1809        mock_get(
1810            &s,
1811            "/api/config",
1812            serde_json::json!({
1813                "providers": {
1814                    "bad": {"url": &s.uri()}
1815                }
1816            }),
1817        )
1818        .await;
1819        Mock::given(method("GET"))
1820            .and(path("/v1/models"))
1821            .respond_with(ResponseTemplate::new(500))
1822            .mount(&s)
1823            .await;
1824        super::cmd_models_scan(&s.uri(), None).await.unwrap();
1825    }
1826
1827    #[tokio::test]
1828    async fn cmd_models_scan_no_models_found() {
1829        let s = MockServer::start().await;
1830        mock_get(
1831            &s,
1832            "/api/config",
1833            serde_json::json!({
1834                "providers": {
1835                    "empty": {"url": &s.uri()}
1836                }
1837            }),
1838        )
1839        .await;
1840        Mock::given(method("GET"))
1841            .and(path("/v1/models"))
1842            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1843            .mount(&s)
1844            .await;
1845        super::cmd_models_scan(&s.uri(), None).await.unwrap();
1846    }
1847
1848    // ── Metrics ───────────────────────────────────────────────
1849
1850    #[tokio::test]
1851    async fn cmd_metrics_costs_with_data() {
1852        let s = MockServer::start().await;
1853        mock_get(&s, "/api/stats/costs", serde_json::json!({
1854            "costs": [
1855                {"model": "qwen3:8b", "provider": "ollama", "tokens_in": 100, "tokens_out": 50, "cost": 0.001, "cached": false},
1856                {"model": "gpt-4o", "provider": "openai", "tokens_in": 200, "tokens_out": 100, "cost": 0.01, "cached": true}
1857            ]
1858        })).await;
1859        super::cmd_metrics(&s.uri(), "costs", None, false)
1860            .await
1861            .unwrap();
1862    }
1863
1864    #[tokio::test]
1865    async fn cmd_metrics_costs_empty() {
1866        let s = MockServer::start().await;
1867        mock_get(&s, "/api/stats/costs", serde_json::json!({"costs": []})).await;
1868        super::cmd_metrics(&s.uri(), "costs", None, false)
1869            .await
1870            .unwrap();
1871    }
1872
1873    #[tokio::test]
1874    async fn cmd_metrics_transactions_with_data() {
1875        let s = MockServer::start().await;
1876        Mock::given(method("GET"))
1877            .and(path("/api/stats/transactions"))
1878            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1879                "transactions": [
1880                    {"id": "tx-001", "tx_type": "inference", "amount": 0.01, "currency": "USD",
1881                     "counterparty": "openai", "created_at": "2025-01-01T12:00:00.000Z"},
1882                    {"id": "tx-002", "tx_type": "transfer", "amount": 5.00, "currency": "USDC",
1883                     "counterparty": "user", "created_at": "2025-01-01T13:00:00Z"}
1884                ]
1885            })))
1886            .mount(&s)
1887            .await;
1888        super::cmd_metrics(&s.uri(), "transactions", Some(48), false)
1889            .await
1890            .unwrap();
1891    }
1892
1893    #[tokio::test]
1894    async fn cmd_metrics_transactions_empty() {
1895        let s = MockServer::start().await;
1896        Mock::given(method("GET"))
1897            .and(path("/api/stats/transactions"))
1898            .respond_with(
1899                ResponseTemplate::new(200).set_body_json(serde_json::json!({"transactions": []})),
1900            )
1901            .mount(&s)
1902            .await;
1903        super::cmd_metrics(&s.uri(), "transactions", None, false)
1904            .await
1905            .unwrap();
1906    }
1907
1908    #[tokio::test]
1909    async fn cmd_metrics_cache_stats() {
1910        let s = MockServer::start().await;
1911        mock_get(
1912            &s,
1913            "/api/stats/cache",
1914            serde_json::json!({
1915                "hits": 42, "misses": 8, "entries": 100, "hit_rate": 84.0
1916            }),
1917        )
1918        .await;
1919        super::cmd_metrics(&s.uri(), "cache", None, false)
1920            .await
1921            .unwrap();
1922    }
1923
1924    #[tokio::test]
1925    async fn cmd_metrics_unknown_kind() {
1926        let s = MockServer::start().await;
1927        let result = super::cmd_metrics(&s.uri(), "bogus", None, false).await;
1928        assert!(result.is_err());
1929    }
1930
1931    // ── Completion ────────────────────────────────────────────
1932
1933    #[test]
1934    fn cmd_completion_bash() {
1935        super::cmd_completion("bash").unwrap();
1936    }
1937
1938    #[test]
1939    fn cmd_completion_zsh() {
1940        super::cmd_completion("zsh").unwrap();
1941    }
1942
1943    #[test]
1944    fn cmd_completion_fish() {
1945        super::cmd_completion("fish").unwrap();
1946    }
1947
1948    #[test]
1949    fn cmd_completion_unknown() {
1950        super::cmd_completion("powershell").unwrap();
1951    }
1952
1953    // ── Logs ──────────────────────────────────────────────────
1954
1955    #[tokio::test]
1956    async fn cmd_logs_static_with_entries() {
1957        let s = MockServer::start().await;
1958        Mock::given(method("GET"))
1959            .and(path("/api/logs"))
1960            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1961                "entries": [
1962                    {"timestamp": "2025-01-01T00:00:00Z", "level": "INFO", "message": "Started", "target": "roboticus"},
1963                    {"timestamp": "2025-01-01T00:00:01Z", "level": "WARN", "message": "Low memory", "target": "system"},
1964                    {"timestamp": "2025-01-01T00:00:02Z", "level": "ERROR", "message": "Failed", "target": "api"},
1965                    {"timestamp": "2025-01-01T00:00:03Z", "level": "DEBUG", "message": "Trace", "target": "db"},
1966                    {"timestamp": "2025-01-01T00:00:04Z", "level": "TRACE", "message": "Deep", "target": "core"}
1967                ]
1968            })))
1969            .mount(&s)
1970            .await;
1971        super::cmd_logs(&s.uri(), 50, false, "info", false)
1972            .await
1973            .unwrap();
1974    }
1975
1976    #[tokio::test]
1977    async fn cmd_logs_static_empty() {
1978        let s = MockServer::start().await;
1979        Mock::given(method("GET"))
1980            .and(path("/api/logs"))
1981            .respond_with(
1982                ResponseTemplate::new(200).set_body_json(serde_json::json!({"entries": []})),
1983            )
1984            .mount(&s)
1985            .await;
1986        super::cmd_logs(&s.uri(), 10, false, "info", false)
1987            .await
1988            .unwrap();
1989    }
1990
1991    #[tokio::test]
1992    async fn cmd_logs_static_no_entries_key() {
1993        let s = MockServer::start().await;
1994        Mock::given(method("GET"))
1995            .and(path("/api/logs"))
1996            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1997            .mount(&s)
1998            .await;
1999        super::cmd_logs(&s.uri(), 10, false, "info", false)
2000            .await
2001            .unwrap();
2002    }
2003
2004    #[tokio::test]
2005    async fn cmd_logs_server_error_falls_back() {
2006        let s = MockServer::start().await;
2007        Mock::given(method("GET"))
2008            .and(path("/api/logs"))
2009            .respond_with(ResponseTemplate::new(500))
2010            .mount(&s)
2011            .await;
2012        super::cmd_logs(&s.uri(), 10, false, "info", false)
2013            .await
2014            .unwrap();
2015    }
2016
2017    // ── Security audit (filesystem) ──────────────────────────
2018
2019    #[test]
2020    fn cmd_security_audit_missing_config() {
2021        super::cmd_security_audit("/tmp/roboticus_test_nonexistent_config.toml", false).unwrap();
2022    }
2023
2024    #[test]
2025    fn cmd_security_audit_clean_config() {
2026        let dir = tempfile::tempdir().unwrap();
2027        let config = dir.path().join("roboticus.toml");
2028        std::fs::write(&config, "[server]\nbind = \"localhost\"\nport = 18789\n").unwrap();
2029        #[cfg(unix)]
2030        {
2031            use std::os::unix::fs::PermissionsExt;
2032            std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o600)).unwrap();
2033        }
2034        super::cmd_security_audit(config.to_str().unwrap(), false).unwrap();
2035    }
2036
2037    #[test]
2038    fn cmd_security_audit_plaintext_keys() {
2039        let dir = tempfile::tempdir().unwrap();
2040        let config = dir.path().join("roboticus.toml");
2041        std::fs::write(&config, "[providers.openai]\napi_key = \"sk-secret123\"\n").unwrap();
2042        #[cfg(unix)]
2043        {
2044            use std::os::unix::fs::PermissionsExt;
2045            std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o600)).unwrap();
2046        }
2047        super::cmd_security_audit(config.to_str().unwrap(), false).unwrap();
2048    }
2049
2050    #[test]
2051    fn cmd_security_audit_env_var_keys() {
2052        let dir = tempfile::tempdir().unwrap();
2053        let config = dir.path().join("roboticus.toml");
2054        std::fs::write(&config, "[providers.openai]\napi_key = \"${OPENAI_KEY}\"\n").unwrap();
2055        super::cmd_security_audit(config.to_str().unwrap(), false).unwrap();
2056    }
2057
2058    #[test]
2059    fn cmd_security_audit_wildcard_cors() {
2060        let dir = tempfile::tempdir().unwrap();
2061        let config = dir.path().join("roboticus.toml");
2062        std::fs::write(
2063            &config,
2064            "[server]\nbind = \"0.0.0.0\"\n\n[cors]\norigins = \"*\"\n",
2065        )
2066        .unwrap();
2067        super::cmd_security_audit(config.to_str().unwrap(), false).unwrap();
2068    }
2069
2070    #[cfg(unix)]
2071    #[test]
2072    fn cmd_security_audit_loose_config_permissions() {
2073        let dir = tempfile::tempdir().unwrap();
2074        let config = dir.path().join("roboticus.toml");
2075        std::fs::write(&config, "[server]\nport = 18789\n").unwrap();
2076        use std::os::unix::fs::PermissionsExt;
2077        std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o644)).unwrap();
2078        super::cmd_security_audit(config.to_str().unwrap(), false).unwrap();
2079    }
2080
2081    // ── Reset (with --yes to skip stdin) ─────────────────────
2082
2083    #[serial_test::serial]
2084    #[test]
2085    fn cmd_reset_yes_no_db() {
2086        let dir = tempfile::tempdir().unwrap();
2087        let roboticus_dir = dir.path().join(".roboticus");
2088        std::fs::create_dir_all(&roboticus_dir).unwrap();
2089        let _home_guard = crate::test_support::EnvGuard::set("HOME", dir.path().to_str().unwrap());
2090        super::cmd_reset(true).unwrap();
2091    }
2092
2093    #[serial_test::serial]
2094    #[test]
2095    fn cmd_reset_yes_with_db_and_config() {
2096        let dir = tempfile::tempdir().unwrap();
2097        let roboticus_dir = dir.path().join(".roboticus");
2098        std::fs::create_dir_all(&roboticus_dir).unwrap();
2099        std::fs::write(roboticus_dir.join("state.db"), "fake db").unwrap();
2100        std::fs::write(roboticus_dir.join("state.db-wal"), "wal").unwrap();
2101        std::fs::write(roboticus_dir.join("state.db-shm"), "shm").unwrap();
2102        std::fs::write(roboticus_dir.join("roboticus.toml"), "[server]").unwrap();
2103        std::fs::create_dir_all(roboticus_dir.join("logs")).unwrap();
2104        std::fs::write(roboticus_dir.join("wallet.json"), "{}").unwrap();
2105        let _home_guard = crate::test_support::EnvGuard::set("HOME", dir.path().to_str().unwrap());
2106        super::cmd_reset(true).unwrap();
2107        assert!(!roboticus_dir.join("state.db").exists());
2108        assert!(!roboticus_dir.join("roboticus.toml").exists());
2109        assert!(!roboticus_dir.join("logs").exists());
2110        assert!(roboticus_dir.join("wallet.json").exists());
2111    }
2112
2113    // ── Mechanic ──────────────────────────────────────────────
2114
2115    #[serial_test::serial]
2116    #[tokio::test]
2117    async fn cmd_mechanic_gateway_up() {
2118        let s = MockServer::start().await;
2119        mock_get(&s, "/api/health", serde_json::json!({"status": "ok"})).await;
2120        mock_get(&s, "/api/config", serde_json::json!({"models": {}})).await;
2121        mock_get(
2122            &s,
2123            "/api/skills",
2124            serde_json::json!({"skills": [{"id": "s1"}]}),
2125        )
2126        .await;
2127        mock_get(
2128            &s,
2129            "/api/wallet/balance",
2130            serde_json::json!({"balance": "1.00"}),
2131        )
2132        .await;
2133        Mock::given(method("GET"))
2134            .and(path("/api/channels/status"))
2135            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
2136                {"connected": true}, {"connected": false}
2137            ])))
2138            .mount(&s)
2139            .await;
2140        let dir = tempfile::tempdir().unwrap();
2141        let roboticus_dir = dir.path().join(".roboticus");
2142        for sub in &["workspace", "skills", "plugins", "logs"] {
2143            std::fs::create_dir_all(roboticus_dir.join(sub)).unwrap();
2144        }
2145        std::fs::write(roboticus_dir.join("roboticus.toml"), "[server]").unwrap();
2146        let _home_guard = crate::test_support::EnvGuard::set("HOME", dir.path().to_str().unwrap());
2147        let _ = super::cmd_mechanic(&s.uri(), false, false, &[]).await;
2148    }
2149
2150    #[serial_test::serial]
2151    #[tokio::test]
2152    async fn cmd_mechanic_gateway_down() {
2153        let s = MockServer::start().await;
2154        Mock::given(method("GET"))
2155            .and(path("/api/health"))
2156            .respond_with(ResponseTemplate::new(503))
2157            .mount(&s)
2158            .await;
2159        let dir = tempfile::tempdir().unwrap();
2160        let _home_guard = crate::test_support::EnvGuard::set("HOME", dir.path().to_str().unwrap());
2161        let _ = super::cmd_mechanic(&s.uri(), false, false, &[]).await;
2162    }
2163
2164    #[serial_test::serial]
2165    #[tokio::test]
2166    #[ignore = "sets HOME globally, racy with parallel tests — run with --ignored"]
2167    async fn cmd_mechanic_repair_creates_dirs() {
2168        let s = MockServer::start().await;
2169        Mock::given(method("GET"))
2170            .and(path("/api/health"))
2171            .respond_with(
2172                ResponseTemplate::new(200).set_body_json(serde_json::json!({"status": "ok"})),
2173            )
2174            .mount(&s)
2175            .await;
2176        mock_get(&s, "/api/config", serde_json::json!({})).await;
2177        mock_get(&s, "/api/skills", serde_json::json!({"skills": []})).await;
2178        mock_get(&s, "/api/wallet/balance", serde_json::json!({})).await;
2179        Mock::given(method("GET"))
2180            .and(path("/api/channels/status"))
2181            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
2182            .mount(&s)
2183            .await;
2184        let dir = tempfile::tempdir().unwrap();
2185        let _home_guard = crate::test_support::EnvGuard::set("HOME", dir.path().to_str().unwrap());
2186        let _ = super::cmd_mechanic(&s.uri(), true, false, &[]).await;
2187        assert!(dir.path().join(".roboticus").join("workspace").exists());
2188    }
2189}