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