Skip to main content

atd_protocol/
cli_binding.rs

1//! Canonical typed config shape for `BindingProtocol::Cli` (SP-cli-binding-v2).
2//!
3//! `ToolBinding.config` is wire-level `serde_json::Value` — kept untyped so
4//! third-party binding implementations can carry their own config without
5//! every change requiring a protocol bump. The CLI binding has a recurring
6//! shape that's worth codifying as a typed sibling, so that:
7//!
8//! 1. ATD servers wrapping arbitrary CLI tools (kubectl / gh / mycli /
9//!    healthkit_cli) can describe their binding declaratively rather than
10//!    each writing custom dispatch logic.
11//! 2. Adopter CLI authors get a stable target schema to populate manifests
12//!    against.
13//! 3. A future `SP-cli-dispatcher-v1` can read this typed shape from
14//!    `ToolBinding.config` and spawn subprocesses without each server
15//!    re-deriving the field meanings.
16//!
17//! ## Wire compatibility
18//!
19//! The v1-era minimal config `{"cmd": "cat"}` parses unchanged through
20//! [`CliBindingConfig::from_value`]; every new field uses
21//! `skip_serializing_if` so default values stay off the wire. Pre-v2
22//! parsers (`serde_json::Value`-typed consumers) see unchanged bytes for
23//! configs that haven't started using the v2 fields.
24
25use serde::{Deserialize, Serialize};
26use std::collections::BTreeMap;
27
28/// Canonical CLI binding config (SP-cli-binding-v2).
29///
30/// All fields except `cmd` are optional and use `skip_serializing_if` so
31/// existing `{"cmd": "..."}` configs from before SP-cli-binding-v2 still
32/// round-trip byte-identically.
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
35pub struct CliBindingConfig {
36    /// Executable name or absolute path. Required.
37    pub cmd: String,
38
39    /// Fixed prefix arguments prepended to `args_template` expansion.
40    /// Useful for subcommand routing (e.g. `["api", "v1"]` so a tool
41    /// always invokes `cmd api v1 <rendered template>`).
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    pub args: Vec<String>,
44
45    /// Templated argv tail. Placeholders are replaced before spawn:
46    ///
47    /// | Placeholder | Substitution |
48    /// |---|---|
49    /// | `{tool_id}` | ATD tool id, e.g. `mycli:gmail.users.messages.list` |
50    /// | `{params_json}` | `RunTool.args` serialized as one JSON argv slot (quoting handled by the dispatcher) |
51    /// | `{dry_run}` | Empty string when `dry_run=false`; the value of [`Self::dry_run_flag`] when `dry_run=true` |
52    /// | `{page_all}` | Empty string normally; [`Self::page_all_flag`] when the dispatcher is fanning out for `call_all` |
53    ///
54    /// The split between `cmd` + `args` (fixed) and `args_template`
55    /// (per-call) lets servers reuse one binding for many tool ids by
56    /// changing only the template. Servers are free to extend the
57    /// placeholder vocabulary; consumers that don't recognize a
58    /// placeholder should pass it through literally.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub args_template: Option<String>,
61
62    /// Environment variables injected into the spawned process.
63    ///
64    /// The literal value `"$ATD_BEARER"` opts into bearer-token
65    /// passthrough — the dispatcher replaces it with the connection's
66    /// bearer (from the SP-12 `hello` handshake) at spawn time. Other
67    /// `$NAME` syntax is not interpolated; values are passed verbatim.
68    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
69    pub env: BTreeMap<String, String>,
70
71    /// How to parse the subprocess stdout into the `ToolResult.data`
72    /// field. Defaults to [`CliOutputFormat::Json`] when absent.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub output_format: Option<CliOutputFormat>,
75
76    /// CLI-side flag the dispatcher appends when fanning out for
77    /// `call_all` pagination. Absent = tool doesn't support pagination;
78    /// the dispatcher MUST surface `call_page` / `call_all` requests as
79    /// unsupported instead of silently calling the tool without paging.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub page_all_flag: Option<String>,
82
83    /// CLI-side flag passed when the ATD client sets `dry_run: true` on
84    /// `RunTool`. Absent = tool doesn't expose dry-run; the dispatcher
85    /// MUST reject the call rather than silently execute the side
86    /// effect.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub dry_run_flag: Option<String>,
89
90    /// Process exit-code → ATD `ToolResult.code` mapping for the error
91    /// envelope. Codes not present in the map default to
92    /// `"TOOL_FAILED"`; exit code 0 always succeeds and never consults
93    /// the map. String values are domain-specific (e.g.
94    /// `"auth_required"` for an OAuth refresh, `"invalid_args"` for
95    /// CLI-side validation errors).
96    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
97    pub exit_code_map: BTreeMap<i32, String>,
98}
99
100/// How to parse subprocess stdout into the ATD `ToolResult.data` payload.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
102#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
103#[serde(rename_all = "snake_case")]
104pub enum CliOutputFormat {
105    /// stdout is a single JSON document. Default.
106    #[default]
107    Json,
108    /// stdout is newline-delimited JSON. Each line becomes one page on
109    /// the wire (when paginated) or is concatenated into a JSON array
110    /// (when called as a single-shot tool).
111    Ndjson,
112    /// stdout is plain text lines. The dispatcher wraps the output as
113    /// `{"lines": [...], "stderr": "..."}` for the `ToolResult.data`
114    /// payload.
115    Lines,
116}
117
118/// Errors from [`CliBindingConfig::from_value`].
119#[derive(Debug, thiserror::Error)]
120pub enum CliBindingConfigError {
121    #[error("CLI binding config is not a JSON object: {0}")]
122    NotAnObject(String),
123    #[error("CLI binding config missing required field `cmd`")]
124    MissingCmd,
125    #[error("CLI binding config invalid: {0}")]
126    Invalid(#[from] serde_json::Error),
127}
128
129impl CliBindingConfig {
130    /// Parse from a [`crate::tool::ToolBinding::config`] value. Returns
131    /// `Err` when `cmd` is missing or other fields fail to deserialize.
132    /// Unknown fields are tolerated (serde's default) so future
133    /// `SP-cli-binding-v3` additions don't break v2 parsers.
134    pub fn from_value(v: &serde_json::Value) -> Result<Self, CliBindingConfigError> {
135        if !v.is_object() {
136            return Err(CliBindingConfigError::NotAnObject(v.to_string()));
137        }
138        if v.get("cmd").and_then(|c| c.as_str()).is_none() {
139            return Err(CliBindingConfigError::MissingCmd);
140        }
141        let parsed: Self = serde_json::from_value(v.clone())?;
142        Ok(parsed)
143    }
144
145    /// Serialize to a `serde_json::Value` suitable for
146    /// [`crate::tool::ToolBinding::config`].
147    pub fn to_value(&self) -> serde_json::Value {
148        serde_json::to_value(self).expect("CliBindingConfig is always serializable")
149    }
150}
151
152impl crate::tool::ToolBinding {
153    /// Convenience: if this binding is `Cli`, parse its config as the
154    /// canonical [`CliBindingConfig`] shape. Returns `Ok(None)` for
155    /// non-CLI bindings; returns `Err` if the protocol is `Cli` but the
156    /// config doesn't match the canonical shape.
157    pub fn cli_config(&self) -> Result<Option<CliBindingConfig>, CliBindingConfigError> {
158        if !matches!(self.protocol, crate::enums::BindingProtocol::Cli) {
159            return Ok(None);
160        }
161        CliBindingConfig::from_value(&self.config).map(Some)
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::enums::BindingProtocol;
169    use crate::tool::ToolBinding;
170    use serde_json::json;
171
172    fn minimal_v1() -> serde_json::Value {
173        // The pre-SP-cli-binding-v2 wire shape — `{"cmd": "cat"}` alone.
174        json!({"cmd": "cat"})
175    }
176
177    fn full_v2() -> CliBindingConfig {
178        CliBindingConfig {
179            cmd: "mycli".into(),
180            args: vec!["api".into(), "v1".into()],
181            args_template: Some("{tool_id} --params {params_json} {dry_run}".into()),
182            env: BTreeMap::from([("MYCLI_TOKEN".into(), "$ATD_BEARER".into())]),
183            output_format: Some(CliOutputFormat::Json),
184            page_all_flag: Some("--page-all".into()),
185            dry_run_flag: Some("--dry-run".into()),
186            exit_code_map: BTreeMap::from([
187                (2, "auth_required".into()),
188                (3, "invalid_args".into()),
189            ]),
190        }
191    }
192
193    #[test]
194    fn pre_v2_minimal_config_round_trips_byte_identical() {
195        let parsed = CliBindingConfig::from_value(&minimal_v1()).unwrap();
196        assert_eq!(parsed.cmd, "cat");
197        assert!(parsed.args.is_empty());
198        assert!(parsed.args_template.is_none());
199        // Round-trip back to JSON must produce the same minimal shape —
200        // this is the load-bearing back-compat guarantee.
201        let back = parsed.to_value();
202        assert_eq!(back, minimal_v1());
203    }
204
205    #[test]
206    fn v2_full_config_round_trips() {
207        let cfg = full_v2();
208        let v = cfg.to_value();
209        let back = CliBindingConfig::from_value(&v).unwrap();
210        assert_eq!(back, cfg);
211    }
212
213    #[test]
214    fn parse_rejects_non_object() {
215        let err = CliBindingConfig::from_value(&json!(42)).unwrap_err();
216        assert!(matches!(err, CliBindingConfigError::NotAnObject(_)));
217    }
218
219    #[test]
220    fn parse_rejects_missing_cmd() {
221        let err = CliBindingConfig::from_value(&json!({"args": ["x"]})).unwrap_err();
222        assert!(matches!(err, CliBindingConfigError::MissingCmd));
223    }
224
225    #[test]
226    fn output_format_snake_case() {
227        assert_eq!(
228            serde_json::to_string(&CliOutputFormat::Ndjson).unwrap(),
229            "\"ndjson\""
230        );
231        assert_eq!(
232            serde_json::to_string(&CliOutputFormat::Lines).unwrap(),
233            "\"lines\""
234        );
235        assert_eq!(
236            serde_json::to_string(&CliOutputFormat::Json).unwrap(),
237            "\"json\""
238        );
239    }
240
241    #[test]
242    fn output_format_default_is_json() {
243        assert_eq!(CliOutputFormat::default(), CliOutputFormat::Json);
244    }
245
246    #[test]
247    fn empty_collections_skip_serialization() {
248        let cfg = CliBindingConfig {
249            cmd: "x".into(),
250            ..Default::default()
251        };
252        let s = serde_json::to_string(&cfg).unwrap();
253        assert_eq!(s, "{\"cmd\":\"x\"}");
254    }
255
256    #[test]
257    fn parse_tolerates_unknown_fields_for_forward_compat() {
258        // Future fields added by SP-cli-binding-v3 should not break v2
259        // parsers. (serde ignores unknown fields by default — verify it.)
260        let v = json!({
261            "cmd": "x",
262            "future_field": {"foo": "bar"},
263        });
264        let parsed = CliBindingConfig::from_value(&v).unwrap();
265        assert_eq!(parsed.cmd, "x");
266    }
267
268    #[test]
269    fn tool_binding_helper_cli_returns_some() {
270        let b = ToolBinding {
271            protocol: BindingProtocol::Cli,
272            config: full_v2().to_value(),
273        };
274        let parsed = b.cli_config().unwrap().expect("expected Some");
275        assert_eq!(parsed, full_v2());
276    }
277
278    #[test]
279    fn tool_binding_helper_non_cli_returns_none() {
280        let b = ToolBinding {
281            protocol: BindingProtocol::Mcp,
282            config: json!({"endpoint": "..."}),
283        };
284        assert!(b.cli_config().unwrap().is_none());
285    }
286
287    #[test]
288    fn tool_binding_helper_cli_with_bad_config_errors() {
289        let b = ToolBinding {
290            protocol: BindingProtocol::Cli,
291            config: json!({"no_cmd": "oops"}),
292        };
293        let err = b.cli_config().unwrap_err();
294        assert!(matches!(err, CliBindingConfigError::MissingCmd));
295    }
296
297    #[test]
298    fn exit_code_map_serialization_key_is_string() {
299        // BTreeMap<i32, String> serializes to an object with string
300        // keys per JSON convention. Verify the wire shape is what the
301        // dispatcher will see.
302        let cfg = CliBindingConfig {
303            cmd: "x".into(),
304            exit_code_map: BTreeMap::from([(2, "auth".into()), (3, "args".into())]),
305            ..Default::default()
306        };
307        let v = cfg.to_value();
308        let map = v["exit_code_map"].as_object().unwrap();
309        assert_eq!(map.get("2").unwrap(), "auth");
310        assert_eq!(map.get("3").unwrap(), "args");
311    }
312}