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