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/// One onboarding step: what it is, and whether it's already satisfied.
279#[derive(Debug, Clone, PartialEq, Eq)]
280pub enum Step {
281    /// Write the generated routing config. Never overwrites an existing file — the caller checks.
282    WriteConfig {
283        /// Where the file goes (`./firstpass.toml`).
284        path: PathBuf,
285        /// Full rendered contents.
286        toml: String,
287    },
288    /// Spawn `firstpass up` detached (observe mode), logging to `firstpass-proxy.log`.
289    StartProxy {
290        /// Config to hand the child via `FIRSTPASS_CONFIG`. The proxy has no default config path,
291        /// so a written file is inert unless it is passed explicitly.
292        config: Option<PathBuf>,
293    },
294    /// Append the marked `ANTHROPIC_BASE_URL` export to the shell rc file.
295    WireShell {
296        /// Rc file the line goes into.
297        rc: PathBuf,
298        /// The exact line (shell-dialect aware).
299        line: String,
300    },
301    /// Print the `claude mcp add` command so Claude Code can query traces/savings as tools.
302    /// (Printed, not executed — mutating another tool's config uninvited isn't onboarding.)
303    SuggestClaudeMcp,
304    /// Probe `/healthz` and `/v1/capabilities` and report what's routed.
305    Verify,
306    /// Nothing to do — with the reason shown to the user.
307    AlreadyDone(&'static str),
308}
309
310/// Detect the environment via injected lookups: `env(key)` for env vars, `on_path(bin)` for PATH
311/// probes, `healthz()` for a live proxy probe. Pure decision logic; all I/O behind the closures.
312pub fn detect(
313    env: impl Fn(&str) -> Option<String>,
314    on_path: impl Fn(&str) -> bool,
315    healthz: impl Fn() -> bool,
316) -> Environment {
317    let bind = env("FIRSTPASS_BIND").unwrap_or_else(|| "127.0.0.1:8080".to_owned());
318    let base_url = format!("http://{bind}");
319    let shell = env("SHELL")
320        .and_then(|s| s.rsplit('/').next().map(str::to_owned))
321        .unwrap_or_else(|| "sh".to_owned());
322    Environment {
323        shell,
324        proxy_running: healthz(),
325        already_routed: env("ANTHROPIC_BASE_URL").is_some_and(|v| v == base_url),
326        has_api_key: env("ANTHROPIC_API_KEY").is_some(),
327        has_claude_cli: on_path("claude"),
328        bind,
329    }
330}
331
332/// The shell-dialect line that routes agents through the proxy, plus the rc file it belongs in.
333/// `home` is injected so tests never touch a real home directory.
334#[must_use]
335pub fn shell_wiring(shell: &str, home: &std::path::Path, bind: &str) -> (PathBuf, String) {
336    let url = format!("http://{bind}");
337    match shell {
338        "fish" => (
339            home.join(".config/fish/config.fish"),
340            format!("set -gx ANTHROPIC_BASE_URL {url}  {RC_MARKER}"),
341        ),
342        "bash" => (
343            home.join(".bashrc"),
344            format!("export ANTHROPIC_BASE_URL={url}  {RC_MARKER}"),
345        ),
346        // zsh and anything else POSIX-ish: default to ~/.zshrc only for zsh, else ~/.profile.
347        "zsh" => (
348            home.join(".zshrc"),
349            format!("export ANTHROPIC_BASE_URL={url}  {RC_MARKER}"),
350        ),
351        _ => (
352            home.join(".profile"),
353            format!("export ANTHROPIC_BASE_URL={url}  {RC_MARKER}"),
354        ),
355    }
356}
357
358/// A config the caller wants written: where it goes and the answers behind it. `None` means the
359/// step is skipped — either a `firstpass.toml` is already there or the user opted out.
360#[derive(Debug, Clone)]
361pub struct ConfigPlan {
362    /// Destination, normally `./firstpass.toml`.
363    pub path: PathBuf,
364    /// The three answers.
365    pub choice: LadderChoice,
366}
367
368/// Build the ordered plan for this environment. `rc_already_wired` is whether the rc file already
369/// carries the marker line (checked by the caller, injected here to stay pure). `config` is the
370/// routing file to generate, if any — it is written *before* the proxy starts so the child can be
371/// handed it, since a config the proxy never loads is worse than no config at all.
372#[must_use]
373pub fn plan(
374    env: &Environment,
375    home: &std::path::Path,
376    rc_already_wired: bool,
377    config: Option<&ConfigPlan>,
378) -> Vec<Step> {
379    let mut steps = Vec::new();
380    if let Some(c) = config {
381        steps.push(Step::WriteConfig {
382            path: c.path.clone(),
383            toml: render_config(&c.choice),
384        });
385    }
386    if env.proxy_running {
387        steps.push(Step::AlreadyDone("proxy already answering /healthz"));
388    } else {
389        steps.push(Step::StartProxy {
390            config: config.map(|c| c.path.clone()),
391        });
392    }
393    if env.already_routed || rc_already_wired {
394        steps.push(Step::AlreadyDone("ANTHROPIC_BASE_URL already wired"));
395    } else {
396        let (rc, line) = shell_wiring(&env.shell, home, &env.bind);
397        steps.push(Step::WireShell { rc, line });
398    }
399    if env.has_claude_cli {
400        steps.push(Step::SuggestClaudeMcp);
401    }
402    steps.push(Step::Verify);
403    steps
404}
405
406/// Render the plan for the dry run (default) or as the running commentary under `--apply`.
407#[must_use]
408pub fn render(env: &Environment, steps: &[Step], apply: bool) -> String {
409    let mut out = String::new();
410    out.push_str(&format!(
411        "detected: shell={} · proxy_running={} · routed={} · api_key={} · claude_cli={}\n\n",
412        env.shell, env.proxy_running, env.already_routed, env.has_api_key, env.has_claude_cli
413    ));
414    for (i, s) in steps.iter().enumerate() {
415        let n = i + 1;
416        match s {
417            Step::WriteConfig { path, toml } => {
418                out.push_str(&format!("{n}. write {} —\n", path.display()));
419                for line in toml.lines() {
420                    out.push_str(&format!("     {line}\n"));
421                }
422            }
423            Step::StartProxy { config } => {
424                out.push_str(&format!(
425                    "{n}. start the proxy — `firstpass up` (observe mode: watches, changes nothing), log → firstpass-proxy.log\n"
426                ));
427                if let Some(c) = config {
428                    out.push_str(&format!(
429                        "     with FIRSTPASS_CONFIG={} — the proxy has no default config path\n",
430                        c.display()
431                    ));
432                }
433            }
434            Step::WireShell { rc, line } => out.push_str(&format!(
435                "{n}. route your agents — append to {}:\n     {line}\n",
436                rc.display()
437            )),
438            Step::SuggestClaudeMcp => out.push_str(&format!(
439                "{n}. (optional) let Claude Code query receipts as tools:\n     claude mcp add firstpass -- firstpass mcp\n"
440            )),
441            Step::Verify => out.push_str(&format!(
442                "{n}. verify — probe /healthz and /v1/capabilities, report what's routed\n"
443            )),
444            Step::AlreadyDone(why) => out.push_str(&format!("{n}. ✓ {why}\n")),
445        }
446    }
447    if !env.has_api_key {
448        out.push_str(
449            "\nnote: ANTHROPIC_API_KEY is not set — observe mode passes your agent's own key \
450             through (BYOK), so this only matters for enforce mode.\n",
451        );
452    }
453    if !apply {
454        out.push_str(
455            "\ndry run — nothing changed. Re-run with `firstpass onboard --apply` to execute.\n",
456        );
457    }
458    out
459}
460
461/// Execute the side-effectful steps. Returns a human report. Failures are reported per step —
462/// onboarding never half-dies silently.
463///
464/// # Errors
465/// Only on I/O failures writing the rc file; every other issue is reported in the returned text.
466pub fn execute(env: &Environment, steps: &[Step]) -> Result<String, std::io::Error> {
467    let mut out = String::new();
468    for s in steps {
469        match s {
470            Step::WriteConfig { path, toml } => {
471                // Never clobber a config the operator already has; the caller only plans this
472                // step when the path is free, but re-check rather than trust the gap between.
473                if path.exists() {
474                    out.push_str(&format!(
475                        "✓ {} already present — left untouched\n",
476                        path.display()
477                    ));
478                } else {
479                    std::fs::write(path, toml)?;
480                    out.push_str(&format!("✓ wrote {}\n", path.display()));
481                }
482            }
483            Step::StartProxy { config } => {
484                let log = std::fs::File::create("firstpass-proxy.log")?;
485                let exe = std::env::current_exe()?;
486                let mut cmd = std::process::Command::new(exe);
487                cmd.arg("up");
488                if let Some(c) = config {
489                    // A written config is inert unless it is named: `ProxyConfig::from_env` reads
490                    // `FIRSTPASS_CONFIG` and has no default path to fall back on.
491                    cmd.env("FIRSTPASS_CONFIG", c);
492                }
493                let child = cmd
494                    .stdin(std::process::Stdio::null())
495                    .stdout(std::process::Stdio::from(log.try_clone()?))
496                    .stderr(std::process::Stdio::from(log))
497                    .spawn();
498                match child {
499                    Ok(c) => {
500                        // Pidfile makes `firstpass offboard` able to stop what onboard started.
501                        let _ = std::fs::write("firstpass-proxy.pid", c.id().to_string());
502                        out.push_str(&format!(
503                            "✓ proxy started (pid {}, observe mode) — log: firstpass-proxy.log\n",
504                            c.id()
505                        ));
506                    }
507                    Err(e) => out.push_str(&format!("✗ could not start proxy: {e}\n")),
508                }
509            }
510            Step::WireShell { rc, line } => {
511                if let Some(parent) = rc.parent() {
512                    std::fs::create_dir_all(parent)?;
513                }
514                let mut f = std::fs::OpenOptions::new()
515                    .create(true)
516                    .append(true)
517                    .open(rc)?;
518                writeln!(f, "{line}")?;
519                out.push_str(&format!(
520                    "✓ wired {} — takes effect in new shells; for this one:\n     {}\n",
521                    rc.display(),
522                    line.trim_end_matches(RC_MARKER).trim_end()
523                ));
524            }
525            Step::SuggestClaudeMcp => {
526                out.push_str("→ optional: claude mcp add firstpass -- firstpass mcp\n");
527            }
528            Step::Verify => {
529                let url = format!("http://{}/healthz", env.bind);
530                let ok = wait_healthz(&url, std::time::Duration::from_secs(6));
531                if ok {
532                    out.push_str(&format!(
533                        "✓ verified — proxy healthy at http://{} · capabilities: http://{}/v1/capabilities\n",
534                        env.bind, env.bind
535                    ));
536                } else {
537                    out.push_str(&format!(
538                        "✗ proxy not answering http://{} after 6s — check firstpass-proxy.log\n",
539                        env.bind
540                    ));
541                }
542            }
543            Step::AlreadyDone(why) => out.push_str(&format!("✓ {why}\n")),
544        }
545    }
546    out.push_str("\noffboard any time: unset ANTHROPIC_BASE_URL (and remove the marked rc line)\n");
547    Ok(out)
548}
549
550/// Poll `/healthz` until it answers 200 or the deadline passes. Blocking + std-only (the CLI calls
551/// this off the async runtime): a plain TCP connect + minimal HTTP/1.1 GET, no client dependency.
552fn wait_healthz(url: &str, deadline: std::time::Duration) -> bool {
553    let Some(addr) = url
554        .strip_prefix("http://")
555        .and_then(|r| r.split('/').next())
556        .map(str::to_owned)
557    else {
558        return false;
559    };
560    let start = std::time::Instant::now();
561    while start.elapsed() < deadline {
562        if let Ok(mut s) = std::net::TcpStream::connect(&addr) {
563            let _ = s.set_read_timeout(Some(std::time::Duration::from_millis(500)));
564            let req = format!("GET /healthz HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
565            if s.write_all(req.as_bytes()).is_ok() {
566                let mut buf = [0u8; 64];
567                use std::io::Read as _;
568                if let Ok(n) = s.read(&mut buf)
569                    && n > 0
570                    && String::from_utf8_lossy(&buf[..n]).contains("200")
571                {
572                    return true;
573                }
574            }
575        }
576        std::thread::sleep(std::time::Duration::from_millis(200));
577    }
578    false
579}
580
581/// Whether the rc file already carries the onboard marker (idempotence check for [`plan`]).
582#[must_use]
583pub fn rc_wired(rc: &std::path::Path) -> bool {
584    std::fs::read_to_string(rc).is_ok_and(|s| s.contains(RC_MARKER))
585}
586
587/// Remove every marked onboard line from `rc`. Returns whether anything was removed. Only lines
588/// carrying [`RC_MARKER`] are touched — nothing else in the user's rc is ever rewritten.
589///
590/// # Errors
591/// Only on I/O failures reading/writing the rc file.
592pub fn offboard_rc(rc: &std::path::Path) -> Result<bool, std::io::Error> {
593    let Ok(content) = std::fs::read_to_string(rc) else {
594        return Ok(false); // no file → nothing to offboard
595    };
596    if !content.contains(RC_MARKER) {
597        return Ok(false);
598    }
599    let kept: Vec<&str> = content.lines().filter(|l| !l.contains(RC_MARKER)).collect();
600    std::fs::write(rc, kept.join("\n") + "\n")?;
601    Ok(true)
602}
603
604/// `firstpass offboard` — the exact mirror of onboard: strip the marked line from every candidate
605/// rc file, stop the proxy onboard started (pidfile), and print the one command for this shell.
606/// Fully idempotent; reports what it found either way.
607///
608/// # Errors
609/// Only on rc-file I/O failures.
610pub fn offboard(home: &std::path::Path) -> Result<String, std::io::Error> {
611    let mut out = String::new();
612    for rc in [
613        home.join(".zshrc"),
614        home.join(".bashrc"),
615        home.join(".profile"),
616        home.join(".config/fish/config.fish"),
617    ] {
618        if offboard_rc(&rc)? {
619            out.push_str(&format!("✓ removed firstpass line from {}\n", rc.display()));
620        }
621    }
622    // Stop the proxy onboard spawned, if its pidfile is present.
623    if let Ok(pid) = std::fs::read_to_string("firstpass-proxy.pid") {
624        let pid = pid.trim().to_owned();
625        #[cfg(unix)]
626        {
627            let killed = std::process::Command::new("kill")
628                .arg(&pid)
629                .status()
630                .is_ok_and(|s| s.success());
631            if killed {
632                out.push_str(&format!("✓ stopped proxy (pid {pid})\n"));
633            } else {
634                out.push_str(&format!(
635                    "→ proxy pid {pid} not running (already stopped)\n"
636                ));
637            }
638        }
639        let _ = std::fs::remove_file("firstpass-proxy.pid");
640    }
641    if out.is_empty() {
642        out.push_str("nothing to offboard — no marked rc lines, no pidfile.\n");
643    }
644    out.push_str("for this shell: unset ANTHROPIC_BASE_URL\n");
645    Ok(out)
646}
647
648#[cfg(test)]
649mod tests {
650    #![allow(clippy::unwrap_used)]
651    use super::*;
652
653    fn env_of<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
654        move |k| {
655            pairs
656                .iter()
657                .find(|(a, _)| *a == k)
658                .map(|(_, v)| (*v).to_owned())
659        }
660    }
661
662    #[test]
663    fn detect_reads_shell_routing_and_tools() {
664        let e = detect(
665            env_of(&[
666                ("SHELL", "/bin/zsh"),
667                ("ANTHROPIC_BASE_URL", "http://127.0.0.1:8080"),
668                ("ANTHROPIC_API_KEY", "sk-x"),
669            ]),
670            |bin| bin == "claude",
671            || true,
672        );
673        assert_eq!(e.shell, "zsh");
674        assert!(e.proxy_running && e.already_routed && e.has_api_key && e.has_claude_cli);
675        assert_eq!(e.bind, "127.0.0.1:8080");
676    }
677
678    #[test]
679    fn detect_respects_custom_bind_and_mismatched_base_url() {
680        let e = detect(
681            env_of(&[
682                ("FIRSTPASS_BIND", "127.0.0.1:9999"),
683                ("ANTHROPIC_BASE_URL", "http://127.0.0.1:8080"), // points elsewhere
684            ]),
685            |_| false,
686            || false,
687        );
688        assert_eq!(e.bind, "127.0.0.1:9999");
689        assert!(
690            !e.already_routed,
691            "routed to a different port is not routed"
692        );
693    }
694
695    #[test]
696    fn shell_wiring_speaks_each_dialect() {
697        let home = std::path::Path::new("/home/u");
698        let (rc, line) = shell_wiring("fish", home, "127.0.0.1:8080");
699        assert!(rc.ends_with(".config/fish/config.fish"));
700        assert!(line.starts_with("set -gx ANTHROPIC_BASE_URL http://127.0.0.1:8080"));
701
702        let (rc, line) = shell_wiring("zsh", home, "127.0.0.1:8080");
703        assert!(rc.ends_with(".zshrc"));
704        assert!(line.starts_with("export ANTHROPIC_BASE_URL="));
705
706        let (rc, _) = shell_wiring("dash", home, "127.0.0.1:8080");
707        assert!(
708            rc.ends_with(".profile"),
709            "unknown shells fall back to .profile"
710        );
711    }
712
713    #[test]
714    fn plan_covers_fresh_machine_and_is_idempotent_when_done() {
715        let home = std::path::Path::new("/home/u");
716        let fresh = Environment {
717            shell: "zsh".into(),
718            proxy_running: false,
719            already_routed: false,
720            has_api_key: false,
721            has_claude_cli: true,
722            bind: "127.0.0.1:8080".into(),
723        };
724        let steps = plan(&fresh, home, false, None);
725        assert!(matches!(steps[0], Step::StartProxy { .. }));
726        assert!(matches!(steps[1], Step::WireShell { .. }));
727        assert!(matches!(steps[2], Step::SuggestClaudeMcp));
728        assert!(matches!(steps.last(), Some(Step::Verify)));
729
730        // Everything already set up → only AlreadyDone + Verify, nothing mutating.
731        let done = Environment {
732            proxy_running: true,
733            already_routed: true,
734            has_claude_cli: false,
735            ..fresh
736        };
737        let steps = plan(&done, home, true, None);
738        assert!(
739            steps
740                .iter()
741                .all(|s| matches!(s, Step::AlreadyDone(_) | Step::Verify))
742        );
743    }
744
745    #[test]
746    fn render_dry_run_says_nothing_changed_and_flags_missing_key() {
747        let home = std::path::Path::new("/home/u");
748        let e = Environment {
749            shell: "bash".into(),
750            proxy_running: false,
751            already_routed: false,
752            has_api_key: false,
753            has_claude_cli: false,
754            bind: "127.0.0.1:8080".into(),
755        };
756        let text = render(&e, &plan(&e, home, false, None), false);
757        assert!(text.contains("dry run — nothing changed"));
758        assert!(text.contains("ANTHROPIC_API_KEY is not set"));
759        assert!(text.contains(".bashrc"));
760    }
761
762    #[test]
763    fn rc_wired_detects_the_marker_and_execute_appends_it_once() {
764        let dir = std::env::temp_dir().join(format!("fp-onboard-{}", uuid::Uuid::now_v7()));
765        std::fs::create_dir_all(&dir).unwrap();
766        let rc = dir.join(".zshrc");
767        assert!(!rc_wired(&rc), "missing file is not wired");
768
769        let e = Environment {
770            shell: "zsh".into(),
771            proxy_running: true, // no spawn in this test
772            already_routed: false,
773            has_api_key: true,
774            has_claude_cli: false,
775            bind: "127.0.0.1:1".into(), // verify step will fail fast — that's fine, we assert wiring
776        };
777        let (rc_path, line) = shell_wiring("zsh", &dir, &e.bind);
778        let steps = vec![Step::WireShell {
779            rc: rc_path.clone(),
780            line,
781        }];
782        let report = execute(&e, &steps).unwrap();
783        assert!(report.contains("✓ wired"));
784        assert!(rc_wired(&rc_path), "marker written");
785        // Re-planning with the marker present must not wire again (idempotent onboarding).
786        let steps = plan(&e, &dir, rc_wired(&rc_path), None);
787        assert!(!steps.iter().any(|s| matches!(s, Step::WireShell { .. })));
788
789        let _ = std::fs::remove_dir_all(&dir);
790    }
791
792    #[test]
793    fn offboard_removes_only_the_marked_line_and_is_idempotent() {
794        let dir = std::env::temp_dir().join(format!("fp-offboard-{}", uuid::Uuid::now_v7()));
795        std::fs::create_dir_all(&dir).unwrap();
796        let rc = dir.join(".zshrc");
797        std::fs::write(
798            &rc,
799            format!("alias ll='ls -l'\nexport ANTHROPIC_BASE_URL=http://127.0.0.1:8080  {RC_MARKER}\nexport EDITOR=vim\n"),
800        )
801        .unwrap();
802
803        assert!(offboard_rc(&rc).unwrap(), "marked line removed");
804        let after = std::fs::read_to_string(&rc).unwrap();
805        assert!(!after.contains(RC_MARKER));
806        assert!(
807            after.contains("alias ll") && after.contains("EDITOR=vim"),
808            "user lines untouched"
809        );
810        assert!(!offboard_rc(&rc).unwrap(), "second offboard is a no-op");
811
812        // The full offboard reports the rc removal and the unset line.
813        std::fs::write(&rc, format!("x  {RC_MARKER}\n")).unwrap();
814        let report = offboard(&dir).unwrap();
815        assert!(report.contains("removed firstpass line"));
816        assert!(report.contains("unset ANTHROPIC_BASE_URL"));
817
818        let _ = std::fs::remove_dir_all(&dir);
819    }
820
821    /// The whole point of generating a config is that it runs. Every answer combination must
822    /// survive the real parser — including the `[[provider]]`/`[[gate]]` blocks each one implies,
823    /// since only `anthropic`/`openai` and `non-empty`/`json-valid` are built in.
824    #[test]
825    fn every_generated_config_parses_and_resolves() {
826        use firstpass_core::Mode;
827        let mut n = 0;
828        for provider in Provider::ALL {
829            for shape in Shape::ALL {
830                for mode in [Mode::Observe, Mode::Enforce] {
831                    n += 1;
832                    let choice = LadderChoice {
833                        provider,
834                        shape,
835                        mode,
836                    };
837                    let toml = render_config(&choice);
838                    let cfg = firstpass_core::Config::parse(&toml).unwrap_or_else(|e| {
839                        panic!(
840                            "{}/{}/{mode:?} did not parse: {e}\n{toml}",
841                            provider.id(),
842                            shape.id()
843                        )
844                    });
845                    let route = &cfg.routes[0];
846                    assert_eq!(
847                        route.mode, mode,
848                        "route mode is what actually gates enforcement"
849                    );
850                    assert_eq!(
851                        route.ladder.len(),
852                        2,
853                        "a ladder needs somewhere to escalate to"
854                    );
855
856                    // Every gate the route names must be built in or declared here.
857                    for g in &route.gates {
858                        let builtin = g == "non-empty" || g == "json-valid";
859                        assert!(
860                            builtin || cfg.gate_defs.iter().any(|d| &d.id == g),
861                            "{}/{}: gate {g:?} is neither built in nor declared",
862                            provider.id(),
863                            shape.id()
864                        );
865                    }
866                    // Same for every provider a rung names.
867                    for rung in &route.ladder {
868                        let pid = rung.split('/').next().unwrap();
869                        let builtin = pid == "anthropic" || pid == "openai";
870                        assert!(
871                            builtin || cfg.providers.iter().any(|d| d.id == pid),
872                            "{}/{}: provider {pid:?} is neither built in nor declared",
873                            provider.id(),
874                            shape.id()
875                        );
876                    }
877                    // maker != checker: a judge drawn from the ladder would be rejected at runtime.
878                    if let Some(j) = cfg.gate_defs.iter().find_map(|d| d.judge.as_ref()) {
879                        assert!(
880                            !route.ladder.contains(&j.model),
881                            "{}: judge {} is on its own ladder",
882                            provider.id(),
883                            j.model
884                        );
885                    }
886                    // Observe must stay inert — an escalation cap implies enforcement.
887                    assert_eq!(
888                        toml.contains("[escalation]"),
889                        mode == Mode::Enforce,
890                        "escalation block should track enforce only"
891                    );
892                }
893            }
894        }
895        assert_eq!(n, 32, "4 providers x 4 shapes x 2 modes");
896    }
897
898    /// A written config the proxy never loads is worse than no config: `ProxyConfig::from_env`
899    /// reads `FIRSTPASS_CONFIG` and has no default path, so the plan must carry it to the child.
900    #[test]
901    fn planned_config_is_written_before_the_proxy_starts_and_is_handed_to_it() {
902        let home = std::path::Path::new("/home/u");
903        let env = Environment {
904            shell: "zsh".into(),
905            proxy_running: false,
906            already_routed: false,
907            has_api_key: true,
908            has_claude_cli: false,
909            bind: "127.0.0.1:8080".into(),
910        };
911        let cfg = ConfigPlan {
912            path: PathBuf::from("firstpass.toml"),
913            choice: LadderChoice::default(),
914        };
915        let steps = plan(&env, home, false, Some(&cfg));
916        assert!(
917            matches!(&steps[0], Step::WriteConfig { path, .. } if path == &cfg.path),
918            "config is written first, before anything reads it"
919        );
920        assert!(
921            matches!(&steps[1], Step::StartProxy { config: Some(p) } if p == &cfg.path),
922            "the spawned proxy is handed the config explicitly"
923        );
924
925        // Without a config the plan is exactly what it always was.
926        let steps = plan(&env, home, false, None);
927        assert!(matches!(steps[0], Step::StartProxy { config: None }));
928        assert!(!steps.iter().any(|s| matches!(s, Step::WriteConfig { .. })));
929    }
930
931    /// Re-running onboard must never clobber a routing file the operator already edited.
932    #[test]
933    fn write_config_refuses_to_overwrite_an_existing_file() {
934        let dir = std::env::temp_dir().join(format!("fp-cfg-{}", uuid::Uuid::now_v7()));
935        std::fs::create_dir_all(&dir).unwrap();
936        let path = dir.join("firstpass.toml");
937        std::fs::write(&path, "# hand-tuned, do not touch\n").unwrap();
938        let env = Environment {
939            shell: "zsh".into(),
940            proxy_running: true, // no spawn
941            already_routed: true,
942            has_api_key: true,
943            has_claude_cli: false,
944            bind: "127.0.0.1:1".into(),
945        };
946        let steps = vec![Step::WriteConfig {
947            path: path.clone(),
948            toml: render_config(&LadderChoice::default()),
949        }];
950        let report = execute(&env, &steps).unwrap();
951        assert!(report.contains("already present"));
952        assert_eq!(
953            std::fs::read_to_string(&path).unwrap(),
954            "# hand-tuned, do not touch\n",
955            "existing config survived untouched"
956        );
957        let _ = std::fs::remove_dir_all(&dir);
958    }
959
960    /// The dry run has to show the actual file, or "onboard asks then writes" is unverifiable
961    /// before the fact.
962    #[test]
963    fn dry_run_shows_the_config_it_would_write() {
964        let home = std::path::Path::new("/home/u");
965        let env = Environment {
966            shell: "bash".into(),
967            proxy_running: false,
968            already_routed: false,
969            has_api_key: true,
970            has_claude_cli: false,
971            bind: "127.0.0.1:8080".into(),
972        };
973        let cfg = ConfigPlan {
974            path: PathBuf::from("firstpass.toml"),
975            choice: LadderChoice {
976                provider: Provider::Local,
977                shape: Shape::Code,
978                mode: firstpass_core::Mode::Enforce,
979            },
980        };
981        let text = render(&env, &plan(&env, home, false, Some(&cfg)), false);
982        assert!(text.contains("write firstpass.toml"));
983        assert!(text.contains("ollama"), "provider block is shown");
984        assert!(text.contains("unit-tests"), "gate block is shown");
985        assert!(
986            text.contains("FIRSTPASS_CONFIG="),
987            "explains how the proxy finds it"
988        );
989        assert!(text.contains("dry run — nothing changed"));
990    }
991}