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#[must_use]
286pub fn presets_js() -> String {
287 use firstpass_core::Mode;
288 let mut out = String::from(
289 "// GENERATED by `cargo test -p firstpass-proxy presets` — do not edit by hand.\n\
290 // Source of truth: crates/firstpass-proxy/src/onboard.rs (render_config).\n\
291 // Regenerate: FIRSTPASS_WRITE_PRESETS=1 cargo test -p firstpass-proxy presets\n\
292 window.FP_LADDER_PRESETS = {\n",
293 );
294 for provider in Provider::ALL {
295 out.push_str(&format!(" {:?}: {{\n", provider.id()));
296 for shape in Shape::ALL {
297 out.push_str(&format!(" {:?}: {{\n", shape.id()));
298 for (mode, key) in [(Mode::Observe, "observe"), (Mode::Enforce, "enforce")] {
299 let toml = render_config(&LadderChoice {
300 provider,
301 shape,
302 mode,
303 });
304 out.push_str(&format!(" {key:?}: {},\n", js_string(&toml)));
305 }
306 out.push_str(" },\n");
307 }
308 out.push_str(" },\n");
309 }
310 out.push_str("};\n");
311 out
312}
313
314fn js_string(s: &str) -> String {
317 let mut out = String::with_capacity(s.len() + 16);
318 out.push('"');
319 for c in s.chars() {
320 match c {
321 '"' => out.push_str("\\\""),
322 '\\' => out.push_str("\\\\"),
323 '\n' => out.push_str("\\n"),
324 '\r' => {}
325 c => out.push(c),
326 }
327 }
328 out.push('"');
329 out
330}
331
332#[derive(Debug, Clone, PartialEq, Eq)]
334pub enum Step {
335 WriteConfig {
337 path: PathBuf,
339 toml: String,
341 },
342 StartProxy {
344 config: Option<PathBuf>,
347 },
348 WireShell {
350 rc: PathBuf,
352 line: String,
354 },
355 SuggestClaudeMcp,
358 Verify,
360 AlreadyDone(&'static str),
362}
363
364pub fn detect(
367 env: impl Fn(&str) -> Option<String>,
368 on_path: impl Fn(&str) -> bool,
369 healthz: impl Fn() -> bool,
370) -> Environment {
371 let bind = env("FIRSTPASS_BIND").unwrap_or_else(|| "127.0.0.1:8080".to_owned());
372 let base_url = format!("http://{bind}");
373 let shell = env("SHELL")
374 .and_then(|s| s.rsplit('/').next().map(str::to_owned))
375 .unwrap_or_else(|| "sh".to_owned());
376 Environment {
377 shell,
378 proxy_running: healthz(),
379 already_routed: env("ANTHROPIC_BASE_URL").is_some_and(|v| v == base_url),
380 has_api_key: env("ANTHROPIC_API_KEY").is_some(),
381 has_claude_cli: on_path("claude"),
382 bind,
383 }
384}
385
386#[must_use]
389pub fn shell_wiring(shell: &str, home: &std::path::Path, bind: &str) -> (PathBuf, String) {
390 let url = format!("http://{bind}");
391 match shell {
392 "fish" => (
393 home.join(".config/fish/config.fish"),
394 format!("set -gx ANTHROPIC_BASE_URL {url} {RC_MARKER}"),
395 ),
396 "bash" => (
397 home.join(".bashrc"),
398 format!("export ANTHROPIC_BASE_URL={url} {RC_MARKER}"),
399 ),
400 "zsh" => (
402 home.join(".zshrc"),
403 format!("export ANTHROPIC_BASE_URL={url} {RC_MARKER}"),
404 ),
405 _ => (
406 home.join(".profile"),
407 format!("export ANTHROPIC_BASE_URL={url} {RC_MARKER}"),
408 ),
409 }
410}
411
412#[derive(Debug, Clone)]
415pub struct ConfigPlan {
416 pub path: PathBuf,
418 pub choice: LadderChoice,
420}
421
422#[must_use]
427pub fn plan(
428 env: &Environment,
429 home: &std::path::Path,
430 rc_already_wired: bool,
431 config: Option<&ConfigPlan>,
432) -> Vec<Step> {
433 let mut steps = Vec::new();
434 if let Some(c) = config {
435 steps.push(Step::WriteConfig {
436 path: c.path.clone(),
437 toml: render_config(&c.choice),
438 });
439 }
440 if env.proxy_running {
441 steps.push(Step::AlreadyDone("proxy already answering /healthz"));
442 } else {
443 steps.push(Step::StartProxy {
444 config: config.map(|c| c.path.clone()),
445 });
446 }
447 if env.already_routed || rc_already_wired {
448 steps.push(Step::AlreadyDone("ANTHROPIC_BASE_URL already wired"));
449 } else {
450 let (rc, line) = shell_wiring(&env.shell, home, &env.bind);
451 steps.push(Step::WireShell { rc, line });
452 }
453 if env.has_claude_cli {
454 steps.push(Step::SuggestClaudeMcp);
455 }
456 steps.push(Step::Verify);
457 steps
458}
459
460#[must_use]
462pub fn render(env: &Environment, steps: &[Step], apply: bool) -> String {
463 let mut out = String::new();
464 out.push_str(&format!(
465 "detected: shell={} · proxy_running={} · routed={} · api_key={} · claude_cli={}\n\n",
466 env.shell, env.proxy_running, env.already_routed, env.has_api_key, env.has_claude_cli
467 ));
468 for (i, s) in steps.iter().enumerate() {
469 let n = i + 1;
470 match s {
471 Step::WriteConfig { path, toml } => {
472 out.push_str(&format!("{n}. write {} —\n", path.display()));
473 for line in toml.lines() {
474 out.push_str(&format!(" {line}\n"));
475 }
476 }
477 Step::StartProxy { config } => {
478 out.push_str(&format!(
479 "{n}. start the proxy — `firstpass up` (observe mode: watches, changes nothing), log → firstpass-proxy.log\n"
480 ));
481 if let Some(c) = config {
482 out.push_str(&format!(
483 " with FIRSTPASS_CONFIG={} — the proxy has no default config path\n",
484 c.display()
485 ));
486 }
487 }
488 Step::WireShell { rc, line } => out.push_str(&format!(
489 "{n}. route your agents — append to {}:\n {line}\n",
490 rc.display()
491 )),
492 Step::SuggestClaudeMcp => out.push_str(&format!(
493 "{n}. (optional) let Claude Code query receipts as tools:\n claude mcp add firstpass -- firstpass mcp\n"
494 )),
495 Step::Verify => out.push_str(&format!(
496 "{n}. verify — probe /healthz and /v1/capabilities, report what's routed\n"
497 )),
498 Step::AlreadyDone(why) => out.push_str(&format!("{n}. ✓ {why}\n")),
499 }
500 }
501 if !env.has_api_key {
502 out.push_str(
503 "\nnote: ANTHROPIC_API_KEY is not set — observe mode passes your agent's own key \
504 through (BYOK), so this only matters for enforce mode.\n",
505 );
506 }
507 if !apply {
508 out.push_str(
509 "\ndry run — nothing changed. Re-run with `firstpass onboard --apply` to execute.\n",
510 );
511 }
512 out
513}
514
515pub fn execute(env: &Environment, steps: &[Step]) -> Result<String, std::io::Error> {
521 let mut out = String::new();
522 for s in steps {
523 match s {
524 Step::WriteConfig { path, toml } => {
525 if path.exists() {
528 out.push_str(&format!(
529 "✓ {} already present — left untouched\n",
530 path.display()
531 ));
532 } else {
533 std::fs::write(path, toml)?;
534 out.push_str(&format!("✓ wrote {}\n", path.display()));
535 }
536 }
537 Step::StartProxy { config } => {
538 let log = std::fs::File::create("firstpass-proxy.log")?;
539 let exe = std::env::current_exe()?;
540 let mut cmd = std::process::Command::new(exe);
541 cmd.arg("up");
542 if let Some(c) = config {
543 cmd.env("FIRSTPASS_CONFIG", c);
546 }
547 let child = cmd
548 .stdin(std::process::Stdio::null())
549 .stdout(std::process::Stdio::from(log.try_clone()?))
550 .stderr(std::process::Stdio::from(log))
551 .spawn();
552 match child {
553 Ok(c) => {
554 let _ = std::fs::write("firstpass-proxy.pid", c.id().to_string());
556 out.push_str(&format!(
557 "✓ proxy started (pid {}, observe mode) — log: firstpass-proxy.log\n",
558 c.id()
559 ));
560 }
561 Err(e) => out.push_str(&format!("✗ could not start proxy: {e}\n")),
562 }
563 }
564 Step::WireShell { rc, line } => {
565 if let Some(parent) = rc.parent() {
566 std::fs::create_dir_all(parent)?;
567 }
568 let mut f = std::fs::OpenOptions::new()
569 .create(true)
570 .append(true)
571 .open(rc)?;
572 writeln!(f, "{line}")?;
573 out.push_str(&format!(
574 "✓ wired {} — takes effect in new shells; for this one:\n {}\n",
575 rc.display(),
576 line.trim_end_matches(RC_MARKER).trim_end()
577 ));
578 }
579 Step::SuggestClaudeMcp => {
580 out.push_str("→ optional: claude mcp add firstpass -- firstpass mcp\n");
581 }
582 Step::Verify => {
583 let url = format!("http://{}/healthz", env.bind);
584 let ok = wait_healthz(&url, std::time::Duration::from_secs(6));
585 if ok {
586 out.push_str(&format!(
587 "✓ verified — proxy healthy at http://{} · capabilities: http://{}/v1/capabilities\n",
588 env.bind, env.bind
589 ));
590 } else {
591 out.push_str(&format!(
592 "✗ proxy not answering http://{} after 6s — check firstpass-proxy.log\n",
593 env.bind
594 ));
595 }
596 }
597 Step::AlreadyDone(why) => out.push_str(&format!("✓ {why}\n")),
598 }
599 }
600 out.push_str("\noffboard any time: unset ANTHROPIC_BASE_URL (and remove the marked rc line)\n");
601 Ok(out)
602}
603
604fn wait_healthz(url: &str, deadline: std::time::Duration) -> bool {
607 let Some(addr) = url
608 .strip_prefix("http://")
609 .and_then(|r| r.split('/').next())
610 .map(str::to_owned)
611 else {
612 return false;
613 };
614 let start = std::time::Instant::now();
615 while start.elapsed() < deadline {
616 if let Ok(mut s) = std::net::TcpStream::connect(&addr) {
617 let _ = s.set_read_timeout(Some(std::time::Duration::from_millis(500)));
618 let req = format!("GET /healthz HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
619 if s.write_all(req.as_bytes()).is_ok() {
620 let mut buf = [0u8; 64];
621 use std::io::Read as _;
622 if let Ok(n) = s.read(&mut buf)
623 && n > 0
624 && String::from_utf8_lossy(&buf[..n]).contains("200")
625 {
626 return true;
627 }
628 }
629 }
630 std::thread::sleep(std::time::Duration::from_millis(200));
631 }
632 false
633}
634
635#[must_use]
637pub fn rc_wired(rc: &std::path::Path) -> bool {
638 std::fs::read_to_string(rc).is_ok_and(|s| s.contains(RC_MARKER))
639}
640
641pub fn offboard_rc(rc: &std::path::Path) -> Result<bool, std::io::Error> {
647 let Ok(content) = std::fs::read_to_string(rc) else {
648 return Ok(false); };
650 if !content.contains(RC_MARKER) {
651 return Ok(false);
652 }
653 let kept: Vec<&str> = content.lines().filter(|l| !l.contains(RC_MARKER)).collect();
654 std::fs::write(rc, kept.join("\n") + "\n")?;
655 Ok(true)
656}
657
658pub fn offboard(home: &std::path::Path) -> Result<String, std::io::Error> {
665 let mut out = String::new();
666 for rc in [
667 home.join(".zshrc"),
668 home.join(".bashrc"),
669 home.join(".profile"),
670 home.join(".config/fish/config.fish"),
671 ] {
672 if offboard_rc(&rc)? {
673 out.push_str(&format!("✓ removed firstpass line from {}\n", rc.display()));
674 }
675 }
676 if let Ok(pid) = std::fs::read_to_string("firstpass-proxy.pid") {
678 let pid = pid.trim().to_owned();
679 #[cfg(unix)]
680 {
681 let killed = std::process::Command::new("kill")
682 .arg(&pid)
683 .status()
684 .is_ok_and(|s| s.success());
685 if killed {
686 out.push_str(&format!("✓ stopped proxy (pid {pid})\n"));
687 } else {
688 out.push_str(&format!(
689 "→ proxy pid {pid} not running (already stopped)\n"
690 ));
691 }
692 }
693 let _ = std::fs::remove_file("firstpass-proxy.pid");
694 }
695 if out.is_empty() {
696 out.push_str("nothing to offboard — no marked rc lines, no pidfile.\n");
697 }
698 out.push_str("for this shell: unset ANTHROPIC_BASE_URL\n");
699 Ok(out)
700}
701
702#[cfg(test)]
703mod tests {
704 #![allow(clippy::unwrap_used)]
705 use super::*;
706
707 fn env_of<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
708 move |k| {
709 pairs
710 .iter()
711 .find(|(a, _)| *a == k)
712 .map(|(_, v)| (*v).to_owned())
713 }
714 }
715
716 #[test]
717 fn detect_reads_shell_routing_and_tools() {
718 let e = detect(
719 env_of(&[
720 ("SHELL", "/bin/zsh"),
721 ("ANTHROPIC_BASE_URL", "http://127.0.0.1:8080"),
722 ("ANTHROPIC_API_KEY", "sk-x"),
723 ]),
724 |bin| bin == "claude",
725 || true,
726 );
727 assert_eq!(e.shell, "zsh");
728 assert!(e.proxy_running && e.already_routed && e.has_api_key && e.has_claude_cli);
729 assert_eq!(e.bind, "127.0.0.1:8080");
730 }
731
732 #[test]
733 fn detect_respects_custom_bind_and_mismatched_base_url() {
734 let e = detect(
735 env_of(&[
736 ("FIRSTPASS_BIND", "127.0.0.1:9999"),
737 ("ANTHROPIC_BASE_URL", "http://127.0.0.1:8080"), ]),
739 |_| false,
740 || false,
741 );
742 assert_eq!(e.bind, "127.0.0.1:9999");
743 assert!(
744 !e.already_routed,
745 "routed to a different port is not routed"
746 );
747 }
748
749 #[test]
750 fn shell_wiring_speaks_each_dialect() {
751 let home = std::path::Path::new("/home/u");
752 let (rc, line) = shell_wiring("fish", home, "127.0.0.1:8080");
753 assert!(rc.ends_with(".config/fish/config.fish"));
754 assert!(line.starts_with("set -gx ANTHROPIC_BASE_URL http://127.0.0.1:8080"));
755
756 let (rc, line) = shell_wiring("zsh", home, "127.0.0.1:8080");
757 assert!(rc.ends_with(".zshrc"));
758 assert!(line.starts_with("export ANTHROPIC_BASE_URL="));
759
760 let (rc, _) = shell_wiring("dash", home, "127.0.0.1:8080");
761 assert!(
762 rc.ends_with(".profile"),
763 "unknown shells fall back to .profile"
764 );
765 }
766
767 #[test]
768 fn plan_covers_fresh_machine_and_is_idempotent_when_done() {
769 let home = std::path::Path::new("/home/u");
770 let fresh = Environment {
771 shell: "zsh".into(),
772 proxy_running: false,
773 already_routed: false,
774 has_api_key: false,
775 has_claude_cli: true,
776 bind: "127.0.0.1:8080".into(),
777 };
778 let steps = plan(&fresh, home, false, None);
779 assert!(matches!(steps[0], Step::StartProxy { .. }));
780 assert!(matches!(steps[1], Step::WireShell { .. }));
781 assert!(matches!(steps[2], Step::SuggestClaudeMcp));
782 assert!(matches!(steps.last(), Some(Step::Verify)));
783
784 let done = Environment {
786 proxy_running: true,
787 already_routed: true,
788 has_claude_cli: false,
789 ..fresh
790 };
791 let steps = plan(&done, home, true, None);
792 assert!(
793 steps
794 .iter()
795 .all(|s| matches!(s, Step::AlreadyDone(_) | Step::Verify))
796 );
797 }
798
799 #[test]
800 fn render_dry_run_says_nothing_changed_and_flags_missing_key() {
801 let home = std::path::Path::new("/home/u");
802 let e = Environment {
803 shell: "bash".into(),
804 proxy_running: false,
805 already_routed: false,
806 has_api_key: false,
807 has_claude_cli: false,
808 bind: "127.0.0.1:8080".into(),
809 };
810 let text = render(&e, &plan(&e, home, false, None), false);
811 assert!(text.contains("dry run — nothing changed"));
812 assert!(text.contains("ANTHROPIC_API_KEY is not set"));
813 assert!(text.contains(".bashrc"));
814 }
815
816 #[test]
817 fn rc_wired_detects_the_marker_and_execute_appends_it_once() {
818 let dir = std::env::temp_dir().join(format!("fp-onboard-{}", uuid::Uuid::now_v7()));
819 std::fs::create_dir_all(&dir).unwrap();
820 let rc = dir.join(".zshrc");
821 assert!(!rc_wired(&rc), "missing file is not wired");
822
823 let e = Environment {
824 shell: "zsh".into(),
825 proxy_running: true, already_routed: false,
827 has_api_key: true,
828 has_claude_cli: false,
829 bind: "127.0.0.1:1".into(), };
831 let (rc_path, line) = shell_wiring("zsh", &dir, &e.bind);
832 let steps = vec![Step::WireShell {
833 rc: rc_path.clone(),
834 line,
835 }];
836 let report = execute(&e, &steps).unwrap();
837 assert!(report.contains("✓ wired"));
838 assert!(rc_wired(&rc_path), "marker written");
839 let steps = plan(&e, &dir, rc_wired(&rc_path), None);
841 assert!(!steps.iter().any(|s| matches!(s, Step::WireShell { .. })));
842
843 let _ = std::fs::remove_dir_all(&dir);
844 }
845
846 #[test]
847 fn offboard_removes_only_the_marked_line_and_is_idempotent() {
848 let dir = std::env::temp_dir().join(format!("fp-offboard-{}", uuid::Uuid::now_v7()));
849 std::fs::create_dir_all(&dir).unwrap();
850 let rc = dir.join(".zshrc");
851 std::fs::write(
852 &rc,
853 format!("alias ll='ls -l'\nexport ANTHROPIC_BASE_URL=http://127.0.0.1:8080 {RC_MARKER}\nexport EDITOR=vim\n"),
854 )
855 .unwrap();
856
857 assert!(offboard_rc(&rc).unwrap(), "marked line removed");
858 let after = std::fs::read_to_string(&rc).unwrap();
859 assert!(!after.contains(RC_MARKER));
860 assert!(
861 after.contains("alias ll") && after.contains("EDITOR=vim"),
862 "user lines untouched"
863 );
864 assert!(!offboard_rc(&rc).unwrap(), "second offboard is a no-op");
865
866 std::fs::write(&rc, format!("x {RC_MARKER}\n")).unwrap();
868 let report = offboard(&dir).unwrap();
869 assert!(report.contains("removed firstpass line"));
870 assert!(report.contains("unset ANTHROPIC_BASE_URL"));
871
872 let _ = std::fs::remove_dir_all(&dir);
873 }
874
875 #[test]
879 fn every_generated_config_parses_and_resolves() {
880 use firstpass_core::Mode;
881 let mut n = 0;
882 for provider in Provider::ALL {
883 for shape in Shape::ALL {
884 for mode in [Mode::Observe, Mode::Enforce] {
885 n += 1;
886 let choice = LadderChoice {
887 provider,
888 shape,
889 mode,
890 };
891 let toml = render_config(&choice);
892 let cfg = firstpass_core::Config::parse(&toml).unwrap_or_else(|e| {
893 panic!(
894 "{}/{}/{mode:?} did not parse: {e}\n{toml}",
895 provider.id(),
896 shape.id()
897 )
898 });
899 let route = &cfg.routes[0];
900 assert_eq!(
901 route.mode, mode,
902 "route mode is what actually gates enforcement"
903 );
904 assert_eq!(
905 route.ladder.len(),
906 2,
907 "a ladder needs somewhere to escalate to"
908 );
909
910 for g in &route.gates {
912 let builtin = g == "non-empty" || g == "json-valid";
913 assert!(
914 builtin || cfg.gate_defs.iter().any(|d| &d.id == g),
915 "{}/{}: gate {g:?} is neither built in nor declared",
916 provider.id(),
917 shape.id()
918 );
919 }
920 for rung in &route.ladder {
922 let pid = rung.split('/').next().unwrap();
923 let builtin = pid == "anthropic" || pid == "openai";
924 assert!(
925 builtin || cfg.providers.iter().any(|d| d.id == pid),
926 "{}/{}: provider {pid:?} is neither built in nor declared",
927 provider.id(),
928 shape.id()
929 );
930 }
931 if let Some(j) = cfg.gate_defs.iter().find_map(|d| d.judge.as_ref()) {
933 assert!(
934 !route.ladder.contains(&j.model),
935 "{}: judge {} is on its own ladder",
936 provider.id(),
937 j.model
938 );
939 }
940 assert_eq!(
942 toml.contains("[escalation]"),
943 mode == Mode::Enforce,
944 "escalation block should track enforce only"
945 );
946 }
947 }
948 }
949 assert_eq!(n, 32, "4 providers x 4 shapes x 2 modes");
950 }
951
952 #[test]
955 fn planned_config_is_written_before_the_proxy_starts_and_is_handed_to_it() {
956 let home = std::path::Path::new("/home/u");
957 let env = Environment {
958 shell: "zsh".into(),
959 proxy_running: false,
960 already_routed: false,
961 has_api_key: true,
962 has_claude_cli: false,
963 bind: "127.0.0.1:8080".into(),
964 };
965 let cfg = ConfigPlan {
966 path: PathBuf::from("firstpass.toml"),
967 choice: LadderChoice::default(),
968 };
969 let steps = plan(&env, home, false, Some(&cfg));
970 assert!(
971 matches!(&steps[0], Step::WriteConfig { path, .. } if path == &cfg.path),
972 "config is written first, before anything reads it"
973 );
974 assert!(
975 matches!(&steps[1], Step::StartProxy { config: Some(p) } if p == &cfg.path),
976 "the spawned proxy is handed the config explicitly"
977 );
978
979 let steps = plan(&env, home, false, None);
981 assert!(matches!(steps[0], Step::StartProxy { config: None }));
982 assert!(!steps.iter().any(|s| matches!(s, Step::WriteConfig { .. })));
983 }
984
985 #[test]
987 fn write_config_refuses_to_overwrite_an_existing_file() {
988 let dir = std::env::temp_dir().join(format!("fp-cfg-{}", uuid::Uuid::now_v7()));
989 std::fs::create_dir_all(&dir).unwrap();
990 let path = dir.join("firstpass.toml");
991 std::fs::write(&path, "# hand-tuned, do not touch\n").unwrap();
992 let env = Environment {
993 shell: "zsh".into(),
994 proxy_running: true, already_routed: true,
996 has_api_key: true,
997 has_claude_cli: false,
998 bind: "127.0.0.1:1".into(),
999 };
1000 let steps = vec![Step::WriteConfig {
1001 path: path.clone(),
1002 toml: render_config(&LadderChoice::default()),
1003 }];
1004 let report = execute(&env, &steps).unwrap();
1005 assert!(report.contains("already present"));
1006 assert_eq!(
1007 std::fs::read_to_string(&path).unwrap(),
1008 "# hand-tuned, do not touch\n",
1009 "existing config survived untouched"
1010 );
1011 let _ = std::fs::remove_dir_all(&dir);
1012 }
1013
1014 #[test]
1017 fn dry_run_shows_the_config_it_would_write() {
1018 let home = std::path::Path::new("/home/u");
1019 let env = Environment {
1020 shell: "bash".into(),
1021 proxy_running: false,
1022 already_routed: false,
1023 has_api_key: true,
1024 has_claude_cli: false,
1025 bind: "127.0.0.1:8080".into(),
1026 };
1027 let cfg = ConfigPlan {
1028 path: PathBuf::from("firstpass.toml"),
1029 choice: LadderChoice {
1030 provider: Provider::Local,
1031 shape: Shape::Code,
1032 mode: firstpass_core::Mode::Enforce,
1033 },
1034 };
1035 let text = render(&env, &plan(&env, home, false, Some(&cfg)), false);
1036 assert!(text.contains("write firstpass.toml"));
1037 assert!(text.contains("ollama"), "provider block is shown");
1038 assert!(text.contains("unit-tests"), "gate block is shown");
1039 assert!(
1040 text.contains("FIRSTPASS_CONFIG="),
1041 "explains how the proxy finds it"
1042 );
1043 assert!(text.contains("dry run — nothing changed"));
1044 }
1045
1046 #[test]
1050 fn docs_presets_are_current() {
1051 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1052 .join("../../docs/assets/ladder-presets.js");
1053 let generated = presets_js();
1054
1055 if std::env::var("FIRSTPASS_WRITE_PRESETS").is_ok() {
1057 std::fs::write(&path, &generated).expect("write presets");
1058 return;
1059 }
1060
1061 let committed = std::fs::read_to_string(&path).unwrap_or_else(|e| {
1062 panic!(
1063 "{}: {e}\nregenerate with:\n \
1064 FIRSTPASS_WRITE_PRESETS=1 cargo test -p firstpass-proxy presets",
1065 path.display()
1066 )
1067 });
1068 assert_eq!(
1069 committed.replace("\r\n", "\n"),
1070 generated,
1071 "docs/assets/ladder-presets.js is stale — the docs builder and `firstpass onboard` \
1072 would emit different config.\nregenerate with:\n \
1073 FIRSTPASS_WRITE_PRESETS=1 cargo test -p firstpass-proxy presets"
1074 );
1075 }
1076
1077 #[test]
1080 fn every_docs_preset_parses() {
1081 let js = presets_js();
1082 let mut n = 0;
1083 for raw in js.split("\": \"").skip(1) {
1084 let lit = &raw[..raw.find("\",\n").unwrap_or(raw.len())];
1085 let toml = lit
1086 .replace("\\n", "\n")
1087 .replace("\\\"", "\"")
1088 .replace("\\\\", "\\");
1089 if !toml.contains("[[route]]") {
1090 continue;
1091 }
1092 n += 1;
1093 firstpass_core::Config::parse(&toml)
1094 .unwrap_or_else(|e| panic!("preset #{n} does not parse: {e}\n{toml}"));
1095 }
1096 assert_eq!(n, 32, "expected all 32 presets to be checked, saw {n}");
1097 }
1098}