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