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