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