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