1pub mod args;
5pub mod cmd;
6pub mod output;
7pub mod spec;
8
9use std::process::ExitCode;
10
11use agent_first_data::{
12 BoundOutcome, 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 BoundOutcome::Run(invocation) => {
49 let _redirect = match install_redirect(invocation.output_plan()) {
52 Ok(redirect) => redirect,
53 Err(code) => return code,
54 };
55 if let Err(code) = install_route(invocation.output_plan()) {
56 return code;
57 }
58 let command = match invocation.run() {
59 Ok(command) => command,
60 Err(error) => {
61 let _ = crate::shared::afdata::emit_process_error(&error);
62 return ExitCode::from(2);
63 }
64 };
65 run_command(command)
66 }
67 BoundOutcome::Docs(docs) => {
70 let _redirect = match install_redirect(docs.output_plan()) {
71 Ok(redirect) => redirect,
72 Err(code) => return code,
73 };
74 write_text(&render_cli_reference(&cli), stream_of(docs.output_plan()))
75 }
76 BoundOutcome::Help(help) => {
77 let _redirect = match install_redirect(help.output_plan()) {
78 Ok(redirect) => redirect,
79 Err(code) => return code,
80 };
81 let format = plan_format(help.output_plan());
82 if format == agent_first_data::OutputFormat::Plain {
83 write_text(&help.plain(), stream_of(help.output_plan()))
84 } else {
85 emit_lifecycle_event(
86 cli_help_event(&help),
87 format,
88 route_of(help.output_plan()),
89 0,
90 )
91 }
92 }
93 BoundOutcome::Version(version) => {
94 let _redirect = match install_redirect(version.output_plan()) {
95 Ok(redirect) => redirect,
96 Err(code) => return code,
97 };
98 emit_lifecycle_event(
99 cli_version_event(&version),
100 plan_format(version.output_plan()),
101 route_of(version.output_plan()),
102 0,
103 )
104 }
105 }
106}
107
108fn run_command(command: args::Command) -> ExitCode {
110 crate::host::bootstrap::install_rustls_provider();
115
116 match std::thread::Builder::new()
122 .name("afhttp-main".to_string())
123 .stack_size(16 * 1024 * 1024)
124 .spawn(move || run_blocking(command))
125 {
126 Ok(handle) => match handle.join() {
127 Ok(code) => code,
128 Err(_) => {
129 emit_bootstrap_error("afhttp worker thread panicked");
130 ExitCode::from(2)
131 }
132 },
133 Err(e) => {
134 emit_bootstrap_error(&format!("spawn worker thread: {e}"));
135 ExitCode::from(2)
136 }
137 }
138}
139
140fn run_blocking(command: args::Command) -> ExitCode {
141 let rt = match tokio::runtime::Builder::new_multi_thread()
142 .enable_all()
143 .thread_stack_size(16 * 1024 * 1024)
144 .build()
145 {
146 Ok(rt) => rt,
147 Err(e) => {
148 emit_bootstrap_error(&format!("tokio runtime: {e}"));
149 return ExitCode::from(2);
150 }
151 };
152 match rt.block_on(dispatch(command)) {
153 Ok(()) => ExitCode::SUCCESS,
154 Err(_) => ExitCode::from(1),
155 }
156}
157
158async fn dispatch(command: args::Command) -> Result<(), Error> {
159 let command = match command {
160 args::Command::Fetch(a) => {
161 return cmd::fetch::run(*a).await;
164 }
165 command => command,
166 };
167 let res = match command {
168 args::Command::Host(a) => cmd::host::run(a).await,
169 args::Command::Fetch(_) => unreachable!("fetch handled above"),
170 args::Command::Upload(a) => cmd::upload::run(a).await,
171 args::Command::Cdp(a) => cmd::cdp::run(a).await,
172 args::Command::Panel(a) => cmd::panel::run(a).await,
173 args::Command::Health(a) => cmd::health::run(a).await,
174 args::Command::Capabilities(a) => cmd::capabilities::run(a).await,
175 args::Command::Profile(a) => cmd::profile::run(a).await,
176 args::Command::Tabs(a) => cmd::tabs::run(a).await,
177 args::Command::Skill(a) => cmd::skill::run(a).await,
178 args::Command::Container(a) => cmd::container::run(a).await,
179 };
180 if let Err(ref e) = res {
181 let _ = crate::shared::afdata::emit_process_error(e);
182 }
183 res
184}
185
186fn install_redirect(
189 plan: &OutputPlan,
190) -> Result<Option<agent_first_data::stream_redirect::InstalledStreamRedirect>, ExitCode> {
191 let config = agent_first_data::stream_redirect::StreamRedirectConfig::new(
192 plan.stdout_file().map(std::path::Path::to_path_buf),
193 plan.stderr_file().map(std::path::Path::to_path_buf),
194 )
195 .map_err(|error| emit_startup_error("output_setup_failed", &error.to_string()))?;
196 config
197 .as_ref()
198 .map(agent_first_data::stream_redirect::install)
199 .transpose()
200 .map_err(|error| emit_startup_error("output_setup_failed", &error.to_string()))
201}
202
203fn install_route(plan: &OutputPlan) -> Result<(), ExitCode> {
205 crate::shared::afdata::install_output_to(route_of(plan)).map_err(|error| {
206 let _ = crate::shared::afdata::emit_process_error(&error);
207 ExitCode::from(2)
208 })
209}
210
211fn route_of(plan: &OutputPlan) -> OutputTo {
212 plan.destination()
213 .and_then(|destination| OutputTo::parse(destination).ok())
214 .unwrap_or(OutputTo::Split)
215}
216
217fn plan_format(plan: &OutputPlan) -> agent_first_data::OutputFormat {
218 plan.format()
219 .and_then(|format| cli_parse_output(format).ok())
220 .unwrap_or(agent_first_data::OutputFormat::Json)
221}
222
223fn stream_of(plan: &OutputPlan) -> OutputTo {
224 if plan.destination() == Some("stderr") {
225 OutputTo::Stderr
226 } else {
227 OutputTo::Stdout
228 }
229}
230
231fn emit_lifecycle_event(
234 event: agent_first_data::Event,
235 format: agent_first_data::OutputFormat,
236 output_to: OutputTo,
237 exit_code: u8,
238) -> ExitCode {
239 let mut emitter =
240 agent_first_data::CliEmitter::from_output_to(output_to, format).with_strict_protocol();
241 match emitter.emit(event) {
242 Ok(()) => ExitCode::from(exit_code),
243 Err(_) => ExitCode::from(4),
244 }
245}
246
247fn write_text(text: &str, output_to: OutputTo) -> ExitCode {
250 match agent_first_data::write_raw(text, output_to) {
251 Ok(()) => ExitCode::SUCCESS,
252 Err(_) => ExitCode::from(4),
253 }
254}
255
256fn emit_startup_error(code: &str, message: &str) -> ExitCode {
260 let event = match agent_first_data::json_error(code, message).build() {
261 Ok(event) => event,
262 Err(_) => return ExitCode::from(4),
263 };
264 emit_lifecycle_event(
265 event,
266 agent_first_data::OutputFormat::Json,
267 OutputTo::Stderr,
268 1,
269 )
270}
271
272fn emit_bootstrap_error(msg: &str) {
273 let err = Error::new(ErrorCode::InternalError, msg);
274 let _ = crate::shared::afdata::emit_process_error(&err);
275}
276
277#[cfg(test)]
278mod tests {
279 use agent_first_data::CliOutcome;
280 use serde_json::Value;
281
282 use super::*;
283
284 fn help(argv: &[&str]) -> Value {
285 let cli = spec::cli_spec().expect("registry must build");
288 let CliOutcome::Help(help) = cli.resolve_from(argv.to_vec()).expect("help resolves") else {
289 panic!("{argv:?} did not resolve to help");
290 };
291 serde_json::to_value(cli_help_event(&help).as_value()).expect("help serializes")
292 }
293
294 #[test]
295 fn root_help_indexes_the_commands_as_ready_to_run_calls() {
296 let event = help(&["afhttp", "--help"]);
297 assert_eq!(event["kind"], "result");
298 let model = &event["result"]["help"];
299 assert_eq!(model["schema"], "cli-help-v2");
300 assert_eq!(model["command_path"], "afhttp");
301 let subcommands: Vec<&str> = model["subcommands"]
304 .as_array()
305 .expect("subcommands")
306 .iter()
307 .filter_map(Value::as_str)
308 .collect();
309 assert!(subcommands.contains(&"afhttp fetch --help"), "{model}");
310 assert!(subcommands.contains(&"afhttp container --help"), "{model}");
311 }
312
313 #[test]
314 fn fetch_help_returns_every_shape_complete_in_one_call() {
315 let event = help(&["afhttp", "fetch", "--help"]);
316 let model = &event["result"]["help"];
317 assert_eq!(model["command_path"], "afhttp fetch");
318 let shapes = model["shapes"].as_array().expect("shapes");
319 let ids: Vec<&str> = shapes
320 .iter()
321 .filter_map(|shape| shape["id"].as_str())
322 .collect();
323 assert_eq!(
324 ids,
325 [
326 "fetch",
327 "fetch-data",
328 "fetch-form",
329 "fetch-takeover",
330 "fetch-takeover-data",
331 "fetch-takeover-form",
332 ]
333 );
334 for shape in shapes {
335 let usage = shape["usage"].as_str().expect("usage");
336 assert!(usage.starts_with("afhttp fetch <URL>"), "{usage}");
337 assert!(usage.contains("[--out <DIR>]"), "{usage}");
339 assert!(
340 shape["about"]
341 .as_str()
342 .is_some_and(|about| !about.is_empty()),
343 "every shape of a multi-shape command says how it differs: {shape}"
344 );
345 }
346 let takeover = shapes
349 .iter()
350 .find(|shape| shape["id"] == "fetch-takeover")
351 .expect("takeover shape");
352 let usage = takeover["usage"].as_str().unwrap_or_default();
353 assert!(usage.contains("[--render <auto|always>]"), "{usage}");
354 assert!(usage.contains("--takeover"), "{usage}");
355
356 assert_eq!(model["defaults"]["--render"], "auto");
357 assert!(model["notes"]["--takeover"].as_str().is_some(), "{model}");
358 }
359}