Skip to main content

agent_first_http/cli/
spec.rs

1//! The closed-world `cli-spec-v1` registry for `afhttp`.
2//!
3//! One registry is the single source for argv parsing, typed invocation values,
4//! which argument mixes are legal, output contracts, `--help`, and `docs/cli.md`
5//! (rendered by `--docs`). Anything the parser can decide is expressed here as a
6//! shape rather than re-checked by a handler.
7
8use agent_first_data::{
9    ArgSpec, BuiltCliSpec, CliSpec, CliSpecError, Combination, CommandSpec, OutputSpec,
10    build_afdata_cli,
11};
12
13use crate::cli::cmd::container::{DEFAULT_CONTAINER_NAME, DEFAULT_CONTAINER_PORT};
14use crate::sdk::fetch::DEFAULT_NETWORK_BODY_MAX_BYTES;
15
16/// Browser backends the inline client and the host can launch.
17pub const BROWSERS: [&str; 8] = [
18    "auto",
19    "chromium",
20    "chrome",
21    "fingerprint-chromium",
22    "edge",
23    "brave",
24    "lightpanda",
25    "camoufox",
26];
27
28/// Artifact tokens `fetch --want` accepts, in `shared::artifacts::Artifact`
29/// order. A `fetch` unit test proves every one of them maps to an artifact.
30pub const ARTIFACTS: [&str; 10] = [
31    "body",
32    "rendered_html",
33    "text",
34    "content",
35    "content_json",
36    "screenshot",
37    "network",
38    "console",
39    "observation",
40    "storage",
41];
42
43/// Optional image components `container install --with` accepts. A `container`
44/// unit test proves every one of them maps to a Dockerfile build arg.
45pub const CONTAINER_COMPONENTS: [&str; 6] = [
46    "lightpanda",
47    "fingerprint-chromium",
48    "camoufox",
49    "chrome",
50    "brave",
51    "kasmvnc",
52];
53
54/// Container runtimes `--runtime` accepts. Omitted means auto-detection.
55pub const CONTAINER_RUNTIMES: [&str; 3] = ["docker", "podman", "apple"];
56
57const RENDER_MODES: [&str; 3] = ["none", "auto", "always"];
58/// Render modes that reach a real browser. Human takeover has nothing to take
59/// over without one, so the takeover shapes are fixed to these.
60const TAKEOVER_RENDER_MODES: [&str; 2] = ["auto", "always"];
61const NETWORK_BODIES: [&str; 3] = ["off", "xhr", "all"];
62const DISPLAY_MODES: [&str; 2] = ["headless", "headful"];
63const HEALTH_PUBLIC: [&str; 2] = ["off", "minimal"];
64const TAKEOVER_PROVIDERS: [&str; 2] = ["off", "kasmvnc"];
65
66/// The named agents, and the fan-out value that is not one of them.
67const AGENTS: [&str; 4] = ["codex", "claude-code", "opencode", "hermes"];
68const EVERY_AGENT: &str = "all";
69
70/// Command results. Every `afhttp` command answers in AFDATA protocol JSON —
71/// there is no human rendering of a fetch — so the format set is exactly that.
72fn protocol_output() -> OutputSpec {
73    OutputSpec::protocol_finite(["json"], ["split", "stdout", "stderr"], "json", "split")
74        .file_sinks(["stdout", "stderr"])
75}
76
77/// `afhttp host` announces `host_ready` and then keeps serving, so a later
78/// failure is a second event on the same run rather than a new invocation.
79/// That is a stream, and a stream is one ordered channel by default.
80fn stream_output() -> OutputSpec {
81    OutputSpec::protocol_stream(["json"], ["split", "stdout", "stderr"], "json", "stdout")
82        .file_sinks(["stdout", "stderr"])
83}
84
85/// Bytes the container runtime wrote, forwarded unchanged.
86fn raw_output() -> OutputSpec {
87    OutputSpec::raw().file_sinks(["stdout", "stderr"])
88}
89
90/// `--help`, `--version` and `--docs`. Unlike a command result these are read
91/// by people as often as by agents, so they keep the full format set.
92fn lifecycle_output() -> OutputSpec {
93    OutputSpec::protocol_finite(
94        ["json", "yaml", "plain"],
95        ["split", "stdout", "stderr"],
96        "json",
97        "split",
98    )
99    .file_sinks(["stdout", "stderr"])
100}
101
102/// Build the whole `afhttp` CLI registry.
103pub fn cli_spec() -> Result<BuiltCliSpec, CliSpecError> {
104    let mut spec = CliSpec::new("afhttp", env!("CARGO_PKG_VERSION"))
105        .about(env!("CARGO_PKG_DESCRIPTION"))
106        .display_name(env!("DISPLAY_NAME"))
107        .lifecycle_output(lifecycle_output())
108        .command(CommandSpec::root())
109        .command(fetch_command())
110        .command(host_command())
111        .command(upload_command())
112        .command(cdp_command())
113        .command(panel_command())
114        .command(endpoint_query_command(
115            "health",
116            "health",
117            "Query a host's /health endpoint.",
118        ))
119        .command(endpoint_query_command(
120            "capabilities",
121            "capabilities",
122            "Query a host's /capabilities endpoint.",
123        ))
124        .command(
125            CommandSpec::new(["profile"])
126                .about("Inspect and delete the on-disk browser profiles this machine owns."),
127        )
128        .command(profile_list_command())
129        .command(profile_info_command(
130            "info",
131            "profile_info",
132            "Show one profile's size, last use, and lock state.",
133        ))
134        .command(profile_info_command(
135            "lock-status",
136            "profile_lock_status",
137            "Report whether a profile is currently locked by a running host.",
138        ))
139        .command(profile_info_command(
140            "downloads",
141            "profile_downloads",
142            "List files captured in a profile's browser download directory.",
143        ))
144        .command(profile_info_command(
145            "cookies",
146            "profile_cookies",
147            "Show the non-expired cookies in a profile's jar, with values redacted.",
148        ))
149        .command(profile_delete_command())
150        .command(profile_prune_command())
151        .command(
152            CommandSpec::new(["tabs"]).about("List and close the CDP targets attached to a host."),
153        )
154        .command(tabs_list_command())
155        .command(tabs_close_command())
156        .command(
157            CommandSpec::new(["ui"])
158                .about("Hand a running host to a person in a window, and wait for them."),
159        )
160        .command(ui_takeover_command())
161        .command(CommandSpec::new(["skill"]).about(
162            "Manage the Agent-First HTTP skill for Codex, Claude Code, opencode, and Hermes.",
163        ))
164        .command(skill_command(
165            "status",
166            "Show whether the Agent-First HTTP skill is installed, valid, and up to date.",
167            false,
168        ))
169        .command(skill_command(
170            "install",
171            "Install or refresh the Agent-First HTTP skill.",
172            true,
173        ))
174        .command(skill_command(
175            "uninstall",
176            "Remove an afhttp-managed Agent-First HTTP skill.",
177            true,
178        ))
179        .command(CommandSpec::new(["container"]).about(
180            "Build and run a long-lived host container from the embedded recipe (Docker, Podman, or Apple).",
181        ))
182        .command(container_install_command())
183        .command(container_uninstall_command())
184        .command(container_status_command())
185        .command(container_logs_command());
186    // Absent from a source tarball with no reachable .git, and the version
187    // payload omits it rather than reporting the literal "unknown".
188    if let Some(build) = Some(env!("GIT_SHA")).filter(|sha| *sha != "unknown") {
189        spec = spec.build_id(build);
190    }
191    build_afdata_cli(spec)
192}
193
194// ── shared arguments ────────────────────────────────────────────────────────
195
196fn endpoint_arg() -> ArgSpec {
197    ArgSpec::option("--endpoint-url", "URL").about(
198        "CDP endpoint of a running host; falls back to AFHTTP_ENDPOINT_URL, then to the local afhttp-host container",
199    )
200}
201
202/// The syntax is the source set's to render; this says only what the value is
203/// and where it falls back to.
204fn token_arg() -> ArgSpec {
205    ArgSpec::option("--token-secret", "SOURCE")
206        .about(
207            "Token for a protected host; falls back to AFHTTP_TOKEN_SECRET, then to the discovered host's own token",
208        )
209        .sources(crate::cli::token_source::set())
210}
211
212/// `host` is the other side of the same flag: the token it will *require*, not
213/// one to go find. Same sources, no fallback — a host that started without one
214/// is unprotected, which is a decision, not a default to inherit from the
215/// environment.
216fn host_token_arg() -> ArgSpec {
217    ArgSpec::option("--token-secret", "SOURCE")
218        .about("Token this host requires from callers")
219        .sources(crate::cli::token_source::set())
220}
221
222fn profile_root_arg() -> ArgSpec {
223    ArgSpec::option("--profile-root", "DIR")
224        .about("Profiles root directory; defaults to $XDG_DATA_HOME/afhttp/profiles")
225}
226
227fn backend_arg() -> ArgSpec {
228    ArgSpec::option("--backend", "BACKEND")
229        .about("Browser backend scope; required when one profile name exists under several")
230}
231
232fn runtime_arg() -> ArgSpec {
233    ArgSpec::option_enum("--runtime", CONTAINER_RUNTIMES)
234        .value_name("RUNTIME")
235        .about("Container runtime; auto-detected when omitted, then AFHTTP_CONTAINER_RUNTIME")
236}
237
238fn container_name_arg() -> ArgSpec {
239    ArgSpec::option("--name", "NAME")
240        .default(DEFAULT_CONTAINER_NAME)
241        .about("Container name")
242}
243
244fn reveal_token_arg() -> ArgSpec {
245    ArgSpec::flag("--reveal-token-secret")
246        .about("Include the long-lived host token in the result instead of redacting it")
247}
248
249// ── fetch ───────────────────────────────────────────────────────────────────
250
251fn fetch_command() -> CommandSpec {
252    CommandSpec::new(["fetch"])
253        .about("Fetch a URL over HTTP or a real browser and write the requested artifacts.")
254        .arg(ArgSpec::positional("url", 0, "URL").about("URL to fetch"))
255        .arg(endpoint_arg())
256        .arg(token_arg())
257        .arg(
258            ArgSpec::option_enum("--browser", BROWSERS)
259                .value_name("BACKEND")
260                .default("auto")
261                .about("Browser backend for the inline host; ignored when --endpoint-url is set"),
262        )
263        .arg(
264            ArgSpec::option("--browser-bin", "PATH")
265                .about("Browser binary for the inline host when auto-discovery cannot find one"),
266        )
267        .arg(
268            ArgSpec::option_enum("--render", RENDER_MODES)
269                .value_name("MODE")
270                .default("auto")
271                .about(
272                    "Render strategy: none is the HTTP fast path, auto escalates on failure, \
273                     always uses the browser",
274                ),
275        )
276        .arg(
277            ArgSpec::option("--tab", "new|<id>")
278                .default("new")
279                .about("new allocates a temporary target; a CDP target id reuses and keeps it"),
280        )
281        .arg(
282            ArgSpec::flag("--takeover")
283                .about("Escalate captcha, login, or 2FA walls to human takeover"),
284        )
285        .arg(ArgSpec::option("--profile", "NAME").about(
286            "Host profile to switch to for this fetch; defaults to the URL's registrable \
287             domain under --takeover",
288        ))
289        .arg(ArgSpec::option("--wait", "SPEC").default("auto").about(
290            "Readiness signal: auto | load | idle | selector:<css> | \
291                     selector-visible:<css> | ms:<n>",
292        ))
293        .arg(
294            ArgSpec::option("--header", "NAME:VALUE")
295                .repeatable()
296                .about("Request header, as Name:value"),
297        )
298        .arg(
299            ArgSpec::option("--cookie", "NAME=VALUE")
300                .repeatable()
301                .about("Request cookie, in Set-Cookie syntax"),
302        )
303        .arg(
304            ArgSpec::option("--user-agent", "UA")
305                .about("Override the User-Agent header for this fetch"),
306        )
307        .arg(
308            ArgSpec::option("--evaluate-after-wait", "JS")
309                .repeatable()
310                .about("JavaScript evaluated in page context after readiness"),
311        )
312        .arg(
313            ArgSpec::option_enum("--want", ARTIFACTS)
314                .value_name("ARTIFACT")
315                .repeatable()
316                .about(
317                    "Artifact to capture; defaults to body on the HTTP path and the browser \
318                     set when rendering. storage is sensitive and opt-in",
319                ),
320        )
321        .arg(
322            ArgSpec::option("--method", "METHOD")
323                .default("GET")
324                .about("HTTP method"),
325        )
326        .arg(
327            ArgSpec::option("--data", "STRING|@FILE")
328                .about("Request body; @ reads the rest as a file path"),
329        )
330        .arg(
331            ArgSpec::option("--form", "NAME=VALUE")
332                .repeatable()
333                .about("URL-encoded form field; sends application/x-www-form-urlencoded"),
334        )
335        .arg(
336            ArgSpec::option_enum("--network-bodies", NETWORK_BODIES)
337                .value_name("MODE")
338                .default("off")
339                .about("Capture network response bodies; xhr captures XHR/fetch only"),
340        )
341        .arg(
342            ArgSpec::option_i64("--network-body-max-bytes", "BYTES")
343                .default_i64(DEFAULT_NETWORK_BODY_MAX_BYTES as i64)
344                .about("Per-body byte cap for captured network sub-requests"),
345        )
346        .arg(
347            ArgSpec::option_i64("--readiness-idle-ms", "MS")
348                .default_i64(800)
349                .about("Network quiet window used by --wait auto"),
350        )
351        .arg(
352            ArgSpec::option_i64("--readiness-stable-ms", "MS")
353                .default_i64(500)
354                .about("DOM/text unchanged window used by --wait auto"),
355        )
356        .arg(
357            ArgSpec::option_i64("--readiness-min-text-bytes", "BYTES")
358                .default_i64(32)
359                .about("Low visible-text threshold that only warns about --wait auto quality"),
360        )
361        .arg(ArgSpec::flag("--no-network-redact").about(
362            "Write raw Authorization/Cookie headers and token query parameters to network.json",
363        ))
364        .arg(
365            ArgSpec::option("--out", "DIR")
366                .about("Directory for artifacts; defaults to afhttp-out under the temp directory"),
367        )
368        .arg(ArgSpec::option("--cookie-jar", "PATH").about(
369            "Override the profile cookie-jar path; must match the host profile. Honors \
370             AFHTTP_COOKIE_JAR",
371        ))
372        .arg(
373            ArgSpec::flag("--no-cookie-jar")
374                .about("Replay no cookies and merge no Set-Cookie back; wins over --cookie-jar"),
375        )
376        .arg(
377            ArgSpec::option_i64("--observe-main-wait-ms", "MS")
378                .default_i64(500)
379                .about("Maximum wait for the browser's main-document event"),
380        )
381        .arg(
382            ArgSpec::option_i64("--max-response-bytes", "BYTES")
383                .default_i64(1_073_741_824)
384                .about("Maximum main HTTP-path response body size; 0 disables the cap"),
385        )
386        .arg(
387            ArgSpec::option_i64("--retry", "N")
388                .default_i64(0)
389                .about("Retry attempts after the first, for retryable errors only"),
390        )
391        .arg(
392            ArgSpec::option_i64("--backoff-ms", "MS")
393                .default_i64(250)
394                .about("Fixed delay between retries"),
395        )
396        .arg(
397            ArgSpec::option("--proxy-url", "URL").about(
398                "Upstream proxy for the HTTP fast path; ambient HTTP_PROXY is never honored",
399            ),
400        )
401        .arg(
402            ArgSpec::option("--ca-cert", "PATH")
403                .about("PEM file with extra root CAs for the HTTP path"),
404        )
405        .arg(
406            ArgSpec::flag("--tls-insecure")
407                .about("Disable TLS certificate verification on the HTTP path"),
408        )
409        .arg(
410            ArgSpec::option_i64("--timeout-ms", "MS")
411                .default_i64(30_000)
412                .about("Overall fetch timeout for both the HTTP and browser paths"),
413        )
414        .arg(
415            ArgSpec::flag("--capture-ws")
416                .about("Capture WebSocket frame payloads; treat the artifact as sensitive"),
417        )
418        .arg(
419            ArgSpec::flag("--capture-sse")
420                .about("Capture SSE event payloads; treat the artifact as sensitive"),
421        )
422        .combination(fetch_shape(
423            "fetch",
424            "Fetch with no request body",
425            false,
426            None,
427        ))
428        .combination(fetch_shape(
429            "fetch-data",
430            "Fetch with a raw request body from --data",
431            false,
432            Some("data"),
433        ))
434        .combination(fetch_shape(
435            "fetch-form",
436            "Fetch with URL-encoded fields from --form",
437            false,
438            Some("form"),
439        ))
440        .combination(fetch_shape(
441            "fetch-takeover",
442            "Hand a wall to a human, with no request body",
443            true,
444            None,
445        ))
446        .combination(fetch_shape(
447            "fetch-takeover-data",
448            "Hand a wall to a human, with a raw request body from --data",
449            true,
450            Some("data"),
451        ))
452        .combination(fetch_shape(
453            "fetch-takeover-form",
454            "Hand a wall to a human, with URL-encoded fields from --form",
455            true,
456            Some("form"),
457        ))
458}
459
460/// Everything a fetch accepts regardless of body kind or takeover.
461fn fetch_common() -> Vec<&'static str> {
462    vec![
463        "endpoint_url",
464        "token_secret",
465        "browser",
466        "browser_bin",
467        "tab",
468        "profile",
469        "wait",
470        "header",
471        "cookie",
472        "user_agent",
473        "evaluate_after_wait",
474        "want",
475        "method",
476        "network_bodies",
477        "network_body_max_bytes",
478        "readiness_idle_ms",
479        "readiness_stable_ms",
480        "readiness_min_text_bytes",
481        "no_network_redact",
482        "out",
483        "cookie_jar",
484        "no_cookie_jar",
485        "observe_main_wait_ms",
486        "max_response_bytes",
487        "retry",
488        "backoff_ms",
489        "proxy_url",
490        "ca_cert",
491        "tls_insecure",
492        "timeout_ms",
493        "capture_ws",
494        "capture_sse",
495    ]
496}
497
498/// One fetch shape.
499///
500/// Two exclusions the handler used to raise at runtime are shapes here: a
501/// request body is either `--data` or `--form` and never both, and `--takeover`
502/// only exists where a browser does, so the takeover shapes pin `--render` to
503/// the modes that reach one.
504fn fetch_shape(id: &str, about: &str, takeover: bool, body: Option<&str>) -> Combination {
505    let mut optional = fetch_common();
506    let mut combination = Combination::new(id)
507        .action("fetch")
508        .about(about)
509        .required(["url"]);
510    if takeover {
511        combination = combination
512            .required(["takeover"])
513            .fixed_one_of("render", TAKEOVER_RENDER_MODES);
514    } else {
515        optional.push("render");
516    }
517    if let Some(body) = body {
518        combination = combination.required([body]);
519    }
520    combination.optional(optional).output(protocol_output())
521}
522
523// ── host ────────────────────────────────────────────────────────────────────
524
525fn host_command() -> CommandSpec {
526    let shared = [
527        "profile",
528        "browser",
529        "browser_bin",
530        "token_secret",
531        "no_health",
532        "health_public",
533        "engine_env",
534        "browser_arg",
535        "proxy_url",
536        "recent_requests_cap",
537    ];
538    let mut headless = shared.to_vec();
539    headless.push("display");
540    let mut takeover = shared.to_vec();
541    takeover.push("takeover_quality_percent");
542
543    CommandSpec::new(["host"])
544        .about("Run the browser host: launch a backend browser and serve /cdp for clients.")
545        .arg(
546            ArgSpec::option("--listen", "tcp:HOST:PORT|unix:PATH")
547                .about("Listener address; a non-loopback TCP address also requires --token-secret"),
548        )
549        .arg(
550            ArgSpec::option("--profile", "NAME")
551                .default("-")
552                .about("Initial profile name, or - for an ephemeral profile"),
553        )
554        .arg(
555            ArgSpec::option_enum("--display", DISPLAY_MODES)
556                .value_name("MODE")
557                .about("Browser display mode"),
558        )
559        .arg(
560            ArgSpec::option_enum("--takeover-provider", TAKEOVER_PROVIDERS)
561                .value_name("PROVIDER")
562                .default("off")
563                .about("Real-display takeover provider served at /takeover/panel"),
564        )
565        .arg(
566            ArgSpec::option_i64("--takeover-quality-percent", "PERCENT")
567                .default_i64(100)
568                .about("Takeover image quality from 0 to 100; lower trades clarity for bandwidth"),
569        )
570        .arg(
571            ArgSpec::option_enum("--browser", BROWSERS)
572                .value_name("BACKEND")
573                .default("auto")
574                .about("Browser backend"),
575        )
576        .arg(ArgSpec::option("--browser-bin", "PATH").about("Override the browser binary path"))
577        .arg(host_token_arg())
578        .arg(
579            ArgSpec::flag("--no-health")
580                .about("Stop serving /health and /capabilities, which are on by default"),
581        )
582        .arg(
583            ArgSpec::option_enum("--health-public", HEALTH_PUBLIC)
584                .value_name("MODE")
585                .default("off")
586                .about("Serve /health unauthenticated with a minimal payload"),
587        )
588        .arg(
589            ArgSpec::option("--engine-env", "NAME=VALUE")
590                .repeatable()
591                .about("Environment variable for the browser subprocess; all others are scrubbed"),
592        )
593        .arg(
594            ArgSpec::option("--browser-arg", "FLAG")
595                .repeatable()
596                .about("Raw backend flag appended after the host's defaults"),
597        )
598        .arg(
599            ArgSpec::option("--proxy-url", "URL")
600                .about("Upstream browser proxy; ambient HTTP_PROXY is never inherited"),
601        )
602        .arg(
603            ArgSpec::option_i64("--recent-requests-cap", "N")
604                .default_i64(0)
605                .about("Enable /recent-requests with a bounded ring of N entries; 0 is off"),
606        )
607        .combination(
608            Combination::new("host")
609                .action("host")
610                .about("Serve no takeover surface; --display picks headless or headful")
611                .fixed("takeover_provider", "off")
612                .required(["listen"])
613                .optional(headless)
614                .output(stream_output()),
615        )
616        .combination(
617            Combination::new("host-takeover")
618                .action("host")
619                .about("Serve a KasmVNC real-display takeover, which is always headful")
620                .fixed("takeover_provider", "kasmvnc")
621                .required(["listen"])
622                .optional(takeover)
623                .output(stream_output()),
624        )
625}
626
627// ── single-endpoint commands ────────────────────────────────────────────────
628
629fn endpoint_query_command(name: &str, action: &str, about: &str) -> CommandSpec {
630    CommandSpec::new([name])
631        .about(about)
632        .arg(endpoint_arg())
633        .arg(token_arg())
634        .combination(
635            Combination::new(name)
636                .action(action)
637                .optional(["endpoint_url", "token_secret"])
638                .output(protocol_output()),
639        )
640}
641
642fn panel_command() -> CommandSpec {
643    CommandSpec::new(["panel"])
644        .about("Mint a short-lived takeover URL for a running takeover-ready host.")
645        .arg(endpoint_arg())
646        .arg(token_arg())
647        .combination(
648            Combination::new("panel")
649                .action("panel")
650                .optional(["endpoint_url", "token_secret"])
651                .output(protocol_output()),
652        )
653}
654
655fn upload_command() -> CommandSpec {
656    CommandSpec::new(["upload"])
657        .about("Upload a local file into a browser tab's file input via DOM.setFileInputFiles.")
658        .arg(endpoint_arg())
659        .arg(token_arg())
660        .arg(ArgSpec::option("--tab", "TARGET_ID").about("CDP target id to operate in"))
661        .arg(
662            ArgSpec::option("--selector", "CSS")
663                .about("CSS selector for the <input type=file> element"),
664        )
665        .arg(ArgSpec::option("--file", "PATH").about("Local file to upload"))
666        .combination(
667            Combination::new("upload")
668                .action("upload")
669                .required(["tab", "selector", "file"])
670                .optional(["endpoint_url", "token_secret"])
671                .output(protocol_output()),
672        )
673}
674
675fn cdp_command() -> CommandSpec {
676    CommandSpec::new(["cdp"])
677        .about("Send one raw CDP method to a tab and return its result.")
678        .arg(ArgSpec::positional("method", 0, "METHOD").about("CDP method name"))
679        .arg(endpoint_arg())
680        .arg(token_arg())
681        .arg(ArgSpec::option("--tab", "TARGET_ID").about("CDP target id to drive"))
682        .arg(
683            ArgSpec::option("--params", "JSON|@-")
684                .about("Method parameters as a JSON literal, or @- to read them from stdin"),
685        )
686        .arg(
687            ArgSpec::option("--wait-event", "EVENT:TIMEOUT")
688                .about("Wait for a CDP event before exiting, for example Page.loadEventFired:5s"),
689        )
690        .combination(
691            Combination::new("cdp")
692                .action("cdp")
693                .required(["method", "tab"])
694                .optional(["endpoint_url", "token_secret", "params", "wait_event"])
695                .output(protocol_output()),
696        )
697}
698
699// ── profile ─────────────────────────────────────────────────────────────────
700
701fn profile_list_command() -> CommandSpec {
702    CommandSpec::new(["profile", "list"])
703        .about("List the on-disk profiles under the profiles root.")
704        .arg(profile_root_arg())
705        .combination(
706            Combination::new("profile-list")
707                .action("profile_list")
708                .optional(["profile_root"])
709                .output(protocol_output()),
710        )
711}
712
713fn profile_info_command(name: &str, action: &str, about: &str) -> CommandSpec {
714    CommandSpec::new(["profile", name])
715        .about(about)
716        .arg(ArgSpec::positional("name", 0, "NAME").about("Profile name"))
717        .arg(backend_arg())
718        .arg(profile_root_arg())
719        .combination(
720            Combination::new(format!("profile-{name}"))
721                .action(action)
722                .required(["name"])
723                .optional(["backend", "profile_root"])
724                .output(protocol_output()),
725        )
726}
727
728fn profile_delete_command() -> CommandSpec {
729    CommandSpec::new(["profile", "delete"])
730        .about("Delete a profile and all of its on-disk state.")
731        .arg(ArgSpec::positional("name", 0, "NAME").about("Profile name to delete"))
732        .arg(backend_arg())
733        .arg(
734            ArgSpec::option("--confirm", "NAME")
735                .about("Confirmation guard; must equal the profile name"),
736        )
737        .arg(profile_root_arg())
738        .combination(
739            Combination::new("profile-delete")
740                .action("profile_delete")
741                .required(["name", "confirm"])
742                .optional(["backend", "profile_root"])
743                .output(protocol_output()),
744        )
745}
746
747fn profile_prune_command() -> CommandSpec {
748    CommandSpec::new(["profile", "prune"])
749        .about("Delete profiles whose last use is older than a cutoff.")
750        .arg(
751            ArgSpec::option("--older-than", "DURATION")
752                .about("Age cutoff such as 30d or 12h; profiles last used before it are removed"),
753        )
754        .arg(
755            ArgSpec::flag("--dry-run")
756                .about("Report what would be deleted without deleting anything"),
757        )
758        .arg(profile_root_arg())
759        .combination(
760            Combination::new("profile-prune")
761                .action("profile_prune")
762                .required(["older_than"])
763                .optional(["dry_run", "profile_root"])
764                .output(protocol_output()),
765        )
766}
767
768// ── tabs ────────────────────────────────────────────────────────────────────
769
770fn tabs_list_command() -> CommandSpec {
771    CommandSpec::new(["tabs", "list"])
772        .about("List the CDP targets currently attached to a host.")
773        .arg(endpoint_arg())
774        .arg(token_arg())
775        .combination(
776            Combination::new("tabs-list")
777                .action("tabs_list")
778                .optional(["endpoint_url", "token_secret"])
779                .output(protocol_output()),
780        )
781}
782
783fn tabs_close_command() -> CommandSpec {
784    CommandSpec::new(["tabs", "close"])
785        .about("Close one CDP target by its target id.")
786        .arg(ArgSpec::option("--tab", "TARGET_ID").about("CDP target id to close"))
787        .arg(endpoint_arg())
788        .arg(token_arg())
789        .combination(
790            Combination::new("tabs-close")
791                .action("tabs_close")
792                .required(["tab"])
793                .optional(["endpoint_url", "token_secret"])
794                .output(protocol_output()),
795        )
796}
797
798// ── ui ──────────────────────────────────────────────────────────────────────
799
800/// `ui takeover`, as two shapes.
801///
802/// Minting a credential needs a host to mint it; opening one that already
803/// exists needs only the URL that carries it, and reaching for a host there
804/// would be a second, pointless handoff. Registering that as two shapes rather
805/// than one shape plus a runtime check means the illegal mix — a URL *and* an
806/// endpoint, with nothing to say which wins — is rejected by the parser.
807///
808/// `--mode` deliberately has no default. AFUI resolves an absent one against
809/// `AFUI_DELIVERY`, and a default here would be indistinguishable from a person
810/// having typed `--mode window` — which is how an unattended machine ends up
811/// popping a window nobody is there to see.
812fn ui_takeover_command() -> CommandSpec {
813    CommandSpec::new(["ui", "takeover"])
814        .about("Put the takeover panel in front of a person and block until that session ends.")
815        .arg(endpoint_arg())
816        .arg(token_arg())
817        .arg(
818            ArgSpec::option("--takeover-url-secret", "URL")
819                .about("Panel URL a previous `panel` or `fetch --takeover` already minted"),
820        )
821        .arg(delivery_mode_arg())
822        .combination(
823            Combination::new("ui-takeover")
824                .action("ui_takeover")
825                .about("Mint a fresh panel credential from a running host, then deliver it")
826                .optional(["endpoint_url", "token_secret", "mode"])
827                .output(stream_output()),
828        )
829        .combination(
830            Combination::new("ui-takeover-url")
831                .action("ui_takeover")
832                .about("Deliver a panel URL that was already minted; needs no host of its own")
833                .required(["takeover_url_secret"])
834                .optional(["mode"])
835                .output(stream_output()),
836        )
837}
838
839/// Which deliveries a takeover panel offers, declared once: this flag and the
840/// plan `cmd::ui` builds come from the same value.
841pub(crate) const PANEL_DELIVERY: agent_first_ui::cli::UiDeliveryOffer =
842    agent_first_ui::cli::UiDeliveryOffer::WithLink;
843
844fn delivery_mode_arg() -> ArgSpec {
845    PANEL_DELIVERY.arg("--mode")
846}
847
848// ── skill ───────────────────────────────────────────────────────────────────
849
850/// One skill verb, as two shapes.
851///
852/// `--skills-dir` names a single directory, so it is meaningless when the verb
853/// fans out across every agent. Registering that as two shapes rather than one
854/// shape plus a runtime check means the illegal mix is rejected by the parser,
855/// and both legal mixes are visible in one `--help`.
856fn skill_command(verb: &str, about: &str, force: bool) -> CommandSpec {
857    let mut command = CommandSpec::new(["skill", verb])
858        .about(about)
859        .arg(
860            ArgSpec::option_enum("--agent", std::iter::once(EVERY_AGENT).chain(AGENTS))
861                .value_name("AGENT")
862                .default(EVERY_AGENT)
863                .about("Agent to manage"),
864        )
865        .arg(
866            ArgSpec::option_enum("--scope", ["personal", "workspace"])
867                .value_name("SCOPE")
868                .default("personal")
869                .about("Skill scope"),
870        )
871        .arg(ArgSpec::option("--skills-dir", "DIR").about("Directory that contains skill folders"));
872
873    let mut every: Vec<&str> = vec!["scope"];
874    let mut named: Vec<&str> = vec!["scope", "skills_dir"];
875    if force {
876        command = command.arg(
877            ArgSpec::flag("--force")
878                .about("Overwrite or remove an Agent-First HTTP skill this tool did not manage"),
879        );
880        every.push("force");
881        named.push("force");
882    }
883
884    command
885        .combination(
886            Combination::new(format!("skill-{verb}-every-agent"))
887                .action(format!("skill_{verb}"))
888                .about("Target every agent that supports the scope")
889                .fixed("agent", EVERY_AGENT)
890                .optional(every)
891                .output(protocol_output()),
892        )
893        .combination(
894            Combination::new(format!("skill-{verb}-one-agent"))
895                .action(format!("skill_{verb}"))
896                .about("Target one named agent; only this shape accepts --skills-dir")
897                .fixed_one_of("agent", AGENTS)
898                .optional(named)
899                .output(protocol_output()),
900        )
901}
902
903// ── container ───────────────────────────────────────────────────────────────
904
905fn container_install_command() -> CommandSpec {
906    let shared = [
907        "runtime",
908        "name",
909        "port",
910        "profile",
911        "shm_size",
912        "takeover_provider",
913        "with",
914        "reveal_token_secret",
915        "host_args",
916    ];
917    let mut release = shared.to_vec();
918    release.push("rebuild");
919    let mut from_source = shared.to_vec();
920    from_source.push("context");
921
922    CommandSpec::new(["container", "install"])
923        .about("Build the host image if missing, run the container, and print the client command.")
924        .arg(runtime_arg())
925        .arg(container_name_arg())
926        .arg(
927            ArgSpec::option_i64("--port", "PORT")
928                .default_i64(i64::from(DEFAULT_CONTAINER_PORT))
929                .about("Host CDP port, published on 127.0.0.1"),
930        )
931        .arg(
932            ArgSpec::option("--profile", "NAME")
933                .about("Initial profile inside the container; defaults to - for ephemeral"),
934        )
935        .arg(
936            ArgSpec::option("--shm-size", "SIZE")
937                .about("Chromium /dev/shm size; defaults to 1g, or 2g when takeover is on"),
938        )
939        .arg(
940            ArgSpec::option_enum("--takeover-provider", TAKEOVER_PROVIDERS)
941                .value_name("PROVIDER")
942                .default("kasmvnc")
943                .about("Takeover provider for the built host; off builds a lean headless host"),
944        )
945        .arg(
946            ArgSpec::option_enum("--with", CONTAINER_COMPONENTS)
947                .value_name("COMPONENT")
948                .repeatable()
949                .about("Extra image component to build"),
950        )
951        .arg(ArgSpec::flag("--rebuild").about("Rebuild the image even if it already exists"))
952        .arg(
953            ArgSpec::flag("--from-source")
954                .about("Compile the image from a source checkout instead of a prebuilt release"),
955        )
956        .arg(ArgSpec::option("--context", "DIR").about(
957            "Source checkout for --from-source; defaults to the current directory, then this \
958             binary's own checkout",
959        ))
960        .arg(reveal_token_arg())
961        .arg(
962            ArgSpec::positional("host_args", 0, "HOST_ARG")
963                .repeatable()
964                .about("Arguments forwarded to `afhttp host` inside the container, after --"),
965        )
966        .combination(
967            Combination::new("container-install")
968                .action("container_install")
969                .about("Build from the release pinned to this binary's version")
970                .optional(release)
971                .output(protocol_output()),
972        )
973        .combination(
974            Combination::new("container-install-from-source")
975                .action("container_install")
976                .about("Compile from a source checkout, which always rebuilds")
977                .required(["from_source"])
978                .optional(from_source)
979                .output(protocol_output()),
980        )
981}
982
983fn container_uninstall_command() -> CommandSpec {
984    CommandSpec::new(["container", "uninstall"])
985        .about("Stop and remove the container.")
986        .arg(runtime_arg())
987        .arg(container_name_arg())
988        .arg(ArgSpec::flag("--purge").about("Also remove the built image and the cached context"))
989        .combination(
990            Combination::new("container-uninstall")
991                .action("container_uninstall")
992                .optional(["runtime", "name", "purge"])
993                .output(protocol_output()),
994        )
995}
996
997fn container_status_command() -> CommandSpec {
998    CommandSpec::new(["container", "status"])
999        .about("Report whether the host is running, with its endpoint and client command.")
1000        .arg(runtime_arg())
1001        .arg(container_name_arg())
1002        .arg(
1003            ArgSpec::option_i64("--port", "PORT")
1004                .default_i64(i64::from(DEFAULT_CONTAINER_PORT))
1005                .about("Published host port, used to format the endpoint and client command"),
1006        )
1007        .arg(reveal_token_arg())
1008        .combination(
1009            Combination::new("container-status")
1010                .action("container_status")
1011                .optional(["runtime", "name", "port", "reveal_token_secret"])
1012                .output(protocol_output()),
1013        )
1014}
1015
1016/// `container logs` in its two lifecycles.
1017///
1018/// The summary is one protocol event about a captured file. Following the
1019/// runtime's own log stream is neither finite nor protocol-shaped, so it is a
1020/// separate shape with a raw output contract — which is also why `--follow`
1021/// without `--raw` is rejected by the parser instead of by the handler.
1022fn container_logs_command() -> CommandSpec {
1023    CommandSpec::new(["container", "logs"])
1024        .about("Capture the container logs, or stream them raw.")
1025        .arg(runtime_arg())
1026        .arg(container_name_arg())
1027        .arg(ArgSpec::flag("--follow").about("Keep following the log output"))
1028        .arg(
1029            ArgSpec::flag("--raw")
1030                .about("Stream raw runtime logs instead of returning a JSON summary"),
1031        )
1032        .combination(
1033            Combination::new("container-logs")
1034                .action("container_logs")
1035                .about("Capture the logs to a file and return a JSON summary of the tail")
1036                .optional(["runtime", "name"])
1037                .output(protocol_output()),
1038        )
1039        .combination(
1040            Combination::new("container-logs-raw")
1041                .action("container_logs")
1042                .about("Forward the runtime's own log bytes; the only shape that can follow")
1043                .required(["raw"])
1044                .optional(["runtime", "name", "follow"])
1045                .output(raw_output()),
1046        )
1047}
1048
1049#[cfg(test)]
1050mod tests {
1051    use agent_first_data::{CliErrorRule, CliOutcome};
1052
1053    use super::*;
1054
1055    fn built() -> BuiltCliSpec {
1056        match cli_spec() {
1057            Ok(cli) => cli,
1058            Err(error) => panic!("registry must build: {error}"),
1059        }
1060    }
1061
1062    #[test]
1063    fn registry_builds_and_every_shape_is_reachable() {
1064        let cli = built();
1065        // Each generated argv must resolve back to the shape it came from, so
1066        // an overlapping or unreachable combination fails here rather than at a
1067        // caller's first invocation.
1068        let synthetics = cli.synthetic_invocations();
1069        assert!(!synthetics.is_empty(), "the registry generated no fixtures");
1070        for synthetic in synthetics {
1071            let argv = synthetic.argv.clone();
1072            match cli.resolve_from(argv.clone()) {
1073                Ok(CliOutcome::Run(invocation)) => assert_eq!(
1074                    invocation.combination_id(),
1075                    synthetic.combination_id,
1076                    "{argv:?} resolved to the wrong shape"
1077                ),
1078                Ok(_) => panic!("{argv:?} did not resolve to a run"),
1079                Err(error) => panic!("{argv:?} failed to resolve: {}", error.message),
1080            }
1081        }
1082    }
1083
1084    #[test]
1085    fn takeover_rejects_the_render_mode_that_has_no_browser() {
1086        let cli = built();
1087        let error = match cli.resolve_from([
1088            "afhttp",
1089            "fetch",
1090            "https://example.com",
1091            "--takeover",
1092            "--render",
1093            "none",
1094        ]) {
1095            Err(error) => error,
1096            Ok(_) => panic!("--takeover --render none must be rejected"),
1097        };
1098        assert_eq!(error.rule, CliErrorRule::UnregisteredCombination);
1099    }
1100
1101    #[test]
1102    fn a_request_body_is_data_or_form_but_never_both() {
1103        let cli = built();
1104        let error = match cli.resolve_from([
1105            "afhttp",
1106            "fetch",
1107            "https://example.com",
1108            "--data",
1109            "x",
1110            "--form",
1111            "a=b",
1112        ]) {
1113            Err(error) => error,
1114            Ok(_) => panic!("--data with --form must be rejected"),
1115        };
1116        assert_eq!(error.rule, CliErrorRule::UnregisteredCombination);
1117    }
1118
1119    #[test]
1120    fn following_logs_needs_the_raw_shape() {
1121        let cli = built();
1122        let error = match cli.resolve_from(["afhttp", "container", "logs", "--follow"]) {
1123            Err(error) => error,
1124            Ok(_) => panic!("--follow without --raw must be rejected"),
1125        };
1126        assert_eq!(error.rule, CliErrorRule::UnregisteredCombination);
1127    }
1128
1129    #[test]
1130    fn a_takeover_host_cannot_be_asked_for_a_headless_display() {
1131        let cli = built();
1132        let error = match cli.resolve_from([
1133            "afhttp",
1134            "host",
1135            "--listen",
1136            "tcp:127.0.0.1:9222",
1137            "--takeover-provider",
1138            "kasmvnc",
1139            "--display",
1140            "headless",
1141        ]) {
1142            Err(error) => error,
1143            Ok(_) => panic!("a headless takeover host must be rejected"),
1144        };
1145        assert_eq!(error.rule, CliErrorRule::UnregisteredCombination);
1146    }
1147
1148    #[test]
1149    fn skills_dir_requires_one_named_agent() {
1150        let cli = built();
1151        let error =
1152            match cli.resolve_from(["afhttp", "skill", "install", "--skills-dir", "/tmp/skills"]) {
1153                Err(error) => error,
1154                Ok(_) => panic!("--skills-dir without an explicit --agent must be rejected"),
1155            };
1156        assert_eq!(error.rule, CliErrorRule::UnregisteredCombination);
1157    }
1158
1159    /// The whole registry as one stable text surface: every command path, every
1160    /// argument with its spelling, closed value set and default, and every
1161    /// shape with the action it runs.
1162    fn surface(cli: &BuiltCliSpec) -> String {
1163        let mut out = String::new();
1164        for command in &cli.spec().commands {
1165            let path = if command.command_path.is_empty() {
1166                "afhttp".to_string()
1167            } else {
1168                command.command_path.join(" ")
1169            };
1170            out.push_str(&format!("command {path}\n"));
1171            for argument in &command.arguments {
1172                let syntax = match &argument.syntax {
1173                    agent_first_data::ArgSyntax::Long { name } => name.clone(),
1174                    agent_first_data::ArgSyntax::Positional { index } => {
1175                        format!("<positional {index}>")
1176                    }
1177                };
1178                out.push_str(&format!("  arg {} {syntax}", argument.argument_id));
1179                if !argument.enum_values.is_empty() {
1180                    out.push_str(&format!(" <{}>", argument.enum_values.join("|")));
1181                }
1182                if let Some(default) = &argument.default {
1183                    out.push_str(&format!(" ={}", plain(default)));
1184                }
1185                if argument.repeatable {
1186                    out.push_str(" ...");
1187                }
1188                out.push('\n');
1189            }
1190            for combination in &command.combinations {
1191                out.push_str(&format!(
1192                    "  shape {} -> {}\n",
1193                    combination.combination_id, combination.action_id
1194                ));
1195            }
1196        }
1197        out
1198    }
1199
1200    fn plain(value: &agent_first_data::CliValue) -> String {
1201        value
1202            .as_str()
1203            .map(str::to_string)
1204            .or_else(|| value.as_i64().map(|value| value.to_string()))
1205            .unwrap_or_default()
1206    }
1207
1208    #[test]
1209    fn command_flag_snapshot_matches() {
1210        assert_eq!(
1211            surface(&built()),
1212            include_str!("../../tests/golden/cli-command-flags.txt")
1213        );
1214    }
1215
1216    #[test]
1217    fn cli_contract_has_no_legacy_aliases() {
1218        let surface = surface(&built());
1219        for forbidden in [
1220            "command download\n",
1221            "command takeover\n",
1222            "command hard-site\n",
1223            "--profile-name",
1224            concat!("profile", "_name"),
1225            concat!("?", "profile="),
1226            "arg timeout --timeout\n",
1227            "arg health --health\n",
1228            "arg network_redact --network-redact\n",
1229            "arg takeover_quality --takeover-quality\n",
1230            "chrome_shell",
1231            "fingerprint_chromium",
1232            "legacy",
1233        ] {
1234            assert!(
1235                !surface.contains(forbidden),
1236                "CLI contract retained forbidden legacy surface {forbidden:?}: {surface}"
1237            );
1238        }
1239    }
1240}