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
8pub const DISPLAY_NAME: &str = env!("DISPLAY_NAME");
9
10#[derive(Parser, Debug)]
11#[command(
12    name = env!("DISPLAY_NAME"),
13    bin_name = "afhttp",
14    version,
15    about = env!("CARGO_PKG_DESCRIPTION"),
16    long_about = concat!(env!("DISPLAY_NAME"), " - ", env!("CARGO_PKG_DESCRIPTION")),
17)]
18pub struct Cli {
19    /// Redirect stdout bytes to this file.
20    #[arg(long = "stdout-file", value_name = "PATH", global = true)]
21    pub stdout_file: Option<String>,
22
23    /// Redirect stderr bytes to this file.
24    #[arg(long = "stderr-file", value_name = "PATH", global = true)]
25    pub stderr_file: Option<String>,
26
27    #[command(subcommand)]
28    pub command: Command,
29}
30
31#[derive(Subcommand, Debug)]
32pub enum Command {
33    /// Fetch a URL.
34    Fetch(Box<crate::cli::cmd::fetch::Args>),
35    /// Run the browser host.
36    Host(crate::cli::cmd::host::Args),
37    /// Upload a local file to a browser tab via DOM.setFileInputFiles.
38    Upload(crate::cli::cmd::upload::Args),
39    /// Send a raw CDP method.
40    Cdp(crate::cli::cmd::cdp::Args),
41    /// Print a short-lived takeover URL.
42    Panel(crate::cli::cmd::panel::Args),
43    /// Query /health.
44    Health(crate::cli::cmd::health::Args),
45    /// Query /capabilities.
46    Capabilities(crate::cli::cmd::capabilities::Args),
47    /// Local profile lifecycle commands.
48    Profile(crate::cli::cmd::profile::Args),
49    /// List and close CDP targets attached to the host.
50    Tabs(crate::cli::cmd::tabs::Args),
51    /// Install, remove, or check the embedded Agent Skill (Codex, Claude Code, opencode, Hermes).
52    Skill(crate::cli::cmd::skill::Args),
53    /// Build and run the host container (Docker or Apple) from the embedded recipe.
54    Container(crate::cli::cmd::container::Args),
55}
56
57pub struct Parsed {
58    pub command: Command,
59}
60
61pub fn parse() -> Result<Parsed, Error> {
62    let cli = Cli::try_parse().map_err(|e| {
63        use clap::error::ErrorKind;
64        // `--version` and per-subcommand `--help` arrive here as clap "errors";
65        // render them to stdout and exit success rather than turning them into
66        // an invalid_argument envelope. (Top-level `--help --output ...`
67        // are handled earlier in `cli::run`.)
68        if matches!(
69            e.kind(),
70            ErrorKind::DisplayHelp
71                | ErrorKind::DisplayVersion
72                | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
73        ) {
74            let _ = e.print();
75            std::process::exit(0);
76        }
77        // clap's error type already includes usage; surface as
78        // invalid_argument so machine consumers can branch.
79        Error::new(ErrorCode::InvalidArgument, e.to_string())
80    })?;
81    let _stream_redirect_args = (&cli.stdout_file, &cli.stderr_file);
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            "  command takeover\n",
112            "  command hard-site\n",
113            "--profile-name",
114            concat!("profile", "_name"),
115            concat!("?", "profile="),
116            "arg timeout --timeout\n",
117            "arg health --health\n",
118            "arg network_redact --network-redact\n",
119            "arg takeover_quality --takeover-quality\n",
120            "legacy",
121        ] {
122            assert!(
123                !snapshot.contains(forbidden),
124                "CLI contract retained forbidden legacy surface {forbidden:?}: {snapshot}"
125            );
126        }
127    }
128
129    fn write_command_snapshot(cmd: &clap::Command, depth: usize, out: &mut String) {
130        let indent = "  ".repeat(depth);
131        out.push_str(&format!("{indent}command {}\n", cmd.get_name()));
132        for arg in cmd.get_arguments() {
133            let long = arg
134                .get_long()
135                .map(|v| format!(" --{v}"))
136                .unwrap_or_default();
137            out.push_str(&format!("{indent}  arg {}{long}\n", arg.get_id()));
138        }
139        for sub in cmd.get_subcommands() {
140            write_command_snapshot(sub, depth + 1, out);
141        }
142    }
143}