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