1use 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
16pub 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
29pub 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
44pub const CONTAINER_COMPONENTS: [&str; 7] = [
47 "chrome-headless-shell",
48 "lightpanda",
49 "fingerprint-chromium",
50 "camoufox",
51 "chrome",
52 "brave",
53 "kasmvnc",
54];
55
56pub const CONTAINER_RUNTIMES: [&str; 3] = ["docker", "podman", "apple"];
58
59const RENDER_MODES: [&str; 3] = ["none", "auto", "always"];
60const 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
68const AGENTS: [&str; 4] = ["codex", "claude-code", "opencode", "hermes"];
70const EVERY_AGENT: &str = "all";
71
72fn protocol_output() -> OutputSpec {
75 OutputSpec::protocol_finite(["json"], ["split", "stdout", "stderr"], "json", "split")
76 .file_sinks(["stdout", "stderr"])
77}
78
79fn stream_output() -> OutputSpec {
83 OutputSpec::protocol_stream(["json"], ["split", "stdout", "stderr"], "json", "stdout")
84 .file_sinks(["stdout", "stderr"])
85}
86
87fn raw_output() -> OutputSpec {
89 OutputSpec::raw().file_sinks(["stdout", "stderr"])
90}
91
92fn 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
104pub 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 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
191fn 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
230fn 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
441fn 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
479fn 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
504fn 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
608fn 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
680fn 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
749fn 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
779fn 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
834fn 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
947fn 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 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 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}