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