agent_first_http/cli/args.rs
1//! Top-level CLI argument parsing. Uses `clap` derive for the subcommand
2//! enum; each subcommand owns its own arg struct under `cmd::<name>`.
3
4use clap::{Parser, Subcommand};
5
6use crate::shared::error::{Error, ErrorCode};
7
8/// A URL acquisition tool for AI agents.
9///
10/// Give afhttp a URL and it returns the page plus the artifacts an agent needs to
11/// decide what to do next: rendered HTML, a DOM observation, a screenshot, and
12/// network and console logs. It covers the whole acquisition range behind one
13/// structured contract — a plain HTTP fetch when that works, a browser-backed
14/// fetch when it does not, deep network capture, a raw CDP escape hatch, and an
15/// ops panel for human takeover (login, captcha, 2FA).
16///
17/// Two roles. `afhttp host` is the long-lived browser-host: it holds Chromium and
18/// one on-disk profile, and exposes a CDP endpoint plus the ops panel. The other
19/// commands are short-lived drivers that connect to a host, do work, and write
20/// artifacts locally. Run the host where the browser needs to be and the driver
21/// wherever the agent runs.
22///
23/// Every output is one line of structured JSON; every failure carries a stable
24/// error_code. The tool never decides what a page means — the agent does.
25#[derive(Parser, Debug)]
26#[command(name = "afhttp", version, verbatim_doc_comment)]
27pub struct Cli {
28 #[command(subcommand)]
29 pub command: Command,
30}
31
32#[derive(Subcommand, Debug)]
33pub enum Command {
34 /// Run the browser host.
35 Host(crate::cli::cmd::host::Args),
36 /// Fetch a URL.
37 Fetch(Box<crate::cli::cmd::fetch::Args>),
38 /// Upload a local file to a browser tab via DOM.setFileInputFiles.
39 Upload(crate::cli::cmd::upload::Args),
40 /// Send a raw CDP method.
41 Cdp(crate::cli::cmd::cdp::Args),
42 /// Print or open the ops panel URL.
43 Ui(crate::cli::cmd::ui::Args),
44 /// Query /health.
45 Health(crate::cli::cmd::health::Args),
46 /// Query /capabilities.
47 Capabilities(crate::cli::cmd::capabilities::Args),
48 /// Local profile lifecycle commands.
49 Profile(crate::cli::cmd::profile::Args),
50 /// List and close CDP targets attached to the host.
51 Tabs(crate::cli::cmd::tabs::Args),
52 /// Install, remove, or check the embedded Agent Skill (Codex, Claude Code, opencode).
53 Skill(crate::cli::cmd::skill::Args),
54 /// Build and run the host container (Docker or Apple) from the embedded recipe.
55 Container(crate::cli::cmd::container::Args),
56}
57
58pub struct Parsed {
59 pub command: Command,
60}
61
62pub fn parse() -> Result<Parsed, Error> {
63 let cli = Cli::try_parse().map_err(|e| {
64 use clap::error::ErrorKind;
65 // `--version` and per-subcommand `--help` arrive here as clap "errors";
66 // render them to stdout and exit success rather than turning them into
67 // an invalid_argument envelope. (Top-level `--help`/`--help-markdown`
68 // are handled earlier in `cli::run`.)
69 if matches!(
70 e.kind(),
71 ErrorKind::DisplayHelp
72 | ErrorKind::DisplayVersion
73 | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
74 ) {
75 let _ = e.print();
76 std::process::exit(0);
77 }
78 // clap's error type already includes usage; surface as
79 // invalid_argument so machine consumers can branch.
80 Error::new(ErrorCode::InvalidArgument, e.to_string())
81 })?;
82 Ok(Parsed {
83 command: cli.command,
84 })
85}
86
87#[cfg(test)]
88mod tests {
89 use clap::CommandFactory;
90
91 use super::*;
92
93 #[test]
94 fn clap_command_flag_snapshot_matches() {
95 let mut snapshot = String::new();
96 write_command_snapshot(&Cli::command(), 0, &mut snapshot);
97 assert_eq!(
98 snapshot,
99 include_str!("../../tests/golden/cli-command-flags.txt")
100 );
101 }
102
103 #[test]
104 fn cli_contract_has_no_legacy_aliases() {
105 let command = Cli::command();
106 assert_eq!(command.get_subcommands().count(), 11);
107 let mut snapshot = String::new();
108 write_command_snapshot(&command, 0, &mut snapshot);
109 for forbidden in [
110 " command download\n",
111 "--profile-name",
112 concat!("profile", "_name"),
113 concat!("?", "profile="),
114 "legacy",
115 ] {
116 assert!(
117 !snapshot.contains(forbidden),
118 "CLI contract retained forbidden legacy surface {forbidden:?}: {snapshot}"
119 );
120 }
121 }
122
123 fn write_command_snapshot(cmd: &clap::Command, depth: usize, out: &mut String) {
124 let indent = " ".repeat(depth);
125 out.push_str(&format!("{indent}command {}\n", cmd.get_name()));
126 for arg in cmd.get_arguments() {
127 let long = arg
128 .get_long()
129 .map(|v| format!(" --{v}"))
130 .unwrap_or_default();
131 out.push_str(&format!("{indent} arg {}{long}\n", arg.get_id()));
132 }
133 for sub in cmd.get_subcommands() {
134 write_command_snapshot(sub, depth + 1, out);
135 }
136 }
137}