Skip to main content

caboodle/
doctor.rs

1// Compatibility: retained library imports, persisted paths, environment keys and
2// explicit old-command/version probes remain readable during the product rename.
3//! Read-only preflight: what will stop `tambo install` on this host, before
4//! anything is downloaded, built, or started.
5//!
6//! Every check here inspects the platform, PATH, the MCP registrations the agent
7//! CLI reports, and the Quipu server named by `QUIPU_SERVER`: health, one read,
8//! and an authenticated no-op write probe that Quipu refuses before writing
9//! anything. Nothing is installed, written, or registered, so running it first is
10//! always safe. A `fail` line is a blocker `install` would hit; a `warn` line is
11//! something that works but will surprise you.
12
13use std::{
14    env,
15    io::Write,
16    path::Path,
17    process::{Command, Stdio},
18};
19
20use anyhow::Result;
21
22use crate::adapter::which_all;
23
24use crate::{
25    adapter,
26    model::{CrewMode, Plan, Profile, QuipuFlavor, State, ToolName},
27};
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Level {
31    Ok,
32    Warn,
33    Fail,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Finding {
38    pub level: Level,
39    pub subject: String,
40    pub detail: String,
41}
42
43impl Finding {
44    fn new(level: Level, subject: impl Into<String>, detail: impl Into<String>) -> Self {
45        Self {
46            level,
47            subject: subject.into(),
48            detail: detail.into(),
49        }
50    }
51}
52
53/// What the doctor inspects. Built from a plan when one exists, else from the
54/// `everything` profile so a first run reports every possible blocker.
55pub struct Scope {
56    pub tools: Vec<ToolName>,
57    pub quipu_flavor: QuipuFlavor,
58    pub external_quipu: bool,
59    pub remote_quipu: Option<String>,
60    pub crew: Option<CrewMode>,
61    /// Doctor runs in the Tambo work directory, which holds the plan. MCP
62    /// servers are registered per indexed repository, so none is expected
63    /// here (aegis-65xet3).
64    pub work_dir: bool,
65}
66
67impl Scope {
68    pub fn from_plan(plan: &Plan) -> Self {
69        Self {
70            tools: plan.tools.clone(),
71            quipu_flavor: plan.quipu_flavor,
72            external_quipu: plan.external_quipu,
73            remote_quipu: env::var("QUIPU_SERVER")
74                .ok()
75                .filter(|url| !url.trim().is_empty())
76                .or_else(|| plan.quipu_mcp.as_ref().map(|mcp| mcp.url.clone())),
77            crew: plan.crew.as_ref().map(|crew| crew.mode),
78            work_dir: false,
79        }
80    }
81
82    pub fn everything() -> Self {
83        Self {
84            tools: Profile::Everything.tools(),
85            quipu_flavor: QuipuFlavor::Release,
86            external_quipu: false,
87            remote_quipu: env::var("QUIPU_SERVER")
88                .ok()
89                .filter(|url| !url.trim().is_empty()),
90            crew: None,
91            work_dir: false,
92        }
93    }
94}
95
96/// Whether `state` records every tool `plan` selects as verified.
97pub fn installed(plan: &Plan, state: &State) -> bool {
98    !plan.tools.is_empty()
99        && plan.tools.iter().all(|tool| {
100            state
101                .tools
102                .get(tool.as_str())
103                .is_some_and(|recorded| recorded.verified)
104        })
105}
106
107/// A plan given by a bare file name lives in the current directory, which is
108/// then the Tambo work directory.
109pub fn is_work_dir(plan: &Path) -> bool {
110    plan.parent().map_or(true, |dir| {
111        dir.as_os_str().is_empty() || dir == Path::new(".")
112    })
113}
114
115/// Versions such as quipu's span several lines; a report line keeps one.
116fn one_line(text: &str) -> String {
117    text.split_whitespace().collect::<Vec<_>>().join(" ")
118}
119
120fn crew_prerequisites(mode: CrewMode) -> Vec<&'static str> {
121    let mut commands = vec!["curl"];
122    if matches!(mode, CrewMode::Shantytown | CrewMode::Both) {
123        commands.push("python3");
124    }
125    if matches!(mode, CrewMode::Creel | CrewMode::Both) {
126        commands.push("tar");
127    }
128    commands
129}
130
131fn prerequisite_hint(command: &str) -> &'static str {
132    match command {
133        "go" => "Desire Path is built from source: install Go 1.22+ from https://go.dev/dl/",
134        "cargo" => "install Rust from https://rustup.rs",
135        "python3" => "install Python 3.9+ from your package manager",
136        "git" | "curl" | "tar" | "bash" => "install it from your package manager",
137        _ => "install it and rerun `tambo doctor`",
138    }
139}
140
141/// This build, as `tambo --version` prints it.
142pub const SELF_VERSION: &str = concat!(
143    "caboodle ",
144    env!("CARGO_PKG_VERSION"),
145    " (",
146    env!("CABOODLE_GIT_SHA"),
147    ")"
148);
149
150/// Is the `caboodle` that PATH runs this one? A different copy earlier on PATH
151/// keeps answering plain `caboodle` commands (aegis-nvw6ye.1: an older copy
152/// shadowed a fresh install, and both reported the same release version).
153fn check_self_on_path(path: Option<&std::ffi::OsStr>) -> Option<Finding> {
154    let current = env::current_exe().ok()?;
155    let command = if current.file_name().is_some_and(|name| name == "tambo") {
156        "tambo"
157    } else {
158        "caboodle"
159    };
160    let self_version = format!(
161        "{command} {} ({})",
162        env!("CARGO_PKG_VERSION"),
163        env!("CABOODLE_GIT_SHA")
164    );
165    let runs = which_all(command, path).into_iter().next()?;
166    let canonical = |p: &Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
167    if canonical(&runs) == canonical(&current) {
168        return None;
169    }
170    let runs_version = Command::new(&runs)
171        .arg("--version")
172        .stdin(Stdio::null())
173        .output()
174        .ok()
175        .map(|out| {
176            String::from_utf8_lossy(&out.stdout)
177                .lines()
178                .next()
179                .unwrap_or("")
180                .trim()
181                .to_owned()
182        })
183        .filter(|v| !v.is_empty())
184        .unwrap_or_else(|| "version unreadable".to_owned());
185    Some(if runs_version == self_version {
186        Finding::new(
187            Level::Ok,
188            format!("{command} on PATH"),
189            format!(
190                "{} is a copy of this build ({self_version})",
191                runs.display()
192            ),
193        )
194    } else {
195        Finding::new(
196            Level::Warn,
197            format!("{command} on PATH"),
198            format!(
199                "PATH runs {} ({runs_version}), NOT this {command} {} ({self_version}). Remove the stale copy or reorder PATH",
200                runs.display(),
201                current.display()
202            ),
203        )
204    })
205}
206
207/// Run every check against the live environment.
208pub fn diagnose(scope: &Scope) -> Vec<Finding> {
209    let path = env::var_os("PATH");
210    let mut findings = vec![Finding::new(
211        Level::Ok,
212        "platform",
213        format!("{}-{}", env::consts::ARCH, env::consts::OS),
214    )];
215    findings.extend(check_bin_dir_on_path(
216        adapter::managed_bin_dir().as_deref(),
217        path.as_deref(),
218    ));
219    findings.extend(check_self_on_path(path.as_deref()));
220    findings.extend(check_prerequisites(scope, path.as_deref()));
221    for &tool in &scope.tools {
222        if tool == ToolName::Quipu && scope.external_quipu {
223            let mut plan = Plan::for_profile(Profile::Kg);
224            plan.external_quipu = true;
225            let identity = adapter::adapter_for_plan(tool, &plan).and_then(|a| a.version());
226            findings.push(match identity {
227                Ok(version) => Finding::new(
228                    Level::Ok,
229                    "quipu",
230                    format!("externally owned ({})", one_line(&version)),
231                ),
232                Err(error) => Finding::new(
233                    Level::Fail,
234                    "quipu",
235                    format!("external identity unavailable: {error:#}"),
236                ),
237            });
238        } else {
239            findings.extend(check_tool(tool, scope.quipu_flavor, path.as_deref()));
240        }
241    }
242    if let Some(server) = &scope.remote_quipu {
243        if scope.tools.contains(&ToolName::Camayoc) || scope.tools.contains(&ToolName::Quipu) {
244            findings.extend(check_graph(server));
245        }
246    }
247    findings.extend(check_mcp(scope));
248    findings
249}
250
251/// MCP servers an agent needs for the selected tools, by the names the install
252/// docs register them under (`claude mcp add bobbin|yupana ...`).
253fn expected_mcp_servers(scope: &Scope) -> Vec<&'static str> {
254    let mut names = Vec::new();
255    if scope.tools.contains(&ToolName::Bobbin) {
256        names.push("bobbin");
257    }
258    if scope.tools.contains(&ToolName::Yupana) {
259        names.push("yupana");
260    }
261    names
262}
263
264/// Parse `claude mcp list`: `name: target - ✔ Connected` per server.
265pub(crate) fn parse_mcp_list(text: &str) -> Vec<(String, bool)> {
266    text.lines()
267        .filter_map(|line| {
268            let (name, rest) = line.split_once(": ")?;
269            let (_, status) = rest.rsplit_once(" - ")?;
270            let name = name.trim();
271            if name.is_empty() || name.contains(' ') && !name.starts_with("claude.ai") {
272                return None;
273            }
274            Some((
275                name.to_owned(),
276                status.contains("Connected") && !status.contains("Failed"),
277            ))
278        })
279        .collect()
280}
281
282/// The registration the README gives: the binary by absolute path, so an
283/// agent started outside a login shell still finds it (aegis-m9txqn), and for
284/// bobbin the indexed repository by absolute path too.
285pub(crate) fn mcp_add_command(want: &str) -> String {
286    if want == "bobbin" {
287        "bobbin init && bobbin index && claude mcp add bobbin -- \"$(command -v bobbin)\" serve \"$PWD\"".to_owned()
288    } else {
289        format!("claude mcp add {want} -- \"$(command -v {want})\" serve")
290    }
291}
292
293pub(crate) fn mcp_findings(
294    expected: &[&str],
295    listed: &[(String, bool)],
296    work_dir: bool,
297) -> Vec<Finding> {
298    expected
299        .iter()
300        .map(|want| match listed.iter().find(|(name, _)| name == want) {
301            Some((_, true)) => Finding::new(Level::Ok, format!("mcp {want}"), "registered and connected"),
302            Some((_, false)) => Finding::new(
303                Level::Warn,
304                format!("mcp {want}"),
305                "registered but NOT connecting; agents in this directory get no tools from it. Run `claude mcp list` for the error",
306            ),
307            None if work_dir => Finding::new(
308                Level::Ok,
309                format!("mcp {want}"),
310                format!("not registered here, as expected: registration is per repository, and this is the Tambo work directory. In each repository you want searchable, run: `{}`", mcp_add_command(want)),
311            ),
312            None => Finding::new(
313                Level::Warn,
314                format!("mcp {want}"),
315                format!("not registered for this directory; agents here get no {want} tools. After install, from inside this repository: `{}`", mcp_add_command(want)),
316            ),
317        })
318        .collect()
319}
320
321fn check_mcp(scope: &Scope) -> Vec<Finding> {
322    let expected = expected_mcp_servers(scope);
323    if expected.is_empty() {
324        return Vec::new();
325    }
326    if which_all("claude", env::var_os("PATH").as_deref()).is_empty() {
327        return vec![Finding::new(
328            Level::Warn,
329            "mcp",
330            "cannot check MCP registration: the `claude` CLI is not on PATH",
331        )];
332    }
333    match Command::new("claude")
334        .args(["mcp", "list"])
335        .stdin(Stdio::null())
336        .output()
337    {
338        Ok(out) if out.status.success() => mcp_findings(
339            &expected,
340            &parse_mcp_list(&String::from_utf8_lossy(&out.stdout)),
341            scope.work_dir,
342        ),
343        Ok(out) => vec![Finding::new(
344            Level::Warn,
345            "mcp",
346            format!(
347                "`claude mcp list` failed ({}); MCP registration unknown",
348                out.status
349            ),
350        )],
351        Err(error) => vec![Finding::new(
352            Level::Warn,
353            "mcp",
354            format!("could not run `claude mcp list`: {error}; MCP registration unknown"),
355        )],
356    }
357}
358
359/// What the no-op write probe's answer means. Quipu authorizes before parsing.
360pub(crate) fn classify_write_probe(code: u16, body: &str, token_set: bool) -> Finding {
361    let token = if token_set {
362        "token from environment or configured file"
363    } else {
364        "no token (environment/file empty or absent)"
365    };
366    let data: serde_json::Value = serde_json::from_str(body).unwrap_or(serde_json::Value::Null);
367    let error = data["error"].as_str().unwrap_or("");
368    match code {
369        403 if data["reason"] == "server_is_read_only" => Finding::new(
370            Level::Warn, "quipu write",
371            "READ-ONLY: no credential can authorize writes; use the intended writable server",
372        ),
373        400 if error.starts_with("invalid episode JSON:") && error.contains("missing field") => Finding::new(
374            Level::Ok,
375            "quipu write",
376            format!("authorized ({token}); the probe was refused as an empty episode, nothing written"),
377        ),
378        401 => Finding::new(
379            Level::Warn,
380            "quipu write",
381            format!("{}: REFUSED with HTTP {code} ({token}). Obtain an accepted credential from the server administrator; install at ~/.config/quipu/token (mode 0400), or set QUIPU_AUTH_TOKEN_FILE. QUIPU_AUTH_TOKEN overrides the file", if token_set { "BAD-TOKEN" } else { "NO-TOKEN" }),
382        ),
383        0 => Finding::new(Level::Warn, "quipu write", "write probe got no HTTP answer; write path unknown"),
384        other => Finding::new(
385            Level::Warn,
386            "quipu write",
387            format!("write probe returned an unexpected HTTP {other} ({token}); write path unknown"),
388        ),
389    }
390}
391
392fn check_graph(server: &str) -> Vec<Finding> {
393    let mut findings = vec![check_quipu_server(server)];
394    let reachable = findings[0].detail.contains("is live");
395    // A rejected credential must not hide the checks that do not need it
396    // (aegis-vgu): the namespace read below is public, so it still runs. Only
397    // the write probe depends on the credential, and it is skipped.
398    let credential_ok = match crate::quipu_credential::verify(server) {
399        Ok(source) => {
400            findings.push(Finding::new(
401                Level::Ok,
402                "quipu credential",
403                format!("canonical file 0400/0700 and authenticated /shapes read accepted; source: {source}"),
404            ));
405            true
406        }
407        Err(error) => {
408            findings.push(Finding::new(
409                Level::Fail,
410                "quipu credential",
411                format!("{error:#}"),
412            ));
413            false
414        }
415    };
416    if !reachable {
417        return findings;
418    }
419    let base = server.trim_end_matches('/');
420    // The namespace is the setting people most often get wrong, so echo it and
421    // check the server actually holds facts under it.
422    match adapter::camayoc_root()
423        .ok()
424        .map(|root| root.join("ontology/core.ttl"))
425        .filter(|path| path.exists())
426        .map(|path| adapter::camayoc_aegis_namespace(&path))
427    {
428        Some(Ok(namespace)) => {
429            // Subject-bound on a class camayoc's ontology declares: an index hit.
430            // A prefix FILTER over `?s ?p ?o` scans the store and 408s on a real
431            // graph (measured), which would report a healthy server as broken.
432            let query = format!("SELECT ?p WHERE {{ <{namespace}Verification> ?p ?o }} LIMIT 1");
433            findings.push(
434                match adapter::curl_json(&format!("{base}/query"), &serde_json::json!({ "query": query })) {
435                    Ok(answer) if answer["count"].as_u64().unwrap_or(0) > 0 => Finding::new(
436                        Level::Ok,
437                        "quipu namespace",
438                        format!("{namespace} on {server}: read OK, camayoc's ontology is loaded here"),
439                    ),
440                    Ok(_) => Finding::new(
441                        Level::Warn,
442                        "quipu namespace",
443                        format!("{namespace} on {server}: read OK but camayoc's ontology is NOT loaded here. Either it was never bootstrapped on this server, or this is the wrong server or namespace"),
444                    ),
445                    Err(error) => Finding::new(
446                        Level::Warn,
447                        "quipu namespace",
448                        format!("{namespace} on {server}: read FAILED: {}", one_line(&format!("{error:#}"))),
449                    ),
450                },
451            );
452        }
453        Some(Err(error)) => findings.push(Finding::new(
454            Level::Warn,
455            "quipu namespace",
456            format!(
457                "cannot resolve camayoc's namespace: {}",
458                one_line(&format!("{error:#}"))
459            ),
460        )),
461        None => findings.push(Finding::new(
462            Level::Ok,
463            "quipu namespace",
464            "camayoc not installed yet; namespace resolves after install",
465        )),
466    }
467    if !credential_ok {
468        return findings;
469    }
470    findings.push(match adapter::quipu_write_probe(base) {
471        Ok((code, body, token_set)) => classify_write_probe(code, &body, token_set),
472        Err(error) => Finding::new(
473            Level::Warn,
474            "quipu write",
475            format!(
476                "write probe could not run: {}",
477                one_line(&format!("{error:#}"))
478            ),
479        ),
480    });
481    findings
482}
483
484fn check_bin_dir_on_path(bin: Option<&Path>, path: Option<&std::ffi::OsStr>) -> Vec<Finding> {
485    let Some(bin) = bin else {
486        return vec![Finding::new(
487            Level::Fail,
488            "install directory",
489            "neither CARGO_HOME nor HOME is set; Tambo has nowhere to install binaries",
490        )];
491    };
492    let on_path = path.is_some_and(|path| env::split_paths(path).any(|entry| entry == bin));
493    if on_path {
494        vec![Finding::new(
495            Level::Ok,
496            "install directory",
497            format!("{} is on PATH", bin.display()),
498        )]
499    } else {
500        vec![Finding::new(
501            Level::Fail,
502            "install directory",
503            format!(
504                "{} is not on PATH, so installed tools cannot be read back; add `export PATH=\"{}:$PATH\"` to your shell profile",
505                bin.display(),
506                bin.display()
507            ),
508        )]
509    }
510}
511
512fn check_prerequisites(scope: &Scope, path: Option<&std::ffi::OsStr>) -> Vec<Finding> {
513    let mut needed: Vec<(&'static str, Vec<&'static str>)> = Vec::new();
514    let mut need = |command: &'static str, by: &'static str| match needed
515        .iter_mut()
516        .find(|(name, _)| *name == command)
517    {
518        Some((_, users)) if !users.contains(&by) => users.push(by),
519        Some(_) => {}
520        None => needed.push((command, vec![by])),
521    };
522    for &tool in &scope.tools {
523        for &command in adapter::prerequisites(tool, scope.quipu_flavor) {
524            need(command, tool.as_str());
525        }
526    }
527    if let Some(mode) = scope.crew {
528        for command in crew_prerequisites(mode) {
529            need(command, "crew");
530        }
531    }
532    needed
533        .into_iter()
534        .map(|(command, users)| match which_all(command, path).first() {
535            Some(found) => Finding::new(
536                Level::Ok,
537                format!("prerequisite {command}"),
538                found.display().to_string(),
539            ),
540            None => Finding::new(
541                Level::Fail,
542                format!("prerequisite {command}"),
543                format!(
544                    "not on PATH, needed by {}; {}",
545                    users.join(", "),
546                    prerequisite_hint(command)
547                ),
548            ),
549        })
550        .collect()
551}
552
553fn check_tool(tool: ToolName, flavor: QuipuFlavor, path: Option<&std::ffi::OsStr>) -> Vec<Finding> {
554    let mut findings = Vec::new();
555    let adapter = adapter::adapter(tool, flavor);
556    let desired = adapter.desired_version();
557    match adapter.version() {
558        Ok(installed) if adapter.is_current(&installed) => findings.push(Finding::new(
559            Level::Ok,
560            tool.as_str(),
561            format!("installed and current ({})", one_line(&installed)),
562        )),
563        Ok(installed) => findings.push(Finding::new(
564            Level::Warn,
565            tool.as_str(),
566            format!(
567                "installed ({}) differs from the reviewed {desired}; `tambo check-updates` explains the drift",
568                one_line(&installed)
569            ),
570        )),
571        Err(_) => match adapter::release_blocker(tool, flavor) {
572            Some(blocker) => findings.push(Finding::new(
573                Level::Fail,
574                tool.as_str(),
575                format!("not installed and cannot be installed here: {blocker}. {}", source_hint(tool)),
576            )),
577            None => findings.push(Finding::new(
578                Level::Ok,
579                tool.as_str(),
580                format!("not installed yet; install will fetch {desired}"),
581            )),
582        },
583    }
584    for &program in adapter::programs(tool) {
585        let copies = which_all(program, path);
586        if copies.len() > 1 {
587            let listed = copies
588                .iter()
589                .map(|copy| copy.display().to_string())
590                .collect::<Vec<_>>()
591                .join(", ");
592            findings.push(Finding::new(
593                Level::Warn,
594                format!("{program} on PATH"),
595                format!(
596                    "{} copies, the first one runs: {listed}. Remove the stale ones so a new install is the one you run",
597                    copies.len()
598                ),
599            ));
600        }
601    }
602    findings
603}
604
605fn source_hint(tool: ToolName) -> String {
606    match adapter::source_install_command(tool) {
607        Some(command) => format!("Build it from source first, then rerun: `{command}`"),
608        None => "Install it by hand first, then rerun; Tambo adopts an installed tool that passes verification".to_owned(),
609    }
610}
611
612fn check_quipu_server(server: &str) -> Finding {
613    let reachable = Command::new("curl")
614        .args([
615            "-sf",
616            "-m",
617            "2",
618            &format!("{}/health", server.trim_end_matches('/')),
619        ])
620        .stdout(Stdio::null())
621        .stderr(Stdio::null())
622        .status()
623        .is_ok_and(|status| status.success());
624    match reachable {
625        false => Finding::new(
626            Level::Ok,
627            "quipu server",
628            format!(
629                "nothing answers at {server}; camayoc verification uses its own scratch server and never touches this address"
630            ),
631        ),
632        true => Finding::new(
633            Level::Ok,
634            "quipu server",
635            format!(
636                "{server} (explicit remote configuration) is live. camayoc verification does not write to it (it uses its own scratch server); run camayoc's scripts/bootstrap.sh yourself to set this server up"
637            ),
638        ),
639    }
640}
641
642/// Print findings and return true when nothing blocks an install. `installed`
643/// is true when the state records every planned tool as verified, so the
644/// summary says the install happened instead of offering one (aegis-65xet3).
645pub fn report<W: Write>(findings: &[Finding], installed: bool, output: &mut W) -> Result<bool> {
646    for finding in findings {
647        let tag = match finding.level {
648            Level::Ok => "ok  ",
649            Level::Warn => "warn",
650            Level::Fail => "FAIL",
651        };
652        writeln!(output, "{tag} {}: {}", finding.subject, finding.detail)?;
653    }
654    let failures = findings
655        .iter()
656        .filter(|finding| finding.level == Level::Fail)
657        .count();
658    let warnings = findings
659        .iter()
660        .filter(|finding| finding.level == Level::Warn)
661        .count();
662    let plural = if warnings == 1 { "" } else { "s" };
663    if failures == 0 && installed {
664        writeln!(
665            output,
666            "doctor: installed and verified ({warnings} warning{plural}); recheck with `tambo verify`"
667        )?;
668    } else if failures == 0 {
669        writeln!(
670            output,
671            "doctor: ready to install ({warnings} warning{plural})"
672        )?;
673    } else {
674        // Say the install happened even when something now blocks it, so a
675        // FAIL line cannot read as "nothing was installed".
676        let earlier = if installed {
677            "installed and verified earlier, but "
678        } else {
679            ""
680        };
681        writeln!(
682            output,
683            "doctor: {earlier}{failures} blocker{} — fix the FAIL lines, then rerun `tambo doctor`",
684            if failures == 1 { "" } else { "s" }
685        )?;
686    }
687    Ok(failures == 0)
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693    use std::{ffi::OsString, fs, path::PathBuf};
694
695    // Verbatim shape of `claude mcp list` (servers renamed, targets shortened).
696    const MCP_LIST: &str = "Checking MCP server health…\n\n\
697claude.ai Gmail: https://gmailmcp.example/mcp/v1 - ✔ Connected\n\
698bobbin: http://bobbin-mcp.example/mcp (HTTP) - ✔ Connected\n\
699yupana: yupana serve - ✗ Failed to connect\n";
700
701    #[test]
702    fn mcp_list_parses_connected_and_failed_servers() {
703        let listed = parse_mcp_list(MCP_LIST);
704        assert!(listed.contains(&("bobbin".to_owned(), true)));
705        assert!(listed.contains(&("yupana".to_owned(), false)));
706        assert!(!listed.iter().any(|(name, _)| name.starts_with("Checking")));
707    }
708
709    #[test]
710    fn mcp_findings_distinguish_connected_failing_and_missing() {
711        let listed = parse_mcp_list(MCP_LIST);
712        let got = mcp_findings(&["bobbin", "yupana", "quipu"], &listed, false);
713        assert_eq!(got[0].level, Level::Ok);
714        assert_eq!(got[1].level, Level::Warn);
715        assert!(got[1].detail.contains("NOT connecting"));
716        assert_eq!(got[2].level, Level::Warn);
717        assert!(got[2].detail.contains("not registered"));
718        // A clean install with zero servers: every expected one is reported, none silently.
719        assert!(mcp_findings(&["bobbin"], &[], false)
720            .iter()
721            .all(|f| f.level == Level::Warn));
722    }
723
724    #[test]
725    fn write_probe_is_authorized_only_on_a_validation_refusal() {
726        let ok = classify_write_probe(
727            400,
728            r#"{"error":"invalid episode JSON: missing field `name`"}"#,
729            true,
730        );
731        assert_eq!(ok.level, Level::Ok);
732        assert!(ok.detail.contains("nothing written"));
733
734        let refused = classify_write_probe(401, r#"{"error":"unauthorized"}"#, false);
735        assert_eq!(refused.level, Level::Warn);
736        assert!(refused.detail.contains("REFUSED") && refused.detail.contains("NO-TOKEN"));
737
738        assert!(classify_write_probe(401, "{}", true)
739            .detail
740            .contains("BAD-TOKEN"));
741        assert!(
742            classify_write_probe(403, r#"{"reason":"server_is_read_only"}"#, true)
743                .detail
744                .contains("READ-ONLY")
745        );
746        assert_eq!(
747            classify_write_probe(400, r#"{"error":"episode failed"}"#, true).level,
748            Level::Warn
749        );
750        // A 400 for some other reason is not proof of authorization.
751        assert_eq!(
752            classify_write_probe(400, "bad gateway html", true).level,
753            Level::Warn
754        );
755        assert_eq!(classify_write_probe(0, "", true).level, Level::Warn);
756        assert_eq!(classify_write_probe(200, "{}", true).level, Level::Warn);
757    }
758
759    fn executable(dir: &Path, name: &str) -> PathBuf {
760        let path = dir.join(name);
761        fs::write(&path, "#!/bin/sh\n").unwrap();
762        #[cfg(unix)]
763        {
764            use std::os::unix::fs::PermissionsExt;
765            fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap();
766        }
767        path
768    }
769
770    #[test]
771    fn which_all_lists_every_copy_in_path_order() {
772        let first = tempfile::tempdir().unwrap();
773        let second = tempfile::tempdir().unwrap();
774        let a = executable(first.path(), "yupana");
775        let b = executable(second.path(), "yupana");
776        fs::write(second.path().join("not-executable"), "").unwrap();
777        let path = env::join_paths([first.path(), second.path(), first.path()]).unwrap();
778        assert_eq!(which_all("yupana", Some(&path)), vec![a, b]);
779        assert!(which_all("not-executable", Some(&path)).is_empty());
780        assert!(which_all("yupana", None).is_empty());
781    }
782
783    #[test]
784    fn missing_install_directory_on_path_is_a_blocker() {
785        let bin = PathBuf::from("/opt/tambo-test/bin");
786        let without = OsString::from("/usr/bin:/bin");
787        let with = env::join_paths([Path::new("/usr/bin"), &bin]).unwrap();
788        let missing = check_bin_dir_on_path(Some(&bin), Some(&without));
789        assert_eq!(missing[0].level, Level::Fail);
790        assert!(missing[0].detail.contains("export PATH="));
791        assert_eq!(
792            check_bin_dir_on_path(Some(&bin), Some(&with))[0].level,
793            Level::Ok
794        );
795    }
796
797    #[test]
798    fn missing_prerequisite_names_the_tools_that_need_it() {
799        let empty = tempfile::tempdir().unwrap();
800        let path = env::join_paths([empty.path()]).unwrap();
801        let findings = check_prerequisites(&Scope::everything(), Some(&path));
802        let go = findings
803            .iter()
804            .find(|finding| finding.subject == "prerequisite go")
805            .expect("desire-path needs go");
806        assert_eq!(go.level, Level::Fail);
807        assert!(go.detail.contains("desire-path"));
808        let curl = findings
809            .iter()
810            .find(|finding| finding.subject == "prerequisite curl")
811            .unwrap();
812        assert!(curl.detail.contains("quipu") && curl.detail.contains("bobbin"));
813        assert_eq!(
814            findings
815                .iter()
816                .filter(|finding| finding.subject == "prerequisite curl")
817                .count(),
818            1,
819            "each command is reported once"
820        );
821    }
822
823    #[test]
824    fn report_fails_only_on_blockers() {
825        let mut out = Vec::new();
826        let warn_only = [Finding::new(Level::Warn, "x", "y")];
827        assert!(report(&warn_only, false, &mut out).unwrap());
828        let blocked = [Finding::new(Level::Fail, "x", "y")];
829        assert!(!report(&blocked, false, &mut out).unwrap());
830        let text = String::from_utf8(out).unwrap();
831        assert!(text.contains("ready to install (1 warning)"));
832        assert!(text.contains("FAIL x: y"));
833    }
834
835    // aegis-65xet3 item 1: the hint is the README's absolute-path form, never
836    // the bare name that failed with ENOENT in clean-room run 22422.
837    #[test]
838    fn missing_registration_hint_uses_the_absolute_path_form() {
839        for work_dir in [false, true] {
840            let got = mcp_findings(&["bobbin"], &[], work_dir);
841            assert!(
842                got[0]
843                    .detail
844                    .contains(r#"claude mcp add bobbin -- "$(command -v bobbin)" serve "$PWD""#),
845                "{}",
846                got[0].detail
847            );
848            assert!(
849                !got[0].detail.contains("-- bobbin serve"),
850                "{}",
851                got[0].detail
852            );
853        }
854        let readme = include_str!("../README.md");
855        assert!(
856            readme.contains(r#"claude mcp add bobbin -- "$(command -v bobbin)" serve "$PWD""#),
857            "doctor and README must give the same registration"
858        );
859    }
860
861    // aegis-65xet3 item 2: after an install every planned tool is verified in
862    // state, and the summary says so instead of offering to install.
863    #[test]
864    fn summary_reports_an_install_that_happened() {
865        let plan = Plan::for_profile(Profile::Retrieval);
866        let mut state = State::read(Path::new("/nonexistent/state.json")).unwrap();
867        assert!(!installed(&plan, &state), "empty state is not installed");
868        for tool in &plan.tools {
869            state.tools.insert(
870                tool.as_str().to_owned(),
871                crate::model::ToolState {
872                    version: "1".into(),
873                    applied: true,
874                    verified: true,
875                },
876            );
877        }
878        assert!(installed(&plan, &state));
879        let first = plan.tools[0].as_str().to_owned();
880        state.tools.get_mut(&first).unwrap().verified = false;
881        assert!(
882            !installed(&plan, &state),
883            "one unverified tool is not installed"
884        );
885
886        let mut out = Vec::new();
887        assert!(report(&[], true, &mut out).unwrap());
888        let text = String::from_utf8(out).unwrap();
889        assert!(text.contains("installed and verified"), "{text}");
890        assert!(!text.contains("ready to install"), "{text}");
891        let blocked = [Finding::new(Level::Fail, "x", "y")];
892        let mut out = Vec::new();
893        assert!(!report(&blocked, true, &mut out).unwrap());
894        let text = String::from_utf8(out).unwrap();
895        assert!(
896            text.contains("installed and verified earlier, but 1 blocker"),
897            "{text}"
898        );
899    }
900
901    // aegis-65xet3 item 3: in the Tambo work dir a missing per-repository
902    // registration is expected, not a warning; elsewhere it still warns.
903    #[test]
904    fn missing_registration_does_not_warn_in_the_work_dir() {
905        assert!(is_work_dir(Path::new("caboodle-plan.toml")));
906        assert!(is_work_dir(Path::new("./caboodle-plan.toml")));
907        assert!(!is_work_dir(Path::new("/elsewhere/caboodle-plan.toml")));
908        let here = mcp_findings(&["bobbin"], &[], true);
909        assert_eq!(here[0].level, Level::Ok, "{}", here[0].detail);
910        assert!(
911            here[0].detail.contains("per repository"),
912            "{}",
913            here[0].detail
914        );
915        let repo = mcp_findings(&["bobbin"], &[], false);
916        assert_eq!(repo[0].level, Level::Warn);
917    }
918}