agent_first_http/cli/
mod.rs1pub mod args;
4pub mod cmd;
5pub mod output;
6
7use std::process::ExitCode;
8
9use crate::shared::error::Error;
10
11pub 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 crate::host::bootstrap::install_rustls_provider();
34
35 if let Some(code) = maybe_render_version_or_help() {
38 return code;
39 }
40 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
64fn 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
92fn 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 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 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}