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