Skip to main content

firstpass_proxy/
onboard.rs

1//! `firstpass onboard` — fully agentic onboarding (SPEC §0.2: the primary user is an agent, and
2//! the human deserves the same one-command experience). Detect the environment, plan the exact
3//! steps, execute them under `--apply`, and verify end-to-end — so "install firstpass and route
4//! my agent through it" is one command, not a doc to follow.
5//!
6//! Structure: [`detect`] and [`plan`] are pure (injected lookups, no I/O) so the decision logic is
7//! unit-tested offline; [`execute`] performs the side effects (spawn proxy, append one marked line
8//! to the shell rc, probe `/healthz`) and is deliberately thin. Without `--apply` the command is a
9//! dry run that prints the plan — agentic, but never surprising.
10
11use std::io::Write as _;
12use std::path::PathBuf;
13
14/// Marker comment appended with the env line so re-running onboard is idempotent and offboarding
15/// is greppable.
16pub const RC_MARKER: &str = "# added by `firstpass onboard`";
17
18/// What onboarding discovered about this machine. Everything is injected-lookup driven so tests
19/// construct arbitrary environments without touching the real one.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Environment {
22    /// User's login shell binary name (`zsh` / `bash` / `fish` / other).
23    pub shell: String,
24    /// The proxy is already answering `/healthz` on the target port.
25    pub proxy_running: bool,
26    /// `ANTHROPIC_BASE_URL` already points at the target proxy.
27    pub already_routed: bool,
28    /// `ANTHROPIC_API_KEY` is set (needed for enforce-mode upstream calls; observe passes BYOK).
29    pub has_api_key: bool,
30    /// The `claude` CLI (Claude Code) is on PATH — we can offer MCP wiring.
31    pub has_claude_cli: bool,
32    /// Target bind, e.g. `127.0.0.1:8080`.
33    pub bind: String,
34}
35
36/// Which provider the ladder opens on. Only `anthropic` and `openai` are built into the provider
37/// registry; the other two must carry a `[[provider]]` block or the config will not resolve.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Provider {
40    /// Built in.
41    Anthropic,
42    /// Built in.
43    OpenAi,
44    /// Gemini dialect; needs a `[[provider]]` block and `GEMINI_API_KEY`.
45    Google,
46    /// A local OpenAI-compatible server (Ollama), escalating to a frontier rung.
47    Local,
48}
49
50impl Provider {
51    /// Every choice, in prompt order.
52    pub const ALL: [Self; 4] = [Self::Anthropic, Self::OpenAi, Self::Google, Self::Local];
53
54    /// Stable id accepted by `--provider`.
55    #[must_use]
56    pub const fn id(self) -> &'static str {
57        match self {
58            Self::Anthropic => "anthropic",
59            Self::OpenAi => "openai",
60            Self::Google => "google",
61            Self::Local => "local",
62        }
63    }
64
65    /// One-line description shown next to the option when prompting.
66    #[must_use]
67    pub const fn blurb(self) -> &'static str {
68        match self {
69            Self::Anthropic => "Claude — haiku opens, sonnet catches (built in)",
70            Self::OpenAi => "GPT — 4.1-mini opens, 5.5 catches (built in)",
71            Self::Google => "Gemini — flash opens, pro catches (needs GEMINI_API_KEY)",
72            Self::Local => "Ollama on localhost, escalating to Claude sonnet",
73        }
74    }
75
76    /// Cheapest-first ladder. Every id here is priced by the built-in table, so `firstpass savings`
77    /// is meaningful out of the box (the local rung is the exception — it is free to run).
78    #[must_use]
79    pub const fn ladder(self) -> [&'static str; 2] {
80        match self {
81            Self::Anthropic => ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"],
82            Self::OpenAi => ["openai/gpt-4.1-mini", "openai/gpt-5.5"],
83            Self::Google => ["google/gemini-3.1-flash", "google/gemini-3.1-pro"],
84            Self::Local => ["ollama/qwen2.5-coder:7b", "anthropic/claude-sonnet-5"],
85        }
86    }
87
88    /// A model for the judge gate that is deliberately NOT on the ladder — the runner enforces
89    /// maker != checker, so a judge drawn from the ladder would be rejected.
90    #[must_use]
91    pub const fn judge_model(self) -> &'static str {
92        match self {
93            Self::Anthropic | Self::Local => "anthropic/claude-opus-4-8",
94            Self::OpenAi | Self::Google => "anthropic/claude-haiku-4-5",
95        }
96    }
97
98    /// The `[[provider]]` block this choice requires, if it is not built in.
99    #[must_use]
100    pub const fn provider_block(self) -> Option<&'static str> {
101        match self {
102            Self::Anthropic | Self::OpenAi => None,
103            Self::Google => Some(
104                "[[provider]]                # only anthropic + openai are built in\n\
105                 id          = \"google\"\n\
106                 dialect     = \"gemini\"\n\
107                 base_url    = \"https://generativelanguage.googleapis.com\"\n\
108                 api_key_env = \"GEMINI_API_KEY\"\n",
109            ),
110            Self::Local => Some(
111                "[[provider]]                # local rung; escalates to a frontier model\n\
112                 id       = \"ollama\"\n\
113                 dialect  = \"openai\"\n\
114                 base_url = \"http://localhost:11434\"   # keyless\n",
115            ),
116        }
117    }
118}
119
120/// What the model is being asked to produce. This is what actually picks the gate — the whole
121/// product rests on gating the real output, so it is the one question worth asking carefully.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum Shape {
124    /// Structured output — checked against a schema.
125    Json,
126    /// Code — checked by running the caller's tests.
127    Code,
128    /// Prose — graded by a judge on a different model.
129    Prose,
130    /// Mixed traffic — scored by k-sample self-consistency.
131    Mixed,
132}
133
134impl Shape {
135    /// Every choice, in prompt order.
136    pub const ALL: [Self; 4] = [Self::Json, Self::Code, Self::Prose, Self::Mixed];
137
138    /// Stable id accepted by `--shape`.
139    #[must_use]
140    pub const fn id(self) -> &'static str {
141        match self {
142            Self::Json => "json",
143            Self::Code => "code",
144            Self::Prose => "prose",
145            Self::Mixed => "mixed",
146        }
147    }
148
149    /// One-line description shown next to the option when prompting.
150    #[must_use]
151    pub const fn blurb(self) -> &'static str {
152        match self {
153            Self::Json => "JSON / API responses — schema gate, cheapest proof there is",
154            Self::Code => "Code — your own test suite is the gate",
155            Self::Prose => "Prose — an LLM judge grades it (maker != checker)",
156            Self::Mixed => "Mixed — k-sample self-consistency scores agreement",
157        }
158    }
159
160    /// Gate ids the route names. `non-empty` and `json-valid` are built in; the rest are declared
161    /// by [`Self::gate_block`] below, because a route naming an undeclared gate will not resolve.
162    #[must_use]
163    pub const fn gates(self) -> [&'static str; 2] {
164        match self {
165            Self::Json => ["json-valid", "extract-shape"],
166            Self::Code => ["json-valid", "unit-tests"],
167            Self::Prose => ["non-empty", "judge"],
168            Self::Mixed => ["non-empty", "uncertainty"],
169        }
170    }
171
172    /// The `[[gate]]` block defining this shape's non-built-in gate.
173    #[must_use]
174    pub fn gate_block(self, provider: Provider) -> String {
175        match self {
176            Self::Json => "[[gate]]\n\
177                 id         = \"extract-shape\"\n\
178                 schema     = { type = \"object\", required = [\"id\", \"total\"] }\n\
179                 on_abstain = \"fail_closed\"\n"
180                .to_owned(),
181            // Deliberately a placeholder, not `cargo test` / `npm test`: a gate is not just any
182            // command. It must read the candidate as JSON on stdin and print
183            // {"verdict":"pass|fail|abstain"} on stdout, so naming a real test runner here would
184            // look wired up while silently abstaining on every call. `doctor` flags this until
185            // it is replaced, which is the intended nudge.
186            Self::Code => {
187                "[[gate]]\n\
188                 # REPLACE ME. A gate reads the candidate as JSON on stdin and prints\n\
189                 #   {\"verdict\":\"pass|fail|abstain\", \"score\"?: 0.0-1.0, \"reason\"?: \"...\"}\n\
190                 # on stdout. Wrap your real test command in that contract — a bare `cargo test`\n\
191                 # or `npm test` will not work, it would abstain on every request.\n\
192                 # `firstpass doctor` fails on this line until you point it at your wrapper.\n\
193                 id  = \"unit-tests\"\n\
194                 cmd = [\"your-test-runner\", \"--from-stdin\"]\n"
195                    .to_owned()
196            }
197            Self::Prose => format!(
198                "[[gate]]                    # judge sits OUTSIDE the ladder: maker != checker\n\
199                 id    = \"judge\"\n\
200                 judge = {{ model = \"{}\", threshold = 0.7, rubric = \"The response fully and \
201                 correctly resolves the request, with no errors.\" }}\n",
202                provider.judge_model()
203            ),
204            Self::Mixed => format!(
205                "[[gate]]                    # k samples; agreement becomes the score\n\
206                 id          = \"uncertainty\"\n\
207                 consistency = {{ model = \"{}\", k = 3, threshold = 0.6 }}\n",
208                provider.ladder()[0]
209            ),
210        }
211    }
212}
213
214/// The three answers that decide a starting ladder. Everything else in the generated file follows
215/// from these, which is why onboarding asks exactly three questions and not a wizard's worth.
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub struct LadderChoice {
218    /// Which provider the ladder opens on.
219    pub provider: Provider,
220    /// What the output is, which picks the gate.
221    pub shape: Shape,
222    /// `observe` forwards unchanged; `enforce` serves from the cheap rung once a gate passes.
223    pub mode: firstpass_core::Mode,
224}
225
226impl Default for LadderChoice {
227    /// The answer set used when nothing is on a terminal to ask: Claude, JSON, and observe — the
228    /// combination that changes no behavior at all.
229    fn default() -> Self {
230        Self {
231            provider: Provider::Anthropic,
232            shape: Shape::Json,
233            mode: firstpass_core::Mode::Observe,
234        }
235    }
236}
237
238/// Render a complete, runnable `firstpass.toml` for these answers — including the `[[provider]]`
239/// and `[[gate]]` blocks the choice requires. Emitting a fragment would be worse than emitting
240/// nothing: only `anthropic`/`openai` are built-in providers and only `non-empty`/`json-valid`
241/// are built-in gates, so anything else must be declared here or the file will not resolve.
242#[must_use]
243pub fn render_config(choice: &LadderChoice) -> String {
244    let enforce = choice.mode == firstpass_core::Mode::Enforce;
245    let mode = if enforce { "enforce" } else { "observe" };
246    let quoted = |xs: [&str; 2]| -> String { format!("\"{}\", \"{}\"", xs[0], xs[1]) };
247    let mut out = format!(
248        "# firstpass.toml — written by `firstpass onboard`. Re-run onboard to regenerate.\n\
249         #   FIRSTPASS_MODE={mode} FIRSTPASS_CONFIG=./firstpass.toml firstpass up\n\n"
250    );
251    if let Some(block) = choice.provider.provider_block() {
252        out.push_str(block);
253        out.push('\n');
254    }
255    out.push_str(&format!(
256        "[[route]]                   # routes match top to bottom; first match wins\n\
257         match  = {{}}                 # everything\n\
258         mode   = \"{mode}\"\n\
259         ladder = [{}]\n\
260         gates  = [{}]\n\n",
261        quoted(choice.provider.ladder()),
262        quoted(choice.shape.gates()),
263    ));
264    out.push_str(&choice.shape.gate_block(choice.provider));
265    if enforce {
266        out.push_str(
267            "\n[escalation]\nmax_rungs_per_request = 2   # one rung up, never a runaway\n",
268        );
269    } else {
270        out.push_str(
271            "\n# observe: every request is forwarded unchanged and a receipt is written off\n\
272             # the hot path. Nothing routes differently until mode = \"enforce\".\n",
273        );
274    }
275    out
276}
277
278/// Render every answer combination as a JavaScript asset for the documentation site.
279///
280/// The docs used to carry their own copy of this template in `docs/assets/docs.js`. Two
281/// implementations of the same config format drift silently — and a docs page that emits config
282/// the binary would reject is worse than a page with no builder at all. So Rust owns the format
283/// and the site consumes what Rust produced; `docs_presets_are_current` fails CI if this output
284/// and the committed asset disagree.
285#[must_use]
286pub fn presets_js() -> String {
287    use firstpass_core::Mode;
288    let mut out = String::from(
289        "// GENERATED by `cargo test -p firstpass-proxy presets` — do not edit by hand.\n\
290         // Source of truth: crates/firstpass-proxy/src/onboard.rs (render_config).\n\
291         // Regenerate: FIRSTPASS_WRITE_PRESETS=1 cargo test -p firstpass-proxy presets\n\
292         window.FP_LADDER_PRESETS = {\n",
293    );
294    for provider in Provider::ALL {
295        out.push_str(&format!("  {:?}: {{\n", provider.id()));
296        for shape in Shape::ALL {
297            out.push_str(&format!("    {:?}: {{\n", shape.id()));
298            for (mode, key) in [(Mode::Observe, "observe"), (Mode::Enforce, "enforce")] {
299                let toml = render_config(&LadderChoice {
300                    provider,
301                    shape,
302                    mode,
303                });
304                out.push_str(&format!("      {key:?}: {},\n", js_string(&toml)));
305            }
306            out.push_str("    },\n");
307        }
308        out.push_str("  },\n");
309    }
310    out.push_str("};\n");
311    out
312}
313
314/// Encode a string as a JS double-quoted literal. Hand-rolled rather than pulling in a
315/// serialiser: the payload is our own generated TOML, and the escape set is small and fixed.
316fn js_string(s: &str) -> String {
317    let mut out = String::with_capacity(s.len() + 16);
318    out.push('"');
319    for c in s.chars() {
320        match c {
321            '"' => out.push_str("\\\""),
322            '\\' => out.push_str("\\\\"),
323            '\n' => out.push_str("\\n"),
324            '\r' => {}
325            c => out.push(c),
326        }
327    }
328    out.push('"');
329    out
330}
331
332/// One onboarding step: what it is, and whether it's already satisfied.
333#[derive(Debug, Clone, PartialEq, Eq)]
334pub enum Step {
335    /// Write the generated routing config. Never overwrites an existing file — the caller checks.
336    WriteConfig {
337        /// Where the file goes (`./firstpass.toml`).
338        path: PathBuf,
339        /// Full rendered contents.
340        toml: String,
341    },
342    /// Spawn `firstpass up` detached (observe mode), logging to `firstpass-proxy.log`.
343    StartProxy {
344        /// Config to hand the child via `FIRSTPASS_CONFIG`. The proxy has no default config path,
345        /// so a written file is inert unless it is passed explicitly.
346        config: Option<PathBuf>,
347    },
348    /// Append the marked `ANTHROPIC_BASE_URL` export to the shell rc file.
349    WireShell {
350        /// Rc file the line goes into.
351        rc: PathBuf,
352        /// The exact line (shell-dialect aware).
353        line: String,
354    },
355    /// Print the `claude mcp add` command so Claude Code can query traces/savings as tools.
356    /// (Printed, not executed — mutating another tool's config uninvited isn't onboarding.)
357    SuggestClaudeMcp,
358    /// Probe `/healthz` and `/v1/capabilities` and report what's routed.
359    Verify,
360    /// Nothing to do — with the reason shown to the user.
361    AlreadyDone(&'static str),
362}
363
364/// Detect the environment via injected lookups: `env(key)` for env vars, `on_path(bin)` for PATH
365/// probes, `healthz()` for a live proxy probe. Pure decision logic; all I/O behind the closures.
366pub fn detect(
367    env: impl Fn(&str) -> Option<String>,
368    on_path: impl Fn(&str) -> bool,
369    healthz: impl Fn() -> bool,
370) -> Environment {
371    let bind = env("FIRSTPASS_BIND").unwrap_or_else(|| "127.0.0.1:8080".to_owned());
372    let base_url = format!("http://{bind}");
373    let shell = env("SHELL")
374        .and_then(|s| s.rsplit('/').next().map(str::to_owned))
375        .unwrap_or_else(|| "sh".to_owned());
376    Environment {
377        shell,
378        proxy_running: healthz(),
379        already_routed: env("ANTHROPIC_BASE_URL").is_some_and(|v| v == base_url),
380        has_api_key: env("ANTHROPIC_API_KEY").is_some(),
381        has_claude_cli: on_path("claude"),
382        bind,
383    }
384}
385
386/// The shell-dialect line that routes agents through the proxy, plus the rc file it belongs in.
387/// `home` is injected so tests never touch a real home directory.
388#[must_use]
389pub fn shell_wiring(shell: &str, home: &std::path::Path, bind: &str) -> (PathBuf, String) {
390    let url = format!("http://{bind}");
391    match shell {
392        "fish" => (
393            home.join(".config/fish/config.fish"),
394            format!("set -gx ANTHROPIC_BASE_URL {url}  {RC_MARKER}"),
395        ),
396        "bash" => (
397            home.join(".bashrc"),
398            format!("export ANTHROPIC_BASE_URL={url}  {RC_MARKER}"),
399        ),
400        // zsh and anything else POSIX-ish: default to ~/.zshrc only for zsh, else ~/.profile.
401        "zsh" => (
402            home.join(".zshrc"),
403            format!("export ANTHROPIC_BASE_URL={url}  {RC_MARKER}"),
404        ),
405        _ => (
406            home.join(".profile"),
407            format!("export ANTHROPIC_BASE_URL={url}  {RC_MARKER}"),
408        ),
409    }
410}
411
412/// A config the caller wants written: where it goes and the answers behind it. `None` means the
413/// step is skipped — either a `firstpass.toml` is already there or the user opted out.
414#[derive(Debug, Clone)]
415pub struct ConfigPlan {
416    /// Destination, normally `./firstpass.toml`.
417    pub path: PathBuf,
418    /// The three answers.
419    pub choice: LadderChoice,
420}
421
422/// Build the ordered plan for this environment. `rc_already_wired` is whether the rc file already
423/// carries the marker line (checked by the caller, injected here to stay pure). `config` is the
424/// routing file to generate, if any — it is written *before* the proxy starts so the child can be
425/// handed it, since a config the proxy never loads is worse than no config at all.
426#[must_use]
427pub fn plan(
428    env: &Environment,
429    home: &std::path::Path,
430    rc_already_wired: bool,
431    config: Option<&ConfigPlan>,
432) -> Vec<Step> {
433    let mut steps = Vec::new();
434    if let Some(c) = config {
435        steps.push(Step::WriteConfig {
436            path: c.path.clone(),
437            toml: render_config(&c.choice),
438        });
439    }
440    if env.proxy_running {
441        steps.push(Step::AlreadyDone("proxy already answering /healthz"));
442    } else {
443        steps.push(Step::StartProxy {
444            config: config.map(|c| c.path.clone()),
445        });
446    }
447    if env.already_routed || rc_already_wired {
448        steps.push(Step::AlreadyDone("ANTHROPIC_BASE_URL already wired"));
449    } else {
450        let (rc, line) = shell_wiring(&env.shell, home, &env.bind);
451        steps.push(Step::WireShell { rc, line });
452    }
453    if env.has_claude_cli {
454        steps.push(Step::SuggestClaudeMcp);
455    }
456    steps.push(Step::Verify);
457    steps
458}
459
460/// Render the plan for the dry run (default) or as the running commentary under `--apply`.
461#[must_use]
462pub fn render(env: &Environment, steps: &[Step], apply: bool) -> String {
463    let mut out = String::new();
464    out.push_str(&format!(
465        "detected: shell={} · proxy_running={} · routed={} · api_key={} · claude_cli={}\n\n",
466        env.shell, env.proxy_running, env.already_routed, env.has_api_key, env.has_claude_cli
467    ));
468    for (i, s) in steps.iter().enumerate() {
469        let n = i + 1;
470        match s {
471            Step::WriteConfig { path, toml } => {
472                out.push_str(&format!("{n}. write {} —\n", path.display()));
473                for line in toml.lines() {
474                    out.push_str(&format!("     {line}\n"));
475                }
476            }
477            Step::StartProxy { config } => {
478                out.push_str(&format!(
479                    "{n}. start the proxy — `firstpass up` (observe mode: watches, changes nothing), log → firstpass-proxy.log\n"
480                ));
481                if let Some(c) = config {
482                    out.push_str(&format!(
483                        "     with FIRSTPASS_CONFIG={} — the proxy has no default config path\n",
484                        c.display()
485                    ));
486                }
487            }
488            Step::WireShell { rc, line } => out.push_str(&format!(
489                "{n}. route your agents — append to {}:\n     {line}\n",
490                rc.display()
491            )),
492            Step::SuggestClaudeMcp => out.push_str(&format!(
493                "{n}. (optional) let Claude Code query receipts as tools:\n     claude mcp add firstpass -- firstpass mcp\n"
494            )),
495            Step::Verify => out.push_str(&format!(
496                "{n}. verify — probe /healthz and /v1/capabilities, report what's routed\n"
497            )),
498            Step::AlreadyDone(why) => out.push_str(&format!("{n}. ✓ {why}\n")),
499        }
500    }
501    if !env.has_api_key {
502        out.push_str(
503            "\nnote: ANTHROPIC_API_KEY is not set — observe mode passes your agent's own key \
504             through (BYOK), so this only matters for enforce mode.\n",
505        );
506    }
507    if !apply {
508        out.push_str(
509            "\ndry run — nothing changed. Re-run with `firstpass onboard --apply` to execute.\n",
510        );
511    }
512    out
513}
514
515/// Execute the side-effectful steps. Returns a human report. Failures are reported per step —
516/// onboarding never half-dies silently.
517///
518/// # Errors
519/// Only on I/O failures writing the rc file; every other issue is reported in the returned text.
520pub fn execute(env: &Environment, steps: &[Step]) -> Result<String, std::io::Error> {
521    let mut out = String::new();
522    for s in steps {
523        match s {
524            Step::WriteConfig { path, toml } => {
525                // Never clobber a config the operator already has; the caller only plans this
526                // step when the path is free, but re-check rather than trust the gap between.
527                if path.exists() {
528                    out.push_str(&format!(
529                        "✓ {} already present — left untouched\n",
530                        path.display()
531                    ));
532                } else {
533                    std::fs::write(path, toml)?;
534                    out.push_str(&format!("✓ wrote {}\n", path.display()));
535                }
536            }
537            Step::StartProxy { config } => {
538                let log = std::fs::File::create("firstpass-proxy.log")?;
539                let exe = std::env::current_exe()?;
540                let mut cmd = std::process::Command::new(exe);
541                cmd.arg("up");
542                if let Some(c) = config {
543                    // A written config is inert unless it is named: `ProxyConfig::from_env` reads
544                    // `FIRSTPASS_CONFIG` and has no default path to fall back on.
545                    cmd.env("FIRSTPASS_CONFIG", c);
546                }
547                let child = cmd
548                    .stdin(std::process::Stdio::null())
549                    .stdout(std::process::Stdio::from(log.try_clone()?))
550                    .stderr(std::process::Stdio::from(log))
551                    .spawn();
552                match child {
553                    Ok(c) => {
554                        // Pidfile makes `firstpass offboard` able to stop what onboard started.
555                        let _ = std::fs::write("firstpass-proxy.pid", c.id().to_string());
556                        out.push_str(&format!(
557                            "✓ proxy started (pid {}, observe mode) — log: firstpass-proxy.log\n",
558                            c.id()
559                        ));
560                    }
561                    Err(e) => out.push_str(&format!("✗ could not start proxy: {e}\n")),
562                }
563            }
564            Step::WireShell { rc, line } => {
565                if let Some(parent) = rc.parent() {
566                    std::fs::create_dir_all(parent)?;
567                }
568                let mut f = std::fs::OpenOptions::new()
569                    .create(true)
570                    .append(true)
571                    .open(rc)?;
572                writeln!(f, "{line}")?;
573                out.push_str(&format!(
574                    "✓ wired {} — takes effect in new shells; for this one:\n     {}\n",
575                    rc.display(),
576                    line.trim_end_matches(RC_MARKER).trim_end()
577                ));
578            }
579            Step::SuggestClaudeMcp => {
580                out.push_str("→ optional: claude mcp add firstpass -- firstpass mcp\n");
581            }
582            Step::Verify => {
583                let url = format!("http://{}/healthz", env.bind);
584                let ok = wait_healthz(&url, std::time::Duration::from_secs(6));
585                if ok {
586                    out.push_str(&format!(
587                        "✓ verified — proxy healthy at http://{} · capabilities: http://{}/v1/capabilities\n",
588                        env.bind, env.bind
589                    ));
590                } else {
591                    out.push_str(&format!(
592                        "✗ proxy not answering http://{} after 6s — check firstpass-proxy.log\n",
593                        env.bind
594                    ));
595                }
596            }
597            Step::AlreadyDone(why) => out.push_str(&format!("✓ {why}\n")),
598        }
599    }
600    out.push_str("\noffboard any time: unset ANTHROPIC_BASE_URL (and remove the marked rc line)\n");
601    Ok(out)
602}
603
604/// Poll `/healthz` until it answers 200 or the deadline passes. Blocking + std-only (the CLI calls
605/// this off the async runtime): a plain TCP connect + minimal HTTP/1.1 GET, no client dependency.
606fn wait_healthz(url: &str, deadline: std::time::Duration) -> bool {
607    let Some(addr) = url
608        .strip_prefix("http://")
609        .and_then(|r| r.split('/').next())
610        .map(str::to_owned)
611    else {
612        return false;
613    };
614    let start = std::time::Instant::now();
615    while start.elapsed() < deadline {
616        if let Ok(mut s) = std::net::TcpStream::connect(&addr) {
617            let _ = s.set_read_timeout(Some(std::time::Duration::from_millis(500)));
618            let req = format!("GET /healthz HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
619            if s.write_all(req.as_bytes()).is_ok() {
620                let mut buf = [0u8; 64];
621                use std::io::Read as _;
622                if let Ok(n) = s.read(&mut buf)
623                    && n > 0
624                    && String::from_utf8_lossy(&buf[..n]).contains("200")
625                {
626                    return true;
627                }
628            }
629        }
630        std::thread::sleep(std::time::Duration::from_millis(200));
631    }
632    false
633}
634
635/// Whether the rc file already carries the onboard marker (idempotence check for [`plan`]).
636#[must_use]
637pub fn rc_wired(rc: &std::path::Path) -> bool {
638    std::fs::read_to_string(rc).is_ok_and(|s| s.contains(RC_MARKER))
639}
640
641/// Remove every marked onboard line from `rc`. Returns whether anything was removed. Only lines
642/// carrying [`RC_MARKER`] are touched — nothing else in the user's rc is ever rewritten.
643///
644/// # Errors
645/// Only on I/O failures reading/writing the rc file.
646pub fn offboard_rc(rc: &std::path::Path) -> Result<bool, std::io::Error> {
647    let Ok(content) = std::fs::read_to_string(rc) else {
648        return Ok(false); // no file → nothing to offboard
649    };
650    if !content.contains(RC_MARKER) {
651        return Ok(false);
652    }
653    let kept: Vec<&str> = content.lines().filter(|l| !l.contains(RC_MARKER)).collect();
654    std::fs::write(rc, kept.join("\n") + "\n")?;
655    Ok(true)
656}
657
658/// `firstpass offboard` — the exact mirror of onboard: strip the marked line from every candidate
659/// rc file, stop the proxy onboard started (pidfile), and print the one command for this shell.
660/// Fully idempotent; reports what it found either way.
661///
662/// # Errors
663/// Only on rc-file I/O failures.
664pub fn offboard(home: &std::path::Path) -> Result<String, std::io::Error> {
665    let mut out = String::new();
666    for rc in [
667        home.join(".zshrc"),
668        home.join(".bashrc"),
669        home.join(".profile"),
670        home.join(".config/fish/config.fish"),
671    ] {
672        if offboard_rc(&rc)? {
673            out.push_str(&format!("✓ removed firstpass line from {}\n", rc.display()));
674        }
675    }
676    // Stop the proxy onboard spawned, if its pidfile is present.
677    if let Ok(pid) = std::fs::read_to_string("firstpass-proxy.pid") {
678        let pid = pid.trim().to_owned();
679        #[cfg(unix)]
680        {
681            let killed = std::process::Command::new("kill")
682                .arg(&pid)
683                .status()
684                .is_ok_and(|s| s.success());
685            if killed {
686                out.push_str(&format!("✓ stopped proxy (pid {pid})\n"));
687            } else {
688                out.push_str(&format!(
689                    "→ proxy pid {pid} not running (already stopped)\n"
690                ));
691            }
692        }
693        let _ = std::fs::remove_file("firstpass-proxy.pid");
694    }
695    if out.is_empty() {
696        out.push_str("nothing to offboard — no marked rc lines, no pidfile.\n");
697    }
698    out.push_str("for this shell: unset ANTHROPIC_BASE_URL\n");
699    Ok(out)
700}
701
702#[cfg(test)]
703mod tests {
704    #![allow(clippy::unwrap_used)]
705    use super::*;
706
707    fn env_of<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
708        move |k| {
709            pairs
710                .iter()
711                .find(|(a, _)| *a == k)
712                .map(|(_, v)| (*v).to_owned())
713        }
714    }
715
716    #[test]
717    fn detect_reads_shell_routing_and_tools() {
718        let e = detect(
719            env_of(&[
720                ("SHELL", "/bin/zsh"),
721                ("ANTHROPIC_BASE_URL", "http://127.0.0.1:8080"),
722                ("ANTHROPIC_API_KEY", "sk-x"),
723            ]),
724            |bin| bin == "claude",
725            || true,
726        );
727        assert_eq!(e.shell, "zsh");
728        assert!(e.proxy_running && e.already_routed && e.has_api_key && e.has_claude_cli);
729        assert_eq!(e.bind, "127.0.0.1:8080");
730    }
731
732    #[test]
733    fn detect_respects_custom_bind_and_mismatched_base_url() {
734        let e = detect(
735            env_of(&[
736                ("FIRSTPASS_BIND", "127.0.0.1:9999"),
737                ("ANTHROPIC_BASE_URL", "http://127.0.0.1:8080"), // points elsewhere
738            ]),
739            |_| false,
740            || false,
741        );
742        assert_eq!(e.bind, "127.0.0.1:9999");
743        assert!(
744            !e.already_routed,
745            "routed to a different port is not routed"
746        );
747    }
748
749    #[test]
750    fn shell_wiring_speaks_each_dialect() {
751        let home = std::path::Path::new("/home/u");
752        let (rc, line) = shell_wiring("fish", home, "127.0.0.1:8080");
753        assert!(rc.ends_with(".config/fish/config.fish"));
754        assert!(line.starts_with("set -gx ANTHROPIC_BASE_URL http://127.0.0.1:8080"));
755
756        let (rc, line) = shell_wiring("zsh", home, "127.0.0.1:8080");
757        assert!(rc.ends_with(".zshrc"));
758        assert!(line.starts_with("export ANTHROPIC_BASE_URL="));
759
760        let (rc, _) = shell_wiring("dash", home, "127.0.0.1:8080");
761        assert!(
762            rc.ends_with(".profile"),
763            "unknown shells fall back to .profile"
764        );
765    }
766
767    #[test]
768    fn plan_covers_fresh_machine_and_is_idempotent_when_done() {
769        let home = std::path::Path::new("/home/u");
770        let fresh = Environment {
771            shell: "zsh".into(),
772            proxy_running: false,
773            already_routed: false,
774            has_api_key: false,
775            has_claude_cli: true,
776            bind: "127.0.0.1:8080".into(),
777        };
778        let steps = plan(&fresh, home, false, None);
779        assert!(matches!(steps[0], Step::StartProxy { .. }));
780        assert!(matches!(steps[1], Step::WireShell { .. }));
781        assert!(matches!(steps[2], Step::SuggestClaudeMcp));
782        assert!(matches!(steps.last(), Some(Step::Verify)));
783
784        // Everything already set up → only AlreadyDone + Verify, nothing mutating.
785        let done = Environment {
786            proxy_running: true,
787            already_routed: true,
788            has_claude_cli: false,
789            ..fresh
790        };
791        let steps = plan(&done, home, true, None);
792        assert!(
793            steps
794                .iter()
795                .all(|s| matches!(s, Step::AlreadyDone(_) | Step::Verify))
796        );
797    }
798
799    #[test]
800    fn render_dry_run_says_nothing_changed_and_flags_missing_key() {
801        let home = std::path::Path::new("/home/u");
802        let e = Environment {
803            shell: "bash".into(),
804            proxy_running: false,
805            already_routed: false,
806            has_api_key: false,
807            has_claude_cli: false,
808            bind: "127.0.0.1:8080".into(),
809        };
810        let text = render(&e, &plan(&e, home, false, None), false);
811        assert!(text.contains("dry run — nothing changed"));
812        assert!(text.contains("ANTHROPIC_API_KEY is not set"));
813        assert!(text.contains(".bashrc"));
814    }
815
816    #[test]
817    fn rc_wired_detects_the_marker_and_execute_appends_it_once() {
818        let dir = std::env::temp_dir().join(format!("fp-onboard-{}", uuid::Uuid::now_v7()));
819        std::fs::create_dir_all(&dir).unwrap();
820        let rc = dir.join(".zshrc");
821        assert!(!rc_wired(&rc), "missing file is not wired");
822
823        let e = Environment {
824            shell: "zsh".into(),
825            proxy_running: true, // no spawn in this test
826            already_routed: false,
827            has_api_key: true,
828            has_claude_cli: false,
829            bind: "127.0.0.1:1".into(), // verify step will fail fast — that's fine, we assert wiring
830        };
831        let (rc_path, line) = shell_wiring("zsh", &dir, &e.bind);
832        let steps = vec![Step::WireShell {
833            rc: rc_path.clone(),
834            line,
835        }];
836        let report = execute(&e, &steps).unwrap();
837        assert!(report.contains("✓ wired"));
838        assert!(rc_wired(&rc_path), "marker written");
839        // Re-planning with the marker present must not wire again (idempotent onboarding).
840        let steps = plan(&e, &dir, rc_wired(&rc_path), None);
841        assert!(!steps.iter().any(|s| matches!(s, Step::WireShell { .. })));
842
843        let _ = std::fs::remove_dir_all(&dir);
844    }
845
846    #[test]
847    fn offboard_removes_only_the_marked_line_and_is_idempotent() {
848        let dir = std::env::temp_dir().join(format!("fp-offboard-{}", uuid::Uuid::now_v7()));
849        std::fs::create_dir_all(&dir).unwrap();
850        let rc = dir.join(".zshrc");
851        std::fs::write(
852            &rc,
853            format!("alias ll='ls -l'\nexport ANTHROPIC_BASE_URL=http://127.0.0.1:8080  {RC_MARKER}\nexport EDITOR=vim\n"),
854        )
855        .unwrap();
856
857        assert!(offboard_rc(&rc).unwrap(), "marked line removed");
858        let after = std::fs::read_to_string(&rc).unwrap();
859        assert!(!after.contains(RC_MARKER));
860        assert!(
861            after.contains("alias ll") && after.contains("EDITOR=vim"),
862            "user lines untouched"
863        );
864        assert!(!offboard_rc(&rc).unwrap(), "second offboard is a no-op");
865
866        // The full offboard reports the rc removal and the unset line.
867        std::fs::write(&rc, format!("x  {RC_MARKER}\n")).unwrap();
868        let report = offboard(&dir).unwrap();
869        assert!(report.contains("removed firstpass line"));
870        assert!(report.contains("unset ANTHROPIC_BASE_URL"));
871
872        let _ = std::fs::remove_dir_all(&dir);
873    }
874
875    /// The whole point of generating a config is that it runs. Every answer combination must
876    /// survive the real parser — including the `[[provider]]`/`[[gate]]` blocks each one implies,
877    /// since only `anthropic`/`openai` and `non-empty`/`json-valid` are built in.
878    #[test]
879    fn every_generated_config_parses_and_resolves() {
880        use firstpass_core::Mode;
881        let mut n = 0;
882        for provider in Provider::ALL {
883            for shape in Shape::ALL {
884                for mode in [Mode::Observe, Mode::Enforce] {
885                    n += 1;
886                    let choice = LadderChoice {
887                        provider,
888                        shape,
889                        mode,
890                    };
891                    let toml = render_config(&choice);
892                    let cfg = firstpass_core::Config::parse(&toml).unwrap_or_else(|e| {
893                        panic!(
894                            "{}/{}/{mode:?} did not parse: {e}\n{toml}",
895                            provider.id(),
896                            shape.id()
897                        )
898                    });
899                    let route = &cfg.routes[0];
900                    assert_eq!(
901                        route.mode, mode,
902                        "route mode is what actually gates enforcement"
903                    );
904                    assert_eq!(
905                        route.ladder.len(),
906                        2,
907                        "a ladder needs somewhere to escalate to"
908                    );
909
910                    // Every gate the route names must be built in or declared here.
911                    for g in &route.gates {
912                        let builtin = g == "non-empty" || g == "json-valid";
913                        assert!(
914                            builtin || cfg.gate_defs.iter().any(|d| &d.id == g),
915                            "{}/{}: gate {g:?} is neither built in nor declared",
916                            provider.id(),
917                            shape.id()
918                        );
919                    }
920                    // Same for every provider a rung names.
921                    for rung in &route.ladder {
922                        let pid = rung.split('/').next().unwrap();
923                        let builtin = pid == "anthropic" || pid == "openai";
924                        assert!(
925                            builtin || cfg.providers.iter().any(|d| d.id == pid),
926                            "{}/{}: provider {pid:?} is neither built in nor declared",
927                            provider.id(),
928                            shape.id()
929                        );
930                    }
931                    // maker != checker: a judge drawn from the ladder would be rejected at runtime.
932                    if let Some(j) = cfg.gate_defs.iter().find_map(|d| d.judge.as_ref()) {
933                        assert!(
934                            !route.ladder.contains(&j.model),
935                            "{}: judge {} is on its own ladder",
936                            provider.id(),
937                            j.model
938                        );
939                    }
940                    // Observe must stay inert — an escalation cap implies enforcement.
941                    assert_eq!(
942                        toml.contains("[escalation]"),
943                        mode == Mode::Enforce,
944                        "escalation block should track enforce only"
945                    );
946                }
947            }
948        }
949        assert_eq!(n, 32, "4 providers x 4 shapes x 2 modes");
950    }
951
952    /// A written config the proxy never loads is worse than no config: `ProxyConfig::from_env`
953    /// reads `FIRSTPASS_CONFIG` and has no default path, so the plan must carry it to the child.
954    #[test]
955    fn planned_config_is_written_before_the_proxy_starts_and_is_handed_to_it() {
956        let home = std::path::Path::new("/home/u");
957        let env = Environment {
958            shell: "zsh".into(),
959            proxy_running: false,
960            already_routed: false,
961            has_api_key: true,
962            has_claude_cli: false,
963            bind: "127.0.0.1:8080".into(),
964        };
965        let cfg = ConfigPlan {
966            path: PathBuf::from("firstpass.toml"),
967            choice: LadderChoice::default(),
968        };
969        let steps = plan(&env, home, false, Some(&cfg));
970        assert!(
971            matches!(&steps[0], Step::WriteConfig { path, .. } if path == &cfg.path),
972            "config is written first, before anything reads it"
973        );
974        assert!(
975            matches!(&steps[1], Step::StartProxy { config: Some(p) } if p == &cfg.path),
976            "the spawned proxy is handed the config explicitly"
977        );
978
979        // Without a config the plan is exactly what it always was.
980        let steps = plan(&env, home, false, None);
981        assert!(matches!(steps[0], Step::StartProxy { config: None }));
982        assert!(!steps.iter().any(|s| matches!(s, Step::WriteConfig { .. })));
983    }
984
985    /// Re-running onboard must never clobber a routing file the operator already edited.
986    #[test]
987    fn write_config_refuses_to_overwrite_an_existing_file() {
988        let dir = std::env::temp_dir().join(format!("fp-cfg-{}", uuid::Uuid::now_v7()));
989        std::fs::create_dir_all(&dir).unwrap();
990        let path = dir.join("firstpass.toml");
991        std::fs::write(&path, "# hand-tuned, do not touch\n").unwrap();
992        let env = Environment {
993            shell: "zsh".into(),
994            proxy_running: true, // no spawn
995            already_routed: true,
996            has_api_key: true,
997            has_claude_cli: false,
998            bind: "127.0.0.1:1".into(),
999        };
1000        let steps = vec![Step::WriteConfig {
1001            path: path.clone(),
1002            toml: render_config(&LadderChoice::default()),
1003        }];
1004        let report = execute(&env, &steps).unwrap();
1005        assert!(report.contains("already present"));
1006        assert_eq!(
1007            std::fs::read_to_string(&path).unwrap(),
1008            "# hand-tuned, do not touch\n",
1009            "existing config survived untouched"
1010        );
1011        let _ = std::fs::remove_dir_all(&dir);
1012    }
1013
1014    /// The dry run has to show the actual file, or "onboard asks then writes" is unverifiable
1015    /// before the fact.
1016    #[test]
1017    fn dry_run_shows_the_config_it_would_write() {
1018        let home = std::path::Path::new("/home/u");
1019        let env = Environment {
1020            shell: "bash".into(),
1021            proxy_running: false,
1022            already_routed: false,
1023            has_api_key: true,
1024            has_claude_cli: false,
1025            bind: "127.0.0.1:8080".into(),
1026        };
1027        let cfg = ConfigPlan {
1028            path: PathBuf::from("firstpass.toml"),
1029            choice: LadderChoice {
1030                provider: Provider::Local,
1031                shape: Shape::Code,
1032                mode: firstpass_core::Mode::Enforce,
1033            },
1034        };
1035        let text = render(&env, &plan(&env, home, false, Some(&cfg)), false);
1036        assert!(text.contains("write firstpass.toml"));
1037        assert!(text.contains("ollama"), "provider block is shown");
1038        assert!(text.contains("unit-tests"), "gate block is shown");
1039        assert!(
1040            text.contains("FIRSTPASS_CONFIG="),
1041            "explains how the proxy finds it"
1042        );
1043        assert!(text.contains("dry run — nothing changed"));
1044    }
1045
1046    /// The documentation site must not carry a second implementation of the config format.
1047    /// This pins the committed asset to what Rust generates; if they diverge, CI fails here
1048    /// rather than the site quietly shipping config the binary would reject.
1049    #[test]
1050    fn docs_presets_are_current() {
1051        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1052            .join("../../docs/assets/ladder-presets.js");
1053        let generated = presets_js();
1054
1055        // Regeneration is an explicit opt-in so a normal test run can never rewrite the repo.
1056        if std::env::var("FIRSTPASS_WRITE_PRESETS").is_ok() {
1057            std::fs::write(&path, &generated).expect("write presets");
1058            return;
1059        }
1060
1061        let committed = std::fs::read_to_string(&path).unwrap_or_else(|e| {
1062            panic!(
1063                "{}: {e}\nregenerate with:\n  \
1064                 FIRSTPASS_WRITE_PRESETS=1 cargo test -p firstpass-proxy presets",
1065                path.display()
1066            )
1067        });
1068        assert_eq!(
1069            committed.replace("\r\n", "\n"),
1070            generated,
1071            "docs/assets/ladder-presets.js is stale — the docs builder and `firstpass onboard` \
1072             would emit different config.\nregenerate with:\n  \
1073             FIRSTPASS_WRITE_PRESETS=1 cargo test -p firstpass-proxy presets"
1074        );
1075    }
1076
1077    /// Every preset the site can hand a user has to parse, for the same reason onboard's output
1078    /// does — the builder's whole value is that its output runs.
1079    #[test]
1080    fn every_docs_preset_parses() {
1081        let js = presets_js();
1082        let mut n = 0;
1083        for raw in js.split("\": \"").skip(1) {
1084            let lit = &raw[..raw.find("\",\n").unwrap_or(raw.len())];
1085            let toml = lit
1086                .replace("\\n", "\n")
1087                .replace("\\\"", "\"")
1088                .replace("\\\\", "\\");
1089            if !toml.contains("[[route]]") {
1090                continue;
1091            }
1092            n += 1;
1093            firstpass_core::Config::parse(&toml)
1094                .unwrap_or_else(|e| panic!("preset #{n} does not parse: {e}\n{toml}"));
1095        }
1096        assert_eq!(n, 32, "expected all 32 presets to be checked, saw {n}");
1097    }
1098}