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 /// Prepare a browser tab for human takeover.
45 Takeover(crate::cli::cmd::takeover::Args),
46 /// Query /health.
47 Health(crate::cli::cmd::health::Args),
48 /// Query /capabilities.
49 Capabilities(crate::cli::cmd::capabilities::Args),
50 /// Local profile lifecycle commands.
51 Profile(crate::cli::cmd::profile::Args),
52 /// List and close CDP targets attached to the host.
53 Tabs(crate::cli::cmd::tabs::Args),
54 /// Install, remove, or check the embedded Agent Skill (Codex, Claude Code, opencode).
55 Skill(crate::cli::cmd::skill::Args),
56 /// Build and run the host container (Docker or Apple) from the embedded recipe.
57 Container(crate::cli::cmd::container::Args),
58}
59
60pub struct Parsed {
61 pub command: Command,
62}
63
64pub fn parse() -> Result<Parsed, Error> {
65 let cli = Cli::try_parse().map_err(|e| {
66 use clap::error::ErrorKind;
67 // `--version` and per-subcommand `--help` arrive here as clap "errors";
68 // render them to stdout and exit success rather than turning them into
69 // an invalid_argument envelope. (Top-level `--help --output ...`
70 // are handled earlier in `cli::run`.)
71 if matches!(
72 e.kind(),
73 ErrorKind::DisplayHelp
74 | ErrorKind::DisplayVersion
75 | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
76 ) {
77 let _ = e.print();
78 std::process::exit(0);
79 }
80 // clap's error type already includes usage; surface as
81 // invalid_argument so machine consumers can branch.
82 Error::new(ErrorCode::InvalidArgument, e.to_string())
83 })?;
84 Ok(Parsed {
85 command: cli.command,
86 })
87}
88
89#[cfg(test)]
90mod tests {
91 use clap::CommandFactory;
92
93 use super::*;
94
95 #[test]
96 fn clap_command_flag_snapshot_matches() {
97 let mut snapshot = String::new();
98 write_command_snapshot(&Cli::command(), 0, &mut snapshot);
99 assert_eq!(
100 snapshot,
101 include_str!("../../tests/golden/cli-command-flags.txt")
102 );
103 }
104
105 #[test]
106 fn cli_contract_has_no_legacy_aliases() {
107 let command = Cli::command();
108 assert_eq!(command.get_subcommands().count(), 12);
109 let mut snapshot = String::new();
110 write_command_snapshot(&command, 0, &mut snapshot);
111 for forbidden in [
112 " command download\n",
113 "--profile-name",
114 concat!("profile", "_name"),
115 concat!("?", "profile="),
116 "arg timeout --timeout\n",
117 "legacy",
118 ] {
119 assert!(
120 !snapshot.contains(forbidden),
121 "CLI contract retained forbidden legacy surface {forbidden:?}: {snapshot}"
122 );
123 }
124 }
125
126 fn write_command_snapshot(cmd: &clap::Command, depth: usize, out: &mut String) {
127 let indent = " ".repeat(depth);
128 out.push_str(&format!("{indent}command {}\n", cmd.get_name()));
129 for arg in cmd.get_arguments() {
130 let long = arg
131 .get_long()
132 .map(|v| format!(" --{v}"))
133 .unwrap_or_default();
134 out.push_str(&format!("{indent} arg {}{long}\n", arg.get_id()));
135 }
136 for sub in cmd.get_subcommands() {
137 write_command_snapshot(sub, depth + 1, out);
138 }
139 }
140}