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/// One onboarding step: what it is, and whether it's already satisfied.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum Step {
39    /// Spawn `firstpass up` detached (observe mode), logging to `firstpass-proxy.log`.
40    StartProxy,
41    /// Append the marked `ANTHROPIC_BASE_URL` export to the shell rc file.
42    WireShell {
43        /// Rc file the line goes into.
44        rc: PathBuf,
45        /// The exact line (shell-dialect aware).
46        line: String,
47    },
48    /// Print the `claude mcp add` command so Claude Code can query traces/savings as tools.
49    /// (Printed, not executed — mutating another tool's config uninvited isn't onboarding.)
50    SuggestClaudeMcp,
51    /// Probe `/healthz` and `/v1/capabilities` and report what's routed.
52    Verify,
53    /// Nothing to do — with the reason shown to the user.
54    AlreadyDone(&'static str),
55}
56
57/// Detect the environment via injected lookups: `env(key)` for env vars, `on_path(bin)` for PATH
58/// probes, `healthz()` for a live proxy probe. Pure decision logic; all I/O behind the closures.
59pub fn detect(
60    env: impl Fn(&str) -> Option<String>,
61    on_path: impl Fn(&str) -> bool,
62    healthz: impl Fn() -> bool,
63) -> Environment {
64    let bind = env("FIRSTPASS_BIND").unwrap_or_else(|| "127.0.0.1:8080".to_owned());
65    let base_url = format!("http://{bind}");
66    let shell = env("SHELL")
67        .and_then(|s| s.rsplit('/').next().map(str::to_owned))
68        .unwrap_or_else(|| "sh".to_owned());
69    Environment {
70        shell,
71        proxy_running: healthz(),
72        already_routed: env("ANTHROPIC_BASE_URL").is_some_and(|v| v == base_url),
73        has_api_key: env("ANTHROPIC_API_KEY").is_some(),
74        has_claude_cli: on_path("claude"),
75        bind,
76    }
77}
78
79/// The shell-dialect line that routes agents through the proxy, plus the rc file it belongs in.
80/// `home` is injected so tests never touch a real home directory.
81#[must_use]
82pub fn shell_wiring(shell: &str, home: &std::path::Path, bind: &str) -> (PathBuf, String) {
83    let url = format!("http://{bind}");
84    match shell {
85        "fish" => (
86            home.join(".config/fish/config.fish"),
87            format!("set -gx ANTHROPIC_BASE_URL {url}  {RC_MARKER}"),
88        ),
89        "bash" => (
90            home.join(".bashrc"),
91            format!("export ANTHROPIC_BASE_URL={url}  {RC_MARKER}"),
92        ),
93        // zsh and anything else POSIX-ish: default to ~/.zshrc only for zsh, else ~/.profile.
94        "zsh" => (
95            home.join(".zshrc"),
96            format!("export ANTHROPIC_BASE_URL={url}  {RC_MARKER}"),
97        ),
98        _ => (
99            home.join(".profile"),
100            format!("export ANTHROPIC_BASE_URL={url}  {RC_MARKER}"),
101        ),
102    }
103}
104
105/// Build the ordered plan for this environment. `rc_already_wired` is whether the rc file already
106/// carries the marker line (checked by the caller, injected here to stay pure).
107#[must_use]
108pub fn plan(env: &Environment, home: &std::path::Path, rc_already_wired: bool) -> Vec<Step> {
109    let mut steps = Vec::new();
110    if env.proxy_running {
111        steps.push(Step::AlreadyDone("proxy already answering /healthz"));
112    } else {
113        steps.push(Step::StartProxy);
114    }
115    if env.already_routed || rc_already_wired {
116        steps.push(Step::AlreadyDone("ANTHROPIC_BASE_URL already wired"));
117    } else {
118        let (rc, line) = shell_wiring(&env.shell, home, &env.bind);
119        steps.push(Step::WireShell { rc, line });
120    }
121    if env.has_claude_cli {
122        steps.push(Step::SuggestClaudeMcp);
123    }
124    steps.push(Step::Verify);
125    steps
126}
127
128/// Render the plan for the dry run (default) or as the running commentary under `--apply`.
129#[must_use]
130pub fn render(env: &Environment, steps: &[Step], apply: bool) -> String {
131    let mut out = String::new();
132    out.push_str(&format!(
133        "detected: shell={} · proxy_running={} · routed={} · api_key={} · claude_cli={}\n\n",
134        env.shell, env.proxy_running, env.already_routed, env.has_api_key, env.has_claude_cli
135    ));
136    for (i, s) in steps.iter().enumerate() {
137        let n = i + 1;
138        match s {
139            Step::StartProxy => out.push_str(&format!(
140                "{n}. start the proxy — `firstpass up` (observe mode: watches, changes nothing), log → firstpass-proxy.log\n"
141            )),
142            Step::WireShell { rc, line } => out.push_str(&format!(
143                "{n}. route your agents — append to {}:\n     {line}\n",
144                rc.display()
145            )),
146            Step::SuggestClaudeMcp => out.push_str(&format!(
147                "{n}. (optional) let Claude Code query receipts as tools:\n     claude mcp add firstpass -- firstpass mcp\n"
148            )),
149            Step::Verify => out.push_str(&format!(
150                "{n}. verify — probe /healthz and /v1/capabilities, report what's routed\n"
151            )),
152            Step::AlreadyDone(why) => out.push_str(&format!("{n}. ✓ {why}\n")),
153        }
154    }
155    if !env.has_api_key {
156        out.push_str(
157            "\nnote: ANTHROPIC_API_KEY is not set — observe mode passes your agent's own key \
158             through (BYOK), so this only matters for enforce mode.\n",
159        );
160    }
161    if !apply {
162        out.push_str(
163            "\ndry run — nothing changed. Re-run with `firstpass onboard --apply` to execute.\n",
164        );
165    }
166    out
167}
168
169/// Execute the side-effectful steps. Returns a human report. Failures are reported per step —
170/// onboarding never half-dies silently.
171///
172/// # Errors
173/// Only on I/O failures writing the rc file; every other issue is reported in the returned text.
174pub fn execute(env: &Environment, steps: &[Step]) -> Result<String, std::io::Error> {
175    let mut out = String::new();
176    for s in steps {
177        match s {
178            Step::StartProxy => {
179                let log = std::fs::File::create("firstpass-proxy.log")?;
180                let exe = std::env::current_exe()?;
181                let child = std::process::Command::new(exe)
182                    .arg("up")
183                    .stdin(std::process::Stdio::null())
184                    .stdout(std::process::Stdio::from(log.try_clone()?))
185                    .stderr(std::process::Stdio::from(log))
186                    .spawn();
187                match child {
188                    Ok(c) => {
189                        // Pidfile makes `firstpass offboard` able to stop what onboard started.
190                        let _ = std::fs::write("firstpass-proxy.pid", c.id().to_string());
191                        out.push_str(&format!(
192                            "✓ proxy started (pid {}, observe mode) — log: firstpass-proxy.log\n",
193                            c.id()
194                        ));
195                    }
196                    Err(e) => out.push_str(&format!("✗ could not start proxy: {e}\n")),
197                }
198            }
199            Step::WireShell { rc, line } => {
200                if let Some(parent) = rc.parent() {
201                    std::fs::create_dir_all(parent)?;
202                }
203                let mut f = std::fs::OpenOptions::new()
204                    .create(true)
205                    .append(true)
206                    .open(rc)?;
207                writeln!(f, "{line}")?;
208                out.push_str(&format!(
209                    "✓ wired {} — takes effect in new shells; for this one:\n     {}\n",
210                    rc.display(),
211                    line.trim_end_matches(RC_MARKER).trim_end()
212                ));
213            }
214            Step::SuggestClaudeMcp => {
215                out.push_str("→ optional: claude mcp add firstpass -- firstpass mcp\n");
216            }
217            Step::Verify => {
218                let url = format!("http://{}/healthz", env.bind);
219                let ok = wait_healthz(&url, std::time::Duration::from_secs(6));
220                if ok {
221                    out.push_str(&format!(
222                        "✓ verified — proxy healthy at http://{} · capabilities: http://{}/v1/capabilities\n",
223                        env.bind, env.bind
224                    ));
225                } else {
226                    out.push_str(&format!(
227                        "✗ proxy not answering http://{} after 6s — check firstpass-proxy.log\n",
228                        env.bind
229                    ));
230                }
231            }
232            Step::AlreadyDone(why) => out.push_str(&format!("✓ {why}\n")),
233        }
234    }
235    out.push_str("\noffboard any time: unset ANTHROPIC_BASE_URL (and remove the marked rc line)\n");
236    Ok(out)
237}
238
239/// Poll `/healthz` until it answers 200 or the deadline passes. Blocking + std-only (the CLI calls
240/// this off the async runtime): a plain TCP connect + minimal HTTP/1.1 GET, no client dependency.
241fn wait_healthz(url: &str, deadline: std::time::Duration) -> bool {
242    let Some(addr) = url
243        .strip_prefix("http://")
244        .and_then(|r| r.split('/').next())
245        .map(str::to_owned)
246    else {
247        return false;
248    };
249    let start = std::time::Instant::now();
250    while start.elapsed() < deadline {
251        if let Ok(mut s) = std::net::TcpStream::connect(&addr) {
252            let _ = s.set_read_timeout(Some(std::time::Duration::from_millis(500)));
253            let req = format!("GET /healthz HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
254            if s.write_all(req.as_bytes()).is_ok() {
255                let mut buf = [0u8; 64];
256                use std::io::Read as _;
257                if let Ok(n) = s.read(&mut buf)
258                    && n > 0
259                    && String::from_utf8_lossy(&buf[..n]).contains("200")
260                {
261                    return true;
262                }
263            }
264        }
265        std::thread::sleep(std::time::Duration::from_millis(200));
266    }
267    false
268}
269
270/// Whether the rc file already carries the onboard marker (idempotence check for [`plan`]).
271#[must_use]
272pub fn rc_wired(rc: &std::path::Path) -> bool {
273    std::fs::read_to_string(rc).is_ok_and(|s| s.contains(RC_MARKER))
274}
275
276/// Remove every marked onboard line from `rc`. Returns whether anything was removed. Only lines
277/// carrying [`RC_MARKER`] are touched — nothing else in the user's rc is ever rewritten.
278///
279/// # Errors
280/// Only on I/O failures reading/writing the rc file.
281pub fn offboard_rc(rc: &std::path::Path) -> Result<bool, std::io::Error> {
282    let Ok(content) = std::fs::read_to_string(rc) else {
283        return Ok(false); // no file → nothing to offboard
284    };
285    if !content.contains(RC_MARKER) {
286        return Ok(false);
287    }
288    let kept: Vec<&str> = content.lines().filter(|l| !l.contains(RC_MARKER)).collect();
289    std::fs::write(rc, kept.join("\n") + "\n")?;
290    Ok(true)
291}
292
293/// `firstpass offboard` — the exact mirror of onboard: strip the marked line from every candidate
294/// rc file, stop the proxy onboard started (pidfile), and print the one command for this shell.
295/// Fully idempotent; reports what it found either way.
296///
297/// # Errors
298/// Only on rc-file I/O failures.
299pub fn offboard(home: &std::path::Path) -> Result<String, std::io::Error> {
300    let mut out = String::new();
301    for rc in [
302        home.join(".zshrc"),
303        home.join(".bashrc"),
304        home.join(".profile"),
305        home.join(".config/fish/config.fish"),
306    ] {
307        if offboard_rc(&rc)? {
308            out.push_str(&format!("✓ removed firstpass line from {}\n", rc.display()));
309        }
310    }
311    // Stop the proxy onboard spawned, if its pidfile is present.
312    if let Ok(pid) = std::fs::read_to_string("firstpass-proxy.pid") {
313        let pid = pid.trim().to_owned();
314        #[cfg(unix)]
315        {
316            let killed = std::process::Command::new("kill")
317                .arg(&pid)
318                .status()
319                .is_ok_and(|s| s.success());
320            if killed {
321                out.push_str(&format!("✓ stopped proxy (pid {pid})\n"));
322            } else {
323                out.push_str(&format!(
324                    "→ proxy pid {pid} not running (already stopped)\n"
325                ));
326            }
327        }
328        let _ = std::fs::remove_file("firstpass-proxy.pid");
329    }
330    if out.is_empty() {
331        out.push_str("nothing to offboard — no marked rc lines, no pidfile.\n");
332    }
333    out.push_str("for this shell: unset ANTHROPIC_BASE_URL\n");
334    Ok(out)
335}
336
337#[cfg(test)]
338mod tests {
339    #![allow(clippy::unwrap_used)]
340    use super::*;
341
342    fn env_of<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
343        move |k| {
344            pairs
345                .iter()
346                .find(|(a, _)| *a == k)
347                .map(|(_, v)| (*v).to_owned())
348        }
349    }
350
351    #[test]
352    fn detect_reads_shell_routing_and_tools() {
353        let e = detect(
354            env_of(&[
355                ("SHELL", "/bin/zsh"),
356                ("ANTHROPIC_BASE_URL", "http://127.0.0.1:8080"),
357                ("ANTHROPIC_API_KEY", "sk-x"),
358            ]),
359            |bin| bin == "claude",
360            || true,
361        );
362        assert_eq!(e.shell, "zsh");
363        assert!(e.proxy_running && e.already_routed && e.has_api_key && e.has_claude_cli);
364        assert_eq!(e.bind, "127.0.0.1:8080");
365    }
366
367    #[test]
368    fn detect_respects_custom_bind_and_mismatched_base_url() {
369        let e = detect(
370            env_of(&[
371                ("FIRSTPASS_BIND", "127.0.0.1:9999"),
372                ("ANTHROPIC_BASE_URL", "http://127.0.0.1:8080"), // points elsewhere
373            ]),
374            |_| false,
375            || false,
376        );
377        assert_eq!(e.bind, "127.0.0.1:9999");
378        assert!(
379            !e.already_routed,
380            "routed to a different port is not routed"
381        );
382    }
383
384    #[test]
385    fn shell_wiring_speaks_each_dialect() {
386        let home = std::path::Path::new("/home/u");
387        let (rc, line) = shell_wiring("fish", home, "127.0.0.1:8080");
388        assert!(rc.ends_with(".config/fish/config.fish"));
389        assert!(line.starts_with("set -gx ANTHROPIC_BASE_URL http://127.0.0.1:8080"));
390
391        let (rc, line) = shell_wiring("zsh", home, "127.0.0.1:8080");
392        assert!(rc.ends_with(".zshrc"));
393        assert!(line.starts_with("export ANTHROPIC_BASE_URL="));
394
395        let (rc, _) = shell_wiring("dash", home, "127.0.0.1:8080");
396        assert!(
397            rc.ends_with(".profile"),
398            "unknown shells fall back to .profile"
399        );
400    }
401
402    #[test]
403    fn plan_covers_fresh_machine_and_is_idempotent_when_done() {
404        let home = std::path::Path::new("/home/u");
405        let fresh = Environment {
406            shell: "zsh".into(),
407            proxy_running: false,
408            already_routed: false,
409            has_api_key: false,
410            has_claude_cli: true,
411            bind: "127.0.0.1:8080".into(),
412        };
413        let steps = plan(&fresh, home, false);
414        assert!(matches!(steps[0], Step::StartProxy));
415        assert!(matches!(steps[1], Step::WireShell { .. }));
416        assert!(matches!(steps[2], Step::SuggestClaudeMcp));
417        assert!(matches!(steps.last(), Some(Step::Verify)));
418
419        // Everything already set up → only AlreadyDone + Verify, nothing mutating.
420        let done = Environment {
421            proxy_running: true,
422            already_routed: true,
423            has_claude_cli: false,
424            ..fresh
425        };
426        let steps = plan(&done, home, true);
427        assert!(
428            steps
429                .iter()
430                .all(|s| matches!(s, Step::AlreadyDone(_) | Step::Verify))
431        );
432    }
433
434    #[test]
435    fn render_dry_run_says_nothing_changed_and_flags_missing_key() {
436        let home = std::path::Path::new("/home/u");
437        let e = Environment {
438            shell: "bash".into(),
439            proxy_running: false,
440            already_routed: false,
441            has_api_key: false,
442            has_claude_cli: false,
443            bind: "127.0.0.1:8080".into(),
444        };
445        let text = render(&e, &plan(&e, home, false), false);
446        assert!(text.contains("dry run — nothing changed"));
447        assert!(text.contains("ANTHROPIC_API_KEY is not set"));
448        assert!(text.contains(".bashrc"));
449    }
450
451    #[test]
452    fn rc_wired_detects_the_marker_and_execute_appends_it_once() {
453        let dir = std::env::temp_dir().join(format!("fp-onboard-{}", uuid::Uuid::now_v7()));
454        std::fs::create_dir_all(&dir).unwrap();
455        let rc = dir.join(".zshrc");
456        assert!(!rc_wired(&rc), "missing file is not wired");
457
458        let e = Environment {
459            shell: "zsh".into(),
460            proxy_running: true, // no spawn in this test
461            already_routed: false,
462            has_api_key: true,
463            has_claude_cli: false,
464            bind: "127.0.0.1:1".into(), // verify step will fail fast — that's fine, we assert wiring
465        };
466        let (rc_path, line) = shell_wiring("zsh", &dir, &e.bind);
467        let steps = vec![Step::WireShell {
468            rc: rc_path.clone(),
469            line,
470        }];
471        let report = execute(&e, &steps).unwrap();
472        assert!(report.contains("✓ wired"));
473        assert!(rc_wired(&rc_path), "marker written");
474        // Re-planning with the marker present must not wire again (idempotent onboarding).
475        let steps = plan(&e, &dir, rc_wired(&rc_path));
476        assert!(!steps.iter().any(|s| matches!(s, Step::WireShell { .. })));
477
478        let _ = std::fs::remove_dir_all(&dir);
479    }
480
481    #[test]
482    fn offboard_removes_only_the_marked_line_and_is_idempotent() {
483        let dir = std::env::temp_dir().join(format!("fp-offboard-{}", uuid::Uuid::now_v7()));
484        std::fs::create_dir_all(&dir).unwrap();
485        let rc = dir.join(".zshrc");
486        std::fs::write(
487            &rc,
488            format!("alias ll='ls -l'\nexport ANTHROPIC_BASE_URL=http://127.0.0.1:8080  {RC_MARKER}\nexport EDITOR=vim\n"),
489        )
490        .unwrap();
491
492        assert!(offboard_rc(&rc).unwrap(), "marked line removed");
493        let after = std::fs::read_to_string(&rc).unwrap();
494        assert!(!after.contains(RC_MARKER));
495        assert!(
496            after.contains("alias ll") && after.contains("EDITOR=vim"),
497            "user lines untouched"
498        );
499        assert!(!offboard_rc(&rc).unwrap(), "second offboard is a no-op");
500
501        // The full offboard reports the rc removal and the unset line.
502        std::fs::write(&rc, format!("x  {RC_MARKER}\n")).unwrap();
503        let report = offboard(&dir).unwrap();
504        assert!(report.contains("removed firstpass line"));
505        assert!(report.contains("unset ANTHROPIC_BASE_URL"));
506
507        let _ = std::fs::remove_dir_all(&dir);
508    }
509}