Skip to main content

agent_first_http/cli/
mod.rs

1//! CLI layer. Parses arguments, calls into the SDK, formats output.
2
3pub mod args;
4pub mod cmd;
5pub mod output;
6
7use std::process::ExitCode;
8
9use crate::shared::error::Error;
10
11/// Binary entry point. Successful structured responses exit zero; structured
12/// errors carry their stable AFDATA shape on the selected output route and
13/// exit nonzero.
14pub fn run() -> ExitCode {
15    if let Err(err) = crate::shared::afdata::install_output_to(std::env::args()) {
16        let _ = crate::shared::afdata::emit_process_error(&err);
17        return ExitCode::from(2);
18    }
19
20    let _stream_redirect =
21        match agent_first_data::stream_redirect::install_from_raw_args(std::env::args()) {
22            Ok(redirect) => redirect,
23            Err(err) => {
24                emit_bootstrap_error(&err.to_string());
25                return ExitCode::from(2);
26            }
27        };
28
29    // Install the process-wide rustls crypto provider before anything builds a
30    // reqwest/TLS client. On the inline-fetch path the host-side CDP fetch runs
31    // before the SDK client's own guard, so without this `afhttp fetch` panics
32    // with "no rustls crypto provider is configured".
33    crate::host::bootstrap::install_rustls_provider();
34
35    // Version and progressively scoped help are rendered without spinning up
36    // the async runtime, and share one machine-readable discovery path.
37    if let Some(code) = maybe_render_version_or_help() {
38        return code;
39    }
40    // The fetch/host pipeline polls a deeply nested future chain (inline host
41    // launch → CDP handshake → …). Polling that depth builds a deep synchronous
42    // call stack that overflows Windows' default 1 MiB main-thread stack
43    // (Linux/macOS default to 8 MiB). Run the runtime on a thread with a generous
44    // stack so behavior is uniform across platforms.
45    match std::thread::Builder::new()
46        .name("afhttp-main".to_string())
47        .stack_size(16 * 1024 * 1024)
48        .spawn(run_blocking)
49    {
50        Ok(handle) => match handle.join() {
51            Ok(code) => code,
52            Err(_) => {
53                emit_bootstrap_error("afhttp worker thread panicked");
54                ExitCode::from(2)
55            }
56        },
57        Err(e) => {
58            emit_bootstrap_error(&format!("spawn worker thread: {e}"));
59            ExitCode::from(2)
60        }
61    }
62}
63
64/// Build the tokio runtime and drive the dispatched command to completion.
65/// Runs on a dedicated large-stack thread spawned by `run`.
66fn run_blocking() -> ExitCode {
67    let parsed = match args::parse() {
68        Ok(parsed) => parsed,
69        Err(err) => {
70            emit_cli_error(&err);
71            return ExitCode::from(2);
72        }
73    };
74    let rt = match tokio::runtime::Builder::new_multi_thread()
75        .enable_all()
76        .thread_stack_size(16 * 1024 * 1024)
77        .build()
78    {
79        Ok(rt) => rt,
80        Err(e) => {
81            emit_bootstrap_error(&format!("tokio runtime: {e}"));
82            return ExitCode::from(2);
83        }
84    };
85    let exit = rt.block_on(dispatch(parsed));
86    match exit {
87        Ok(()) => ExitCode::SUCCESS,
88        Err(_) => ExitCode::from(1),
89    }
90}
91
92/// Render version/help and return an exit code, or `None` to continue parsing.
93fn maybe_render_version_or_help() -> Option<ExitCode> {
94    use clap::CommandFactory;
95
96    let raw: Vec<String> = std::env::args().collect();
97    let build = match env!("GIT_SHA") {
98        "unknown" => None,
99        sha => Some(sha),
100    };
101    match agent_first_data::cli_handle_version_or_help_or_continue(
102        &raw,
103        &args::Cli::command(),
104        &help_config(),
105        "afhttp",
106        Some(env!("DISPLAY_NAME")),
107        env!("CARGO_PKG_VERSION"),
108        build,
109    ) {
110        Ok(Some(output)) => match crate::shared::afdata::write_process_result(&output) {
111            Ok(()) => Some(ExitCode::SUCCESS),
112            Err(_) => Some(ExitCode::from(4)),
113        },
114        Ok(None) => None,
115        Err(err) => {
116            let err = Error::new(
117                crate::shared::error::ErrorCode::InvalidArgument,
118                err.to_string(),
119            );
120            let _ = crate::shared::afdata::emit_process_error(&err);
121            Some(ExitCode::from(2))
122        }
123    }
124}
125
126fn help_config() -> agent_first_data::HelpConfig {
127    // afhttp intentionally has no business `--output` flag: every command
128    // emits protocol JSON. Help therefore uses JSON as its caller fallback,
129    // while an explicit help-only `--output plain|yaml|markdown` still wins.
130    agent_first_data::HelpConfig::output_aware_with_fallback(agent_first_data::HelpFormat::Json)
131}
132
133async fn dispatch(parsed: args::Parsed) -> Result<(), Error> {
134    let command = match parsed.command {
135        args::Command::Fetch(a) => {
136            // `fetch` owns error emission so it can attach the fetch-local trace
137            // without adding trace fields to the global Error type.
138            return cmd::fetch::run(*a).await;
139        }
140        command => command,
141    };
142    let res = match command {
143        args::Command::Host(a) => cmd::host::run(a).await,
144        args::Command::Fetch(_) => unreachable!("fetch handled above"),
145        args::Command::Upload(a) => cmd::upload::run(a).await,
146        args::Command::Cdp(a) => cmd::cdp::run(a).await,
147        args::Command::Panel(a) => cmd::panel::run(a).await,
148        args::Command::Health(a) => cmd::health::run(a).await,
149        args::Command::Capabilities(a) => cmd::capabilities::run(a).await,
150        args::Command::Profile(a) => cmd::profile::run(a).await,
151        args::Command::Tabs(a) => cmd::tabs::run(a).await,
152        args::Command::Skill(a) => cmd::skill::run(a).await,
153        args::Command::Container(a) => cmd::container::run(a).await,
154    };
155    if let Err(ref e) = res {
156        emit_cli_error(e);
157    }
158    res
159}
160
161fn emit_cli_error(err: &Error) {
162    let _ = crate::shared::afdata::emit_process_error(err);
163}
164
165fn emit_bootstrap_error(msg: &str) {
166    let err = Error::new(crate::shared::error::ErrorCode::InternalError, msg);
167    let _ = crate::shared::afdata::emit_process_error(&err);
168}
169
170#[cfg(test)]
171mod tests {
172    use clap::CommandFactory;
173    use serde_json::Value;
174
175    use super::*;
176
177    fn render_discovery(raw: &[&str]) -> String {
178        let raw = raw.iter().map(ToString::to_string).collect::<Vec<_>>();
179        agent_first_data::cli_handle_version_or_help_or_continue(
180            &raw,
181            &args::Cli::command(),
182            &help_config(),
183            "afhttp",
184            Some(env!("DISPLAY_NAME")),
185            env!("CARGO_PKG_VERSION"),
186            None,
187        )
188        .expect("valid discovery request")
189        .expect("discovery request should render")
190    }
191
192    fn count_help_surface(command: &Value) -> (usize, usize) {
193        let mut commands = 1;
194        let mut arguments = command["arguments"].as_array().map_or(0, Vec::len);
195        if let Some(subcommands) = command["subcommands"].as_array() {
196            for subcommand in subcommands {
197                let (subcommand_count, argument_count) = count_help_surface(subcommand);
198                commands += subcommand_count;
199                arguments += argument_count;
200            }
201        }
202        (commands, arguments)
203    }
204
205    #[test]
206    fn bare_help_uses_fixed_json_contract() {
207        let rendered = render_discovery(&["afhttp", "--help"]);
208        let event: Value = serde_json::from_str(&rendered).expect("bare help must be JSON");
209        let help = &event["result"]["help"];
210        assert_eq!(event["kind"], "result");
211        assert_eq!(event["result"]["code"], "help");
212        assert_eq!(help["scope"], "one_level");
213        assert_eq!(help["command_path"], "afhttp");
214        assert!(
215            help["arguments"]
216                .as_array()
217                .expect("root arguments")
218                .iter()
219                .filter(|argument| {
220                    matches!(
221                        argument["name"].as_str(),
222                        Some("--stdout-file" | "--stderr-file")
223                    )
224                })
225                .all(|argument| argument["global"] == true),
226            "stream redirect arguments must be marked global: {help}"
227        );
228        assert!(
229            help["subcommands"]
230                .as_array()
231                .expect("root subcommands")
232                .iter()
233                .all(|command| command["name"] != "help"),
234            "the clap help pseudo-command must not be advertised: {help}"
235        );
236    }
237
238    #[test]
239    fn fetch_help_is_scoped_and_keeps_details_progressive() {
240        let rendered = render_discovery(&["afhttp", "fetch", "--help"]);
241        let event: Value = serde_json::from_str(&rendered).expect("scoped help must be JSON");
242        let help = &event["result"]["help"];
243        assert_eq!(help["command_path"], "afhttp fetch");
244        assert_eq!(
245            help["inherited_arguments_from"],
246            serde_json::json!(["afhttp"])
247        );
248        assert!(
249            help["arguments"]
250                .as_array()
251                .expect("fetch arguments")
252                .iter()
253                .all(|argument| {
254                    !matches!(
255                        argument["name"].as_str(),
256                        Some("--stdout-file" | "--stderr-file")
257                    )
258                }),
259            "scoped structured help must not repeat inherited globals: {help}"
260        );
261        let takeover_help = help["arguments"]
262            .as_array()
263            .expect("fetch arguments")
264            .iter()
265            .find(|argument| argument["name"] == "--takeover")
266            .and_then(|argument| argument["help"].as_str())
267            .expect("--takeover help");
268        assert_eq!(
269            takeover_help,
270            "Escalate captcha, login, or 2FA walls to human takeover"
271        );
272        assert!(
273            !rendered.contains("next_action"),
274            "compact structured help eagerly exposed long-form detail"
275        );
276
277        let plain = render_discovery(&["afhttp", "fetch", "--help", "--output", "plain"]);
278        assert!(plain.contains("Usage: afhttp fetch"));
279        assert!(plain.contains("--stdout-file"));
280
281        let markdown = render_discovery(&["afhttp", "fetch", "--help", "--output", "markdown"]);
282        assert!(
283            markdown.contains("next_action"),
284            "Markdown export must retain the long-form takeover detail"
285        );
286    }
287
288    #[test]
289    fn recursive_help_stays_within_structural_budget() {
290        let rendered = render_discovery(&["afhttp", "--help", "--recursive", "--output", "json"]);
291        let event: Value = serde_json::from_str(&rendered).expect("recursive help must be JSON");
292        let help = &event["result"]["help"];
293        let (commands, arguments) = count_help_surface(help);
294        let budget = 512 + commands * 160 + arguments * 120;
295        assert!(
296            rendered.len() < budget,
297            "recursive help exceeded its payload budget: {} >= {budget}",
298            rendered.len()
299        );
300    }
301}