1use std::ffi::OsString;
4use std::path::{Path, PathBuf};
5use std::process::ExitCode;
6
7use agent_client_protocol::schema::v1::SessionConfigOption;
8
9mod host_integration;
10pub mod meta;
11mod slash_commands;
12mod updates;
13pub use host_integration::{
14 HostIntegration, ResumeCommand, HOST_INTEGRATION_FORMAT, SESSION_ID_PLACEHOLDER,
15};
16pub use slash_commands::{
17 is_valid_slash_command_name, sanitize_slash_command_description, validate_slash_commands,
18 SlashCommand, SlashCommandKind, MAX_SLASH_COMMANDS, MAX_SLASH_COMMAND_DESCRIPTION_CHARS,
19 MAX_SLASH_COMMAND_NAME_CHARS, SLASH_COMMANDS_FORMAT,
20};
21pub use updates::{UpdateReport, UPDATE_REPORT_FORMAT};
22
23pub const MODEL_DISCOVERY_FORMAT: &str = "acp-config-options-v1";
24pub const ACP_V1_PROTOCOL: &str = "acp-v1";
25
26#[derive(Debug)]
27pub struct ProviderError(String);
28
29impl ProviderError {
30 pub fn new(message: impl Into<String>) -> Self {
31 Self(message.into())
32 }
33}
34
35impl std::fmt::Display for ProviderError {
36 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 formatter.write_str(&self.0)
38 }
39}
40
41impl std::error::Error for ProviderError {}
42
43impl From<std::io::Error> for ProviderError {
44 fn from(error: std::io::Error) -> Self {
45 Self::new(error.to_string())
46 }
47}
48
49impl From<serde_json::Error> for ProviderError {
50 fn from(error: serde_json::Error) -> Self {
51 Self::new(error.to_string())
52 }
53}
54
55pub trait CadencrProvider {
56 fn models(
57 &self,
58 cwd: &Path,
59 provider_args: &[OsString],
60 ) -> Result<Vec<SessionConfigOption>, ProviderError>;
61 fn run_acp(&self, provider_args: &[OsString]) -> Result<ExitCode, ProviderError>;
62 fn version(&self) -> &str;
63
64 fn updates(&self) -> Result<Option<UpdateReport>, ProviderError> {
67 Ok(None)
68 }
69
70 fn update(&self, _target: Option<&str>) -> Result<Option<String>, ProviderError> {
73 Err(ProviderError::new("this provider does not manage updates"))
74 }
75
76 fn host_integration(&self) -> Result<Option<HostIntegration>, ProviderError> {
80 Ok(None)
81 }
82
83 fn slash_commands(&self, _cwd: &Path) -> Result<Option<Vec<SlashCommand>>, ProviderError> {
88 Ok(None)
89 }
90}
91
92pub fn run_cli(provider: &impl CadencrProvider) -> ExitCode {
93 match execute(provider, std::env::args_os().skip(1).collect()) {
94 Ok(code) => code,
95 Err(error) => {
96 eprintln!("{error}");
97 ExitCode::FAILURE
98 }
99 }
100}
101
102fn execute(
103 provider: &impl CadencrProvider,
104 args: Vec<OsString>,
105) -> Result<ExitCode, ProviderError> {
106 let Some(command) = args.first().and_then(|value| value.to_str()) else {
107 return Err(ProviderError::new(
108 "expected `models`, `run`, `version`, `updates`, `update`, `host-integration`, \
109 or `slash-commands`",
110 ));
111 };
112 match command {
113 "models" => execute_models(provider, &args[1..]),
114 "run" => execute_run(provider, &args[1..]),
115 "updates" => updates::execute_updates(provider, &args[1..]),
116 "update" => updates::execute_update(provider, &args[1..]),
117 "host-integration" => host_integration::execute_host_integration(provider, &args[1..]),
118 "slash-commands" => slash_commands::execute_slash_commands(provider, &args[1..]),
119 "version" if args.len() == 1 => {
120 println!("{}", provider.version());
121 Ok(ExitCode::SUCCESS)
122 }
123 _ => Err(ProviderError::new(format!(
124 "unknown or invalid provider command `{command}`"
125 ))),
126 }
127}
128
129fn execute_models(
130 provider: &impl CadencrProvider,
131 args: &[OsString],
132) -> Result<ExitCode, ProviderError> {
133 if args.len() < 4 || args[0] != "--format" || args[2] != "--cwd" {
134 return Err(ProviderError::new(format!(
135 "models requires `--format {MODEL_DISCOVERY_FORMAT} --cwd <absolute-path>`"
136 )));
137 }
138 if args[1] != MODEL_DISCOVERY_FORMAT {
139 return Err(ProviderError::new(format!(
140 "models requires `--format {MODEL_DISCOVERY_FORMAT}`"
141 )));
142 }
143 let cwd = PathBuf::from(&args[3]);
144 if !cwd.is_absolute() {
145 return Err(ProviderError::new("models `--cwd` must be absolute"));
146 }
147 serde_json::to_writer(
148 std::io::stdout().lock(),
149 &provider.models(&cwd, &args[4..])?,
150 )?;
151 println!();
152 Ok(ExitCode::SUCCESS)
153}
154
155fn execute_run(
156 provider: &impl CadencrProvider,
157 args: &[OsString],
158) -> Result<ExitCode, ProviderError> {
159 if args.len() < 2 || args[0] != "--protocol" || args[1] != ACP_V1_PROTOCOL {
160 return Err(ProviderError::new(format!(
161 "run requires `--protocol {ACP_V1_PROTOCOL}`"
162 )));
163 }
164 provider.run_acp(&args[2..])
165}
166
167#[cfg(test)]
168mod tests {
169 use super::{execute, CadencrProvider, ProviderError};
170 use agent_client_protocol::schema::v1::SessionConfigOption;
171 use std::path::Path;
172 use std::process::ExitCode;
173
174 struct FixtureProvider;
175
176 impl CadencrProvider for FixtureProvider {
177 fn models(
178 &self,
179 _cwd: &Path,
180 _provider_args: &[std::ffi::OsString],
181 ) -> Result<Vec<SessionConfigOption>, ProviderError> {
182 Ok(Vec::new())
183 }
184
185 fn run_acp(
186 &self,
187 _provider_args: &[std::ffi::OsString],
188 ) -> Result<ExitCode, ProviderError> {
189 Ok(ExitCode::SUCCESS)
190 }
191
192 fn version(&self) -> &str {
193 "1.2.3"
194 }
195 }
196
197 #[test]
198 fn rejects_missing_and_non_absolute_model_arguments() {
199 let provider = FixtureProvider;
200 assert!(execute(&provider, vec!["models".into()]).is_err());
201 assert!(execute(
202 &provider,
203 vec![
204 "models".into(),
205 "--format".into(),
206 "acp-config-options-v1".into(),
207 "--cwd".into(),
208 "relative".into(),
209 ],
210 )
211 .is_err());
212 }
213
214 #[test]
215 fn update_commands_default_to_unsupported() {
216 let updates = execute(
217 &FixtureProvider,
218 vec![
219 "updates".into(),
220 "--format".into(),
221 "cadencr-updates-v1".into(),
222 ],
223 );
224 assert_eq!(updates.unwrap(), ExitCode::SUCCESS);
225 assert!(execute(&FixtureProvider, vec!["updates".into()]).is_err());
226 assert_eq!(
227 execute(&FixtureProvider, vec!["update".into()]).unwrap(),
228 ExitCode::FAILURE
229 );
230 assert!(execute(
231 &FixtureProvider,
232 vec!["update".into(), "--version".into(), " ".into()],
233 )
234 .is_err());
235 }
236
237 #[test]
238 fn host_integration_defaults_to_none_and_checks_the_format() {
239 let command = |format: &str| {
240 execute(
241 &FixtureProvider,
242 vec!["host-integration".into(), "--format".into(), format.into()],
243 )
244 };
245 assert_eq!(
246 command("cadencr-host-integration-v1").unwrap(),
247 ExitCode::SUCCESS
248 );
249 assert!(command("cadencr-host-integration-v2").is_err());
250 assert!(execute(&FixtureProvider, vec!["host-integration".into()]).is_err());
251 }
252
253 #[test]
254 fn slash_commands_default_to_unsupported_and_check_arguments() {
255 let command = |args: &[&str]| {
256 let mut full = vec![std::ffi::OsString::from("slash-commands")];
257 full.extend(args.iter().map(std::ffi::OsString::from));
258 execute(&FixtureProvider, full)
259 };
260 let valid = ["--format", "cadencr-slash-commands-v1", "--cwd", "/repo"];
261 assert!(command(&valid).is_err());
263 assert!(command(&["--format", "cadencr-slash-commands-v2", "--cwd", "/repo"]).is_err());
264 assert!(command(&["--format", "cadencr-slash-commands-v1", "--cwd", "repo"]).is_err());
265 assert!(command(&["--format", "cadencr-slash-commands-v1"]).is_err());
266 }
267
268 #[test]
269 fn slash_commands_print_or_report_failures() {
270 struct Listing(Result<Vec<super::SlashCommand>, &'static str>);
271 impl CadencrProvider for Listing {
272 fn models(
273 &self,
274 _cwd: &Path,
275 _provider_args: &[std::ffi::OsString],
276 ) -> Result<Vec<SessionConfigOption>, ProviderError> {
277 Ok(Vec::new())
278 }
279 fn run_acp(
280 &self,
281 _provider_args: &[std::ffi::OsString],
282 ) -> Result<ExitCode, ProviderError> {
283 Ok(ExitCode::SUCCESS)
284 }
285 fn version(&self) -> &str {
286 "1.0.0"
287 }
288 fn slash_commands(
289 &self,
290 cwd: &Path,
291 ) -> Result<Option<Vec<super::SlashCommand>>, ProviderError> {
292 assert_eq!(cwd, Path::new("/repo"));
293 self.0.clone().map(Some).map_err(ProviderError::new)
294 }
295 }
296 let args = || {
297 [
298 "slash-commands",
299 "--format",
300 "cadencr-slash-commands-v1",
301 "--cwd",
302 "/repo",
303 ]
304 .map(std::ffi::OsString::from)
305 .to_vec()
306 };
307 let listed = Listing(Ok(vec![super::SlashCommand::builder()
308 .name("review")
309 .build()]));
310 assert_eq!(execute(&listed, args()).unwrap(), ExitCode::SUCCESS);
311 let failing = Listing(Err("config unreadable"));
312 assert_eq!(execute(&failing, args()).unwrap(), ExitCode::FAILURE);
313 let invalid = Listing(Ok(vec![super::SlashCommand::builder()
314 .name("/bad")
315 .build()]));
316 assert_eq!(execute(&invalid, args()).unwrap(), ExitCode::FAILURE);
317 }
318
319 #[test]
320 fn accepts_the_versioned_runtime_contract() {
321 let result = execute(
322 &FixtureProvider,
323 vec!["run".into(), "--protocol".into(), "acp-v1".into()],
324 )
325 .unwrap();
326 assert_eq!(result, ExitCode::SUCCESS);
327 }
328}