use amberfork_align::{AlignParams, DiffParams, LexicalCost, diff};
use amberfork_ingest::Ingested;
use amberfork_judge::{ExplainContext, Judge, OllamaJudge, ground};
use amberfork_layout::{Document, ViewModel};
use amberfork_model::{DiffResult, Run, Warning};
use amberfork_record::{CaptureProxy, Cassette};
use amberfork_server::Server;
use clap::{Args, Parser, Subcommand};
use load::{LoadError, load_run};
use render::{RenderOpts, resolve_color_mode};
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
mod html_export;
mod load;
mod render;
mod verify;
const EXIT_CONVERGED: u8 = 0;
const EXIT_FORKED: u8 = 1;
const EXIT_TROUBLE: u8 = 2;
const JUDGE_OLLAMA_URL: &str = "http://127.0.0.1:11434";
const JUDGE_DEFAULT_MODEL: &str = "smollm2:135m";
const JUDGE_WINDOW_K: usize = 2;
const DEMO_GOOD: &str = include_str!("../assets/demo/good.json");
const DEMO_BAD: &str = include_str!("../assets/demo/bad.json");
const DEMO_HINT: &str =
" bundled sample pair · try your own runs: amberfork diff <bad> --against <good>";
const DEMO_SERVE_HINT: &str =
" bundled sample pair · serve your own runs: amberfork serve <bad> --against <good>";
#[derive(Parser)]
#[command(name = "amberfork", version, about)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Diff(DiffArgs),
Demo(DemoArgs),
Serve(ServeArgs),
Record(RecordArgs),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
enum JudgeMode {
Off,
Local,
}
#[derive(Args)]
struct DiffArgs {
#[arg(value_name = "BAD")]
bad: PathBuf,
#[arg(long, value_name = "GOOD")]
against: PathBuf,
#[arg(long, value_name = "N", default_value_t = AlignParams::DEFAULT_MAX_STEPS)]
max_steps: usize,
#[arg(long)]
verify: bool,
#[arg(long, value_name = "URL")]
upstream: Option<String>,
#[arg(long = "base-url-env", value_name = "NAME")]
base_url_env: Vec<String>,
#[arg(long, value_name = "N", default_value_t = 3, value_parser = clap::value_parser!(u32).range(1..))]
runs: u32,
#[arg(long, value_enum, default_value_t = JudgeMode::Off)]
judge: JudgeMode,
#[arg(long, value_name = "FILE")]
html: Option<PathBuf>,
#[command(flatten)]
output: OutputArgs,
#[arg(value_name = "CMD", last = true, num_args = 1..)]
command: Vec<String>,
}
#[derive(Args)]
struct DemoArgs {
#[command(flatten)]
output: OutputArgs,
}
#[derive(Args)]
struct ServeArgs {
#[arg(
value_name = "BAD",
required_unless_present = "demo",
conflicts_with = "demo"
)]
bad: Option<PathBuf>,
#[arg(
long,
value_name = "GOOD",
required_unless_present = "demo",
conflicts_with = "demo"
)]
against: Option<PathBuf>,
#[arg(long)]
demo: bool,
#[arg(long, value_name = "N", default_value_t = AlignParams::DEFAULT_MAX_STEPS)]
max_steps: usize,
#[arg(long, value_name = "PORT")]
port: Option<u16>,
#[arg(long)]
open: bool,
}
#[derive(Args)]
struct RecordArgs {
#[arg(long, value_name = "URL")]
upstream: String,
#[arg(long = "base-url-env", value_name = "NAME", required = true)]
base_url_env: Vec<String>,
#[arg(long, value_name = "FILE")]
out: Option<PathBuf>,
#[arg(long, value_name = "ID", default_value = "recording")]
id: String,
#[arg(value_name = "CMD", last = true, required = true, num_args = 1..)]
command: Vec<String>,
}
impl RecordArgs {
fn out_path(&self) -> PathBuf {
self.out
.clone()
.unwrap_or_else(|| PathBuf::from(format!("{}.cassette.json", self.id)))
}
}
#[derive(Args)]
struct OutputArgs {
#[arg(long)]
json: bool,
#[arg(long)]
no_color: bool,
}
fn main() -> ExitCode {
let cli = Cli::parse();
match cli.command {
Command::Diff(args) => run_diff(&args).unwrap_or_else(|err| {
eprintln!("amberfork: {err}");
ExitCode::from(EXIT_TROUBLE)
}),
Command::Demo(args) => run_demo(&args),
Command::Serve(args) => run_serve(&args).unwrap_or_else(|err| {
eprintln!("amberfork: {err}");
ExitCode::from(EXIT_TROUBLE)
}),
Command::Record(args) => run_record(&args),
}
}
fn run_diff(args: &DiffArgs) -> Result<ExitCode, LoadError> {
match verify::VerifyConfig::resolve(
args.verify,
args.upstream.as_deref(),
&args.base_url_env,
&args.command,
args.runs,
) {
Ok(None) => {
let good = load_run(&args.against)?;
let bad = load_run(&args.bad)?;
Ok(diff_and_report(
good,
bad,
args.max_steps,
&args.output,
None,
args.judge,
args.html.as_deref(),
))
}
Ok(Some(config)) => {
if !matches!(args.judge, JudgeMode::Off) {
eprintln!("amberfork: warning: --judge has no effect with --verify yet");
}
run_diff_verify(args, &config)
}
Err(message) => {
eprintln!("amberfork: {message}");
Ok(ExitCode::from(EXIT_TROUBLE))
}
}
}
fn run_diff_verify(args: &DiffArgs, config: &verify::VerifyConfig) -> Result<ExitCode, LoadError> {
let good = load::load_cassette(&args.against)?;
let bad = load::load_cassette(&args.bad)?;
let good_run = amberfork_record::normalize(&good);
let bad_run = amberfork_record::normalize(&bad);
let params = match engine_params(args.max_steps) {
Ok(params) => params,
Err(code) => return Ok(code),
};
let mut result = match diff(&good_run, &bad_run, &LexicalCost, ¶ms) {
Ok(result) => result,
Err(err) => {
eprintln!("amberfork: {err}");
return Ok(ExitCode::from(EXIT_TROUBLE));
}
};
result.warnings = Vec::new();
match verify::verify_attribution(&result, &good, &bad, config, ¶ms) {
Ok(Some(attribution)) => result.attribution = Some(attribution),
Ok(None) => {}
Err(err) => {
eprintln!("amberfork: {err}");
return Ok(ExitCode::from(EXIT_TROUBLE));
}
}
Ok(report(
&result,
&good_run,
&bad_run,
&args.output,
None,
args.html.as_deref(),
))
}
fn run_demo(args: &DemoArgs) -> ExitCode {
let (good, bad) = demo_pair();
diff_and_report(
good,
bad,
AlignParams::DEFAULT_MAX_STEPS,
&args.output,
Some(DEMO_HINT),
JudgeMode::Off,
None,
)
}
fn demo_pair() -> (Ingested, Ingested) {
let good = amberfork_ingest::from_json_str(DEMO_GOOD)
.expect("embedded demo trace good.json parses (locked by demo_cli tests)");
let bad = amberfork_ingest::from_json_str(DEMO_BAD)
.expect("embedded demo trace bad.json parses (locked by demo_cli tests)");
(good, bad)
}
fn run_serve(args: &ServeArgs) -> Result<ExitCode, LoadError> {
let (good, bad) = if args.demo {
demo_pair()
} else {
let good = load_run(
args.against
.as_ref()
.expect("clap requires --against unless --demo"),
)?;
let bad = load_run(args.bad.as_ref().expect("clap requires BAD unless --demo"))?;
(good, bad)
};
let mut result = match run_engine(&good, &bad, args.max_steps) {
Ok(result) => result,
Err(code) => return Ok(code),
};
result.warnings = merged_warnings(good.warnings, bad.warnings);
let view = ViewModel::compute(&result, &good.run, &bad.run);
let document = Document::new(view.clone());
Ok(serve_document(&document, &view, args))
}
fn serve_source(args: &ServeArgs) -> String {
if args.demo {
return "the bundled demo pair".to_string();
}
let bad = args.bad.as_ref().expect("clap requires BAD unless --demo");
let against = args
.against
.as_ref()
.expect("clap requires --against unless --demo");
format!("{} vs {}", bad.display(), against.display())
}
fn serve_document(document: &Document, view: &ViewModel, args: &ServeArgs) -> ExitCode {
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_io()
.build()
{
Ok(runtime) => runtime,
Err(err) => {
eprintln!("amberfork: cannot start the async runtime: {err}");
return ExitCode::from(EXIT_TROUBLE);
}
};
runtime.block_on(async {
let server = match Server::bind(document, view, args.port.unwrap_or(0)).await {
Ok(server) => server,
Err(err) => {
eprintln!("amberfork: {err}");
return ExitCode::from(EXIT_TROUBLE);
}
};
let url = format!("http://{}", server.local_addr());
println!("{}", document.view.headline());
println!("serving {} → {url} (Ctrl-C to stop)", serve_source(args));
if args.demo {
println!("{DEMO_SERVE_HINT}");
}
for warning in &document.view.warnings {
eprintln!("amberfork: warning: {}", warning.msg);
}
if args.open
&& let Err(err) = webbrowser::open(&url)
{
eprintln!("amberfork: warning: cannot open a browser: {err}");
}
match server.serve().await {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("amberfork: {err}");
ExitCode::from(EXIT_TROUBLE)
}
}
})
}
fn run_record(args: &RecordArgs) -> ExitCode {
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_io()
.build()
{
Ok(runtime) => runtime,
Err(err) => {
eprintln!("amberfork: cannot start the async runtime: {err}");
return ExitCode::from(EXIT_TROUBLE);
}
};
runtime.block_on(record_session(args))
}
async fn record_session(args: &RecordArgs) -> ExitCode {
let proxy = match CaptureProxy::bind(&args.id, &args.upstream).await {
Ok(proxy) => proxy,
Err(err) => {
eprintln!("amberfork: {err}");
return ExitCode::from(EXIT_TROUBLE);
}
};
eprintln!(
"amberfork: recording unredacted request/response bodies — do not share a cassette \
without scrubbing it first (see docs/cassette-format.md#privacy-contract)"
);
let (program, program_args) = args
.command
.split_first()
.expect("clap requires a command after --");
let mut child = tokio::process::Command::new(program);
child.args(program_args);
for name in &args.base_url_env {
child.env(name, proxy.base_url());
}
let status = match child.status().await {
Ok(status) => status,
Err(err) => {
eprintln!("amberfork: cannot run agent `{program}`: {err}");
return ExitCode::from(EXIT_TROUBLE);
}
};
let cassette = proxy.cassette();
let out = args.out_path();
if let Err(code) = write_cassette(&cassette, &out) {
return code;
}
eprintln!(
"amberfork: recorded {} exchange(s) → {}",
cassette.exchanges.len(),
out.display()
);
ExitCode::from(exit_code_of(&status))
}
fn write_cassette(cassette: &Cassette, out: &Path) -> Result<(), ExitCode> {
let json = serde_json::to_string_pretty(cassette)
.expect("cassette serialization is infallible (Value bodies, string keys)");
std::fs::write(out, json).map_err(|err| {
eprintln!(
"amberfork: cannot write cassette to {}: {err}",
out.display()
);
ExitCode::from(EXIT_TROUBLE)
})
}
fn exit_code_of(status: &std::process::ExitStatus) -> u8 {
status.code().map_or(1, |code| code as u8)
}
fn engine_params(max_steps: usize) -> Result<DiffParams, ExitCode> {
let params = DiffParams {
align: AlignParams {
max_steps,
..AlignParams::default()
},
..DiffParams::default()
};
params.validated().map_err(|err| {
eprintln!("amberfork: {err}");
ExitCode::from(EXIT_TROUBLE)
})
}
fn run_engine(good: &Ingested, bad: &Ingested, max_steps: usize) -> Result<DiffResult, ExitCode> {
let params = engine_params(max_steps)?;
diff(&good.run, &bad.run, &LexicalCost, ¶ms).map_err(|err| {
eprintln!("amberfork: {err}");
ExitCode::from(EXIT_TROUBLE)
})
}
fn diff_and_report(
good: Ingested,
bad: Ingested,
max_steps: usize,
output: &OutputArgs,
footer: Option<&str>,
judge: JudgeMode,
html: Option<&Path>,
) -> ExitCode {
let mut result = match run_engine(&good, &bad, max_steps) {
Ok(result) => result,
Err(code) => return code,
};
result.warnings = merged_warnings(good.warnings, bad.warnings);
let code = report(&result, &good.run, &bad.run, output, footer, html);
print_judge_explanation(judge, &result, &good.run, &bad.run, output);
code
}
fn print_judge_explanation(
mode: JudgeMode,
result: &DiffResult,
good: &Run,
bad: &Run,
output: &OutputArgs,
) {
if matches!(mode, JudgeMode::Off) || output.json {
return;
}
match judge_explanation(result, good, bad) {
Ok(grounded) => {
let color = resolve_color_mode(
output.no_color,
std::io::stdout().is_terminal(),
std::env::var("NO_COLOR").ok().as_deref(),
std::env::var("TERM").ok().as_deref(),
std::env::var("COLORTERM").ok().as_deref(),
);
println!(
"{}",
color.dim(&format!("AI (unverified): {}", grounded.narrative))
);
if let Some(fix) = grounded.speculative_fix {
println!("{}", color.dim(&format!("AI (speculative fix): {fix}")));
}
}
Err(message) => eprintln!("amberfork: warning: judge: {message}"),
}
}
fn judge_explanation(
result: &DiffResult,
good: &Run,
bad: &Run,
) -> Result<amberfork_judge::Grounded, String> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
.map_err(|err| format!("cannot start the async runtime: {err}"))?;
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(2))
.build()
.map_err(|err| err.to_string())?;
let judge = OllamaJudge::new(client, JUDGE_OLLAMA_URL, JUDGE_DEFAULT_MODEL);
let ctx = ExplainContext::windowed(result, good, bad, JUDGE_WINDOW_K);
runtime.block_on(async {
let explanation = judge.explain(&ctx).await.map_err(|err| err.to_string())?;
ground(result, explanation).map_err(|err| err.to_string())
})
}
fn report(
result: &DiffResult,
good_run: &Run,
bad_run: &Run,
output: &OutputArgs,
footer: Option<&str>,
html: Option<&Path>,
) -> ExitCode {
if output.json {
let json = serde_json::to_string_pretty(result)
.expect("DiffResult serialization is infallible (no non-string map keys)");
println!("{json}");
} else {
let color = resolve_color_mode(
output.no_color,
std::io::stdout().is_terminal(),
std::env::var("NO_COLOR").ok().as_deref(),
std::env::var("TERM").ok().as_deref(),
std::env::var("COLORTERM").ok().as_deref(),
);
let width = terminal_size::terminal_size().map_or(100, |(w, _)| usize::from(w.0));
let opts = RenderOpts {
color,
width: width.max(60),
};
let view = ViewModel::compute(result, good_run, bad_run);
print!("{}", render::render(&view, &opts));
if let Some(footer) = footer {
println!("{}", color.dim(footer));
}
for warning in &result.warnings {
eprintln!("amberfork: warning: {}", warning.msg);
}
}
if let Some(path) = html {
let document = Document::new(ViewModel::compute(result, good_run, bad_run));
if let Err(err) = std::fs::write(path, html_export::render(&document)) {
eprintln!("amberfork: --html {}: {err}", path.display());
return ExitCode::from(EXIT_TROUBLE);
}
}
let code = if result.fork.is_some() {
EXIT_FORKED
} else {
EXIT_CONVERGED
};
ExitCode::from(code)
}
fn merged_warnings(reference: Vec<Warning>, observed: Vec<Warning>) -> Vec<Warning> {
fn tag(side: char, warnings: Vec<Warning>) -> impl Iterator<Item = Warning> {
warnings.into_iter().map(move |w| Warning {
code: w.code,
msg: format!("run {side}: {}", w.msg),
})
}
tag('a', reference).chain(tag('b', observed)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn demo_pair_is_the_single_forking_source() {
let (good, bad) = demo_pair();
let params = DiffParams::default()
.validated()
.expect("default params validate");
let result =
diff(&good.run, &bad.run, &LexicalCost, ¶ms).expect("the demo pair aligns");
assert!(
result.fork.is_some(),
"the embedded demo pair must encode a real divergence"
);
}
fn record_args(out: Option<PathBuf>, id: &str) -> RecordArgs {
RecordArgs {
upstream: "https://api.example.com".to_string(),
base_url_env: vec!["OPENAI_BASE_URL".to_string()],
out,
id: id.to_string(),
command: vec!["python".to_string(), "agent.py".to_string()],
}
}
#[test]
fn record_out_defaults_to_the_id_cassette_when_unset() {
assert_eq!(
record_args(None, "run-42").out_path(),
PathBuf::from("run-42.cassette.json")
);
}
#[test]
fn record_explicit_out_wins_over_the_default() {
let out = PathBuf::from("/tmp/custom.cassette.json");
assert_eq!(record_args(Some(out.clone()), "run-42").out_path(), out);
}
}