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//!
11//! Projection stays pure. A `--token-secret` source is classified here but read
12//! later, and a missing endpoint is carried here but discovered later, so
13//! nothing in this file opens a file, spawns a container runtime, or reaches a
14//! host — argv is either legal or it is not, before any of that can happen.
15
16use 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
33/// One dispatchable command, already projected off its invocation.
34pub 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
49/// What every action handler produces. Projection can still fail — an endpoint
50/// that is neither on argv nor in the environment, an integer outside its
51/// domain range — and those are argument errors, not run failures.
52pub type Projected = Result<Command, Error>;
53
54type Handler = fn(&ResolvedInvocation) -> Projected;
55
56/// Exactly one handler per action id in the registry; `bind_actions` proves the
57/// two sets match at startup.
58pub 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
87// ── value projection ────────────────────────────────────────────────────────
88
89/// A value the matched shape declares as required or fixed.
90///
91/// Reading it cannot fail: the shape that matched supplies every id it
92/// declares. Asking for an id it does not declare is a defect in this file, and
93/// `call_every_combination` is where that surfaces, from a test.
94fn 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
129/// Registry integers are `i64`. Every afhttp count is unsigned and bounded, and
130/// that range is the one thing `cli-spec-v1` cannot type, so it is checked here
131/// and reported as the argument error it is.
132fn 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
148/// An environment fallback the registry cannot express: a value is legal on
149/// argv, in the environment, or — where the command can still run without one —
150/// nowhere at all.
151fn 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
163/// `--token-secret` names where the token is, so an environment variable is one
164/// source among several rather than the only alternative to typing the secret.
165/// `AFHTTP_TOKEN_SECRET` stays a fallback and holds the value itself.
166fn 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
176/// The host a client command talks to. Neither half is required on argv: an
177/// absent endpoint means the standard local container, discovered at connect
178/// time, and an absent token means whatever that host's own token is.
179fn 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
244// ── actions ─────────────────────────────────────────────────────────────────
245
246fn 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        // The token this host will require of its callers, not one to go find:
336        // no environment fallback, but the same sources, so the secret can come
337        // from a config file instead of the process environment.
338        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
463/// Both `ui takeover` shapes, told apart by the one argument only the second
464/// declares. An already-minted URL carries its own credential, so that shape
465/// never reaches for a host — no environment fallback, and no discovery, which
466/// would otherwise make a stray variable or a stray container look load-bearing.
467///
468/// The third environment fallback this file applies lives here: an absent
469/// `--mode` is AFUI's to resolve, against `AFUI_DELIVERY` and then a window.
470fn 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
487/// A delivery word that does not name a delivery — typed, or left in
488/// `AFUI_DELIVERY` — is an argument error, and AFUI's message already names
489/// all three words.
490fn 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    /// Every declared shape, run through its own handler with strict reads: a
596    /// handler that asks for an argument id its shape cannot supply names
597    /// itself here rather than degrading to an empty string in production.
598    ///
599    /// Safe to run because these handlers only project — nothing here opens a
600    /// socket, launches a host, or touches a profile directory.
601    #[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    /// A command that needs a host no longer refuses argv that omits one: the
634    /// endpoint is carried absent and answered by discovery at connect time,
635    /// which is what makes the local case a bare command.
636    #[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    /// The registry now owns the source grammar, so a malformed one is refused
645    /// while argv is resolved — beside the other usage errors, at exit 2 — and
646    /// a legal one reaches projection classified but unread. Nothing on this
647    /// path opens a file or spawns a container runtime.
648    #[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            // A file source with no dot path…
675            "file:/etc/afhttp/hosts.json",
676            // …and a scheme this argument does not accept.
677            "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}