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