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