1use std::path::PathBuf;
17
18use agent_first_data::{CliValue, ResolvedInvocation, ValueSource};
19
20use crate::cli::cmd;
21use crate::cli::connect::Connection;
22use crate::cli::token_source;
23use crate::host::bootstrap::{
24 BrowserChoice, DisplayMode, HealthPublic, Takeover, TakeoverProviderKind,
25};
26use crate::sdk::fetch::{NetworkBodies, RenderMode};
27use crate::shared::error::{Error, ErrorCode};
28
29const ENDPOINT_ENV: &str = "AFHTTP_ENDPOINT_URL";
30const TOKEN_ENV: &str = "AFHTTP_TOKEN_SECRET";
31
32pub enum Command {
34 Fetch(Box<cmd::fetch::Args>),
35 Host(cmd::host::Args),
36 Upload(cmd::upload::Args),
37 Cdp(cmd::cdp::Args),
38 Panel(cmd::panel::Args),
39 Health(cmd::health::Args),
40 Capabilities(cmd::capabilities::Args),
41 Profile(cmd::profile::Args),
42 Tabs(cmd::tabs::Args),
43 Ui(cmd::ui::Args),
44 Skill(cmd::skill::Args),
45 Container(cmd::container::Args),
46}
47
48pub type Projected = Result<Command, Error>;
52
53type Handler = fn(&ResolvedInvocation) -> Projected;
54
55pub fn handlers() -> [(&'static str, Handler); 24] {
58 [
59 ("fetch", fetch as Handler),
60 ("host", host),
61 ("upload", upload),
62 ("cdp", cdp),
63 ("panel", panel),
64 ("health", health),
65 ("capabilities", capabilities),
66 ("profile_list", profile_list),
67 ("profile_info", profile_info),
68 ("profile_lock_status", profile_lock_status),
69 ("profile_downloads", profile_downloads),
70 ("profile_cookies", profile_cookies),
71 ("profile_delete", profile_delete),
72 ("profile_prune", profile_prune),
73 ("tabs_list", tabs_list),
74 ("tabs_close", tabs_close),
75 ("ui_takeover", ui_takeover),
76 ("skill_status", skill_status),
77 ("skill_install", skill_install),
78 ("skill_uninstall", skill_uninstall),
79 ("container_install", container_install),
80 ("container_uninstall", container_uninstall),
81 ("container_status", container_status),
82 ("container_logs", container_logs),
83 ]
84}
85
86fn string(invocation: &ResolvedInvocation, id: &str) -> String {
94 invocation
95 .required(id)
96 .as_str()
97 .unwrap_or_default()
98 .to_string()
99}
100
101fn opt_string(invocation: &ResolvedInvocation, id: &str) -> Option<String> {
102 invocation
103 .optional(id)
104 .and_then(CliValue::as_str)
105 .map(str::to_string)
106}
107
108fn strings(invocation: &ResolvedInvocation, id: &str) -> Vec<String> {
109 invocation
110 .repeated(id)
111 .iter()
112 .filter_map(CliValue::as_str)
113 .map(str::to_string)
114 .collect()
115}
116
117fn flag(invocation: &ResolvedInvocation, id: &str) -> bool {
118 invocation
119 .optional(id)
120 .and_then(CliValue::as_bool)
121 .unwrap_or(false)
122}
123
124fn opt_path(invocation: &ResolvedInvocation, id: &str) -> Option<PathBuf> {
125 opt_string(invocation, id).map(PathBuf::from)
126}
127
128fn unsigned(invocation: &ResolvedInvocation, id: &str, name: &str, max: u64) -> Result<u64, Error> {
132 let value = invocation
133 .optional(id)
134 .and_then(CliValue::as_i64)
135 .unwrap_or(0);
136 u64::try_from(value)
137 .ok()
138 .filter(|value| *value <= max)
139 .ok_or_else(|| {
140 Error::new(
141 ErrorCode::InvalidArgument,
142 format!("{name}: expected an integer from 0 to {max}, got {value}"),
143 )
144 })
145}
146
147fn from_env(value: Option<String>, variable: &str) -> Option<String> {
151 value.or_else(|| {
152 std::env::var(variable)
153 .ok()
154 .filter(|value| !value.is_empty())
155 })
156}
157
158fn endpoint(invocation: &ResolvedInvocation) -> Option<String> {
159 from_env(opt_string(invocation, "endpoint_url"), ENDPOINT_ENV)
160}
161
162fn token(invocation: &ResolvedInvocation) -> Result<Option<ValueSource>, Error> {
166 match opt_string(invocation, "token_secret") {
167 Some(raw) => token_source::parse(&raw).map(Some),
168 None => Ok(std::env::var(TOKEN_ENV)
169 .ok()
170 .filter(|value| !value.is_empty())
171 .map(ValueSource::Literal)),
172 }
173}
174
175fn connection(invocation: &ResolvedInvocation) -> Result<Connection, Error> {
179 Ok(Connection::new(endpoint(invocation), token(invocation)?))
180}
181
182fn browser(invocation: &ResolvedInvocation) -> BrowserChoice {
183 opt_string(invocation, "browser")
184 .and_then(|value| value.parse().ok())
185 .unwrap_or_default()
186}
187
188fn render(invocation: &ResolvedInvocation) -> RenderMode {
189 match opt_string(invocation, "render").as_deref() {
190 Some("none") => RenderMode::None,
191 Some("always") => RenderMode::Always,
192 _ => RenderMode::Auto,
193 }
194}
195
196fn network_bodies(invocation: &ResolvedInvocation) -> NetworkBodies {
197 match opt_string(invocation, "network_bodies").as_deref() {
198 Some("xhr") => NetworkBodies::Xhr,
199 Some("all") => NetworkBodies::All,
200 _ => NetworkBodies::Off,
201 }
202}
203
204fn takeover_provider(invocation: &ResolvedInvocation) -> Takeover {
205 match opt_string(invocation, "takeover_provider").as_deref() {
206 Some("kasmvnc") => Takeover::On {
207 provider: TakeoverProviderKind::KasmVnc,
208 },
209 _ => Takeover::Off,
210 }
211}
212
213fn skill_target(invocation: &ResolvedInvocation) -> cmd::skill::TargetArgs {
214 cmd::skill::TargetArgs {
215 agent: opt_string(invocation, "agent").unwrap_or_else(|| "all".to_string()),
216 scope: opt_string(invocation, "scope").unwrap_or_else(|| "personal".to_string()),
217 skills_dir: opt_string(invocation, "skills_dir"),
218 }
219}
220
221fn profile_info_args(invocation: &ResolvedInvocation) -> cmd::profile::InfoArgs {
222 cmd::profile::InfoArgs {
223 name: string(invocation, "name"),
224 backend: opt_string(invocation, "backend"),
225 profile_root: opt_path(invocation, "profile_root"),
226 }
227}
228
229fn container_common(invocation: &ResolvedInvocation) -> Result<cmd::container::CommonArgs, Error> {
230 Ok(cmd::container::CommonArgs {
231 runtime: opt_string(invocation, "runtime")
232 .map(|value| cmd::container::runtime_from_str(&value))
233 .transpose()?,
234 name: string(invocation, "name"),
235 })
236}
237
238fn container_port(invocation: &ResolvedInvocation) -> Result<u16, Error> {
239 let port = unsigned(invocation, "port", "--port", u64::from(u16::MAX))?;
240 Ok(port as u16)
241}
242
243fn fetch(invocation: &ResolvedInvocation) -> Projected {
246 Ok(Command::Fetch(Box::new(cmd::fetch::Args {
247 url: string(invocation, "url"),
248 connection: connection(invocation)?,
249 browser: browser(invocation),
250 browser_bin: opt_path(invocation, "browser_bin"),
251 render: render(invocation),
252 tab: opt_string(invocation, "tab").unwrap_or_else(|| "new".to_string()),
253 takeover: flag(invocation, "takeover"),
254 profile: opt_string(invocation, "profile"),
255 wait: opt_string(invocation, "wait").unwrap_or_else(|| "auto".to_string()),
256 headers: strings(invocation, "header"),
257 cookies: strings(invocation, "cookie"),
258 user_agent: opt_string(invocation, "user_agent"),
259 evaluate_after_wait: strings(invocation, "evaluate_after_wait"),
260 want: strings(invocation, "want"),
261 method: opt_string(invocation, "method").unwrap_or_else(|| "GET".to_string()),
262 data: opt_string(invocation, "data"),
263 form: strings(invocation, "form"),
264 network_bodies: network_bodies(invocation),
265 network_body_max_bytes: unsigned(
266 invocation,
267 "network_body_max_bytes",
268 "--network-body-max-bytes",
269 u64::MAX,
270 )?,
271 readiness_idle_ms: unsigned(
272 invocation,
273 "readiness_idle_ms",
274 "--readiness-idle-ms",
275 u64::MAX,
276 )?,
277 readiness_stable_ms: unsigned(
278 invocation,
279 "readiness_stable_ms",
280 "--readiness-stable-ms",
281 u64::MAX,
282 )?,
283 readiness_min_text_bytes: unsigned(
284 invocation,
285 "readiness_min_text_bytes",
286 "--readiness-min-text-bytes",
287 u64::MAX,
288 )?,
289 no_network_redact: flag(invocation, "no_network_redact"),
290 out: opt_path(invocation, "out"),
291 cookie_jar: opt_path(invocation, "cookie_jar"),
292 no_cookie_jar: flag(invocation, "no_cookie_jar"),
293 observe_main_wait_ms: unsigned(
294 invocation,
295 "observe_main_wait_ms",
296 "--observe-main-wait-ms",
297 u64::MAX,
298 )?,
299 max_response_bytes: unsigned(
300 invocation,
301 "max_response_bytes",
302 "--max-response-bytes",
303 u64::MAX,
304 )?,
305 retry: unsigned(invocation, "retry", "--retry", u64::from(u32::MAX))? as u32,
306 backoff_ms: unsigned(invocation, "backoff_ms", "--backoff-ms", u64::MAX)?,
307 proxy: opt_string(invocation, "proxy_url"),
308 ca_cert: opt_path(invocation, "ca_cert"),
309 tls_insecure: flag(invocation, "tls_insecure"),
310 timeout_ms: unsigned(invocation, "timeout_ms", "--timeout-ms", u64::MAX)?,
311 capture_ws: flag(invocation, "capture_ws"),
312 capture_sse: flag(invocation, "capture_sse"),
313 })))
314}
315
316fn host(invocation: &ResolvedInvocation) -> Projected {
317 Ok(Command::Host(cmd::host::Args {
318 listen: string(invocation, "listen"),
319 profile: opt_string(invocation, "profile").unwrap_or_else(|| "-".to_string()),
320 display: match opt_string(invocation, "display").as_deref() {
321 Some("headful") => Some(DisplayMode::Headful),
322 Some("headless") => Some(DisplayMode::Headless),
323 _ => None,
324 },
325 takeover: takeover_provider(invocation),
326 takeover_quality_percent: unsigned(
327 invocation,
328 "takeover_quality_percent",
329 "--takeover-quality-percent",
330 100,
331 )? as u8,
332 browser: browser(invocation),
333 browser_bin: opt_path(invocation, "browser_bin"),
334 token: opt_string(invocation, "token_secret")
338 .map(|raw| token_source::parse(&raw))
339 .transpose()?,
340 no_health: flag(invocation, "no_health"),
341 health_public: match opt_string(invocation, "health_public").as_deref() {
342 Some("minimal") => HealthPublic::Minimal,
343 _ => HealthPublic::Off,
344 },
345 engine_envs: strings(invocation, "engine_env"),
346 browser_args: strings(invocation, "browser_arg"),
347 proxy: opt_string(invocation, "proxy_url"),
348 recent_requests_cap: unsigned(
349 invocation,
350 "recent_requests_cap",
351 "--recent-requests-cap",
352 u64::try_from(usize::MAX).unwrap_or(u64::MAX),
353 )? as usize,
354 }))
355}
356
357fn upload(invocation: &ResolvedInvocation) -> Projected {
358 Ok(Command::Upload(cmd::upload::Args {
359 connection: connection(invocation)?,
360 tab: string(invocation, "tab"),
361 selector: string(invocation, "selector"),
362 file: PathBuf::from(string(invocation, "file")),
363 }))
364}
365
366fn cdp(invocation: &ResolvedInvocation) -> Projected {
367 Ok(Command::Cdp(cmd::cdp::Args {
368 method: string(invocation, "method"),
369 connection: connection(invocation)?,
370 tab: string(invocation, "tab"),
371 params: opt_string(invocation, "params"),
372 wait: opt_string(invocation, "wait_event"),
373 }))
374}
375
376fn panel(invocation: &ResolvedInvocation) -> Projected {
377 Ok(Command::Panel(cmd::panel::Args {
378 connection: connection(invocation)?,
379 }))
380}
381
382fn health(invocation: &ResolvedInvocation) -> Projected {
383 Ok(Command::Health(cmd::health::Args {
384 connection: connection(invocation)?,
385 }))
386}
387
388fn capabilities(invocation: &ResolvedInvocation) -> Projected {
389 Ok(Command::Capabilities(cmd::capabilities::Args {
390 connection: connection(invocation)?,
391 }))
392}
393
394fn profile_command(sub: cmd::profile::ProfileSub) -> Projected {
395 Ok(Command::Profile(cmd::profile::Args { sub }))
396}
397
398fn profile_list(invocation: &ResolvedInvocation) -> Projected {
399 profile_command(cmd::profile::ProfileSub::List(cmd::profile::ListArgs {
400 profile_root: opt_path(invocation, "profile_root"),
401 }))
402}
403
404fn profile_info(invocation: &ResolvedInvocation) -> Projected {
405 profile_command(cmd::profile::ProfileSub::Info(profile_info_args(
406 invocation,
407 )))
408}
409
410fn profile_lock_status(invocation: &ResolvedInvocation) -> Projected {
411 profile_command(cmd::profile::ProfileSub::LockStatus(profile_info_args(
412 invocation,
413 )))
414}
415
416fn profile_downloads(invocation: &ResolvedInvocation) -> Projected {
417 profile_command(cmd::profile::ProfileSub::Downloads(profile_info_args(
418 invocation,
419 )))
420}
421
422fn profile_cookies(invocation: &ResolvedInvocation) -> Projected {
423 profile_command(cmd::profile::ProfileSub::Cookies(profile_info_args(
424 invocation,
425 )))
426}
427
428fn profile_delete(invocation: &ResolvedInvocation) -> Projected {
429 profile_command(cmd::profile::ProfileSub::Delete(cmd::profile::DeleteArgs {
430 name: string(invocation, "name"),
431 backend: opt_string(invocation, "backend"),
432 confirm: string(invocation, "confirm"),
433 profile_root: opt_path(invocation, "profile_root"),
434 }))
435}
436
437fn profile_prune(invocation: &ResolvedInvocation) -> Projected {
438 profile_command(cmd::profile::ProfileSub::Prune(cmd::profile::PruneArgs {
439 older_than: string(invocation, "older_than"),
440 dry_run: flag(invocation, "dry_run"),
441 profile_root: opt_path(invocation, "profile_root"),
442 }))
443}
444
445fn tabs_list(invocation: &ResolvedInvocation) -> Projected {
446 Ok(Command::Tabs(cmd::tabs::Args {
447 sub: cmd::tabs::TabsSub::List(cmd::tabs::EndpointArgs {
448 connection: connection(invocation)?,
449 }),
450 }))
451}
452
453fn tabs_close(invocation: &ResolvedInvocation) -> Projected {
454 Ok(Command::Tabs(cmd::tabs::Args {
455 sub: cmd::tabs::TabsSub::Close(cmd::tabs::CloseArgs {
456 tab: string(invocation, "tab"),
457 connection: connection(invocation)?,
458 }),
459 }))
460}
461
462fn ui_takeover(invocation: &ResolvedInvocation) -> Projected {
467 let takeover = match opt_string(invocation, "takeover_url_secret") {
468 Some(takeover_url_secret) => cmd::ui::TakeoverArgs::Open {
469 takeover_url_secret,
470 },
471 None => cmd::ui::TakeoverArgs::Mint {
472 connection: connection(invocation)?,
473 },
474 };
475 Ok(Command::Ui(cmd::ui::Args {
476 sub: cmd::ui::UiSub::Takeover(takeover),
477 delivery: if flag(invocation, "takeover_no_window") {
478 cmd::ui::Delivery::Listed
479 } else {
480 cmd::ui::Delivery::Window
481 },
482 }))
483}
484
485fn skill_status(invocation: &ResolvedInvocation) -> Projected {
486 Ok(Command::Skill(cmd::skill::Args {
487 sub: cmd::skill::SkillSub::Status(skill_target(invocation)),
488 }))
489}
490
491fn skill_install(invocation: &ResolvedInvocation) -> Projected {
492 Ok(Command::Skill(cmd::skill::Args {
493 sub: cmd::skill::SkillSub::Install(cmd::skill::WriteArgs {
494 target: skill_target(invocation),
495 force: flag(invocation, "force"),
496 }),
497 }))
498}
499
500fn skill_uninstall(invocation: &ResolvedInvocation) -> Projected {
501 Ok(Command::Skill(cmd::skill::Args {
502 sub: cmd::skill::SkillSub::Uninstall(cmd::skill::WriteArgs {
503 target: skill_target(invocation),
504 force: flag(invocation, "force"),
505 }),
506 }))
507}
508
509fn container_install(invocation: &ResolvedInvocation) -> Projected {
510 Ok(Command::Container(cmd::container::Args {
511 sub: cmd::container::ContainerSub::Install(cmd::container::InstallArgs {
512 common: container_common(invocation)?,
513 port: container_port(invocation)?,
514 profile: opt_string(invocation, "profile"),
515 shm_size: opt_string(invocation, "shm_size"),
516 takeover_provider: takeover_provider(invocation),
517 with: strings(invocation, "with"),
518 rebuild: flag(invocation, "rebuild"),
519 from_source: flag(invocation, "from_source"),
520 context: opt_string(invocation, "context"),
521 host_args: strings(invocation, "host_args"),
522 reveal_token_secret: flag(invocation, "reveal_token_secret"),
523 }),
524 }))
525}
526
527fn container_uninstall(invocation: &ResolvedInvocation) -> Projected {
528 Ok(Command::Container(cmd::container::Args {
529 sub: cmd::container::ContainerSub::Uninstall(cmd::container::UninstallArgs {
530 common: container_common(invocation)?,
531 purge: flag(invocation, "purge"),
532 }),
533 }))
534}
535
536fn container_status(invocation: &ResolvedInvocation) -> Projected {
537 Ok(Command::Container(cmd::container::Args {
538 sub: cmd::container::ContainerSub::Status(cmd::container::StatusArgs {
539 common: container_common(invocation)?,
540 port: container_port(invocation)?,
541 reveal_token_secret: flag(invocation, "reveal_token_secret"),
542 }),
543 }))
544}
545
546fn container_logs(invocation: &ResolvedInvocation) -> Projected {
547 Ok(Command::Container(cmd::container::Args {
548 sub: cmd::container::ContainerSub::Logs(cmd::container::LogsArgs {
549 common: container_common(invocation)?,
550 follow: flag(invocation, "follow"),
551 raw: flag(invocation, "raw"),
552 }),
553 }))
554}
555
556#[cfg(test)]
557mod tests {
558 use agent_first_data::BoundOutcome;
559
560 use super::*;
561 use crate::cli::spec::cli_spec;
562
563 fn project(argv: &[&str]) -> Projected {
564 let cli = match cli_spec() {
565 Ok(cli) => cli,
566 Err(error) => panic!("registry must build: {error}"),
567 };
568 let app = match cli.bind_actions(handlers()) {
569 Ok(app) => app,
570 Err(error) => panic!("handlers must cover every action: {error}"),
571 };
572 match app.resolve_from(argv.to_vec()) {
573 Ok(BoundOutcome::Run(invocation)) => invocation.run(),
574 Ok(_) => panic!("{argv:?} did not resolve to a run"),
575 Err(error) => panic!("{argv:?} failed to resolve: {}", error.message),
576 }
577 }
578
579 #[test]
580 fn every_action_has_exactly_one_handler() {
581 let cli = cli_spec().expect("registry must build");
582 cli.bind_actions(handlers())
583 .expect("handlers must cover every action exactly once");
584 }
585
586 #[test]
593 fn every_combination_reads_only_ids_its_shape_declares() {
594 let cli = cli_spec().expect("registry must build");
595 let app = cli
596 .bind_actions(handlers())
597 .expect("handlers must cover every action exactly once");
598 app.call_every_combination();
599 }
600
601 #[test]
602 fn an_environment_fallback_never_overrides_argv() {
603 const ABSENT: &str = "AFHTTP_TEST_ABSENT_ENDPOINT_URL";
604 assert_eq!(from_env(None, ABSENT), None);
605 assert_eq!(
606 from_env(Some("ws://argv".to_string()), ABSENT),
607 Some("ws://argv".to_string())
608 );
609 }
610
611 #[test]
612 fn an_explicit_endpoint_reaches_the_command() {
613 let projected = project(&["afhttp", "health", "--endpoint-url", "ws://127.0.0.1:9222"]);
614 let Ok(Command::Health(args)) = projected else {
615 panic!("health must project to a health command");
616 };
617 assert_eq!(
618 args.connection.endpoint.as_deref(),
619 Some("ws://127.0.0.1:9222")
620 );
621 assert!(args.connection.is_explicit());
622 }
623
624 #[test]
628 fn a_missing_endpoint_is_left_for_discovery_rather_than_rejected() {
629 let Ok(Command::Health(args)) = project(&["afhttp", "health"]) else {
630 panic!("health must project without an endpoint");
631 };
632 assert!(!args.connection.is_explicit());
633 }
634
635 #[test]
640 fn a_token_source_is_classified_but_not_read() {
641 let Ok(Command::Cdp(args)) = project(&[
642 "afhttp",
643 "cdp",
644 "Page.enable",
645 "--tab",
646 "T1",
647 "--token-secret",
648 "container:afhttp-host",
649 ]) else {
650 panic!("cdp must project with a container token source");
651 };
652 assert_eq!(
653 args.connection.token,
654 Some(ValueSource::Host {
655 scheme: "container".to_string(),
656 value: "afhttp-host".to_string(),
657 })
658 );
659
660 let cli = cli_spec().expect("registry must build");
661 let app = cli
662 .bind_actions(handlers())
663 .expect("handlers must cover every action");
664 for raw in [
665 "file:/etc/afhttp/hosts.json",
667 "prompt",
669 ] {
670 let error = app
671 .resolve_from(vec![
672 "afhttp",
673 "cdp",
674 "Page.enable",
675 "--tab",
676 "T1",
677 "--token-secret",
678 raw,
679 ])
680 .err()
681 .unwrap_or_else(|| panic!("{raw} must be refused"));
682 assert_eq!(
683 error.rule,
684 agent_first_data::CliErrorRule::InvalidArgumentValue,
685 "{raw}"
686 );
687 assert_eq!(error.exit_code(), 2, "{raw}");
688 }
689 }
690
691 #[test]
692 fn quality_percent_is_bounded_where_the_registry_cannot_type_it() {
693 let error = project(&[
694 "afhttp",
695 "host",
696 "--listen",
697 "tcp:127.0.0.1:9222",
698 "--takeover-provider",
699 "kasmvnc",
700 "--takeover-quality-percent",
701 "101",
702 ])
703 .err()
704 .expect("out-of-range quality");
705 assert_eq!(error.error_code, ErrorCode::InvalidArgument);
706 }
707}