Skip to main content

agent_first_http/cli/
args.rs

1//! Turn one resolved `cli-spec-v1` invocation into the typed command the async
2//! dispatcher runs.
3//!
4//! The registry in [`crate::cli::spec`] has already decided that the argv is
5//! legal, which shape it matched, and what every value's type is. What is left
6//! here is projection: reading those values, applying the two environment
7//! fallbacks the registry cannot see (`AFHTTP_ENDPOINT_URL` and
8//! `AFHTTP_TOKEN_SECRET`), and narrowing registry integers to the unsigned
9//! ranges the domain types use.
10
11use 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
25/// One dispatchable command, already projected off its invocation.
26pub 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
40/// What every action handler produces. Projection can still fail — an endpoint
41/// that is neither on argv nor in the environment, an integer outside its
42/// domain range — and those are argument errors, not run failures.
43pub type Projected = Result<Command, Error>;
44
45type Handler = fn(&ResolvedInvocation) -> Projected;
46
47/// Exactly one handler per action id in the registry; `bind_actions` proves the
48/// two sets match at startup.
49pub 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
77// ── value projection ────────────────────────────────────────────────────────
78
79fn 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
114/// Registry integers are `i64`. Every afhttp count is unsigned and bounded, and
115/// that range is the one thing `cli-spec-v1` cannot type, so it is checked here
116/// and reported as the argument error it is.
117fn 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
133/// An environment fallback the registry cannot express: a value is legal on
134/// argv, in the environment, or — where the command can still run without one —
135/// nowhere at all.
136fn 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
152/// Commands that only talk to an already-running host cannot invent one.
153fn 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
225// ── actions ─────────────────────────────────────────────────────────────────
226
227fn 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}