Skip to main content

cadencr_provider_sdk/
lib.rs

1//! SDK and CLI harness for code-backed Cadencr provider packages.
2
3use 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    /// Versions of whatever this connector can upgrade (usually its native
65    /// CLI). `None` (the default) tells Cadencr updates are not managed here.
66    fn updates(&self) -> Result<Option<UpdateReport>, ProviderError> {
67        Ok(None)
68    }
69
70    /// Install `target` (or the latest version when `None`). Returns an
71    /// optional human-readable summary shown to the user.
72    fn update(&self, _target: Option<&str>) -> Result<Option<String>, ProviderError> {
73        Err(ProviderError::new("this provider does not manage updates"))
74    }
75
76    /// Host conventions this provider opts into (compaction command, worktree
77    /// configuration paths, terminal resume command). `None` (the default)
78    /// prints `{}`: no host integration.
79    fn host_integration(&self) -> Result<Option<HostIntegration>, ProviderError> {
80        Ok(None)
81    }
82
83    /// The `/name` commands and skills the native agent accepts in a prompt
84    /// for `cwd`, matching what its ACP `available_commands_update` would
85    /// list there. `None` (the default) exits non-zero: Cadencr then shows
86    /// the list the last live session advertised for that directory.
87    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        // Unsupported is an error without stdout, like an older package.
262        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}