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}