Skip to main content

acp_agent/commands/
mod.rs

1use std::io::Write;
2use std::process::ExitStatus;
3
4use crate::runtime::serve::ServeTransport;
5use anyhow::Context;
6use clap::{Parser, Subcommand};
7
8/// Agent installation entrypoints and install outcome types.
9pub mod install;
10/// Runtime dependency detection and optional bootstrap installers.
11pub mod install_env;
12/// Registry listing output helpers.
13pub mod list;
14/// Stdio execution helpers for launching an agent directly.
15pub mod run;
16/// Registry search output helpers.
17pub mod search;
18/// Network-serving helpers that wrap the runtime transport layer.
19pub mod serve;
20
21/// High-level CLI entrypoint shared by the binaries in this crate.
22///
23/// `acp-agent` exposes discover/install/run/serve operations for agents
24/// hosted in the public registry. The CLI delegates the actual work to
25/// the helpers defined in the sibling modules so that tests can exercise them
26/// programmatically.
27
28/// CLI arguments consumed by the `acp-agent` binary.
29///
30/// The parser is intentionally thin: it only captures which subcommand the user
31/// invoked so that `execute_cli` can route to the appropriate handler and keep
32/// the binary minimal while still exposing helpers for other callers.
33#[derive(Debug, Parser)]
34#[command(
35    name = "acp-agent",
36    version,
37    about = "Install, discover, run, and serve ACP agents from the public registry."
38)]
39pub struct Cli {
40    #[command(subcommand)]
41    command: Commands,
42}
43
44#[derive(Debug, Subcommand)]
45enum Commands {
46    List,
47    Search {
48        agent_id: String,
49    },
50    InstallEnv {
51        #[arg(short = 'y', long = "yes")]
52        yes: bool,
53    },
54    Install {
55        agent_id: String,
56    },
57    #[command(about = "Run an ACP agent over stdio.")]
58    Run {
59        agent_id: String,
60        #[arg(help = "Arguments passed through to the agent process.")]
61        args: Vec<String>,
62    },
63    #[command(
64        about = "Serve an ACP agent over a network transport.",
65        trailing_var_arg = true
66    )]
67    Serve {
68        agent_id: String,
69        #[arg(
70            long,
71            default_value = "http",
72            help = "Network transport to expose (http, tcp, or ws)."
73        )]
74        transport: ServeTransport,
75        #[arg(long, default_value = "127.0.0.1")]
76        host: String,
77        #[arg(long, default_value_t = 0)]
78        port: u16,
79        #[arg(help = "Arguments passed through to the agent process.")]
80        #[arg(allow_hyphen_values = true)]
81        args: Vec<String>,
82    },
83}
84
85/// Normalized exit status returned by `execute_cli`.
86///
87/// CLI callers still receive an `anyhow::Result`, but this enum represents the
88/// exit code that should be returned to the OS if `execute_cli` succeeds. A
89/// `Success` value maps to `0`, whereas `Code` preserves a non-zero process
90/// status (including signals on Unix).
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum CliExit {
93    /// The command completed successfully and should exit with code `0`.
94    Success,
95    /// The command completed and wants the process to exit with the provided code.
96    Code(i32),
97}
98
99/// Dispatches the parsed `Cli` to the concrete command handlers.
100///
101/// The function mirrors the binary’s subcommand list so that the CLI can be
102/// exercised from tests or other binaries by composing `Cli` + a writer that,
103/// for example, records output instead of writing to `stdout`.
104pub async fn execute_cli<W: Write>(cli: Cli, writer: &mut W) -> anyhow::Result<CliExit> {
105    match cli.command {
106        Commands::List => {
107            list::list_agents(writer)
108                .await
109                .with_context(|| "failed to list registry agents".to_string())?;
110            Ok(CliExit::Success)
111        }
112        Commands::Search { agent_id } => {
113            search::search_agents(&agent_id, writer)
114                .await
115                .with_context(|| format!("failed to search registry agents for \"{agent_id}\""))?;
116            Ok(CliExit::Success)
117        }
118        Commands::InstallEnv { yes } => {
119            install_env::install_env(writer, yes)
120                .await
121                .with_context(|| "failed to install environment dependencies".to_string())?;
122            Ok(CliExit::Success)
123        }
124        Commands::Install { agent_id } => {
125            let outcome = install::install_agent(&agent_id)
126                .await
127                .with_context(|| format!("failed to install agent \"{agent_id}\""))?;
128            writeln!(writer, "{outcome}")?;
129            Ok(CliExit::Success)
130        }
131        Commands::Run { agent_id, args } => {
132            let status = run::run_agent(&agent_id, &args)
133                .await
134                .with_context(|| format!("failed to run agent \"{agent_id}\""))?;
135            Ok(exit_from_status(status))
136        }
137        Commands::Serve {
138            agent_id,
139            transport,
140            host,
141            port,
142            args,
143        } => {
144            let status = serve::serve_agent(&agent_id, transport, host, port, &args)
145                .await
146                .with_context(|| format!("failed to serve agent \"{agent_id}\""))?;
147            Ok(exit_from_status(status))
148        }
149    }
150}
151
152/// Converts process `ExitStatus` into the API-friendly `CliExit` representation.
153fn exit_from_status(status: ExitStatus) -> CliExit {
154    if status.success() {
155        return CliExit::Success;
156    }
157
158    if let Some(code) = status.code() {
159        return CliExit::Code(code);
160    }
161
162    CliExit::Code(signal_exit_code(status))
163}
164
165#[cfg(unix)]
166fn signal_exit_code(status: ExitStatus) -> i32 {
167    use std::os::unix::process::ExitStatusExt;
168
169    status.signal().map_or(1, |signal| 128 + signal)
170}
171
172#[cfg(not(unix))]
173fn signal_exit_code(_: ExitStatus) -> i32 {
174    1
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use clap::error::ErrorKind;
181
182    #[test]
183    fn parses_install_subcommand() {
184        let cli = Cli::try_parse_from(["acp-agent", "install", "demo-agent"]).unwrap();
185
186        match cli.command {
187            Commands::Install { agent_id } => assert_eq!(agent_id, "demo-agent"),
188            command => panic!("unexpected command: {command:?}"),
189        }
190    }
191
192    #[test]
193    fn parses_list_subcommand() {
194        let cli = Cli::try_parse_from(["acp-agent", "list"]).unwrap();
195
196        match cli.command {
197            Commands::List => {}
198            command => panic!("unexpected command: {command:?}"),
199        }
200    }
201
202    #[test]
203    fn parses_search_subcommand() {
204        let cli = Cli::try_parse_from(["acp-agent", "search", "demo"]).unwrap();
205
206        match cli.command {
207            Commands::Search { agent_id } => assert_eq!(agent_id, "demo"),
208            command => panic!("unexpected command: {command:?}"),
209        }
210    }
211
212    #[test]
213    fn parses_install_env_subcommand() {
214        let cli = Cli::try_parse_from(["acp-agent", "install-env"]).unwrap();
215
216        match cli.command {
217            Commands::InstallEnv { yes } => assert!(!yes),
218            command => panic!("unexpected command: {command:?}"),
219        }
220    }
221
222    #[test]
223    fn parses_install_env_subcommand_with_yes_flag() {
224        let cli = Cli::try_parse_from(["acp-agent", "install-env", "-y"]).unwrap();
225
226        match cli.command {
227            Commands::InstallEnv { yes } => assert!(yes),
228            command => panic!("unexpected command: {command:?}"),
229        }
230    }
231
232    #[test]
233    fn parses_run_subcommand_with_model_args() {
234        let cli = Cli::try_parse_from(["acp-agent", "run", "demo-agent", "--", "--model", "gpt-5"])
235            .unwrap();
236
237        match cli.command {
238            Commands::Run { agent_id, args } => {
239                assert_eq!(agent_id, "demo-agent");
240                assert_eq!(args, vec!["--model", "gpt-5"]);
241            }
242            command => panic!("unexpected command: {command:?}"),
243        }
244    }
245
246    #[test]
247    fn parses_serve_subcommand_with_defaults() {
248        let cli = Cli::try_parse_from(["acp-agent", "serve", "demo-agent"]).unwrap();
249
250        match cli.command {
251            Commands::Serve {
252                agent_id,
253                transport,
254                host,
255                port,
256                args,
257            } => {
258                assert_eq!(agent_id, "demo-agent");
259                assert_eq!(transport, ServeTransport::Http);
260                assert_eq!(host, "127.0.0.1");
261                assert_eq!(port, 0);
262                assert!(args.is_empty());
263            }
264            command => panic!("unexpected command: {command:?}"),
265        }
266    }
267
268    #[test]
269    fn parses_serve_subcommand_with_explicit_options() {
270        let cli = Cli::try_parse_from([
271            "acp-agent",
272            "serve",
273            "demo-agent",
274            "--transport",
275            "ws",
276            "--host",
277            "0.0.0.0",
278            "--port",
279            "8010",
280            "--",
281            "--model",
282            "gpt-6",
283        ])
284        .unwrap();
285
286        match cli.command {
287            Commands::Serve {
288                transport,
289                host,
290                port,
291                args,
292                ..
293            } => {
294                assert_eq!(transport, ServeTransport::Ws);
295                assert_eq!(host, "0.0.0.0");
296                assert_eq!(port, 8010);
297                assert_eq!(args, vec!["--model", "gpt-6"]);
298            }
299            command => panic!("unexpected command: {command:?}"),
300        }
301    }
302
303    #[test]
304    fn parses_serve_subcommand_with_tcp_transport() {
305        let cli = Cli::try_parse_from(["acp-agent", "serve", "demo-agent", "--transport", "tcp"])
306            .unwrap();
307
308        match cli.command {
309            Commands::Serve { transport, .. } => assert_eq!(transport, ServeTransport::Tcp),
310            command => panic!("unexpected command: {command:?}"),
311        }
312    }
313
314    #[test]
315    fn rejects_serve_subcommand_with_stdio_transport() {
316        let error =
317            Cli::try_parse_from(["acp-agent", "serve", "demo-agent", "--transport", "stdio"])
318                .unwrap_err();
319
320        assert_eq!(error.kind(), ErrorKind::InvalidValue);
321    }
322
323    #[test]
324    fn exit_from_status_returns_success_for_zero_exit() {
325        assert_eq!(exit_from_status(success_exit_status()), CliExit::Success);
326    }
327
328    #[test]
329    fn exit_from_status_returns_process_code_for_non_zero_exit() {
330        assert_eq!(exit_from_status(exit_status_with_code(5)), CliExit::Code(5));
331    }
332
333    #[cfg(unix)]
334    #[test]
335    fn exit_from_status_returns_signal_convention_for_signal_exit() {
336        assert_eq!(exit_from_status(signal_exit_status(15)), CliExit::Code(143));
337    }
338
339    fn success_exit_status() -> ExitStatus {
340        exit_status_with_code(0)
341    }
342
343    #[cfg(unix)]
344    fn exit_status_with_code(code: i32) -> ExitStatus {
345        use std::os::unix::process::ExitStatusExt;
346
347        ExitStatus::from_raw(code << 8)
348    }
349
350    #[cfg(windows)]
351    fn exit_status_with_code(code: i32) -> ExitStatus {
352        use std::os::windows::process::ExitStatusExt;
353
354        ExitStatus::from_raw(code as u32)
355    }
356
357    #[cfg(unix)]
358    fn signal_exit_status(signal: i32) -> ExitStatus {
359        use std::os::unix::process::ExitStatusExt;
360
361        ExitStatus::from_raw(signal)
362    }
363}