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, Copy, PartialEq, Eq)]
39pub enum Provider {
40 Anthropic,
42 OpenAi,
44 Google,
46 Local,
48}
49
50impl Provider {
51 pub const ALL: [Self; 4] = [Self::Anthropic, Self::OpenAi, Self::Google, Self::Local];
53
54 #[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 #[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 #[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 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum Shape {
124 Json,
126 Code,
128 Prose,
130 Mixed,
132}
133
134impl Shape {
135 pub const ALL: [Self; 4] = [Self::Json, Self::Code, Self::Prose, Self::Mixed];
137
138 #[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 #[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 #[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 #[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub struct LadderChoice {
218 pub provider: Provider,
220 pub shape: Shape,
222 pub mode: firstpass_core::Mode,
224}
225
226impl Default for LadderChoice {
227 fn default() -> Self {
230 Self {
231 provider: Provider::Anthropic,
232 shape: Shape::Json,
233 mode: firstpass_core::Mode::Observe,
234 }
235 }
236}
237
238#[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#[derive(Debug, Clone, PartialEq, Eq)]
280pub enum Step {
281 WriteConfig {
283 path: PathBuf,
285 toml: String,
287 },
288 StartProxy {
290 config: Option<PathBuf>,
293 },
294 WireShell {
296 rc: PathBuf,
298 line: String,
300 },
301 SuggestClaudeMcp,
304 Verify,
306 AlreadyDone(&'static str),
308}
309
310pub 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#[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" => (
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#[derive(Debug, Clone)]
361pub struct ConfigPlan {
362 pub path: PathBuf,
364 pub choice: LadderChoice,
366}
367
368#[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#[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
461pub 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 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 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 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
550fn 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#[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
587pub 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); };
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
604pub 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 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"), ]),
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 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, already_routed: false,
773 has_api_key: true,
774 has_claude_cli: false,
775 bind: "127.0.0.1:1".into(), };
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 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 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 #[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 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 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 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 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 #[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 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 #[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, 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 #[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}