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