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