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