1use std::io::Write;
2use std::process::ExitStatus;
3
4use crate::runtime::serve::ServeTransport;
5use anyhow::Context;
6use clap::{Parser, Subcommand};
7
8pub mod install;
10pub mod install_env;
12pub mod list;
14pub mod run;
16pub mod search;
18pub mod serve;
20
21#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum CliExit {
93 Success,
95 Code(i32),
97}
98
99pub 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
152fn 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}