Skip to main content

mecha_core/
onboarding.rs

1//! What a new install still needs, and the one command that fixes each.
2//!
3//! Two halves, split the way `compact.rs` and `candidate.rs` are split, and
4//! for the same reason: getting onboarding wrong is *silent*. A missing
5//! `context_window` does not error, it makes a long run die at a threshold
6//! nobody set; a missing `vision` does not error, it makes every screenshot
7//! arrive as a line of text. So the deciding is a pure function over
8//! already-gathered facts and is unit-tested without a machine, and the
9//! part that touches the world is thin enough to read.
10//!
11//! **The rule that makes this worth having: never write down a number the
12//! user believes.** `GET /props` reports the served alias, the per-slot
13//! `n_ctx` and whether a projector is loaded. Writing config from *that*
14//! retires a whole class of bug — `context_window` naming `-c` instead of
15//! `-c / -np`, `vision` unset against a multimodal model, `model` naming
16//! weights the server is not serving — none of which anything can detect
17//! later, because each one degrades quietly rather than failing.
18//!
19//! It is the same argument `Sandbox::preflight` makes, one step earlier:
20//! ask the thing itself rather than trusting what was configured about it.
21
22use crate::config::Config;
23use crate::doctor::Remedy;
24use serde::Serialize;
25use std::path::Path;
26
27/// Where a step stands. Deliberately three-valued: "cannot tell from here"
28/// is a real answer and must not be printed as "not done" — a person told
29/// their mail is unconfigured, when it is merely unreadable from this
30/// process, goes and re-runs an OAuth flow they did not need.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
32#[serde(rename_all = "lowercase")]
33pub enum Status {
34    Done,
35    Missing,
36    /// Configured, but something about it disagrees with reality.
37    Wrong,
38    Unknown,
39}
40
41/// One thing a new install might still need.
42#[derive(Debug, Clone, Serialize)]
43pub struct Step {
44    /// Stable slug, so `--json` output can be matched on.
45    pub id: String,
46    pub title: String,
47    pub status: Status,
48    pub detail: String,
49    /// Optional because plenty of steps are somebody else's to do — picking
50    /// a model, deciding whether they want mail at all.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub remedy: Option<Remedy>,
53}
54
55impl Step {
56    fn new(id: &str, title: &str, status: Status, detail: impl Into<String>) -> Self {
57        Step {
58            id: id.into(),
59            title: title.into(),
60            status,
61            detail: detail.into(),
62            remedy: None,
63        }
64    }
65    fn with(mut self, description: &str, argv: &[&str], needs_terminal: bool) -> Self {
66        self.remedy = Some(Remedy {
67            description: description.into(),
68            argv: argv.iter().map(|s| s.to_string()).collect(),
69            needs_terminal,
70        });
71        self
72    }
73}
74
75/// Everything the impure half gathered, so [`plan`] can stay a function.
76///
77/// A struct rather than a pile of arguments because it is going to grow, and
78/// because a caller that has to remember the order of six booleans will
79/// eventually get one wrong in a way that reads as a working install.
80#[derive(Debug, Clone, Default)]
81pub struct Facts {
82    /// Which helper binaries are on `PATH`. A crates.io install gets each
83    /// from its own `cargo install`, so "is it in the workspace" is the
84    /// wrong question — presence on `PATH` is the one that matters.
85    pub has_mail_binary: bool,
86    pub has_docs_binary: bool,
87    pub has_graph_binary: bool,
88    /// Whether any account/credential store has something in it. `None`
89    /// where the directory could not be read — see [`Status::Unknown`].
90    pub mail_accounts: Option<usize>,
91    pub docs_accounts: Option<usize>,
92    pub slack_linked: Option<bool>,
93    /// What the default provider's server said about itself, when it is
94    /// local and answered.
95    pub props: Option<crate::provider::preflight::Props>,
96    /// Whether the configured provider has a usable credential.
97    pub provider_credential: bool,
98    /// Whether a trigger scheduler is running or installed.
99    pub scheduler_installed: bool,
100    pub trigger_count: usize,
101}
102
103/// What still needs doing. Empty means a complete install.
104///
105/// Ordered by what blocks what: a provider that cannot answer makes every
106/// step below it untestable, so it comes first, and integrations come before
107/// scheduling because an unattended run with nothing wired to it is a cron
108/// slot that prints "no mail configured" every morning.
109pub fn plan(cfg: &Config, provider_name: &str, facts: &Facts) -> Vec<Step> {
110    let mut steps = Vec::new();
111    let local = cfg
112        .providers
113        .get(provider_name)
114        .filter(|p| p.kind == "local");
115
116    // --- 1. can anything answer at all
117    if !facts.provider_credential && local.is_none() {
118        steps.push(
119            Step::new(
120                "provider-credential",
121                "A provider that can answer",
122                Status::Missing,
123                format!(
124                    "`{provider_name}` has no usable credential. Set the environment variable \
125                     its `api_key_env` names, or configure a local server instead."
126                ),
127            )
128            .with(
129                "Show which providers are configured and what each is missing.",
130                &["mecha", "config", "show"],
131                false,
132            ),
133        );
134    }
135
136    // --- 2. a local server, checked against itself
137    if let Some(pcfg) = local {
138        match &facts.props {
139            None => steps.push(Step::new(
140                "local-server",
141                "The local server is reachable",
142                Status::Missing,
143                format!(
144                    "Nothing answered at {}. Start the server before the rest of this can be \
145                     checked — every value below is read back from it rather than guessed.",
146                    pcfg.base_url.as_deref().unwrap_or("(no base_url)")
147                ),
148            )),
149            Some(props) => {
150                let mismatches =
151                    crate::provider::preflight::disagreements(provider_name, pcfg, props);
152                if mismatches.is_empty() {
153                    steps.push(Step::new(
154                        "local-server",
155                        "The local server agrees with the config",
156                        Status::Done,
157                        format!(
158                            "serving {}, {} tokens per slot, vision {}",
159                            props.model_alias.as_deref().unwrap_or("(unnamed)"),
160                            props
161                                .default_generation_settings
162                                .n_ctx
163                                .map(|n| n.to_string())
164                                .unwrap_or_else(|| "?".into()),
165                            if props.modalities.vision { "on" } else { "off" },
166                        ),
167                    ));
168                } else {
169                    steps.push(
170                        Step::new(
171                            "local-server",
172                            "The config disagrees with what is served",
173                            Status::Wrong,
174                            mismatches.join("\n\n"),
175                        )
176                        .with(
177                            "Rewrite these from what the server reports, rather than editing \
178                             them by hand.",
179                            &["mecha", "setup", "--write"],
180                            false,
181                        ),
182                    );
183                }
184            }
185        }
186    }
187
188    steps.extend(integration_steps(facts));
189
190    // --- 4. scheduling, and nothing is turned on for anyone
191    //
192    // A scheduled unattended agent run on a machine holding your mail is
193    // never a default — the same argument that keeps `[[trigger]]` out of a
194    // project's `mecha.toml`, where a cloned repository would be handing
195    // itself a cron slot. What is offered is the *scheduler*, not a schedule.
196    if !facts.scheduler_installed && facts.trigger_count > 0 {
197        steps.push(
198            Step::new(
199                "scheduler",
200                "Something to fire the triggers",
201                Status::Missing,
202                format!(
203                    "{} trigger(s) are defined and nothing is running them. Being due is a \
204                     function of the ledger and the clock, so any of a systemd timer, a \
205                     crontab line running `mecha trigger tick`, or `mecha trigger daemon` \
206                     will do.",
207                    facts.trigger_count
208                ),
209            )
210            .with(
211                "Print a systemd user unit for the daemon, to review before installing.",
212                &["mecha", "trigger", "daemon", "--print-unit"],
213                false,
214            ),
215        );
216    }
217
218    steps
219}
220
221/// The integrations, each detected the same way: is the binary there, and has
222/// anything been authorised through it.
223///
224/// **mecha's own integrations are offered as commands to run; the graph's are
225/// only ever named.** mecha reaches the knowledge graph through the MCP tool
226/// surface and nothing else — no dependency, no second reader of its store —
227/// and a setup flow that drove `mecha-graph source add` would be exactly the
228/// coupling that rule exists to prevent. So a graph source is a sentence
229/// pointing at that project's own CLI, never a command this one spawns.
230fn integration_steps(facts: &Facts) -> Vec<Step> {
231    let mut steps = Vec::new();
232
233    steps.push(match (facts.has_mail_binary, facts.mail_accounts) {
234        (false, _) => Step::new(
235            "mail",
236            "Mail and calendar",
237            Status::Missing,
238            "`mecha-mail` is not on PATH. It is a separate crate, and optional — nothing else \
239             needs it.",
240        )
241        .with(
242            "Install the mail and calendar MCP servers.",
243            &["cargo", "install", "mecha-mail", "--locked"],
244            false,
245        ),
246        (true, Some(0)) => Step::new(
247            "mail",
248            "Mail and calendar",
249            Status::Missing,
250            "`mecha-mail` is installed with no accounts authorised. The model names an \
251             *account*, never a provider, so add one per mailbox.",
252        )
253        .with(
254            "Authorise a mailbox. Needs a browser, or `--paste` over SSH.",
255            &["mecha-mail", "auth", "personal", "--provider", "google"],
256            true,
257        ),
258        (true, Some(n)) => Step::new(
259            "mail",
260            "Mail and calendar",
261            Status::Done,
262            format!("{n} account(s) authorised"),
263        ),
264        (true, None) => Step::new(
265            "mail",
266            "Mail and calendar",
267            Status::Unknown,
268            "`mecha-mail` is installed; its credential store could not be read from here.",
269        ),
270    });
271
272    steps.push(match (facts.has_docs_binary, facts.docs_accounts) {
273        (false, _) => Step::new(
274            "docs",
275            "Google Docs, Sheets and Slides",
276            Status::Missing,
277            "`mecha-docs` ships with the mail crate. Under `drive.file` it reaches only files \
278             it created or you handed it in Google's own picker — which is the reason to want \
279             it, and no instruction inside a run can widen that.",
280        )
281        .with(
282            "Install the documents server (same crate as mail).",
283            &["cargo", "install", "mecha-mail", "--locked"],
284            false,
285        ),
286        (true, Some(0)) => Step::new(
287            "docs",
288            "Google Docs, Sheets and Slides",
289            Status::Missing,
290            "`mecha-docs` is installed with no account authorised.",
291        )
292        .with(
293            "Authorise Drive access. Use `--paste` if there is no browser here.",
294            &["mecha-docs", "auth", "personal"],
295            true,
296        ),
297        (true, Some(n)) => Step::new(
298            "docs",
299            "Google Docs, Sheets and Slides",
300            Status::Done,
301            format!("{n} account(s) authorised"),
302        ),
303        (true, None) => Step::new(
304            "docs",
305            "Google Docs, Sheets and Slides",
306            Status::Unknown,
307            "installed; the credential store could not be read from here.",
308        ),
309    });
310
311    steps.push(match facts.slack_linked {
312        Some(true) => Step::new(
313            "slack",
314            "Slack as a remote control",
315            Status::Done,
316            "linked to a workspace",
317        ),
318        Some(false) => Step::new(
319            "slack",
320            "Slack as a remote control",
321            Status::Missing,
322            "Watch a run from a phone, approve what it wants to send, and hand files in and \
323             out. The owner is bound by a nonce printed on this machine, so proving shell \
324             access here is what claims it.",
325        )
326        .with(
327            "Start the Slack setup, which prints the binding nonce.",
328            &["mecha", "slack", "auth"],
329            true,
330        ),
331        None => Step::new(
332            "slack",
333            "Slack as a remote control",
334            Status::Unknown,
335            "the binding store could not be read from here.",
336        ),
337    });
338
339    steps.push(if facts.has_graph_binary {
340        Step::new(
341            "graph",
342            "The personal knowledge graph",
343            Status::Done,
344            "`mecha-graph-mcp` is on PATH. Its own sources — ambient conversations, a \
345             calendar ICS feed, Slack, messages, mail — are configured with `mecha-graph \
346             source`, in that project. mecha reaches the graph only through its MCP tools \
347             and deliberately knows nothing else about it.",
348        )
349    } else {
350        Step::new(
351            "graph",
352            "The personal knowledge graph",
353            Status::Missing,
354            "Memory: who people are, what happened when. A separate project, wired in as an \
355             MCP server whose reads are marked untrusted — a graph fed by mail and messages \
356             holds third-party text by construction.",
357        )
358        .with(
359            "Install the graph's MCP server.",
360            &["cargo", "install", "mecha-graph-mcp", "--locked"],
361            false,
362        )
363    });
364
365    steps
366}
367
368/// The values a local server reports about itself, ready to be written down.
369///
370/// Returned rather than applied, so the caller can show them before changing
371/// anything: this rewrites settings a person may have had reasons for, and
372/// "here is what I would write" is a different act from writing it.
373pub fn verified_settings(props: &crate::provider::preflight::Props) -> Vec<(&'static str, String)> {
374    let mut out = Vec::new();
375    if let Some(alias) = &props.model_alias {
376        out.push(("model", format!("{alias:?}")));
377    }
378    // The per-slot figure, which is the one a request actually gets. Reading
379    // it back is what makes `-c` versus `-c / -np` a non-question.
380    if let Some(n) = props.default_generation_settings.n_ctx {
381        out.push(("context_window", n.to_string()));
382    }
383    out.push(("vision", props.modalities.vision.to_string()));
384    out
385}
386
387/// Count the per-account directories under a credential root.
388///
389/// `None` when the root cannot be read *and does not simply not exist* — an
390/// absent directory is a confident zero, where an unreadable one is genuinely
391/// unknown and must not be reported as "no accounts".
392pub fn count_accounts(root: &Path) -> Option<usize> {
393    match std::fs::read_dir(root) {
394        Ok(entries) => Some(
395            entries
396                .flatten()
397                .filter(|e| e.path().join("oauth.json").is_file())
398                .count(),
399        ),
400        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(0),
401        Err(_) => None,
402    }
403}
404
405/// Is `name` runnable from `PATH`?
406pub fn on_path(name: &str) -> bool {
407    let Some(path) = std::env::var_os("PATH") else {
408        return false;
409    };
410    std::env::split_paths(&path).any(|dir| dir.join(name).is_file())
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::provider::preflight::{GenerationSettings, Modalities, Props};
417
418    fn cfg_with_local(context_window: u64, vision: Option<bool>) -> Config {
419        let mut cfg = Config::default();
420        let mut p = cfg.providers.get("anthropic").cloned().unwrap();
421        p.kind = "local".into();
422        p.model = Some("qwen3.6-35b-a3b".into());
423        p.base_url = Some("http://127.0.0.1:8080".into());
424        p.api_key_env = None;
425        p.context_window = Some(context_window);
426        p.vision = vision;
427        cfg.providers.insert("local".into(), p);
428        cfg
429    }
430
431    fn props(n_ctx: u64, slots: u64, vision: bool) -> Props {
432        Props {
433            model_alias: Some("qwen3.6-35b-a3b".into()),
434            total_slots: Some(slots),
435            modalities: Modalities { vision },
436            default_generation_settings: GenerationSettings { n_ctx: Some(n_ctx) },
437        }
438    }
439
440    fn facts(props: Option<Props>) -> Facts {
441        Facts {
442            provider_credential: true,
443            props,
444            mail_accounts: Some(1),
445            docs_accounts: Some(1),
446            slack_linked: Some(true),
447            has_mail_binary: true,
448            has_docs_binary: true,
449            has_graph_binary: true,
450            scheduler_installed: true,
451            trigger_count: 0,
452        }
453    }
454
455    fn step<'a>(steps: &'a [Step], id: &str) -> &'a Step {
456        steps.iter().find(|s| s.id == id).expect("step missing")
457    }
458
459    /// A complete install has nothing to say about itself.
460    #[test]
461    fn everything_configured_and_agreeing_reports_no_work() {
462        let cfg = cfg_with_local(262144, Some(true));
463        let steps = plan(&cfg, "local", &facts(Some(props(262144, 4, true))));
464        assert!(
465            steps.iter().all(|s| s.status == Status::Done),
466            "unexpected work: {:?}",
467            steps
468                .iter()
469                .filter(|s| s.status != Status::Done)
470                .map(|s| &s.id)
471                .collect::<Vec<_>>()
472        );
473    }
474
475    /// The trap this exists to retire: `context_window` naming `-c` rather
476    /// than `-c / -np`. The two are the same number until `-np` moves off 1,
477    /// which is what makes it easy to write down wrong and impossible to
478    /// notice afterwards.
479    #[test]
480    fn a_context_window_that_names_c_rather_than_c_over_np_is_reported_wrong() {
481        let cfg = cfg_with_local(1048576, Some(true));
482        let steps = plan(&cfg, "local", &facts(Some(props(262144, 4, true))));
483        let s = step(&steps, "local-server");
484        assert_eq!(s.status, Status::Wrong);
485        assert!(s.detail.contains("262144"), "{}", s.detail);
486        assert!(s.remedy.is_some(), "and it is fixable without hand-editing");
487    }
488
489    /// The bug that hid for months, in the direction nobody looks.
490    #[test]
491    fn a_vision_model_nobody_configured_to_use_is_reported_wrong() {
492        let cfg = cfg_with_local(262144, None); // vision unset → false for local
493        let steps = plan(&cfg, "local", &facts(Some(props(262144, 4, true))));
494        assert_eq!(step(&steps, "local-server").status, Status::Wrong);
495    }
496
497    /// A server that is simply not running must not be reported as a
498    /// misconfiguration — there is nothing to compare against yet, and
499    /// telling someone their config is wrong when it may be fine sends them
500    /// editing a correct file.
501    #[test]
502    fn a_server_that_is_not_up_is_missing_rather_than_wrong() {
503        let cfg = cfg_with_local(262144, Some(true));
504        let steps = plan(&cfg, "local", &facts(None));
505        assert_eq!(step(&steps, "local-server").status, Status::Missing);
506        assert!(step(&steps, "local-server").remedy.is_none());
507    }
508
509    /// "Cannot tell from here" is not "not done". A person told their mail is
510    /// unconfigured, when the store is merely unreadable, re-runs an OAuth
511    /// flow they did not need.
512    #[test]
513    fn an_unreadable_store_is_unknown_and_offers_nothing() {
514        let mut f = facts(Some(props(262144, 4, true)));
515        f.mail_accounts = None;
516        let steps = plan(&cfg_with_local(262144, Some(true)), "local", &f);
517        let s = step(&steps, "mail");
518        assert_eq!(s.status, Status::Unknown);
519        assert!(s.remedy.is_none(), "unknown must not propose a fix");
520    }
521
522    /// The boundary that keeps mecha from growing a second way to reach the
523    /// graph. Its sources belong to that project's CLI, and this one must
524    /// never spawn them.
525    #[test]
526    fn a_graph_step_never_offers_to_run_a_graph_source_command() {
527        let steps = plan(
528            &cfg_with_local(262144, Some(true)),
529            "local",
530            &facts(Some(props(262144, 4, true))),
531        );
532        for s in &steps {
533            if let Some(r) = &s.remedy {
534                assert!(
535                    !r.argv.iter().any(|a| a == "source"),
536                    "{} would drive the graph's own source CLI: {:?}",
537                    s.id,
538                    r.argv
539                );
540            }
541        }
542    }
543
544    /// Nothing schedules anything for anyone. A cron slot on a machine
545    /// holding your mail is never a default, and the offer is the *runner*,
546    /// never a schedule.
547    #[test]
548    fn a_scheduler_is_only_offered_once_a_trigger_exists() {
549        let cfg = cfg_with_local(262144, Some(true));
550        let mut f = facts(Some(props(262144, 4, true)));
551        f.scheduler_installed = false;
552
553        f.trigger_count = 0;
554        assert!(
555            !plan(&cfg, "local", &f).iter().any(|s| s.id == "scheduler"),
556            "no triggers means nothing to run; do not offer a runner"
557        );
558
559        f.trigger_count = 2;
560        let steps = plan(&cfg, "local", &f);
561        let s = step(&steps, "scheduler");
562        assert_eq!(s.status, Status::Missing);
563        assert!(
564            !s.remedy.as_ref().unwrap().argv.contains(&"add".to_string()),
565            "offer the runner, never a schedule"
566        );
567    }
568
569    /// What gets written comes off the wire, not out of the config.
570    #[test]
571    fn verified_settings_are_read_back_from_the_server() {
572        let got = verified_settings(&props(65536, 4, true));
573        assert!(got.contains(&("context_window", "65536".into())), "{got:?}");
574        assert!(got.contains(&("vision", "true".into())), "{got:?}");
575        assert!(
576            got.iter().any(|(k, v)| *k == "model" && v.contains("qwen")),
577            "{got:?}"
578        );
579    }
580
581    /// An absent directory is a confident zero; an unreadable one is not.
582    #[test]
583    fn a_missing_credential_root_counts_zero_rather_than_unknown() {
584        assert_eq!(count_accounts(Path::new("/no/such/root")), Some(0));
585    }
586}