1use std::io::Write as _;
12use std::path::PathBuf;
13
14pub const RC_MARKER: &str = "# added by `firstpass onboard`";
17
18#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Environment {
22 pub shell: String,
24 pub proxy_running: bool,
26 pub already_routed: bool,
28 pub has_api_key: bool,
30 pub has_claude_cli: bool,
32 pub bind: String,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum Step {
39 StartProxy,
41 WireShell {
43 rc: PathBuf,
45 line: String,
47 },
48 SuggestClaudeMcp,
51 Verify,
53 AlreadyDone(&'static str),
55}
56
57pub 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#[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" => (
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#[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#[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
169pub 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 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
239fn 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#[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
276pub 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); };
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
293pub 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 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"), ]),
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 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, already_routed: false,
462 has_api_key: true,
463 has_claude_cli: false,
464 bind: "127.0.0.1:1".into(), };
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 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 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}