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.
807fn ui_takeover_command() -> CommandSpec {
808    CommandSpec::new(["ui", "takeover"])
809        .about(
810            "Open the takeover panel in an isolated window and block until the person closes it.",
811        )
812        .arg(endpoint_arg())
813        .arg(token_arg())
814        .arg(
815            ArgSpec::option("--takeover-url-secret", "URL")
816                .about("Panel URL a previous `panel` or `fetch --takeover` already minted"),
817        )
818        .arg(ArgSpec::flag("--takeover-no-window").about(
819            "Publish the panel for `afui session list` and wait, instead of opening a window",
820        ))
821        .combination(
822            Combination::new("ui-takeover")
823                .action("ui_takeover")
824                .about("Mint a fresh panel credential from a running host, then open it")
825                .optional(["endpoint_url", "token_secret", "takeover_no_window"])
826                .output(stream_output()),
827        )
828        .combination(
829            Combination::new("ui-takeover-url")
830                .action("ui_takeover")
831                .about("Open a panel URL that was already minted; needs no host of its own")
832                .required(["takeover_url_secret"])
833                .optional(["takeover_no_window"])
834                .output(stream_output()),
835        )
836}
837
838// ── skill ───────────────────────────────────────────────────────────────────
839
840/// One skill verb, as two shapes.
841///
842/// `--skills-dir` names a single directory, so it is meaningless when the verb
843/// fans out across every agent. Registering that as two shapes rather than one
844/// shape plus a runtime check means the illegal mix is rejected by the parser,
845/// and both legal mixes are visible in one `--help`.
846fn skill_command(verb: &str, about: &str, force: bool) -> CommandSpec {
847    let mut command = CommandSpec::new(["skill", verb])
848        .about(about)
849        .arg(
850            ArgSpec::option_enum("--agent", std::iter::once(EVERY_AGENT).chain(AGENTS))
851                .value_name("AGENT")
852                .default(EVERY_AGENT)
853                .about("Agent to manage"),
854        )
855        .arg(
856            ArgSpec::option_enum("--scope", ["personal", "workspace"])
857                .value_name("SCOPE")
858                .default("personal")
859                .about("Skill scope"),
860        )
861        .arg(ArgSpec::option("--skills-dir", "DIR").about("Directory that contains skill folders"));
862
863    let mut every: Vec<&str> = vec!["scope"];
864    let mut named: Vec<&str> = vec!["scope", "skills_dir"];
865    if force {
866        command = command.arg(
867            ArgSpec::flag("--force")
868                .about("Overwrite or remove an Agent-First HTTP skill this tool did not manage"),
869        );
870        every.push("force");
871        named.push("force");
872    }
873
874    command
875        .combination(
876            Combination::new(format!("skill-{verb}-every-agent"))
877                .action(format!("skill_{verb}"))
878                .about("Target every agent that supports the scope")
879                .fixed("agent", EVERY_AGENT)
880                .optional(every)
881                .output(protocol_output()),
882        )
883        .combination(
884            Combination::new(format!("skill-{verb}-one-agent"))
885                .action(format!("skill_{verb}"))
886                .about("Target one named agent; only this shape accepts --skills-dir")
887                .fixed_one_of("agent", AGENTS)
888                .optional(named)
889                .output(protocol_output()),
890        )
891}
892
893// ── container ───────────────────────────────────────────────────────────────
894
895fn container_install_command() -> CommandSpec {
896    let shared = [
897        "runtime",
898        "name",
899        "port",
900        "profile",
901        "shm_size",
902        "takeover_provider",
903        "with",
904        "reveal_token_secret",
905        "host_args",
906    ];
907    let mut release = shared.to_vec();
908    release.push("rebuild");
909    let mut from_source = shared.to_vec();
910    from_source.push("context");
911
912    CommandSpec::new(["container", "install"])
913        .about("Build the host image if missing, run the container, and print the client command.")
914        .arg(runtime_arg())
915        .arg(container_name_arg())
916        .arg(
917            ArgSpec::option_i64("--port", "PORT")
918                .default_i64(i64::from(DEFAULT_CONTAINER_PORT))
919                .about("Host CDP port, published on 127.0.0.1"),
920        )
921        .arg(
922            ArgSpec::option("--profile", "NAME")
923                .about("Initial profile inside the container; defaults to - for ephemeral"),
924        )
925        .arg(
926            ArgSpec::option("--shm-size", "SIZE")
927                .about("Chromium /dev/shm size; defaults to 1g, or 2g when takeover is on"),
928        )
929        .arg(
930            ArgSpec::option_enum("--takeover-provider", TAKEOVER_PROVIDERS)
931                .value_name("PROVIDER")
932                .default("kasmvnc")
933                .about("Takeover provider for the built host; off builds a lean headless host"),
934        )
935        .arg(
936            ArgSpec::option_enum("--with", CONTAINER_COMPONENTS)
937                .value_name("COMPONENT")
938                .repeatable()
939                .about("Extra image component to build"),
940        )
941        .arg(ArgSpec::flag("--rebuild").about("Rebuild the image even if it already exists"))
942        .arg(
943            ArgSpec::flag("--from-source")
944                .about("Compile the image from a source checkout instead of a prebuilt release"),
945        )
946        .arg(ArgSpec::option("--context", "DIR").about(
947            "Source checkout for --from-source; defaults to the current directory, then this \
948             binary's own checkout",
949        ))
950        .arg(reveal_token_arg())
951        .arg(
952            ArgSpec::positional("host_args", 0, "HOST_ARG")
953                .repeatable()
954                .about("Arguments forwarded to `afhttp host` inside the container, after --"),
955        )
956        .combination(
957            Combination::new("container-install")
958                .action("container_install")
959                .about("Build from the release pinned to this binary's version")
960                .optional(release)
961                .output(protocol_output()),
962        )
963        .combination(
964            Combination::new("container-install-from-source")
965                .action("container_install")
966                .about("Compile from a source checkout, which always rebuilds")
967                .required(["from_source"])
968                .optional(from_source)
969                .output(protocol_output()),
970        )
971}
972
973fn container_uninstall_command() -> CommandSpec {
974    CommandSpec::new(["container", "uninstall"])
975        .about("Stop and remove the container.")
976        .arg(runtime_arg())
977        .arg(container_name_arg())
978        .arg(ArgSpec::flag("--purge").about("Also remove the built image and the cached context"))
979        .combination(
980            Combination::new("container-uninstall")
981                .action("container_uninstall")
982                .optional(["runtime", "name", "purge"])
983                .output(protocol_output()),
984        )
985}
986
987fn container_status_command() -> CommandSpec {
988    CommandSpec::new(["container", "status"])
989        .about("Report whether the host is running, with its endpoint and client command.")
990        .arg(runtime_arg())
991        .arg(container_name_arg())
992        .arg(
993            ArgSpec::option_i64("--port", "PORT")
994                .default_i64(i64::from(DEFAULT_CONTAINER_PORT))
995                .about("Published host port, used to format the endpoint and client command"),
996        )
997        .arg(reveal_token_arg())
998        .combination(
999            Combination::new("container-status")
1000                .action("container_status")
1001                .optional(["runtime", "name", "port", "reveal_token_secret"])
1002                .output(protocol_output()),
1003        )
1004}
1005
1006/// `container logs` in its two lifecycles.
1007///
1008/// The summary is one protocol event about a captured file. Following the
1009/// runtime's own log stream is neither finite nor protocol-shaped, so it is a
1010/// separate shape with a raw output contract — which is also why `--follow`
1011/// without `--raw` is rejected by the parser instead of by the handler.
1012fn container_logs_command() -> CommandSpec {
1013    CommandSpec::new(["container", "logs"])
1014        .about("Capture the container logs, or stream them raw.")
1015        .arg(runtime_arg())
1016        .arg(container_name_arg())
1017        .arg(ArgSpec::flag("--follow").about("Keep following the log output"))
1018        .arg(
1019            ArgSpec::flag("--raw")
1020                .about("Stream raw runtime logs instead of returning a JSON summary"),
1021        )
1022        .combination(
1023            Combination::new("container-logs")
1024                .action("container_logs")
1025                .about("Capture the logs to a file and return a JSON summary of the tail")
1026                .optional(["runtime", "name"])
1027                .output(protocol_output()),
1028        )
1029        .combination(
1030            Combination::new("container-logs-raw")
1031                .action("container_logs")
1032                .about("Forward the runtime's own log bytes; the only shape that can follow")
1033                .required(["raw"])
1034                .optional(["runtime", "name", "follow"])
1035                .output(raw_output()),
1036        )
1037}
1038
1039#[cfg(test)]
1040mod tests {
1041    use agent_first_data::{CliErrorRule, CliOutcome};
1042
1043    use super::*;
1044
1045    fn built() -> BuiltCliSpec {
1046        match cli_spec() {
1047            Ok(cli) => cli,
1048            Err(error) => panic!("registry must build: {error}"),
1049        }
1050    }
1051
1052    #[test]
1053    fn registry_builds_and_every_shape_is_reachable() {
1054        let cli = built();
1055        // Each generated argv must resolve back to the shape it came from, so
1056        // an overlapping or unreachable combination fails here rather than at a
1057        // caller's first invocation.
1058        let synthetics = cli.synthetic_invocations();
1059        assert!(!synthetics.is_empty(), "the registry generated no fixtures");
1060        for synthetic in synthetics {
1061            let argv = synthetic.argv.clone();
1062            match cli.resolve_from(argv.clone()) {
1063                Ok(CliOutcome::Run(invocation)) => assert_eq!(
1064                    invocation.combination_id(),
1065                    synthetic.combination_id,
1066                    "{argv:?} resolved to the wrong shape"
1067                ),
1068                Ok(_) => panic!("{argv:?} did not resolve to a run"),
1069                Err(error) => panic!("{argv:?} failed to resolve: {}", error.message),
1070            }
1071        }
1072    }
1073
1074    #[test]
1075    fn takeover_rejects_the_render_mode_that_has_no_browser() {
1076        let cli = built();
1077        let error = match cli.resolve_from([
1078            "afhttp",
1079            "fetch",
1080            "https://example.com",
1081            "--takeover",
1082            "--render",
1083            "none",
1084        ]) {
1085            Err(error) => error,
1086            Ok(_) => panic!("--takeover --render none must be rejected"),
1087        };
1088        assert_eq!(error.rule, CliErrorRule::UnregisteredCombination);
1089    }
1090
1091    #[test]
1092    fn a_request_body_is_data_or_form_but_never_both() {
1093        let cli = built();
1094        let error = match cli.resolve_from([
1095            "afhttp",
1096            "fetch",
1097            "https://example.com",
1098            "--data",
1099            "x",
1100            "--form",
1101            "a=b",
1102        ]) {
1103            Err(error) => error,
1104            Ok(_) => panic!("--data with --form must be rejected"),
1105        };
1106        assert_eq!(error.rule, CliErrorRule::UnregisteredCombination);
1107    }
1108
1109    #[test]
1110    fn following_logs_needs_the_raw_shape() {
1111        let cli = built();
1112        let error = match cli.resolve_from(["afhttp", "container", "logs", "--follow"]) {
1113            Err(error) => error,
1114            Ok(_) => panic!("--follow without --raw must be rejected"),
1115        };
1116        assert_eq!(error.rule, CliErrorRule::UnregisteredCombination);
1117    }
1118
1119    #[test]
1120    fn a_takeover_host_cannot_be_asked_for_a_headless_display() {
1121        let cli = built();
1122        let error = match cli.resolve_from([
1123            "afhttp",
1124            "host",
1125            "--listen",
1126            "tcp:127.0.0.1:9222",
1127            "--takeover-provider",
1128            "kasmvnc",
1129            "--display",
1130            "headless",
1131        ]) {
1132            Err(error) => error,
1133            Ok(_) => panic!("a headless takeover host must be rejected"),
1134        };
1135        assert_eq!(error.rule, CliErrorRule::UnregisteredCombination);
1136    }
1137
1138    #[test]
1139    fn skills_dir_requires_one_named_agent() {
1140        let cli = built();
1141        let error =
1142            match cli.resolve_from(["afhttp", "skill", "install", "--skills-dir", "/tmp/skills"]) {
1143                Err(error) => error,
1144                Ok(_) => panic!("--skills-dir without an explicit --agent must be rejected"),
1145            };
1146        assert_eq!(error.rule, CliErrorRule::UnregisteredCombination);
1147    }
1148
1149    /// The whole registry as one stable text surface: every command path, every
1150    /// argument with its spelling, closed value set and default, and every
1151    /// shape with the action it runs.
1152    fn surface(cli: &BuiltCliSpec) -> String {
1153        let mut out = String::new();
1154        for command in &cli.spec().commands {
1155            let path = if command.command_path.is_empty() {
1156                "afhttp".to_string()
1157            } else {
1158                command.command_path.join(" ")
1159            };
1160            out.push_str(&format!("command {path}\n"));
1161            for argument in &command.arguments {
1162                let syntax = match &argument.syntax {
1163                    agent_first_data::ArgSyntax::Long { name } => name.clone(),
1164                    agent_first_data::ArgSyntax::Positional { index } => {
1165                        format!("<positional {index}>")
1166                    }
1167                };
1168                out.push_str(&format!("  arg {} {syntax}", argument.argument_id));
1169                if !argument.enum_values.is_empty() {
1170                    out.push_str(&format!(" <{}>", argument.enum_values.join("|")));
1171                }
1172                if let Some(default) = &argument.default {
1173                    out.push_str(&format!(" ={}", plain(default)));
1174                }
1175                if argument.repeatable {
1176                    out.push_str(" ...");
1177                }
1178                out.push('\n');
1179            }
1180            for combination in &command.combinations {
1181                out.push_str(&format!(
1182                    "  shape {} -> {}\n",
1183                    combination.combination_id, combination.action_id
1184                ));
1185            }
1186        }
1187        out
1188    }
1189
1190    fn plain(value: &agent_first_data::CliValue) -> String {
1191        value
1192            .as_str()
1193            .map(str::to_string)
1194            .or_else(|| value.as_i64().map(|value| value.to_string()))
1195            .unwrap_or_default()
1196    }
1197
1198    #[test]
1199    fn command_flag_snapshot_matches() {
1200        assert_eq!(
1201            surface(&built()),
1202            include_str!("../../tests/golden/cli-command-flags.txt")
1203        );
1204    }
1205
1206    #[test]
1207    fn cli_contract_has_no_legacy_aliases() {
1208        let surface = surface(&built());
1209        for forbidden in [
1210            "command download\n",
1211            "command takeover\n",
1212            "command hard-site\n",
1213            "--profile-name",
1214            concat!("profile", "_name"),
1215            concat!("?", "profile="),
1216            "arg timeout --timeout\n",
1217            "arg health --health\n",
1218            "arg network_redact --network-redact\n",
1219            "arg takeover_quality --takeover-quality\n",
1220            "chrome_shell",
1221            "fingerprint_chromium",
1222            "legacy",
1223        ] {
1224            assert!(
1225                !surface.contains(forbidden),
1226                "CLI contract retained forbidden legacy surface {forbidden:?}: {surface}"
1227            );
1228        }
1229    }
1230}