1pub 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
18pub 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 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 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
106fn run_command(command: args::Command) -> ExitCode {
108 crate::host::bootstrap::install_rustls_provider();
113
114 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 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
184fn 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
201fn 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
229fn 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
245fn 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
254fn 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 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 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 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}