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
79/// A value the matched shape declares as required or fixed.
80///
81/// Reading it cannot fail: the shape that matched supplies every id it
82/// declares. Asking for an id it does not declare is a defect in this file, and
83/// `call_every_combination` is where that surfaces, from a test.
84fn 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
119/// Registry integers are `i64`. Every afhttp count is unsigned and bounded, and
120/// that range is the one thing `cli-spec-v1` cannot type, so it is checked here
121/// and reported as the argument error it is.
122fn 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
138/// An environment fallback the registry cannot express: a value is legal on
139/// argv, in the environment, or — where the command can still run without one —
140/// nowhere at all.
141fn 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
157/// Commands that only talk to an already-running host cannot invent one.
158fn 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
230// ── actions ─────────────────────────────────────────────────────────────────
231
232fn 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    /// Every declared shape, run through its own handler with strict reads: a
554    /// handler that asks for an argument id its shape cannot supply names
555    /// itself here rather than degrading to an empty string in production.
556    ///
557    /// Safe to run because these handlers only project — nothing here opens a
558    /// socket, launches a host, or touches a profile directory.
559    #[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}