use std::io::Write as _;
use std::path::PathBuf;
pub const RC_MARKER: &str = "# added by `firstpass onboard`";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Environment {
pub shell: String,
pub proxy_running: bool,
pub already_routed: bool,
pub has_api_key: bool,
pub has_claude_cli: bool,
pub bind: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Step {
StartProxy,
WireShell {
rc: PathBuf,
line: String,
},
SuggestClaudeMcp,
Verify,
AlreadyDone(&'static str),
}
pub fn detect(
env: impl Fn(&str) -> Option<String>,
on_path: impl Fn(&str) -> bool,
healthz: impl Fn() -> bool,
) -> Environment {
let bind = env("FIRSTPASS_BIND").unwrap_or_else(|| "127.0.0.1:8080".to_owned());
let base_url = format!("http://{bind}");
let shell = env("SHELL")
.and_then(|s| s.rsplit('/').next().map(str::to_owned))
.unwrap_or_else(|| "sh".to_owned());
Environment {
shell,
proxy_running: healthz(),
already_routed: env("ANTHROPIC_BASE_URL").is_some_and(|v| v == base_url),
has_api_key: env("ANTHROPIC_API_KEY").is_some(),
has_claude_cli: on_path("claude"),
bind,
}
}
#[must_use]
pub fn shell_wiring(shell: &str, home: &std::path::Path, bind: &str) -> (PathBuf, String) {
let url = format!("http://{bind}");
match shell {
"fish" => (
home.join(".config/fish/config.fish"),
format!("set -gx ANTHROPIC_BASE_URL {url} {RC_MARKER}"),
),
"bash" => (
home.join(".bashrc"),
format!("export ANTHROPIC_BASE_URL={url} {RC_MARKER}"),
),
"zsh" => (
home.join(".zshrc"),
format!("export ANTHROPIC_BASE_URL={url} {RC_MARKER}"),
),
_ => (
home.join(".profile"),
format!("export ANTHROPIC_BASE_URL={url} {RC_MARKER}"),
),
}
}
#[must_use]
pub fn plan(env: &Environment, home: &std::path::Path, rc_already_wired: bool) -> Vec<Step> {
let mut steps = Vec::new();
if env.proxy_running {
steps.push(Step::AlreadyDone("proxy already answering /healthz"));
} else {
steps.push(Step::StartProxy);
}
if env.already_routed || rc_already_wired {
steps.push(Step::AlreadyDone("ANTHROPIC_BASE_URL already wired"));
} else {
let (rc, line) = shell_wiring(&env.shell, home, &env.bind);
steps.push(Step::WireShell { rc, line });
}
if env.has_claude_cli {
steps.push(Step::SuggestClaudeMcp);
}
steps.push(Step::Verify);
steps
}
#[must_use]
pub fn render(env: &Environment, steps: &[Step], apply: bool) -> String {
let mut out = String::new();
out.push_str(&format!(
"detected: shell={} · proxy_running={} · routed={} · api_key={} · claude_cli={}\n\n",
env.shell, env.proxy_running, env.already_routed, env.has_api_key, env.has_claude_cli
));
for (i, s) in steps.iter().enumerate() {
let n = i + 1;
match s {
Step::StartProxy => out.push_str(&format!(
"{n}. start the proxy — `firstpass up` (observe mode: watches, changes nothing), log → firstpass-proxy.log\n"
)),
Step::WireShell { rc, line } => out.push_str(&format!(
"{n}. route your agents — append to {}:\n {line}\n",
rc.display()
)),
Step::SuggestClaudeMcp => out.push_str(&format!(
"{n}. (optional) let Claude Code query receipts as tools:\n claude mcp add firstpass -- firstpass mcp\n"
)),
Step::Verify => out.push_str(&format!(
"{n}. verify — probe /healthz and /v1/capabilities, report what's routed\n"
)),
Step::AlreadyDone(why) => out.push_str(&format!("{n}. ✓ {why}\n")),
}
}
if !env.has_api_key {
out.push_str(
"\nnote: ANTHROPIC_API_KEY is not set — observe mode passes your agent's own key \
through (BYOK), so this only matters for enforce mode.\n",
);
}
if !apply {
out.push_str(
"\ndry run — nothing changed. Re-run with `firstpass onboard --apply` to execute.\n",
);
}
out
}
pub fn execute(env: &Environment, steps: &[Step]) -> Result<String, std::io::Error> {
let mut out = String::new();
for s in steps {
match s {
Step::StartProxy => {
let log = std::fs::File::create("firstpass-proxy.log")?;
let exe = std::env::current_exe()?;
let child = std::process::Command::new(exe)
.arg("up")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::from(log.try_clone()?))
.stderr(std::process::Stdio::from(log))
.spawn();
match child {
Ok(c) => {
let _ = std::fs::write("firstpass-proxy.pid", c.id().to_string());
out.push_str(&format!(
"✓ proxy started (pid {}, observe mode) — log: firstpass-proxy.log\n",
c.id()
));
}
Err(e) => out.push_str(&format!("✗ could not start proxy: {e}\n")),
}
}
Step::WireShell { rc, line } => {
if let Some(parent) = rc.parent() {
std::fs::create_dir_all(parent)?;
}
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(rc)?;
writeln!(f, "{line}")?;
out.push_str(&format!(
"✓ wired {} — takes effect in new shells; for this one:\n {}\n",
rc.display(),
line.trim_end_matches(RC_MARKER).trim_end()
));
}
Step::SuggestClaudeMcp => {
out.push_str("→ optional: claude mcp add firstpass -- firstpass mcp\n");
}
Step::Verify => {
let url = format!("http://{}/healthz", env.bind);
let ok = wait_healthz(&url, std::time::Duration::from_secs(6));
if ok {
out.push_str(&format!(
"✓ verified — proxy healthy at http://{} · capabilities: http://{}/v1/capabilities\n",
env.bind, env.bind
));
} else {
out.push_str(&format!(
"✗ proxy not answering http://{} after 6s — check firstpass-proxy.log\n",
env.bind
));
}
}
Step::AlreadyDone(why) => out.push_str(&format!("✓ {why}\n")),
}
}
out.push_str("\noffboard any time: unset ANTHROPIC_BASE_URL (and remove the marked rc line)\n");
Ok(out)
}
fn wait_healthz(url: &str, deadline: std::time::Duration) -> bool {
let Some(addr) = url
.strip_prefix("http://")
.and_then(|r| r.split('/').next())
.map(str::to_owned)
else {
return false;
};
let start = std::time::Instant::now();
while start.elapsed() < deadline {
if let Ok(mut s) = std::net::TcpStream::connect(&addr) {
let _ = s.set_read_timeout(Some(std::time::Duration::from_millis(500)));
let req = format!("GET /healthz HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
if s.write_all(req.as_bytes()).is_ok() {
let mut buf = [0u8; 64];
use std::io::Read as _;
if let Ok(n) = s.read(&mut buf)
&& n > 0
&& String::from_utf8_lossy(&buf[..n]).contains("200")
{
return true;
}
}
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
false
}
#[must_use]
pub fn rc_wired(rc: &std::path::Path) -> bool {
std::fs::read_to_string(rc).is_ok_and(|s| s.contains(RC_MARKER))
}
pub fn offboard_rc(rc: &std::path::Path) -> Result<bool, std::io::Error> {
let Ok(content) = std::fs::read_to_string(rc) else {
return Ok(false); };
if !content.contains(RC_MARKER) {
return Ok(false);
}
let kept: Vec<&str> = content.lines().filter(|l| !l.contains(RC_MARKER)).collect();
std::fs::write(rc, kept.join("\n") + "\n")?;
Ok(true)
}
pub fn offboard(home: &std::path::Path) -> Result<String, std::io::Error> {
let mut out = String::new();
for rc in [
home.join(".zshrc"),
home.join(".bashrc"),
home.join(".profile"),
home.join(".config/fish/config.fish"),
] {
if offboard_rc(&rc)? {
out.push_str(&format!("✓ removed firstpass line from {}\n", rc.display()));
}
}
if let Ok(pid) = std::fs::read_to_string("firstpass-proxy.pid") {
let pid = pid.trim().to_owned();
#[cfg(unix)]
{
let killed = std::process::Command::new("kill")
.arg(&pid)
.status()
.is_ok_and(|s| s.success());
if killed {
out.push_str(&format!("✓ stopped proxy (pid {pid})\n"));
} else {
out.push_str(&format!(
"→ proxy pid {pid} not running (already stopped)\n"
));
}
}
let _ = std::fs::remove_file("firstpass-proxy.pid");
}
if out.is_empty() {
out.push_str("nothing to offboard — no marked rc lines, no pidfile.\n");
}
out.push_str("for this shell: unset ANTHROPIC_BASE_URL\n");
Ok(out)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
fn env_of<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |k| {
pairs
.iter()
.find(|(a, _)| *a == k)
.map(|(_, v)| (*v).to_owned())
}
}
#[test]
fn detect_reads_shell_routing_and_tools() {
let e = detect(
env_of(&[
("SHELL", "/bin/zsh"),
("ANTHROPIC_BASE_URL", "http://127.0.0.1:8080"),
("ANTHROPIC_API_KEY", "sk-x"),
]),
|bin| bin == "claude",
|| true,
);
assert_eq!(e.shell, "zsh");
assert!(e.proxy_running && e.already_routed && e.has_api_key && e.has_claude_cli);
assert_eq!(e.bind, "127.0.0.1:8080");
}
#[test]
fn detect_respects_custom_bind_and_mismatched_base_url() {
let e = detect(
env_of(&[
("FIRSTPASS_BIND", "127.0.0.1:9999"),
("ANTHROPIC_BASE_URL", "http://127.0.0.1:8080"), ]),
|_| false,
|| false,
);
assert_eq!(e.bind, "127.0.0.1:9999");
assert!(
!e.already_routed,
"routed to a different port is not routed"
);
}
#[test]
fn shell_wiring_speaks_each_dialect() {
let home = std::path::Path::new("/home/u");
let (rc, line) = shell_wiring("fish", home, "127.0.0.1:8080");
assert!(rc.ends_with(".config/fish/config.fish"));
assert!(line.starts_with("set -gx ANTHROPIC_BASE_URL http://127.0.0.1:8080"));
let (rc, line) = shell_wiring("zsh", home, "127.0.0.1:8080");
assert!(rc.ends_with(".zshrc"));
assert!(line.starts_with("export ANTHROPIC_BASE_URL="));
let (rc, _) = shell_wiring("dash", home, "127.0.0.1:8080");
assert!(
rc.ends_with(".profile"),
"unknown shells fall back to .profile"
);
}
#[test]
fn plan_covers_fresh_machine_and_is_idempotent_when_done() {
let home = std::path::Path::new("/home/u");
let fresh = Environment {
shell: "zsh".into(),
proxy_running: false,
already_routed: false,
has_api_key: false,
has_claude_cli: true,
bind: "127.0.0.1:8080".into(),
};
let steps = plan(&fresh, home, false);
assert!(matches!(steps[0], Step::StartProxy));
assert!(matches!(steps[1], Step::WireShell { .. }));
assert!(matches!(steps[2], Step::SuggestClaudeMcp));
assert!(matches!(steps.last(), Some(Step::Verify)));
let done = Environment {
proxy_running: true,
already_routed: true,
has_claude_cli: false,
..fresh
};
let steps = plan(&done, home, true);
assert!(
steps
.iter()
.all(|s| matches!(s, Step::AlreadyDone(_) | Step::Verify))
);
}
#[test]
fn render_dry_run_says_nothing_changed_and_flags_missing_key() {
let home = std::path::Path::new("/home/u");
let e = Environment {
shell: "bash".into(),
proxy_running: false,
already_routed: false,
has_api_key: false,
has_claude_cli: false,
bind: "127.0.0.1:8080".into(),
};
let text = render(&e, &plan(&e, home, false), false);
assert!(text.contains("dry run — nothing changed"));
assert!(text.contains("ANTHROPIC_API_KEY is not set"));
assert!(text.contains(".bashrc"));
}
#[test]
fn rc_wired_detects_the_marker_and_execute_appends_it_once() {
let dir = std::env::temp_dir().join(format!("fp-onboard-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&dir).unwrap();
let rc = dir.join(".zshrc");
assert!(!rc_wired(&rc), "missing file is not wired");
let e = Environment {
shell: "zsh".into(),
proxy_running: true, already_routed: false,
has_api_key: true,
has_claude_cli: false,
bind: "127.0.0.1:1".into(), };
let (rc_path, line) = shell_wiring("zsh", &dir, &e.bind);
let steps = vec![Step::WireShell {
rc: rc_path.clone(),
line,
}];
let report = execute(&e, &steps).unwrap();
assert!(report.contains("✓ wired"));
assert!(rc_wired(&rc_path), "marker written");
let steps = plan(&e, &dir, rc_wired(&rc_path));
assert!(!steps.iter().any(|s| matches!(s, Step::WireShell { .. })));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn offboard_removes_only_the_marked_line_and_is_idempotent() {
let dir = std::env::temp_dir().join(format!("fp-offboard-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&dir).unwrap();
let rc = dir.join(".zshrc");
std::fs::write(
&rc,
format!("alias ll='ls -l'\nexport ANTHROPIC_BASE_URL=http://127.0.0.1:8080 {RC_MARKER}\nexport EDITOR=vim\n"),
)
.unwrap();
assert!(offboard_rc(&rc).unwrap(), "marked line removed");
let after = std::fs::read_to_string(&rc).unwrap();
assert!(!after.contains(RC_MARKER));
assert!(
after.contains("alias ll") && after.contains("EDITOR=vim"),
"user lines untouched"
);
assert!(!offboard_rc(&rc).unwrap(), "second offboard is a no-op");
std::fs::write(&rc, format!("x {RC_MARKER}\n")).unwrap();
let report = offboard(&dir).unwrap();
assert!(report.contains("removed firstpass line"));
assert!(report.contains("unset ANTHROPIC_BASE_URL"));
let _ = std::fs::remove_dir_all(&dir);
}
}