Skip to main content

caliban_plugins/
expand.rs

1//! `${CALIBAN_PLUGIN_ROOT}` (+ `${CLAUDE_PLUGIN_ROOT}` alias) expansion.
2//!
3//! Other `${VAR}` references are passed through unchanged — downstream
4//! loaders (MCP client, hooks) own their own env-var expansion rules.
5//!
6//! This module delegates the actual parsing to
7//! [`caliban_common::expand::expand_vars`] with the plugin-root binding
8//! pre-seeded and a pass-through missing-var policy so unrelated vars
9//! survive untouched.
10
11use std::path::Path;
12
13use caliban_common::expand::{ExpandContext, MissingPolicy, expand_vars};
14
15/// Recognized aliases for the plugin root variable.
16pub const PLUGIN_ROOT_VARS: &[&str] = &["CALIBAN_PLUGIN_ROOT", "CLAUDE_PLUGIN_ROOT"];
17
18fn plugin_ctx(plugin_root: &Path) -> ExpandContext {
19    let root = plugin_root.to_string_lossy().into_owned();
20    let mut ctx = ExpandContext {
21        // Plugins file expansion never accepts `:-default` syntax — preserve
22        // the original behavior of pass-through-as-literal for that case.
23        allow_default: false,
24        missing_policy: MissingPolicy::PassThrough,
25        ..Default::default()
26    };
27    for v in PLUGIN_ROOT_VARS {
28        ctx.set(*v, root.clone());
29    }
30    ctx
31}
32
33/// Replace every occurrence of `${CALIBAN_PLUGIN_ROOT}` and the
34/// `${CLAUDE_PLUGIN_ROOT}` alias in `s` with the plugin's absolute path.
35/// Other `${VAR}` references are passed through untouched.
36#[must_use]
37pub fn expand(s: &str, plugin_root: &Path) -> String {
38    let ctx = plugin_ctx(plugin_root);
39    // The only error case is `UnclosedBrace`. Preserve historical behavior
40    // (copy the broken tail literally) by falling back to the input.
41    expand_vars(s, &ctx).unwrap_or_else(|_| {
42        // Emit everything up to the first `${` and then the broken tail
43        // literally — matches the previous hand-rolled impl.
44        let mut out = String::with_capacity(s.len());
45        if let Some(idx) = s.find("${") {
46            out.push_str(&s[..idx]);
47            out.push_str(&s[idx..]);
48        } else {
49            out.push_str(s);
50        }
51        out
52    })
53}
54
55/// In-place expand every string in a `serde_json::Value` tree (objects,
56/// arrays, and string scalars). Numbers and booleans are left alone.
57/// Useful for stamping hook config / mcp config snippets.
58pub fn expand_json_in_place(v: &mut serde_json::Value, plugin_root: &Path) {
59    match v {
60        serde_json::Value::String(s) => {
61            let new = expand(s, plugin_root);
62            *s = new;
63        }
64        serde_json::Value::Array(arr) => {
65            for child in arr.iter_mut() {
66                expand_json_in_place(child, plugin_root);
67            }
68        }
69        serde_json::Value::Object(map) => {
70            for (_, val) in map.iter_mut() {
71                expand_json_in_place(val, plugin_root);
72            }
73        }
74        _ => {}
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use std::path::Path;
82
83    #[test]
84    fn expands_caliban_plugin_root() {
85        let s = "${CALIBAN_PLUGIN_ROOT}/bin/x";
86        let out = expand(s, Path::new("/p/demo"));
87        assert_eq!(out, "/p/demo/bin/x");
88    }
89
90    #[test]
91    fn expands_claude_plugin_root_alias() {
92        let s = "${CLAUDE_PLUGIN_ROOT}/bin/x";
93        let out = expand(s, Path::new("/p/demo"));
94        assert_eq!(out, "/p/demo/bin/x");
95    }
96
97    #[test]
98    fn passes_through_unrelated_vars() {
99        let s = "${HOME}/keys/${CALIBAN_PLUGIN_ROOT}/bin";
100        let out = expand(s, Path::new("/p/demo"));
101        assert_eq!(out, "${HOME}/keys//p/demo/bin");
102    }
103
104    #[test]
105    fn no_braces_returns_input() {
106        let s = "no vars here";
107        assert_eq!(expand(s, Path::new("/p/demo")), s);
108    }
109
110    #[test]
111    fn unclosed_brace_passes_through() {
112        let s = "broken ${UNCLOSED";
113        let out = expand(s, Path::new("/p/demo"));
114        assert_eq!(out, "broken ${UNCLOSED");
115    }
116
117    #[test]
118    fn expands_nested_json_strings() {
119        let mut v: serde_json::Value = serde_json::json!({
120            "command": "${CALIBAN_PLUGIN_ROOT}/bin/srv",
121            "args": ["--root", "${CLAUDE_PLUGIN_ROOT}"],
122            "nested": { "path": "${CALIBAN_PLUGIN_ROOT}/sub" }
123        });
124        expand_json_in_place(&mut v, Path::new("/p/demo"));
125        assert_eq!(v["command"], "/p/demo/bin/srv");
126        assert_eq!(v["args"][1], "/p/demo");
127        assert_eq!(v["nested"]["path"], "/p/demo/sub");
128    }
129}