Skip to main content

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}