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, PathBuf};
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 /// The owner said they do not want this one.
40 ///
41 /// **Not a fifth shade of "not done" — its opposite.** Every other status
42 /// here describes the machine; this one describes a decision, and the two
43 /// were indistinguishable until it existed: somebody who does not use
44 /// Slack read `not set up` on every `mecha setup` forever, and every
45 /// scripted run exited non-zero over a choice they had already made. The
46 /// same "a dash is never zero" rule [`crate::backlog`] states, one noun
47 /// over — an absence of the thing and a decision against the thing are
48 /// different findings, and a reader that cannot tell them apart turns a
49 /// finished install into a permanent defect list.
50 Declined,
51}
52
53/// One thing a new install might still need.
54#[derive(Debug, Clone, Serialize)]
55pub struct Step {
56 /// Stable slug, so `--json` output can be matched on.
57 pub id: String,
58 pub title: String,
59 pub status: Status,
60 pub detail: String,
61 /// Optional because plenty of steps are somebody else's to do — picking
62 /// a model, deciding whether they want mail at all.
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub remedy: Option<Remedy>,
65 /// May the owner say they never want this?
66 ///
67 /// **A property of the step, not of its status**, and the distinction is
68 /// load-bearing: inferring it from `Missing` made *"a provider that can
69 /// answer"* declinable, so a person could decline the one thing without
70 /// which nothing runs and be told `Nothing outstanding.` on an install
71 /// that could not answer a single prompt. Found by running the flow
72 /// rather than by reading it.
73 ///
74 /// True only where "I don't want this" is a coherent sentence — the
75 /// integrations, and the charter. Never for a credential, a server that
76 /// disagrees with its config, or anything else that is the machine being
77 /// wrong rather than a feature going unused.
78 pub optional: bool,
79}
80
81impl Step {
82 fn new(id: &str, title: &str, status: Status, detail: impl Into<String>) -> Self {
83 Step {
84 id: id.into(),
85 title: title.into(),
86 status,
87 detail: detail.into(),
88 remedy: None,
89 optional: false,
90 }
91 }
92 /// Mark a step as one the owner may decline outright.
93 fn optional(mut self) -> Self {
94 self.optional = true;
95 self
96 }
97 fn with(mut self, description: &str, argv: &[&str], needs_terminal: bool) -> Self {
98 self.remedy = Some(Remedy {
99 description: description.into(),
100 argv: argv.iter().map(|s| s.to_string()).collect(),
101 needs_terminal,
102 });
103 self
104 }
105}
106
107/// Everything the impure half gathered, so [`plan`] can stay a function.
108///
109/// A struct rather than a pile of arguments because it is going to grow, and
110/// because a caller that has to remember the order of six booleans will
111/// eventually get one wrong in a way that reads as a working install.
112#[derive(Debug, Clone, Default)]
113pub struct Facts {
114 /// Which helper binaries are on `PATH`. A crates.io install gets each
115 /// from its own `cargo install`, so "is it in the workspace" is the
116 /// wrong question — presence on `PATH` is the one that matters.
117 pub has_mail_binary: bool,
118 pub has_docs_binary: bool,
119 pub has_graph_binary: bool,
120 /// Whether any account/credential store has something in it. `None`
121 /// where the directory could not be read — see [`Status::Unknown`].
122 pub mail_accounts: Option<usize>,
123 pub docs_accounts: Option<usize>,
124 pub slack_linked: Option<bool>,
125 /// What the default provider's server said about itself, when it is
126 /// local and answered.
127 pub props: Option<crate::provider::preflight::Props>,
128 /// Whether the configured provider has a usable credential.
129 pub provider_credential: bool,
130 /// Whether a global config file exists at all.
131 ///
132 /// `Config::load_global` tolerates its absence and returns defaults,
133 /// which is right — mecha must work before anybody has written one — but
134 /// it meant a new install was never told the file exists or where it
135 /// lives, and the first thing anyone needs to change lives in it.
136 pub config_file: bool,
137 /// What the loopback probe found — **including whether it ran at all**.
138 ///
139 /// See [`LocalProbe`]. The point of the probe is to turn *"`anthropic` has
140 /// no usable credential"* into *"there is a server running right here,
141 /// shall I write it down"*, which is the difference between a remedy and
142 /// a diagnosis.
143 pub local_probe: LocalProbe,
144 /// Whether a trigger scheduler is running or installed.
145 pub scheduler_installed: bool,
146 pub trigger_count: usize,
147 /// What the owner's charter is doing, read through the ordinary loader.
148 pub charter: CharterState,
149 /// Step ids the owner has said they do not want, from
150 /// [`read_declined`].
151 ///
152 /// An unreadable store yields an **empty** set rather than a failure, and
153 /// the direction is deliberate: showing a step somebody declined is a
154 /// nuisance, hiding one they never declined is a silently incomplete
155 /// install. This is the one place in this module where unknown resolves
156 /// towards *more* noise, because here noise is the safe side. `setup`
157 /// says out loud that the store could not be read — **on stderr, and
158 /// before the `--json` return**, so the scriptable surface carries it
159 /// too and stdout stays a parseable array.
160 pub declined: std::collections::BTreeSet<String>,
161}
162
163/// A local server nobody has configured yet: where it is, and what it says
164/// about itself.
165#[derive(Debug, Clone)]
166pub struct LocalServer {
167 pub base_url: String,
168 pub props: crate::provider::preflight::Props,
169}
170
171/// What the loopback probe found — **three-valued, because two of these were
172/// being reported as the third.**
173///
174/// An `Option` here collapsed *"asked, and nothing answered"* into *"never
175/// asked"*, and the step then printed the first sentence for both: somebody
176/// with a configured-but-unselected `[providers.local]` and a llama-server
177/// happily running on it was told **"Nothing was answering at
178/// http://127.0.0.1:8080 when this ran"** — a fact asserted with no
179/// observation behind it, which is this module's own header rule
180/// (*never write down a number the user merely believes*) inverted. The
181/// distinction is the same one [`Status::Unknown`] and [`Facts::declined`]
182/// both keep: an absence and an unasked question are different findings.
183#[derive(Debug, Clone, Default)]
184pub enum LocalProbe {
185 /// No probe was attempted — something can already answer, or a local
186 /// provider is configured and it is not this module's place to go looking
187 /// for a second one.
188 #[default]
189 NotAttempted,
190 /// Asked, and nothing that looks like a model server answered.
191 NothingAnswered,
192 /// Asked, and found a server no provider names.
193 Found(LocalServer),
194}
195
196/// Does this `/props` answer come from something that is actually a model
197/// server?
198///
199/// **`preflight::Props` defaults every field on purpose**, so a llama-server
200/// version bump costs a check rather than a parse failure — which means `{}`
201/// with a 200 deserializes perfectly, and *any* JSON service answering on
202/// :8080 (a catch-all API, a proxy) parses as a `Props`. That tolerance is
203/// right where it is used for a server the owner has already told us about,
204/// and wrong here, where the whole question is whether this is a model server
205/// at all: without a check, an unrelated service gets announced as "already
206/// serving (an unnamed model)" and one `y` repoints `default_provider` at it,
207/// with no `model` and no `context_window` — the two settings this module's
208/// header is about, both of which degrade quietly rather than failing.
209///
210/// So the discovery site asks for one thing only llama-server supplies.
211/// Deliberately a *disjunction* rather than a required pair: either field
212/// alone is enough to say "a model server answered", and demanding both would
213/// reject a real server whose build reports one of them differently — which
214/// is the forward-compatibility `Props` was made tolerant for.
215pub fn answers_like_a_model_server(props: &crate::provider::preflight::Props) -> bool {
216 props.model_alias.is_some() || props.default_generation_settings.n_ctx.is_some()
217}
218
219/// Where to look for a local server when the configured provider cannot
220/// answer.
221///
222/// **Loopback only, and only on an install that has nothing working.** Every
223/// other network call in this module is to a server the config already names;
224/// this one is a guess, so it is confined to the address the documentation
225/// tells people to serve on and to a machine that is otherwise stuck. Nothing
226/// leaves the box, and a `mecha setup` on a working install makes no extra
227/// call at all.
228///
229/// One address rather than a scan: probing a range would be a port scanner in
230/// a setup tool, and the payoff — finding a server on a port nobody
231/// documented — is not worth a command that behaves like one.
232pub fn local_probe_candidates() -> &'static [&'static str] {
233 &["http://127.0.0.1:8080"]
234}
235
236/// What `~/.mecha/charter.toml` is doing — the five answers a reader has to
237/// tell apart.
238///
239/// `Absent` and `Empty` are **not** folded together, for the reason
240/// [`crate::doctor`]'s own charter check keeps them apart: a file that parses
241/// cleanly to zero lines is an authoring mistake by construction (nobody
242/// writes an empty charter on purpose), where no file at all is the ordinary
243/// state of a fresh install and is the one this module exists to offer
244/// something about.
245#[derive(Debug, Clone, Default, PartialEq, Eq)]
246pub enum CharterState {
247 /// No file. A new install, and the case worth prompting.
248 #[default]
249 Absent,
250 /// A file with no `[[line]]` entries — a template nobody filled in, or an
251 /// edit that removed the last line.
252 Empty,
253 /// `n` lines, loading cleanly.
254 Lines(usize),
255 /// It exists and does not load: every run is starting un-chartered.
256 Broken(String),
257 /// Could not be established from here.
258 Unknown,
259}
260
261/// What still needs doing. Empty means a complete install.
262///
263/// Ordered by what blocks what: a provider that cannot answer makes every
264/// step below it untestable, so it comes first, and integrations come before
265/// scheduling because an unattended run with nothing wired to it is a cron
266/// slot that prints "no mail configured" every morning.
267pub fn plan(cfg: &Config, provider_name: &str, facts: &Facts) -> Vec<Step> {
268 let mut steps = Vec::new();
269 let local = cfg
270 .providers
271 .get(provider_name)
272 .filter(|p| p.kind == "local");
273
274 // --- 0. somewhere to put settings at all
275 //
276 // `Config::load_global` returns defaults when there is no file, which is
277 // right — mecha must work before anybody has written one — but it also
278 // meant nothing ever told a new install that the file exists or where it
279 // lives, while every remaining step here is fixed by editing it.
280 if !facts.config_file {
281 steps.push(
282 Step::new(
283 "config-file",
284 "A config file to change things in",
285 Status::Missing,
286 concat!(
287 "Everything runs on defaults until there is one, and defaults are ",
288 "fine — but the model, the server and the budgets are all set here, ",
289 "so the first thing anyone needs is a file to put them in. It is ",
290 "written commented, so it doubles as the list of what is adjustable."
291 ),
292 )
293 .with(
294 "Write a commented starter config to ~/.mecha/config.toml.",
295 &["mecha", "config", "init"],
296 false,
297 ),
298 );
299 }
300
301 // --- 1. can anything answer at all
302 if !facts.provider_credential && local.is_none() {
303 steps.push(provider_step(provider_name, cfg, facts));
304 }
305
306 // --- 2. a local server, checked against itself
307 if let Some(pcfg) = local {
308 match &facts.props {
309 None => steps.push(Step::new(
310 "local-server",
311 "The local server is reachable",
312 Status::Missing,
313 format!(
314 "Nothing answered at {}. Start the server before the rest of this can be \
315 checked — every value below is read back from it rather than guessed.",
316 pcfg.base_url.as_deref().unwrap_or("(no base_url)")
317 ),
318 )),
319 Some(props) => {
320 let mismatches =
321 crate::provider::preflight::disagreements(provider_name, pcfg, props);
322 if mismatches.is_empty() {
323 steps.push(Step::new(
324 "local-server",
325 "The local server agrees with the config",
326 Status::Done,
327 format!(
328 "serving {}, {} tokens per slot, vision {}",
329 props.model_alias.as_deref().unwrap_or("(unnamed)"),
330 props
331 .default_generation_settings
332 .n_ctx
333 .map(|n| n.to_string())
334 .unwrap_or_else(|| "?".into()),
335 if props.modalities.vision { "on" } else { "off" },
336 ),
337 ));
338 } else {
339 steps.push(
340 Step::new(
341 "local-server",
342 "The config disagrees with what is served",
343 Status::Wrong,
344 mismatches.join("\n\n"),
345 )
346 .with(
347 "Rewrite these from what the server reports, rather than editing \
348 them by hand.",
349 &["mecha", "setup", "--write"],
350 false,
351 ),
352 );
353 }
354 }
355 }
356 }
357
358 steps.extend(integration_steps(facts));
359 steps.push(charter_step(&facts.charter));
360
361 // --- 4. scheduling, and nothing is turned on for anyone
362 //
363 // A scheduled unattended agent run on a machine holding your mail is
364 // never a default — the same argument that keeps `[[trigger]]` out of a
365 // project's `mecha.toml`, where a cloned repository would be handing
366 // itself a cron slot. What is offered is the *scheduler*, not a schedule.
367 if !facts.scheduler_installed && facts.trigger_count > 0 {
368 steps.push(
369 Step::new(
370 "scheduler",
371 "Something to fire the triggers",
372 Status::Missing,
373 format!(
374 "{} trigger(s) are defined and nothing is running them. Being due is a \
375 function of the ledger and the clock, so any of a systemd timer, a \
376 crontab line running `mecha trigger tick`, or `mecha trigger daemon` \
377 will do.",
378 facts.trigger_count
379 ),
380 )
381 .with(
382 "Print a systemd user unit for the daemon, to review before installing.",
383 &["mecha", "trigger", "daemon", "--print-unit"],
384 false,
385 ),
386 );
387 }
388
389 // Applied last, over the finished list, so a decline can never change
390 // what a step *says* — only whether it is still being asked for. A
391 // declined step keeps its detail and loses its remedy, because a remedy
392 // is an offer and this one has been answered.
393 //
394 // **A `Done` step is never overwritten.** Declining Slack and then
395 // linking it anyway (from the phone, from `mecha slack auth` directly)
396 // must read as done rather than as refused — the machine's state is a
397 // fact and the decision is a preference, and where they disagree the
398 // fact wins. Otherwise a stale decline would hide a working integration
399 // from its own owner. The same reason `Wrong` and `Broken` survive it:
400 // "I don't want mail" is not "I don't want to be told my mail is
401 // broken", and a decline that could suppress a failure would be a
402 // silently-degrading guard.
403 //
404 // Gated on `optional` as well as on the status, so the guarantee holds
405 // against a **hand-edited** store too: `setup-declined.json` is a plain
406 // file, and a decline that only the prompt refused to record would be
407 // one anybody could add with a text editor.
408 for step in &mut steps {
409 if step.optional
410 && matches!(step.status, Status::Missing)
411 && facts.declined.contains(&step.id)
412 {
413 step.status = Status::Declined;
414 step.remedy = None;
415 }
416 }
417
418 steps
419}
420
421/// The one step that blocks every other, and the only one whose remedy used
422/// to be a *viewer*.
423///
424/// It said `anthropic has no usable credential` and offered
425/// `mecha config show` — which displays a file and fixes nothing. The step
426/// that makes all the others untestable was the step with no path forward,
427/// which is precisely backwards.
428///
429/// There are exactly two ways out and they are not symmetric, so the step
430/// says which one this machine is actually in:
431///
432/// - **A local server is already running.** The common case for anybody who
433/// followed the hardware pages first, and a real remedy: every value that
434/// would be written is read back off `/props`, so the *existence* of the
435/// provider is as much a measured fact as its context window.
436/// - **No server.** Then the fix is a secret, and a secret is the one thing
437/// this tool must not write — mecha stores the **name of an environment
438/// variable**, never a key, so a config file can be read, copied or
439/// committed without leaking one. Naming the exact variable and the exact
440/// line is the most a remedy can honestly be here, so the detail carries
441/// both rather than pointing at a command that would only show what is
442/// already known.
443fn provider_step(provider_name: &str, cfg: &Config, facts: &Facts) -> Step {
444 let env_var = cfg
445 .providers
446 .get(provider_name)
447 .and_then(|p| p.api_key_env.clone());
448
449 let step = |detail: String| {
450 Step::new(
451 "provider-credential",
452 "A provider that can answer",
453 Status::Missing,
454 detail,
455 )
456 };
457
458 // 1. Something is serving, and no provider names it. The only branch with
459 // a command behind it, because it is the only one where the fix is a
460 // fact mecha can read rather than a secret only the owner has.
461 if let LocalProbe::Found(found) = &facts.local_probe {
462 let serving = found
463 .props
464 .model_alias
465 .as_deref()
466 .unwrap_or("(an unnamed model)");
467 return step(format!(
468 concat!(
469 "`{provider_name}` has no usable credential — but something is ",
470 "already serving {serving} at {url}, and nothing in the config names ",
471 "it. Writing it down reads every value back off the server rather ",
472 "than asking you for any of them, which is the only way ",
473 "`context_window` ever gets to be the per-slot figure rather than ",
474 "`-c`."
475 ),
476 provider_name = provider_name,
477 serving = serving,
478 url = found.base_url
479 ))
480 .with(
481 "Write the local server down as a provider, from what it reports about itself.",
482 &["mecha", "setup", "--write"],
483 false,
484 );
485 }
486
487 // 2. A local provider is configured and simply is not the selected one.
488 //
489 // **This branch is why the probe's absence had to become visible.**
490 // Nothing probes when a local provider exists, so the "nothing
491 // answered" sentence below would be printed about an address never
492 // asked — and this is exactly the config that hits it: a
493 // `[providers.local]` on :8080 with a server running on it, and
494 // `default_provider` still pointing at a cloud provider whose key was
495 // never exported. Before this, that person got one step telling them
496 // to do the thing they had already done, and was never told the actual
497 // one-line fix.
498 if let Some((name, pcfg)) = cfg
499 .providers
500 .iter()
501 .find(|(name, p)| p.kind == "local" && *name != provider_name)
502 {
503 let where_it_points = pcfg
504 .base_url
505 .as_deref()
506 .map(|u| format!(" ({u})"))
507 .unwrap_or_default();
508 return step(format!(
509 concat!(
510 "`{provider_name}` has no usable credential — but you already have a ",
511 "local provider configured, `{name}`{where_it_points}, and it is not ",
512 "the default. Point `default_provider` at it in the config, or select ",
513 "it for one run with `-p {name}`. Nothing here probed {name}: whether ",
514 "it is up is what `mecha setup` reports once it is the one being used."
515 ),
516 provider_name = provider_name,
517 name = name,
518 where_it_points = where_it_points
519 ));
520 }
521
522 // 3. Nothing configured can answer and nothing was found. The fix is a
523 // secret, and a secret is the one thing this tool must not write —
524 // mecha stores the **name of an environment variable**, never a key, so
525 // a config file can be read, copied or committed without leaking one.
526 // Naming the exact variable is the most a remedy can honestly be, so
527 // the detail carries it rather than pointing at a command that would
528 // only show what is already known.
529 let key_line = match &env_var {
530 Some(var) => format!(
531 concat!(
532 "Set `{var}` in your shell (`export {var}=…`) and start a new one — ",
533 "mecha stores the variable's *name* in the config and never the key ",
534 "itself, so nothing here has to hold a secret."
535 ),
536 var = var
537 ),
538 // A provider configured with no `api_key_env` at all cannot be fixed
539 // by exporting anything, and saying "set the variable it names" about
540 // a provider that names none is the kind of instruction that sends
541 // somebody looking for a typo they did not make.
542 None => format!(
543 concat!(
544 "`{provider_name}` names no `api_key_env`, so there is no variable to ",
545 "set — give it one, or point `default_provider` at a local server."
546 ),
547 provider_name = provider_name
548 ),
549 };
550 // **Only said when a probe actually ran.** Reporting "nothing was
551 // answering at X" after not asking is the same class of claim as writing
552 // down a `context_window` nobody read off the wire.
553 let local_line = match &facts.local_probe {
554 LocalProbe::NothingAnswered => format!(
555 concat!(
556 "2. Run a model locally — the target rather than the fallback. Serve ",
557 "it, then `mecha setup --write` reads the settings off it. Nothing ",
558 "was answering at {tried} when this ran."
559 ),
560 tried = local_probe_candidates().join(", ")
561 ),
562 _ => concat!(
563 "2. Run a model locally — the target rather than the fallback. Serve it, ",
564 "then `mecha setup --write` reads the settings off it."
565 )
566 .to_string(),
567 };
568 step(format!(
569 "Nothing can answer a prompt yet, so nothing below this can be tested. \
570 Two ways out.\n\n1. {key_line}\n\n{local_line}"
571 ))
572}
573
574/// The charter: what mecha is for, in the owner's own words.
575///
576/// **Nothing anywhere used to mention this.** `doctor`'s charter check
577/// returns early on a file that does not exist — correctly, because never
578/// having written one is not a fault — so a fresh install had no surface at
579/// all that named the feature, and a never-written charter was
580/// indistinguishable from a deliberately-empty one. Discovery was scrolling
581/// the TUI's `/help` or finding the gear on the web page. That is the wrong
582/// way round for the one document that says what the machine is *for*.
583///
584/// The remedy hands over `$EDITOR`; it never composes a line. See
585/// [`crate::charter`]'s module doc for why that distinction, rather than the
586/// absence of a verb, is the actual invariant.
587fn charter_step(state: &CharterState) -> Step {
588 const WHY: &str = concat!(
589 "A short ranked list of standing priorities, in your own words, that rides in ",
590 "every run's prompt. Order is rank: when two conflict, the higher one wins ",
591 "outright. mecha never writes a line of it."
592 );
593 match state {
594 CharterState::Lines(n) => Step::new(
595 "charter",
596 "Your charter",
597 Status::Done,
598 format!(
599 "{n} standing priorit{} in rank order",
600 if *n == 1 { "y" } else { "ies" }
601 ),
602 ),
603 // Distinguished from `Absent` in the *detail*, not the status: both
604 // are "nothing rides in the prompt", and both are fixed by the same
605 // command, but only one of them is a half-finished edit somebody
606 // should be told about rather than a fresh install.
607 CharterState::Empty => Step::new(
608 "charter",
609 "Your charter",
610 Status::Missing,
611 format!(
612 "The file exists with no `[[line]]` entries yet, so nothing from it rides \
613 in any prompt. {WHY}"
614 ),
615 )
616 .with(
617 "Open the charter in $EDITOR.",
618 &["mecha", "charter", "edit"],
619 true,
620 )
621 .optional(),
622 CharterState::Absent => Step::new(
623 "charter",
624 "Your charter",
625 Status::Missing,
626 format!("Nothing written yet — every run is proceeding un-chartered. {WHY}"),
627 )
628 .with(
629 "Create it from a commented template and open it in $EDITOR.",
630 &["mecha", "charter", "edit"],
631 true,
632 )
633 .optional(),
634 // `Wrong`, not `Missing`: there is a document and it disagrees with
635 // what a run can load, which is a different thing to do about it —
636 // and unlike the two above, this one is already a `doctor` finding,
637 // because it is a fault rather than an absence.
638 CharterState::Broken(e) => Step::new(
639 "charter",
640 "Your charter does not load",
641 Status::Wrong,
642 format!(
643 "{e}
644
645Every run is starting un-chartered until this parses."
646 ),
647 )
648 .with(
649 "Open the charter in $EDITOR and fix it.",
650 &["mecha", "charter", "edit"],
651 true,
652 ),
653 CharterState::Unknown => Step::new(
654 "charter",
655 "Your charter",
656 Status::Unknown,
657 "the charter could not be read from here.",
658 ),
659 }
660}
661
662/// The integrations, each detected the same way: is the binary there, and has
663/// anything been authorised through it.
664///
665/// **mecha's own integrations are offered as commands to run; the graph's are
666/// only ever named.** mecha reaches the knowledge graph through the MCP tool
667/// surface and nothing else — no dependency, no second reader of its store —
668/// and a setup flow that drove `mecha-graph source add` would be exactly the
669/// coupling that rule exists to prevent. So a graph source is a sentence
670/// pointing at that project's own CLI, never a command this one spawns.
671fn integration_steps(facts: &Facts) -> Vec<Step> {
672 let mut steps = Vec::new();
673
674 steps.push(match (facts.has_mail_binary, facts.mail_accounts) {
675 (false, _) => Step::new(
676 "mail",
677 "Mail and calendar",
678 Status::Missing,
679 "`mecha-mail` is not on PATH. It is a separate crate, and optional — nothing else \
680 needs it.",
681 )
682 .with(
683 "Install the mail and calendar MCP servers.",
684 &["cargo", "install", "mecha-mail", "--locked"],
685 false,
686 )
687 .optional(),
688 (true, Some(0)) => Step::new(
689 "mail",
690 "Mail and calendar",
691 Status::Missing,
692 "`mecha-mail` is installed with no accounts authorised. The model names an \
693 *account*, never a provider, so add one per mailbox.",
694 )
695 .with(
696 "Authorise a mailbox. Needs a browser, or `--paste` over SSH.",
697 &["mecha-mail", "auth", "personal", "--provider", "google"],
698 true,
699 )
700 .optional(),
701 (true, Some(n)) => Step::new(
702 "mail",
703 "Mail and calendar",
704 Status::Done,
705 format!("{n} account(s) authorised"),
706 ),
707 (true, None) => Step::new(
708 "mail",
709 "Mail and calendar",
710 Status::Unknown,
711 "`mecha-mail` is installed; its credential store could not be read from here.",
712 ),
713 });
714
715 steps.push(match (facts.has_docs_binary, facts.docs_accounts) {
716 (false, _) => Step::new(
717 "docs",
718 "Google Docs, Sheets and Slides",
719 Status::Missing,
720 "`mecha-docs` ships with the mail crate. Under `drive.file` it reaches only files \
721 it created or you handed it in Google's own picker — which is the reason to want \
722 it, and no instruction inside a run can widen that.",
723 )
724 .with(
725 "Install the documents server (same crate as mail).",
726 &["cargo", "install", "mecha-mail", "--locked"],
727 false,
728 )
729 .optional(),
730 (true, Some(0)) => Step::new(
731 "docs",
732 "Google Docs, Sheets and Slides",
733 Status::Missing,
734 "`mecha-docs` is installed with no account authorised.",
735 )
736 .with(
737 "Authorise Drive access. Use `--paste` if there is no browser here.",
738 &["mecha-docs", "auth", "personal"],
739 true,
740 )
741 .optional(),
742 (true, Some(n)) => Step::new(
743 "docs",
744 "Google Docs, Sheets and Slides",
745 Status::Done,
746 format!("{n} account(s) authorised"),
747 ),
748 (true, None) => Step::new(
749 "docs",
750 "Google Docs, Sheets and Slides",
751 Status::Unknown,
752 "installed; the credential store could not be read from here.",
753 ),
754 });
755
756 steps.push(match facts.slack_linked {
757 Some(true) => Step::new(
758 "slack",
759 "Slack as a remote control",
760 Status::Done,
761 "linked to a workspace",
762 ),
763 Some(false) => Step::new(
764 "slack",
765 "Slack as a remote control",
766 Status::Missing,
767 "Watch a run from a phone, approve what it wants to send, and hand files in and \
768 out. The owner is bound by a nonce printed on this machine, so proving shell \
769 access here is what claims it.",
770 )
771 .with(
772 "Start the Slack setup, which prints the binding nonce.",
773 &["mecha", "slack", "auth"],
774 true,
775 )
776 .optional(),
777 None => Step::new(
778 "slack",
779 "Slack as a remote control",
780 Status::Unknown,
781 "the binding store could not be read from here.",
782 ),
783 });
784
785 steps.push(if facts.has_graph_binary {
786 Step::new(
787 "graph",
788 "The personal knowledge graph",
789 Status::Done,
790 "`mecha-graph-mcp` is on PATH. Its own sources — ambient conversations, a \
791 calendar ICS feed, Slack, messages, mail — are configured with `mecha-graph \
792 source`, in that project. mecha reaches the graph only through its MCP tools \
793 and deliberately knows nothing else about it.",
794 )
795 } else {
796 Step::new(
797 "graph",
798 "The personal knowledge graph",
799 Status::Missing,
800 "Memory: who people are, what happened when. A separate project, wired in as an \
801 MCP server whose reads are marked untrusted — a graph fed by mail and messages \
802 holds third-party text by construction.",
803 )
804 .with(
805 "Install the graph's MCP server.",
806 &["cargo", "install", "mecha-graph-mcp", "--locked"],
807 false,
808 )
809 .optional()
810 });
811
812 steps
813}
814
815/// The values a local server reports about itself, ready to be written down.
816///
817/// Returned rather than applied, so the caller can show them before changing
818/// anything: this rewrites settings a person may have had reasons for, and
819/// "here is what I would write" is a different act from writing it.
820pub fn verified_settings(props: &crate::provider::preflight::Props) -> Vec<(&'static str, String)> {
821 let mut out = Vec::new();
822 if let Some(alias) = &props.model_alias {
823 out.push(("model", toml_string(alias)));
824 }
825 // The per-slot figure, which is the one a request actually gets. Reading
826 // it back is what makes `-c` versus `-c / -np` a non-question.
827 if let Some(n) = props.default_generation_settings.n_ctx {
828 out.push(("context_window", n.to_string()));
829 }
830 out.push(("vision", props.modalities.vision.to_string()));
831 out
832}
833
834/// A TOML string literal, escaped the way **TOML** escapes.
835///
836/// **Not `format!("{s:?}")`, which is Rust escaping.** `str`'s `Debug`
837/// renders a control character as `\u{1b}`; TOML's `\u` takes exactly four
838/// hex digits and no braces, so that value is written into a config file that
839/// then fails to parse. Quotes, backslashes, tabs and newlines happen to
840/// escape compatibly, which is why this survived unnoticed — only
841/// non-printables bite.
842///
843/// It matters here more than it looks because of where these bytes come from:
844/// a server's own `/props`, on the discovery path, where the server is one
845/// nobody has named. [`answers_like_a_model_server`] establishes that
846/// something answering `:8080` may be a stranger; this makes sure a stranger's
847/// answer cannot produce a config that no later `mecha` command can load.
848pub fn toml_string(s: &str) -> String {
849 toml::Value::String(s.to_string()).to_string()
850}
851
852/// Count the per-account directories under a credential root.
853///
854/// `None` when the root cannot be read *and does not simply not exist* — an
855/// absent directory is a confident zero, where an unreadable one is genuinely
856/// unknown and must not be reported as "no accounts".
857pub fn count_accounts(root: &Path) -> Option<usize> {
858 match std::fs::read_dir(root) {
859 Ok(entries) => Some(
860 entries
861 .flatten()
862 .filter(|e| e.path().join("oauth.json").is_file())
863 .count(),
864 ),
865 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(0),
866 Err(_) => None,
867 }
868}
869
870/// Where declines are recorded.
871///
872/// **In `~/.mecha/`, never in layered config**, on the rule triggers, skills
873/// and the charter all keep: a project's `mecha.toml` arrives with a cloned
874/// repository, and a repo that could decline your integrations would be
875/// deciding what your machine offers you. There is deliberately no config
876/// field pointing anywhere else, which is the same way the other three keep
877/// the guarantee — by having no configurable path at all rather than by
878/// asking callers to choose the global loader.
879pub fn declined_path(home: &Path) -> PathBuf {
880 home.join("setup-declined.json")
881}
882
883/// Step ids the owner has said they do not want.
884///
885/// An absent file is a confident empty set; an unreadable or malformed one
886/// is `None`, so the caller can *say so* rather than quietly proceeding as
887/// though nothing had been declined. See [`Facts::declined`] for why the
888/// resolution of `None` is nonetheless "offer everything".
889pub fn read_declined(home: &Path) -> Option<std::collections::BTreeSet<String>> {
890 let path = declined_path(home);
891 match std::fs::read_to_string(&path) {
892 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
893 Some(std::collections::BTreeSet::new())
894 }
895 Err(_) => None,
896 Ok(text) => serde_json::from_str::<Declined>(&text)
897 .ok()
898 .map(|d| d.declined),
899 }
900}
901
902/// Record that the owner does not want `id`. Idempotent.
903///
904/// Read-modify-write rather than append, because this is a *set* and the
905/// file is small enough that the whole document is the unit. Written to a
906/// temp sibling and renamed, so a crash mid-write leaves the previous
907/// answer whole rather than a truncated file that reads as "nothing
908/// declined" — which would silently re-offer everything.
909///
910/// `Ok(Some(path))` says an unreadable store was **set aside** at `path`
911/// rather than overwritten — see [`salvage_unreadable`]. The caller is
912/// expected to say so; losing somebody's recorded answers is not a thing to
913/// do quietly.
914pub fn decline(home: &Path, id: &str) -> std::io::Result<DeclineWrite> {
915 let (mut set, salvaged) = read_for_write(home);
916 let changed = set.insert(id.to_string());
917 write_declined(home, &set)?;
918 Ok(DeclineWrite { salvaged, changed })
919}
920
921/// Take one back out — the undo, so a decline is a preference rather than a
922/// door that locks behind you. `id` of `None` clears every one.
923pub fn undecline(home: &Path, id: Option<&str>) -> std::io::Result<DeclineWrite> {
924 let (mut set, salvaged) = read_for_write(home);
925 let changed = match id {
926 Some(id) => set.remove(id),
927 None => {
928 let had = !set.is_empty();
929 set.clear();
930 had
931 }
932 };
933 write_declined(home, &set)?;
934 Ok(DeclineWrite { salvaged, changed })
935}
936
937/// What a write to the decline store actually did.
938///
939/// **`changed` is graded off the set, never off the argument.** `undecline`
940/// used to discard `BTreeSet::remove`'s answer, so a typo'd id wrote the set
941/// back untouched and the caller still announced *"`slak` will be offered
942/// again"* and exited 0 — the person believed the way back had been taken,
943/// ran `mecha setup`, and saw `you said no thanks` on the step they had just
944/// "restored", with nothing anywhere saying the two disagreed. A claim about
945/// a local write is worth checking against the write, which is the same rule
946/// [`salvage_unreadable`] one line up exists for and the same rule the
947/// harness applies to everything a *model* says about its own work.
948#[derive(Debug, Default, Clone, PartialEq, Eq)]
949pub struct DeclineWrite {
950 /// Where an unreadable store was set aside, when it had to be.
951 pub salvaged: Option<PathBuf>,
952 /// Whether the recorded set actually changed.
953 pub changed: bool,
954}
955
956/// The set to modify, and where the previous file went if it could not be
957/// read.
958///
959/// **`unwrap_or_default()` here was this module's own rule inverted.**
960/// [`read_declined`] answers `None` for an unreadable or malformed store
961/// precisely so a caller can tell *unknown* from *empty* — and collapsing it
962/// to empty on the write path then **persisted** the collapse: somebody with
963/// a typo in `setup-declined.json` saw `setup`'s honest "could not be read"
964/// warning, answered `never` to one step, and had the file rewritten with
965/// exactly that one id, every previously recorded answer gone with no further
966/// word. [`write_declined`]'s own comment argues that a partial write "would
967/// silently re-offer everything"; this path was doing it on purpose.
968///
969/// So the bytes are kept. Same move `mecha setup --write` makes before
970/// editing a config it did not author: a file somebody may have meant
971/// something by is moved aside, never overwritten.
972fn read_for_write(home: &Path) -> (std::collections::BTreeSet<String>, Option<PathBuf>) {
973 match read_declined(home) {
974 Some(set) => (set, None),
975 None => (Default::default(), salvage_unreadable(home)),
976 }
977}
978
979/// Move an unreadable decline store aside so the write about to happen
980/// cannot destroy it. Best-effort: a salvage that fails must not stop the
981/// answer being recorded, or a read-only directory would make `never`
982/// permanently unavailable.
983fn salvage_unreadable(home: &Path) -> Option<PathBuf> {
984 let path = declined_path(home);
985 // Stamped, so a second corruption later does not overwrite the first
986 // salvage — the whole point here is that nothing is lost quietly.
987 let aside = path.with_extension(format!(
988 "json.unreadable.{}",
989 std::time::SystemTime::now()
990 .duration_since(std::time::UNIX_EPOCH)
991 .map(|d| d.as_secs())
992 .unwrap_or(0)
993 ));
994 std::fs::rename(&path, &aside).ok().map(|()| aside)
995}
996
997fn write_declined(home: &Path, set: &std::collections::BTreeSet<String>) -> std::io::Result<()> {
998 let path = declined_path(home);
999 if let Some(parent) = path.parent() {
1000 std::fs::create_dir_all(parent)?;
1001 }
1002 let body = serde_json::to_string_pretty(&Declined {
1003 declined: set.clone(),
1004 })
1005 .map_err(std::io::Error::other)?;
1006 // Same directory, so the rename cannot cross a filesystem — the shape
1007 // `serve::settings`' charter save already uses, and for the same reason.
1008 let tmp = path.with_extension(format!("json.tmp.{}", std::process::id()));
1009 std::fs::write(&tmp, body)?;
1010 std::fs::rename(&tmp, &path)
1011}
1012
1013/// The file's own shape. A named field rather than a bare array so the
1014/// document can grow a sibling (a timestamp, a reason) without the next
1015/// version having to guess what a top-level list meant.
1016#[derive(Debug, Default, Serialize, serde::Deserialize)]
1017struct Declined {
1018 #[serde(default)]
1019 declined: std::collections::BTreeSet<String>,
1020}
1021
1022/// Read the charter the way a run does, for [`Facts`].
1023///
1024/// Through [`crate::charter::Charter::load`] rather than by looking at the
1025/// file, because the question is not "is there a file" but "would a run get
1026/// anything from it" — and those differ for a template nobody filled in and
1027/// for a document with a typo'd table name.
1028pub fn charter_state(path: &Path) -> CharterState {
1029 match std::fs::metadata(path) {
1030 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return CharterState::Absent,
1031 Err(_) => return CharterState::Unknown,
1032 Ok(_) => {}
1033 }
1034 match crate::charter::Charter::load(path) {
1035 Err(e) => CharterState::Broken(format!("{e:#}")),
1036 Ok(c) if c.is_empty() => CharterState::Empty,
1037 Ok(c) => CharterState::Lines(c.lines().len()),
1038 }
1039}
1040
1041/// Is `name` runnable from `PATH`?
1042pub fn on_path(name: &str) -> bool {
1043 let Some(path) = std::env::var_os("PATH") else {
1044 return false;
1045 };
1046 std::env::split_paths(&path).any(|dir| dir.join(name).is_file())
1047}
1048
1049#[cfg(test)]
1050mod tests {
1051 use super::*;
1052 use crate::provider::preflight::{GenerationSettings, Modalities, Props};
1053
1054 fn cfg_with_local(context_window: u64, vision: Option<bool>) -> Config {
1055 let mut cfg = Config::default();
1056 let mut p = cfg.providers.get("anthropic").cloned().unwrap();
1057 p.kind = "local".into();
1058 p.model = Some("qwen3.6-35b-a3b".into());
1059 p.base_url = Some("http://127.0.0.1:8080".into());
1060 p.api_key_env = None;
1061 p.context_window = Some(context_window);
1062 p.vision = vision;
1063 cfg.providers.insert("local".into(), p);
1064 cfg
1065 }
1066
1067 fn props(n_ctx: u64, slots: u64, vision: bool) -> Props {
1068 Props {
1069 model_alias: Some("qwen3.6-35b-a3b".into()),
1070 total_slots: Some(slots),
1071 modalities: Modalities { vision },
1072 default_generation_settings: GenerationSettings { n_ctx: Some(n_ctx) },
1073 }
1074 }
1075
1076 fn facts(props: Option<Props>) -> Facts {
1077 Facts {
1078 provider_credential: true,
1079 props,
1080 mail_accounts: Some(1),
1081 docs_accounts: Some(1),
1082 slack_linked: Some(true),
1083 has_mail_binary: true,
1084 has_docs_binary: true,
1085 has_graph_binary: true,
1086 scheduler_installed: true,
1087 trigger_count: 0,
1088 // A complete install has a charter and a config file, so
1089 // `everything_configured…` keeps meaning what it says.
1090 charter: CharterState::Lines(3),
1091 config_file: true,
1092 local_probe: LocalProbe::NotAttempted,
1093 declined: Default::default(),
1094 }
1095 }
1096
1097 fn step<'a>(steps: &'a [Step], id: &str) -> &'a Step {
1098 steps.iter().find(|s| s.id == id).expect("step missing")
1099 }
1100
1101 /// A complete install has nothing to say about itself.
1102 #[test]
1103 fn everything_configured_and_agreeing_reports_no_work() {
1104 let cfg = cfg_with_local(262144, Some(true));
1105 let steps = plan(&cfg, "local", &facts(Some(props(262144, 4, true))));
1106 assert!(
1107 steps.iter().all(|s| s.status == Status::Done),
1108 "unexpected work: {:?}",
1109 steps
1110 .iter()
1111 .filter(|s| s.status != Status::Done)
1112 .map(|s| &s.id)
1113 .collect::<Vec<_>>()
1114 );
1115 }
1116
1117 /// A fresh install is *offered* a charter, and the offer never composes
1118 /// one.
1119 ///
1120 /// The gap this closes: `doctor::check_charter` returns early on a file
1121 /// that does not exist — right, because never having written one is not
1122 /// a fault — so before this step nothing on any surface named the
1123 /// feature to a new user at all.
1124 #[test]
1125 fn a_fresh_install_is_offered_a_charter_and_the_offer_authors_nothing() {
1126 let cfg = cfg_with_local(262144, Some(true));
1127 let mut f = facts(Some(props(262144, 4, true)));
1128 f.charter = CharterState::Absent;
1129 let steps = plan(&cfg, "local", &f);
1130 let charter = step(&steps, "charter");
1131 assert_eq!(charter.status, Status::Missing);
1132 let remedy = charter.remedy.as_ref().expect("a fresh charter is offered");
1133 assert_eq!(remedy.argv, ["mecha", "charter", "edit"]);
1134 assert!(
1135 remedy.needs_terminal,
1136 "handing over $EDITOR needs a keyboard"
1137 );
1138 // The offer must not put words in anyone's mouth: the *only* text
1139 // this module supplies about a charter is a description of what one
1140 // is. Nothing here may read as a suggested priority, because a
1141 // priority mecha proposed is a priority a model could later argue
1142 // from — see `charter.rs`'s module doc for the invariant.
1143 assert!(
1144 charter.detail.contains("in your own words"),
1145 "the detail should say whose words these are: {}",
1146 charter.detail
1147 );
1148 }
1149
1150 /// A file with no lines and no file at all are both "nothing rides in
1151 /// the prompt" and are told apart in the detail, because only one of
1152 /// them is a half-finished edit.
1153 #[test]
1154 fn an_empty_charter_reads_differently_from_an_absent_one() {
1155 let cfg = cfg_with_local(262144, Some(true));
1156 let mut f = facts(Some(props(262144, 4, true)));
1157
1158 f.charter = CharterState::Empty;
1159 let empty = step(&plan(&cfg, "local", &f), "charter").clone();
1160 f.charter = CharterState::Absent;
1161 let absent = step(&plan(&cfg, "local", &f), "charter").clone();
1162
1163 assert_eq!(empty.status, Status::Missing);
1164 assert_eq!(absent.status, Status::Missing);
1165 assert_ne!(
1166 empty.detail, absent.detail,
1167 "a template nobody filled in is not the same finding as a fresh install"
1168 );
1169 }
1170
1171 /// A charter that does not load is `Wrong`, not `Missing`: there *is* a
1172 /// document and it disagrees with what a run can load, which is a
1173 /// different thing to do about it — and, unlike the other two, already
1174 /// a `doctor` finding.
1175 #[test]
1176 fn a_charter_that_does_not_load_is_wrong_rather_than_missing() {
1177 let cfg = cfg_with_local(262144, Some(true));
1178 let mut f = facts(Some(props(262144, 4, true)));
1179 f.charter = CharterState::Broken("duplicate id `x`".into());
1180 let charter = step(&plan(&cfg, "local", &f), "charter").clone();
1181 assert_eq!(charter.status, Status::Wrong);
1182 assert!(charter.detail.contains("duplicate id"));
1183 assert!(
1184 charter.detail.contains("un-chartered"),
1185 "say what it costs, not just that it failed: {}",
1186 charter.detail
1187 );
1188 }
1189
1190 /// A decline is remembered, and it is not a fifth shade of "not done".
1191 #[test]
1192 fn a_declined_step_reports_the_decision_rather_than_the_absence() {
1193 let cfg = cfg_with_local(262144, Some(true));
1194 let mut f = facts(Some(props(262144, 4, true)));
1195 f.slack_linked = Some(false);
1196
1197 // Without the decline it is ordinary outstanding work, with a remedy.
1198 let before = step(&plan(&cfg, "local", &f), "slack").clone();
1199 assert_eq!(before.status, Status::Missing);
1200 assert!(before.remedy.is_some());
1201
1202 f.declined.insert("slack".into());
1203 let after = step(&plan(&cfg, "local", &f), "slack").clone();
1204 assert_eq!(after.status, Status::Declined);
1205 assert!(
1206 after.remedy.is_none(),
1207 "a remedy is an offer, and this one has been answered"
1208 );
1209 assert_eq!(
1210 after.detail, before.detail,
1211 "a decline changes whether a step is asked for, never what it says"
1212 );
1213 }
1214
1215 /// The step that blocks every other one carries a **remedy**, not a
1216 /// viewer, when there is something to run.
1217 ///
1218 /// It used to say `anthropic has no usable credential` and offer
1219 /// `mecha config show`, which displays a file and fixes nothing — the one
1220 /// step that makes all the others untestable was the one with no path
1221 /// forward.
1222 #[test]
1223 fn a_running_local_server_turns_the_blocking_step_into_something_runnable() {
1224 let cfg = Config::default();
1225 let mut f = facts(None);
1226 f.provider_credential = false;
1227 f.local_probe = LocalProbe::Found(LocalServer {
1228 base_url: "http://127.0.0.1:8080".into(),
1229 props: props(32768, 4, false),
1230 });
1231
1232 let s = plan(&cfg, "anthropic", &f);
1233 let step = step(&s, "provider-credential");
1234 assert_eq!(
1235 step.remedy.as_ref().map(|r| r.argv.clone()),
1236 Some(vec!["mecha".into(), "setup".into(), "--write".into()]),
1237 "a server is running: writing it down is a thing this tool can do"
1238 );
1239 assert!(
1240 step.detail.contains("127.0.0.1:8080") && step.detail.contains("qwen3.6-35b-a3b"),
1241 "name what was found, so the offer is checkable: {}",
1242 step.detail
1243 );
1244 // Never declinable, however it is phrased — a credential is not a
1245 // feature going unused.
1246 assert!(!step.optional);
1247 }
1248
1249 /// With nothing answering, the fix is a secret — and a secret is the one
1250 /// thing this tool must not write. So the step names the exact variable
1251 /// and says the key never lands in a file, rather than pointing at a
1252 /// command that could not help.
1253 #[test]
1254 fn with_no_server_the_step_names_the_variable_and_promises_not_to_store_it() {
1255 let cfg = Config::default();
1256 let mut f = facts(None);
1257 f.provider_credential = false;
1258 f.local_probe = LocalProbe::NothingAnswered;
1259
1260 let s = plan(&cfg, "anthropic", &f);
1261 let step = step(&s, "provider-credential");
1262 assert!(
1263 step.detail.contains("ANTHROPIC_API_KEY"),
1264 "name the variable rather than describing it: {}",
1265 step.detail
1266 );
1267 assert!(
1268 step.detail.contains("never the key itself"),
1269 "say where the secret does *not* go: {}",
1270 step.detail
1271 );
1272 // Both ways out are named, including the one this project is for.
1273 assert!(step.detail.contains("locally"), "{}", step.detail);
1274 assert!(
1275 step.remedy.is_none(),
1276 "there is no command that can set somebody's environment for them, \
1277 and offering one that only prints is what this replaced"
1278 );
1279 }
1280
1281 /// A provider with no `api_key_env` cannot be fixed by exporting
1282 /// anything, and telling somebody to "set the variable it names" about a
1283 /// provider that names none sends them looking for a typo they did not
1284 /// make.
1285 #[test]
1286 fn a_provider_naming_no_key_variable_is_not_told_to_set_one() {
1287 let mut cfg = Config::default();
1288 cfg.providers.get_mut("anthropic").unwrap().api_key_env = None;
1289 let mut f = facts(None);
1290 f.provider_credential = false;
1291
1292 f.local_probe = LocalProbe::NothingAnswered;
1293 let detail = step(&plan(&cfg, "anthropic", &f), "provider-credential")
1294 .detail
1295 .clone();
1296 assert!(detail.contains("names no `api_key_env`"), "{detail}");
1297 assert!(!detail.contains("export "), "nothing to export: {detail}");
1298 }
1299
1300 /// **A probe that never ran must not be reported as one that found
1301 /// nothing.**
1302 ///
1303 /// The config that hits this: a `[providers.local]` on :8080 with a
1304 /// server running on it, and `default_provider` still pointing at a
1305 /// cloud provider whose key was never exported. Nothing probes (a local
1306 /// provider is configured), so an `Option<LocalServer>` made "never
1307 /// asked" indistinguishable from "asked and heard nothing" — and the
1308 /// person with a running server was told *"Nothing was answering at
1309 /// http://127.0.0.1:8080 when this ran"*. A fact with no observation
1310 /// behind it, which is this module's own header rule inverted.
1311 #[test]
1312 fn an_unattempted_probe_is_never_reported_as_a_failed_one() {
1313 let cfg = Config::default();
1314 let mut f = facts(None);
1315 f.provider_credential = false;
1316
1317 f.local_probe = LocalProbe::NothingAnswered;
1318 let asked = step(&plan(&cfg, "anthropic", &f), "provider-credential")
1319 .detail
1320 .clone();
1321 assert!(
1322 asked.contains("Nothing was answering"),
1323 "a probe that ran may report what it found: {asked}"
1324 );
1325
1326 f.local_probe = LocalProbe::NotAttempted;
1327 let never_asked = step(&plan(&cfg, "anthropic", &f), "provider-credential")
1328 .detail
1329 .clone();
1330 assert!(
1331 !never_asked.contains("Nothing was answering"),
1332 "a probe that never ran must claim nothing about what is there: {never_asked}"
1333 );
1334 // And the rest of the advice survives — this is about dropping one
1335 // unearned sentence, not the route it belongs to.
1336 assert!(never_asked.contains("Run a model locally"), "{never_asked}");
1337 }
1338
1339 /// A configured local provider that simply is not selected gets named,
1340 /// with the one-line fix.
1341 ///
1342 /// Before this branch existed, that install produced a single step
1343 /// telling somebody to serve a model they were already serving, and
1344 /// never mentioned the provider sitting in their own config.
1345 #[test]
1346 fn a_configured_but_unselected_local_provider_is_named_as_the_way_out() {
1347 let mut cfg = Config::default();
1348 let mut local = cfg.providers.get("anthropic").cloned().unwrap();
1349 local.kind = "local".into();
1350 local.base_url = Some("http://127.0.0.1:8080".into());
1351 local.api_key_env = None;
1352 cfg.providers.insert("local".into(), local);
1353
1354 let mut f = facts(None);
1355 f.provider_credential = false;
1356 // Nothing probed, because a local provider exists — which is exactly
1357 // the state that used to produce a false report.
1358 f.local_probe = LocalProbe::NotAttempted;
1359
1360 let detail = step(&plan(&cfg, "anthropic", &f), "provider-credential")
1361 .detail
1362 .clone();
1363 assert!(
1364 detail.contains("`local`") && detail.contains("127.0.0.1:8080"),
1365 "name the provider they already have, and where it points: {detail}"
1366 );
1367 assert!(
1368 detail.contains("default_provider"),
1369 "and the one-line fix: {detail}"
1370 );
1371 assert!(
1372 !detail.contains("Nothing was answering"),
1373 "nothing probed it, so nothing may be claimed about it: {detail}"
1374 );
1375 }
1376
1377 /// **Parsing is not identification.** `Props` defaults every field so a
1378 /// llama-server version bump costs a check rather than a parse failure —
1379 /// so `{}` from any JSON service on :8080 parses perfectly. Without this
1380 /// check it was announced as "already serving (an unnamed model)", and
1381 /// one `y` would repoint `default_provider` at it with no `model` and no
1382 /// `context_window`: the two settings this module exists to stop people
1383 /// getting wrong, both of which degrade quietly.
1384 #[test]
1385 fn a_stranger_answering_200_is_not_a_model_server() {
1386 use crate::provider::preflight::Props;
1387
1388 // The premise, pinned rather than assumed: an empty object really
1389 // does parse. If `Props` ever stops being fully-defaulted this
1390 // assertion fails and the check below can be reconsidered, instead of
1391 // quietly guarding against something that can no longer happen.
1392 let stranger: Props =
1393 serde_json::from_str("{}").expect("Props defaults every field, so `{}` parses");
1394 assert!(
1395 !answers_like_a_model_server(&stranger),
1396 "any JSON service answering 200 would otherwise read as a model server"
1397 );
1398 // A plausible non-model service: valid JSON, unrelated keys, still a
1399 // clean parse because unknown fields are ignored.
1400 let proxy: Props = serde_json::from_str(r#"{"status":"ok","uptime":42}"#)
1401 .expect("unknown fields are ignored");
1402 assert!(!answers_like_a_model_server(&proxy));
1403
1404 // Either field alone identifies a real one — a disjunction on
1405 // purpose, so a build that reports one of them differently is not
1406 // rejected, which is what the tolerance was for.
1407 let named = Props {
1408 model_alias: Some("qwen3-14b".into()),
1409 ..Props::default()
1410 };
1411 assert!(answers_like_a_model_server(&named));
1412 assert!(answers_like_a_model_server(&props(32768, 4, false)));
1413 }
1414
1415 /// A new install is told the config file exists and where — nothing did.
1416 /// `Config::load_global` tolerating its absence is right, and is also why
1417 /// nobody ever learned about it.
1418 #[test]
1419 fn a_missing_config_file_is_offered_and_a_present_one_is_not_mentioned() {
1420 let cfg = Config::default();
1421 let mut f = facts(None);
1422 f.config_file = false;
1423 let s = plan(&cfg, "anthropic", &f);
1424 assert_eq!(
1425 step(&s, "config-file")
1426 .remedy
1427 .as_ref()
1428 .map(|r| r.argv.clone()),
1429 Some(vec!["mecha".into(), "config".into(), "init".into()])
1430 );
1431
1432 f.config_file = true;
1433 assert!(
1434 !plan(&cfg, "anthropic", &f)
1435 .iter()
1436 .any(|s| s.id == "config-file"),
1437 "a file that exists is not a step"
1438 );
1439 }
1440
1441 /// **The step that makes everything else work cannot be declined.**
1442 ///
1443 /// The bug this fails on was found by running the flow rather than by
1444 /// reading it: `declinable` was inferred from `Status::Missing`, and a
1445 /// provider with no credential is missing — so declining every "missing"
1446 /// step reported `Nothing outstanding.` on an install that could not
1447 /// answer a single prompt. Asserted against a *hand-edited* store,
1448 /// because the file is plain JSON and a guarantee that only the prompt
1449 /// enforced would be one anybody could edit around.
1450 #[test]
1451 fn a_step_that_is_not_optional_cannot_be_declined_even_by_editing_the_file() {
1452 let mut cfg = Config::default();
1453 // A provider with no credential and no local server: the one step
1454 // that blocks every other.
1455 let p = cfg.providers.get_mut("anthropic").unwrap();
1456 p.api_key_env = Some("MECHA_TEST_NO_SUCH_KEY".into());
1457 let mut f = facts(None);
1458 f.provider_credential = false;
1459 f.slack_linked = Some(false);
1460
1461 for id in [
1462 "provider-credential",
1463 "mail",
1464 "docs",
1465 "slack",
1466 "graph",
1467 "charter",
1468 ] {
1469 f.declined.insert(id.to_string());
1470 }
1471 let steps = plan(&cfg, "anthropic", &f);
1472
1473 assert_eq!(
1474 step(&steps, "provider-credential").status,
1475 Status::Missing,
1476 "a credential is not a feature somebody can decline"
1477 );
1478 // The integrations, which genuinely are optional, still honour it —
1479 // otherwise this test would pass on a decline that never worked.
1480 assert_eq!(step(&steps, "slack").status, Status::Declined);
1481 }
1482
1483 /// Every declinable step is one where "I don't want this" is a coherent
1484 /// sentence. Asserted over the whole plan rather than per step, so the
1485 /// next step added has to be decided about rather than defaulting into
1486 /// being refusable.
1487 #[test]
1488 fn only_genuinely_optional_things_are_declinable() {
1489 let cfg = Config::default();
1490 let mut f = facts(None);
1491 f.provider_credential = false;
1492 f.mail_accounts = Some(0);
1493 f.docs_accounts = Some(0);
1494 f.slack_linked = Some(false);
1495 f.has_graph_binary = false;
1496 f.charter = CharterState::Absent;
1497 f.trigger_count = 1;
1498 f.scheduler_installed = false;
1499
1500 let steps = plan(&cfg, "anthropic", &f);
1501 let optional: Vec<&str> = steps
1502 .iter()
1503 .filter(|s| s.optional)
1504 .map(|s| s.id.as_str())
1505 .collect();
1506 assert_eq!(optional, ["mail", "docs", "slack", "graph", "charter"]);
1507 }
1508
1509 /// The offer text is one paragraph, not a wrapped source literal.
1510 ///
1511 /// A `\`-continued string that loses its backslash keeps the source's
1512 /// indentation, and the result reads as a bug to the one person least
1513 /// able to tell it is cosmetic — somebody on their first five minutes.
1514 /// Caught by running the command; kept by this.
1515 #[test]
1516 fn no_step_detail_carries_its_source_indentation() {
1517 let cfg = Config::default();
1518 let mut f = facts(None);
1519 f.provider_credential = false;
1520 f.charter = CharterState::Empty;
1521 f.trigger_count = 1;
1522 f.scheduler_installed = false;
1523 for s in plan(&cfg, "anthropic", &f) {
1524 assert!(
1525 !s.detail.contains(" "),
1526 "`{}` carries a run of spaces from its source literal: {:?}",
1527 s.id,
1528 s.detail
1529 );
1530 }
1531 }
1532
1533 /// The fact beats the preference. Declining Slack and then linking it
1534 /// anyway — from the phone, or by running `mecha slack auth` directly —
1535 /// must read as done, or a stale decline hides a working integration
1536 /// from its own owner.
1537 #[test]
1538 fn a_decline_never_overwrites_a_step_that_is_actually_done() {
1539 let cfg = cfg_with_local(262144, Some(true));
1540 let mut f = facts(Some(props(262144, 4, true)));
1541 f.slack_linked = Some(true);
1542 f.declined.insert("slack".into());
1543 assert_eq!(step(&plan(&cfg, "local", &f), "slack").status, Status::Done);
1544 }
1545
1546 /// And it never suppresses a failure. "I don't want mail" is not "I
1547 /// don't want to be told my mail is broken" — a decline that could hide
1548 /// a `Wrong` would be a silently-degrading guard.
1549 #[test]
1550 fn a_decline_cannot_suppress_a_broken_one() {
1551 let cfg = cfg_with_local(262144, Some(true));
1552 let mut f = facts(Some(props(262144, 4, true)));
1553 f.charter = CharterState::Broken("bad toml".into());
1554 f.declined.insert("charter".into());
1555 assert_eq!(
1556 step(&plan(&cfg, "local", &f), "charter").status,
1557 Status::Wrong,
1558 "a decline must not hide a document that stops every run being chartered"
1559 );
1560 }
1561
1562 /// An unknown store is not silently declined either — `Unknown` is
1563 /// already "cannot tell from here" and must not become "answered".
1564 #[test]
1565 fn a_decline_does_not_apply_to_an_unknown_step() {
1566 let cfg = cfg_with_local(262144, Some(true));
1567 let mut f = facts(Some(props(262144, 4, true)));
1568 f.mail_accounts = None;
1569 f.declined.insert("mail".into());
1570 assert_eq!(
1571 step(&plan(&cfg, "local", &f), "mail").status,
1572 Status::Unknown
1573 );
1574 }
1575
1576 /// The store round-trips, is idempotent, and can be undone — a decline
1577 /// is a preference, not a door that locks behind you.
1578 #[test]
1579 fn declines_round_trip_and_can_be_taken_back() {
1580 let home = std::env::temp_dir().join(format!(
1581 "mecha-declined-test-{}-{}",
1582 std::process::id(),
1583 line!()
1584 ));
1585 let _ = std::fs::remove_dir_all(&home);
1586 std::fs::create_dir_all(&home).unwrap();
1587
1588 // An absent file is a confident empty set, never `None` — the same
1589 // rule `count_accounts` keeps for a credential root.
1590 assert_eq!(read_declined(&home), Some(Default::default()));
1591
1592 decline(&home, "slack").unwrap();
1593 decline(&home, "slack").unwrap();
1594 decline(&home, "docs").unwrap();
1595 let set = read_declined(&home).unwrap();
1596 assert_eq!(set.len(), 2, "declining twice declines once");
1597 assert!(set.contains("slack") && set.contains("docs"));
1598
1599 undecline(&home, Some("slack")).unwrap();
1600 assert_eq!(
1601 read_declined(&home)
1602 .unwrap()
1603 .into_iter()
1604 .collect::<Vec<_>>(),
1605 ["docs"]
1606 );
1607 undecline(&home, None).unwrap();
1608 assert!(read_declined(&home).unwrap().is_empty());
1609
1610 // A file that is there but is not a decline store is `None`, so the
1611 // caller can say so — not an empty set, which would read as "you
1612 // have declined nothing" about a document nobody could parse.
1613 std::fs::write(declined_path(&home), "{not json").unwrap();
1614 assert_eq!(read_declined(&home), None);
1615
1616 let _ = std::fs::remove_dir_all(&home);
1617 }
1618
1619 /// **An unreadable store is kept, never overwritten.**
1620 ///
1621 /// The bug this fails on: `decline` collapsed `read_declined`'s `None`
1622 /// to an empty set and then *persisted* it, so somebody with a typo in
1623 /// `setup-declined.json` who answered `never` to one step had the file
1624 /// rewritten with exactly that one id and every earlier answer gone —
1625 /// with no message beyond the "could not be read" line they had already
1626 /// seen and reasonably read as "so nothing is recorded".
1627 #[test]
1628 fn declining_over_an_unreadable_store_keeps_the_old_bytes() {
1629 let home = std::env::temp_dir().join(format!(
1630 "mecha-declined-salvage-{}-{}",
1631 std::process::id(),
1632 line!()
1633 ));
1634 let _ = std::fs::remove_dir_all(&home);
1635 std::fs::create_dir_all(&home).unwrap();
1636
1637 let damaged = r#"{"declined": ["slack", "docs"] "#; // truncated
1638 std::fs::write(declined_path(&home), damaged).unwrap();
1639 assert_eq!(
1640 read_declined(&home),
1641 None,
1642 "the fixture is genuinely unreadable"
1643 );
1644
1645 let salvaged = decline(&home, "mail")
1646 .unwrap()
1647 .salvaged
1648 .expect("the old file is kept");
1649 assert_eq!(
1650 std::fs::read_to_string(&salvaged).unwrap(),
1651 damaged,
1652 "kept byte for byte — a salvage that rewrites is not a salvage"
1653 );
1654
1655 // The new store is well-formed and holds the answer just given.
1656 let now = read_declined(&home).unwrap();
1657 assert_eq!(now.into_iter().collect::<Vec<_>>(), ["mail"]);
1658
1659 // And the ordinary path reports no salvage, so a caller cannot print
1660 // the warning on every decline.
1661 assert_eq!(decline(&home, "slack").unwrap().salvaged, None);
1662
1663 // `undecline` takes the same care: it also writes the whole document.
1664 std::fs::write(declined_path(&home), damaged).unwrap();
1665 assert!(
1666 undecline(&home, Some("slack")).unwrap().salvaged.is_some(),
1667 "the undo path overwrites the same file and must salvage too"
1668 );
1669
1670 let _ = std::fs::remove_dir_all(&home);
1671 }
1672
1673 /// **`changed` is read off the set, never off the argument.**
1674 ///
1675 /// `undecline` discarded `BTreeSet::remove`'s answer, so a typo'd id
1676 /// wrote the set back untouched while the caller announced the restore
1677 /// and exited 0 — the person then met `you said no thanks` on the step
1678 /// they thought they had just brought back.
1679 #[test]
1680 fn a_write_reports_what_changed_rather_than_what_was_asked() {
1681 let home = std::env::temp_dir().join(format!(
1682 "mecha-declined-changed-{}-{}",
1683 std::process::id(),
1684 line!()
1685 ));
1686 let _ = std::fs::remove_dir_all(&home);
1687 std::fs::create_dir_all(&home).unwrap();
1688
1689 assert!(
1690 decline(&home, "slack").unwrap().changed,
1691 "a new decline changes it"
1692 );
1693 assert!(
1694 !decline(&home, "slack").unwrap().changed,
1695 "declining twice is idempotent, and the second one changed nothing"
1696 );
1697
1698 // The finding: an id nobody declined.
1699 assert!(
1700 !undecline(&home, Some("slak")).unwrap().changed,
1701 "a typo restores nothing, and must not report that it did"
1702 );
1703 assert!(
1704 undecline(&home, Some("slack")).unwrap().changed,
1705 "and a real one does"
1706 );
1707
1708 // `all` over an empty set is vacuous rather than false, but it is
1709 // still not a restore — saying so costs a word and saves a wrong
1710 // belief.
1711 assert!(!undecline(&home, None).unwrap().changed);
1712 decline(&home, "docs").unwrap();
1713 assert!(undecline(&home, None).unwrap().changed);
1714
1715 let _ = std::fs::remove_dir_all(&home);
1716 }
1717
1718 /// A value read off a stranger's `/props` cannot produce a config that
1719 /// will not parse.
1720 ///
1721 /// `format!("{alias:?}")` is *Rust* escaping: a control character renders
1722 /// as `\u{1b}`, and TOML's `\u` takes four hex digits with no braces, so
1723 /// the written file is a parse error — and every later `mecha` command
1724 /// then dies at `Config::load_global` with a message pointing at
1725 /// `mecha config init` rather than at what happened.
1726 #[test]
1727 fn a_model_alias_is_escaped_for_toml_rather_than_for_rust() {
1728 use crate::provider::preflight::Props;
1729
1730 for alias in [
1731 "qwen3-14b",
1732 "has \"quotes\"",
1733 "has\\backslash",
1734 // The one that bites: `Debug` writes `\u{1b}`, TOML cannot read it.
1735 "esc\u{1b}ape",
1736 "new\nline",
1737 "tab\there",
1738 ] {
1739 let props = Props {
1740 model_alias: Some(alias.to_string()),
1741 ..Props::default()
1742 };
1743 let rendered = verified_settings(&props)
1744 .into_iter()
1745 .find(|(k, _)| *k == "model")
1746 .expect("a named model is written down")
1747 .1;
1748
1749 // Round-tripped through the parser that will actually read it,
1750 // rather than eyeballed: the question is whether a *run* can load
1751 // the file, so ask the same reader.
1752 let doc: toml::Table = format!("model = {rendered}")
1753 .parse()
1754 .unwrap_or_else(|e| panic!("{alias:?} rendered as {rendered} — unparseable: {e}"));
1755 assert_eq!(
1756 doc["model"].as_str(),
1757 Some(alias),
1758 "value survived the trip"
1759 );
1760 }
1761 }
1762
1763 /// `charter_state` answers the question a run would ask, not "is there
1764 /// a file" — a template nobody filled in loads fine and supplies
1765 /// nothing.
1766 #[test]
1767 fn charter_state_reads_what_a_run_would_get() {
1768 let dir = std::env::temp_dir().join(format!(
1769 "mecha-charter-state-test-{}-{}",
1770 std::process::id(),
1771 line!()
1772 ));
1773 let _ = std::fs::remove_dir_all(&dir);
1774 std::fs::create_dir_all(&dir).unwrap();
1775 let path = dir.join("charter.toml");
1776
1777 assert_eq!(charter_state(&path), CharterState::Absent);
1778
1779 std::fs::write(&path, crate::charter::TEMPLATE).unwrap();
1780 assert_eq!(
1781 charter_state(&path),
1782 CharterState::Empty,
1783 "the shipped template must parse to zero lines, or it is authoring priorities"
1784 );
1785
1786 std::fs::write(&path, "[[line]]\nid = \"a\"\ntext = \"b\"\n").unwrap();
1787 assert_eq!(charter_state(&path), CharterState::Lines(1));
1788
1789 std::fs::write(&path, "[[lines]]\nid = \"a\"\n").unwrap();
1790 assert!(matches!(charter_state(&path), CharterState::Broken(_)));
1791
1792 let _ = std::fs::remove_dir_all(&dir);
1793 }
1794
1795 /// The trap this exists to retire: `context_window` naming `-c` rather
1796 /// than `-c / -np`. The two are the same number until `-np` moves off 1,
1797 /// which is what makes it easy to write down wrong and impossible to
1798 /// notice afterwards.
1799 #[test]
1800 fn a_context_window_that_names_c_rather_than_c_over_np_is_reported_wrong() {
1801 let cfg = cfg_with_local(1048576, Some(true));
1802 let steps = plan(&cfg, "local", &facts(Some(props(262144, 4, true))));
1803 let s = step(&steps, "local-server");
1804 assert_eq!(s.status, Status::Wrong);
1805 assert!(s.detail.contains("262144"), "{}", s.detail);
1806 assert!(s.remedy.is_some(), "and it is fixable without hand-editing");
1807 }
1808
1809 /// The bug that hid for months, in the direction nobody looks.
1810 #[test]
1811 fn a_vision_model_nobody_configured_to_use_is_reported_wrong() {
1812 let cfg = cfg_with_local(262144, None); // vision unset → false for local
1813 let steps = plan(&cfg, "local", &facts(Some(props(262144, 4, true))));
1814 assert_eq!(step(&steps, "local-server").status, Status::Wrong);
1815 }
1816
1817 /// A server that is simply not running must not be reported as a
1818 /// misconfiguration — there is nothing to compare against yet, and
1819 /// telling someone their config is wrong when it may be fine sends them
1820 /// editing a correct file.
1821 #[test]
1822 fn a_server_that_is_not_up_is_missing_rather_than_wrong() {
1823 let cfg = cfg_with_local(262144, Some(true));
1824 let steps = plan(&cfg, "local", &facts(None));
1825 assert_eq!(step(&steps, "local-server").status, Status::Missing);
1826 assert!(step(&steps, "local-server").remedy.is_none());
1827 }
1828
1829 /// "Cannot tell from here" is not "not done". A person told their mail is
1830 /// unconfigured, when the store is merely unreadable, re-runs an OAuth
1831 /// flow they did not need.
1832 #[test]
1833 fn an_unreadable_store_is_unknown_and_offers_nothing() {
1834 let mut f = facts(Some(props(262144, 4, true)));
1835 f.mail_accounts = None;
1836 let steps = plan(&cfg_with_local(262144, Some(true)), "local", &f);
1837 let s = step(&steps, "mail");
1838 assert_eq!(s.status, Status::Unknown);
1839 assert!(s.remedy.is_none(), "unknown must not propose a fix");
1840 }
1841
1842 /// The boundary that keeps mecha from growing a second way to reach the
1843 /// graph. Its sources belong to that project's CLI, and this one must
1844 /// never spawn them.
1845 #[test]
1846 fn a_graph_step_never_offers_to_run_a_graph_source_command() {
1847 let steps = plan(
1848 &cfg_with_local(262144, Some(true)),
1849 "local",
1850 &facts(Some(props(262144, 4, true))),
1851 );
1852 for s in &steps {
1853 if let Some(r) = &s.remedy {
1854 assert!(
1855 !r.argv.iter().any(|a| a == "source"),
1856 "{} would drive the graph's own source CLI: {:?}",
1857 s.id,
1858 r.argv
1859 );
1860 }
1861 }
1862 }
1863
1864 /// Nothing schedules anything for anyone. A cron slot on a machine
1865 /// holding your mail is never a default, and the offer is the *runner*,
1866 /// never a schedule.
1867 #[test]
1868 fn a_scheduler_is_only_offered_once_a_trigger_exists() {
1869 let cfg = cfg_with_local(262144, Some(true));
1870 let mut f = facts(Some(props(262144, 4, true)));
1871 f.scheduler_installed = false;
1872
1873 f.trigger_count = 0;
1874 assert!(
1875 !plan(&cfg, "local", &f).iter().any(|s| s.id == "scheduler"),
1876 "no triggers means nothing to run; do not offer a runner"
1877 );
1878
1879 f.trigger_count = 2;
1880 let steps = plan(&cfg, "local", &f);
1881 let s = step(&steps, "scheduler");
1882 assert_eq!(s.status, Status::Missing);
1883 assert!(
1884 !s.remedy.as_ref().unwrap().argv.contains(&"add".to_string()),
1885 "offer the runner, never a schedule"
1886 );
1887 }
1888
1889 /// What gets written comes off the wire, not out of the config.
1890 #[test]
1891 fn verified_settings_are_read_back_from_the_server() {
1892 let got = verified_settings(&props(65536, 4, true));
1893 assert!(got.contains(&("context_window", "65536".into())), "{got:?}");
1894 assert!(got.contains(&("vision", "true".into())), "{got:?}");
1895 assert!(
1896 got.iter().any(|(k, v)| *k == "model" && v.contains("qwen")),
1897 "{got:?}"
1898 );
1899 }
1900
1901 /// An absent directory is a confident zero; an unreadable one is not.
1902 #[test]
1903 fn a_missing_credential_root_counts_zero_rather_than_unknown() {
1904 assert_eq!(count_accounts(Path::new("/no/such/root")), Some(0));
1905 }
1906}