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