1pub 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
19pub 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 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 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
107fn run_command(command: args::Command) -> ExitCode {
109 crate::host::bootstrap::install_rustls_provider();
114
115 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 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
185fn 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
202fn 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
230fn 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
258fn 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 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 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 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}