Skip to main content

agent_first_http/cli/
mod.rs

1//! CLI layer. Resolves argv against the closed-world registry, calls into the
2//! SDK, and formats output.
3
4pub mod args;
5pub mod cmd;
6pub mod output;
7pub mod spec;
8
9use std::process::ExitCode;
10
11use agent_first_data::{
12    BoundOutcome, OutputPlan, OutputTo, cli_error_event, cli_help_event, cli_parse_output,
13    cli_version_event, render_cli_reference,
14};
15
16use crate::shared::error::{Error, ErrorCode};
17
18/// Binary entry point. Successful structured responses exit zero; structured
19/// errors carry their stable AFDATA shape on the selected output route and
20/// exit nonzero.
21pub fn run() -> ExitCode {
22    let cli = match spec::cli_spec() {
23        Ok(cli) => cli,
24        Err(error) => return emit_startup_error("cli_spec_invalid", &error.to_string()),
25    };
26    let app = match cli.bind_actions(args::handlers()) {
27        Ok(app) => app,
28        Err(error) => return emit_startup_error("cli_actions_invalid", &error.to_string()),
29    };
30
31    // Rejected before anything ran: the error names its own rule in
32    // `error.code`, and always lands on the diagnostic stream. No output plan
33    // exists yet, so this is the one path that cannot honor `--stdout-file`.
34    let outcome = match app.resolve_from(std::env::args_os()) {
35        Ok(outcome) => outcome,
36        Err(error) => {
37            let code = error.exit_code();
38            return emit_lifecycle_event(
39                cli_error_event(&error),
40                agent_first_data::OutputFormat::Json,
41                OutputTo::Stderr,
42                code,
43            );
44        }
45    };
46
47    match outcome {
48        BoundOutcome::Run(invocation) => {
49            // The plan is readable before the handler runs, so output routing is
50            // established before anything can be written.
51            let _redirect = match install_redirect(invocation.output_plan()) {
52                Ok(redirect) => redirect,
53                Err(code) => return code,
54            };
55            if let Err(code) = install_route(invocation.output_plan()) {
56                return code;
57            }
58            let command = match invocation.run() {
59                Ok(command) => command,
60                Err(error) => {
61                    let _ = crate::shared::afdata::emit_process_error(&error);
62                    return ExitCode::from(2);
63                }
64            };
65            run_command(command)
66        }
67        // `--docs` renders the whole registry as raw Markdown, so it carries no
68        // format of its own and never becomes a protocol event.
69        BoundOutcome::Docs(docs) => {
70            let _redirect = match install_redirect(docs.output_plan()) {
71                Ok(redirect) => redirect,
72                Err(code) => return code,
73            };
74            write_text(&render_cli_reference(&cli), stream_of(docs.output_plan()))
75        }
76        BoundOutcome::Help(help) => {
77            let _redirect = match install_redirect(help.output_plan()) {
78                Ok(redirect) => redirect,
79                Err(code) => return code,
80            };
81            let format = plan_format(help.output_plan());
82            if format == agent_first_data::OutputFormat::Plain {
83                write_text(&help.plain(), stream_of(help.output_plan()))
84            } else {
85                emit_lifecycle_event(
86                    cli_help_event(&help),
87                    format,
88                    route_of(help.output_plan()),
89                    0,
90                )
91            }
92        }
93        BoundOutcome::Version(version) => {
94            let _redirect = match install_redirect(version.output_plan()) {
95                Ok(redirect) => redirect,
96                Err(code) => return code,
97            };
98            emit_lifecycle_event(
99                cli_version_event(&version),
100                plan_format(version.output_plan()),
101                route_of(version.output_plan()),
102                0,
103            )
104        }
105    }
106}
107
108/// Build the tokio runtime and drive the dispatched command to completion.
109fn run_command(command: args::Command) -> ExitCode {
110    // Install the process-wide rustls crypto provider before anything builds a
111    // reqwest/TLS client. On the inline-fetch path the host-side CDP fetch runs
112    // before the SDK client's own guard, so without this `afhttp fetch` panics
113    // with "no rustls crypto provider is configured".
114    crate::host::bootstrap::install_rustls_provider();
115
116    // The fetch/host pipeline polls a deeply nested future chain (inline host
117    // launch → CDP handshake → …). Polling that depth builds a deep synchronous
118    // call stack that overflows Windows' default 1 MiB main-thread stack
119    // (Linux/macOS default to 8 MiB). Run the runtime on a thread with a generous
120    // stack so behavior is uniform across platforms.
121    match std::thread::Builder::new()
122        .name("afhttp-main".to_string())
123        .stack_size(16 * 1024 * 1024)
124        .spawn(move || run_blocking(command))
125    {
126        Ok(handle) => match handle.join() {
127            Ok(code) => code,
128            Err(_) => {
129                emit_bootstrap_error("afhttp worker thread panicked");
130                ExitCode::from(2)
131            }
132        },
133        Err(e) => {
134            emit_bootstrap_error(&format!("spawn worker thread: {e}"));
135            ExitCode::from(2)
136        }
137    }
138}
139
140fn run_blocking(command: args::Command) -> ExitCode {
141    let rt = match tokio::runtime::Builder::new_multi_thread()
142        .enable_all()
143        .thread_stack_size(16 * 1024 * 1024)
144        .build()
145    {
146        Ok(rt) => rt,
147        Err(e) => {
148            emit_bootstrap_error(&format!("tokio runtime: {e}"));
149            return ExitCode::from(2);
150        }
151    };
152    match rt.block_on(dispatch(command)) {
153        Ok(()) => ExitCode::SUCCESS,
154        Err(_) => ExitCode::from(1),
155    }
156}
157
158async fn dispatch(command: args::Command) -> Result<(), Error> {
159    let command = match command {
160        args::Command::Fetch(a) => {
161            // `fetch` owns error emission so it can attach the fetch-local trace
162            // without adding trace fields to the global Error type.
163            return cmd::fetch::run(*a).await;
164        }
165        command => command,
166    };
167    let res = match command {
168        args::Command::Host(a) => cmd::host::run(a).await,
169        args::Command::Fetch(_) => unreachable!("fetch handled above"),
170        args::Command::Upload(a) => cmd::upload::run(a).await,
171        args::Command::Cdp(a) => cmd::cdp::run(a).await,
172        args::Command::Panel(a) => cmd::panel::run(a).await,
173        args::Command::Health(a) => cmd::health::run(a).await,
174        args::Command::Capabilities(a) => cmd::capabilities::run(a).await,
175        args::Command::Profile(a) => cmd::profile::run(a).await,
176        args::Command::Tabs(a) => cmd::tabs::run(a).await,
177        args::Command::Skill(a) => cmd::skill::run(a).await,
178        args::Command::Container(a) => cmd::container::run(a).await,
179    };
180    if let Err(ref e) = res {
181        let _ = crate::shared::afdata::emit_process_error(e);
182    }
183    res
184}
185
186/// Send this run's process streams to the files the plan names, for as long as
187/// the returned guard lives.
188fn install_redirect(
189    plan: &OutputPlan,
190) -> Result<Option<agent_first_data::stream_redirect::InstalledStreamRedirect>, ExitCode> {
191    let config = agent_first_data::stream_redirect::StreamRedirectConfig::new(
192        plan.stdout_file().map(std::path::Path::to_path_buf),
193        plan.stderr_file().map(std::path::Path::to_path_buf),
194    )
195    .map_err(|error| emit_startup_error("output_setup_failed", &error.to_string()))?;
196    config
197        .as_ref()
198        .map(agent_first_data::stream_redirect::install)
199        .transpose()
200        .map_err(|error| emit_startup_error("output_setup_failed", &error.to_string()))
201}
202
203/// Record where protocol events go for the rest of the process.
204fn install_route(plan: &OutputPlan) -> Result<(), ExitCode> {
205    crate::shared::afdata::install_output_to(route_of(plan)).map_err(|error| {
206        let _ = crate::shared::afdata::emit_process_error(&error);
207        ExitCode::from(2)
208    })
209}
210
211fn route_of(plan: &OutputPlan) -> OutputTo {
212    plan.destination()
213        .and_then(|destination| OutputTo::parse(destination).ok())
214        .unwrap_or(OutputTo::Split)
215}
216
217fn plan_format(plan: &OutputPlan) -> agent_first_data::OutputFormat {
218    plan.format()
219        .and_then(|format| cli_parse_output(format).ok())
220        .unwrap_or(agent_first_data::OutputFormat::Json)
221}
222
223fn stream_of(plan: &OutputPlan) -> OutputTo {
224    if plan.destination() == Some("stderr") {
225        OutputTo::Stderr
226    } else {
227        OutputTo::Stdout
228    }
229}
230
231/// Emit one lifecycle event (`--help`, `--version`, or a rejected argv) before
232/// the process-wide route exists.
233fn emit_lifecycle_event(
234    event: agent_first_data::Event,
235    format: agent_first_data::OutputFormat,
236    output_to: OutputTo,
237    exit_code: u8,
238) -> ExitCode {
239    let mut emitter =
240        agent_first_data::CliEmitter::from_output_to(output_to, format).with_strict_protocol();
241    match emitter.emit(event) {
242        Ok(()) => ExitCode::from(exit_code),
243        Err(_) => ExitCode::from(4),
244    }
245}
246
247// AFDATA injects the raw outcomes this writes (`--docs`, plain help), so it owns
248// the routing and the rule that a closed reader is success rather than failure.
249fn write_text(text: &str, output_to: OutputTo) -> ExitCode {
250    match agent_first_data::write_raw(text, output_to) {
251        Ok(()) => ExitCode::SUCCESS,
252        Err(_) => ExitCode::from(4),
253    }
254}
255
256/// A registry that fails to build, or a route that cannot be installed, is a
257/// programming or environment fault rather than a caller mistake, so it reports
258/// before any output contract has been resolved.
259fn emit_startup_error(code: &str, message: &str) -> ExitCode {
260    let event = match agent_first_data::json_error(code, message).build() {
261        Ok(event) => event,
262        Err(_) => return ExitCode::from(4),
263    };
264    emit_lifecycle_event(
265        event,
266        agent_first_data::OutputFormat::Json,
267        OutputTo::Stderr,
268        1,
269    )
270}
271
272fn emit_bootstrap_error(msg: &str) {
273    let err = Error::new(ErrorCode::InternalError, msg);
274    let _ = crate::shared::afdata::emit_process_error(&err);
275}
276
277#[cfg(test)]
278mod tests {
279    use agent_first_data::CliOutcome;
280    use serde_json::Value;
281
282    use super::*;
283
284    fn help(argv: &[&str]) -> Value {
285        // The unbound registry, which resolves to `CliOutcome` — help needs no
286        // handler, so there is nothing to bind.
287        let cli = spec::cli_spec().expect("registry must build");
288        let CliOutcome::Help(help) = cli.resolve_from(argv.to_vec()).expect("help resolves") else {
289            panic!("{argv:?} did not resolve to help");
290        };
291        serde_json::to_value(cli_help_event(&help).as_value()).expect("help serializes")
292    }
293
294    #[test]
295    fn root_help_indexes_the_commands_as_ready_to_run_calls() {
296        let event = help(&["afhttp", "--help"]);
297        assert_eq!(event["kind"], "result");
298        let model = &event["result"]["help"];
299        assert_eq!(model["schema"], "cli-help-v2");
300        assert_eq!(model["command_path"], "afhttp");
301        // The root has no shapes of its own; what it owes a caller is the list
302        // of commands, each already a runnable next call.
303        let subcommands: Vec<&str> = model["subcommands"]
304            .as_array()
305            .expect("subcommands")
306            .iter()
307            .filter_map(Value::as_str)
308            .collect();
309        assert!(subcommands.contains(&"afhttp fetch --help"), "{model}");
310        assert!(subcommands.contains(&"afhttp container --help"), "{model}");
311    }
312
313    #[test]
314    fn fetch_help_returns_every_shape_complete_in_one_call() {
315        let event = help(&["afhttp", "fetch", "--help"]);
316        let model = &event["result"]["help"];
317        assert_eq!(model["command_path"], "afhttp fetch");
318        let shapes = model["shapes"].as_array().expect("shapes");
319        let ids: Vec<&str> = shapes
320            .iter()
321            .filter_map(|shape| shape["id"].as_str())
322            .collect();
323        assert_eq!(
324            ids,
325            [
326                "fetch",
327                "fetch-data",
328                "fetch-form",
329                "fetch-takeover",
330                "fetch-takeover-data",
331                "fetch-takeover-form",
332            ]
333        );
334        for shape in shapes {
335            let usage = shape["usage"].as_str().expect("usage");
336            assert!(usage.starts_with("afhttp fetch <URL>"), "{usage}");
337            // Optional arguments are in the answer, not behind a second call.
338            assert!(usage.contains("[--out <DIR>]"), "{usage}");
339            assert!(
340                shape["about"]
341                    .as_str()
342                    .is_some_and(|about| !about.is_empty()),
343                "every shape of a multi-shape command says how it differs: {shape}"
344            );
345        }
346        // The takeover shapes advertise only the render modes that reach a
347        // browser, which is the constraint that used to be a runtime error.
348        let takeover = shapes
349            .iter()
350            .find(|shape| shape["id"] == "fetch-takeover")
351            .expect("takeover shape");
352        let usage = takeover["usage"].as_str().unwrap_or_default();
353        assert!(usage.contains("[--render <auto|always>]"), "{usage}");
354        assert!(usage.contains("--takeover"), "{usage}");
355
356        assert_eq!(model["defaults"]["--render"], "auto");
357        assert!(model["notes"]["--takeover"].as_str().is_some(), "{model}");
358    }
359}