Skip to main content

openclaw_plugins/
bridge.rs

1//! TypeScript plugin bridge via IPC.
2
3use std::path::{Path, PathBuf};
4use std::process::Child;
5
6use async_trait::async_trait;
7use openclaw_ipc::{IpcMessage, IpcTransport};
8
9use crate::api::{Plugin, PluginError, PluginHook};
10
11/// Bridge to existing TypeScript plugins.
12pub struct TsPluginBridge {
13    transport: Option<IpcTransport>,
14    plugins_dir: PathBuf,
15    child_process: Option<Child>,
16    ipc_address: String,
17    manifest: Option<SkillManifest>,
18}
19
20impl TsPluginBridge {
21    /// Create a new bridge (not connected).
22    #[must_use]
23    pub fn new(plugins_dir: &Path) -> Self {
24        Self {
25            transport: None,
26            plugins_dir: plugins_dir.to_path_buf(),
27            child_process: None,
28            ipc_address: IpcTransport::default_address(),
29            manifest: None,
30        }
31    }
32
33    /// Set a custom IPC address.
34    #[must_use]
35    pub fn with_address(mut self, address: impl Into<String>) -> Self {
36        self.ipc_address = address.into();
37        self
38    }
39
40    /// Connect to an already-running TypeScript plugin host.
41    ///
42    /// # Errors
43    ///
44    /// Returns error if connection fails.
45    pub fn connect(&mut self, address: &str) -> Result<(), PluginError> {
46        let transport = IpcTransport::new_client(address, std::time::Duration::from_secs(30))
47            .map_err(|e| PluginError::Ipc(e.to_string()))?;
48        self.transport = Some(transport);
49        Ok(())
50    }
51
52    /// Spawn the TypeScript plugin host process and connect.
53    ///
54    /// This looks for a `plugin-host.js` or `plugin-host.ts` entry point
55    /// in the plugins directory and runs it with Node.js or Bun.
56    ///
57    /// # Errors
58    ///
59    /// Returns error if process spawn or connection fails.
60    pub fn spawn_and_connect(&mut self) -> Result<(), PluginError> {
61        let entry_point = self.find_entry_point()?;
62        let runtime = self.find_runtime();
63
64        tracing::info!(
65            runtime = %runtime,
66            entry = %entry_point.display(),
67            address = %self.ipc_address,
68            "Spawning TypeScript plugin host"
69        );
70
71        let child = std::process::Command::new(&runtime)
72            .arg(&entry_point)
73            .env("OPENCLAW_IPC_ADDRESS", &self.ipc_address)
74            .env("OPENCLAW_PLUGINS_DIR", &self.plugins_dir)
75            .stdout(std::process::Stdio::piped())
76            .stderr(std::process::Stdio::piped())
77            .spawn()
78            .map_err(|e| PluginError::LoadFailed(format!("Failed to spawn plugin host: {e}")))?;
79
80        self.child_process = Some(child);
81
82        // Wait briefly for the process to start its IPC server
83        std::thread::sleep(std::time::Duration::from_millis(500));
84
85        // Connect as client
86        self.connect(&self.ipc_address.clone())?;
87
88        // Load skill manifest
89        self.manifest = self.load_skills().ok();
90
91        Ok(())
92    }
93
94    /// Find the plugin host entry point.
95    fn find_entry_point(&self) -> Result<PathBuf, PluginError> {
96        let candidates = [
97            "plugin-host.js",
98            "plugin-host.ts",
99            "index.js",
100            "index.ts",
101            "host.js",
102            "host.ts",
103        ];
104
105        for name in &candidates {
106            let path = self.plugins_dir.join(name);
107            if path.exists() {
108                return Ok(path);
109            }
110        }
111
112        Err(PluginError::LoadFailed(format!(
113            "No plugin host entry point found in {}",
114            self.plugins_dir.display()
115        )))
116    }
117
118    /// Find the best JavaScript runtime (bun > node).
119    fn find_runtime(&self) -> String {
120        if which_exists("bun") {
121            "bun".to_string()
122        } else {
123            "node".to_string()
124        }
125    }
126
127    /// Check if the host process is still running.
128    #[must_use]
129    pub fn is_running(&mut self) -> bool {
130        match &mut self.child_process {
131            Some(child) => child.try_wait().ok().flatten().is_none(),
132            None => self.transport.is_some(),
133        }
134    }
135
136    /// Stop the plugin host process.
137    pub fn stop(&mut self) {
138        if let Some(mut child) = self.child_process.take() {
139            let _ = child.kill();
140            let _ = child.wait();
141        }
142        self.transport = None;
143        self.manifest = None;
144    }
145
146    /// Get the cached skill manifest.
147    #[must_use]
148    pub const fn skill_manifest(&self) -> Option<&SkillManifest> {
149        self.manifest.as_ref()
150    }
151
152    /// Load skills from TypeScript layer.
153    ///
154    /// # Errors
155    ///
156    /// Returns error if not connected or IPC fails.
157    pub fn load_skills(&self) -> Result<SkillManifest, PluginError> {
158        let transport = self
159            .transport
160            .as_ref()
161            .ok_or_else(|| PluginError::Ipc("Not connected".to_string()))?;
162
163        let request = IpcMessage::request("loadSkills", serde_json::json!({}));
164        let response = transport
165            .request(&request)
166            .map_err(|e| PluginError::Ipc(e.to_string()))?;
167
168        if let openclaw_ipc::messages::IpcPayload::Response(resp) = response.payload {
169            if resp.success {
170                let manifest: SkillManifest =
171                    serde_json::from_value(resp.result.unwrap_or_default())
172                        .map_err(|e| PluginError::Ipc(e.to_string()))?;
173                return Ok(manifest);
174            }
175            return Err(PluginError::Ipc(resp.error.unwrap_or_default()));
176        }
177
178        Err(PluginError::Ipc("Invalid response".to_string()))
179    }
180
181    /// Execute a tool registered by TypeScript plugin.
182    ///
183    /// # Errors
184    ///
185    /// Returns error if not connected or tool execution fails.
186    pub fn execute_tool(
187        &self,
188        name: &str,
189        params: serde_json::Value,
190    ) -> Result<serde_json::Value, PluginError> {
191        let transport = self
192            .transport
193            .as_ref()
194            .ok_or_else(|| PluginError::Ipc("Not connected".to_string()))?;
195
196        let request = IpcMessage::request(
197            "executeTool",
198            serde_json::json!({
199                "name": name,
200                "params": params
201            }),
202        );
203
204        let response = transport
205            .request(&request)
206            .map_err(|e| PluginError::Ipc(e.to_string()))?;
207
208        if let openclaw_ipc::messages::IpcPayload::Response(resp) = response.payload {
209            if resp.success {
210                return Ok(resp.result.unwrap_or_default());
211            }
212            return Err(PluginError::Ipc(resp.error.unwrap_or_default()));
213        }
214
215        Err(PluginError::Ipc("Invalid response".to_string()))
216    }
217
218    /// Call a plugin hook.
219    ///
220    /// # Errors
221    ///
222    /// Returns error if not connected or hook execution fails.
223    pub fn call_hook(
224        &self,
225        hook: &str,
226        data: serde_json::Value,
227    ) -> Result<serde_json::Value, PluginError> {
228        let transport = self
229            .transport
230            .as_ref()
231            .ok_or_else(|| PluginError::Ipc("Not connected".to_string()))?;
232
233        let request = IpcMessage::request(
234            "callHook",
235            serde_json::json!({
236                "hook": hook,
237                "data": data
238            }),
239        );
240
241        let response = transport
242            .request(&request)
243            .map_err(|e| PluginError::Ipc(e.to_string()))?;
244
245        if let openclaw_ipc::messages::IpcPayload::Response(resp) = response.payload {
246            if resp.success {
247                return Ok(resp.result.unwrap_or_default());
248            }
249            return Err(PluginError::Ipc(resp.error.unwrap_or_default()));
250        }
251
252        Err(PluginError::Ipc("Invalid response".to_string()))
253    }
254}
255
256impl Drop for TsPluginBridge {
257    fn drop(&mut self) {
258        self.stop();
259    }
260}
261
262/// Implement the Plugin trait so the bridge can be registered.
263#[async_trait]
264impl Plugin for TsPluginBridge {
265    fn id(&self) -> &'static str {
266        "ts-bridge"
267    }
268
269    fn name(&self) -> &'static str {
270        "TypeScript Plugin Bridge"
271    }
272
273    fn version(&self) -> &'static str {
274        env!("CARGO_PKG_VERSION")
275    }
276
277    fn hooks(&self) -> &[PluginHook] {
278        &[
279            PluginHook::BeforeMessage,
280            PluginHook::AfterMessage,
281            PluginHook::BeforeToolCall,
282            PluginHook::AfterToolCall,
283            PluginHook::SessionStart,
284            PluginHook::SessionEnd,
285            PluginHook::AgentResponse,
286            PluginHook::Error,
287        ]
288    }
289
290    async fn execute_hook(
291        &self,
292        hook: PluginHook,
293        data: serde_json::Value,
294    ) -> Result<serde_json::Value, PluginError> {
295        let hook_name = match hook {
296            PluginHook::BeforeMessage => "beforeMessage",
297            PluginHook::AfterMessage => "afterMessage",
298            PluginHook::BeforeToolCall => "beforeToolCall",
299            PluginHook::AfterToolCall => "afterToolCall",
300            PluginHook::SessionStart => "sessionStart",
301            PluginHook::SessionEnd => "sessionEnd",
302            PluginHook::AgentResponse => "agentResponse",
303            PluginHook::Error => "error",
304        };
305
306        self.call_hook(hook_name, data)
307    }
308
309    async fn activate(&self) -> Result<(), PluginError> {
310        // Already handled by spawn_and_connect or connect
311        Ok(())
312    }
313
314    async fn deactivate(&self) -> Result<(), PluginError> {
315        // Handled by Drop
316        Ok(())
317    }
318}
319
320/// Skill manifest from TypeScript layer.
321#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
322pub struct SkillManifest {
323    /// Available skills.
324    pub skills: Vec<SkillEntry>,
325    /// Formatted prompt for LLM.
326    pub prompt: String,
327}
328
329/// Skill entry.
330#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
331#[serde(rename_all = "camelCase")]
332pub struct SkillEntry {
333    /// Skill name.
334    pub name: String,
335    /// Skill description.
336    pub description: String,
337    /// Slash command (e.g., "/commit").
338    pub slash_command: Option<String>,
339}
340
341/// Discover TypeScript plugins in a directory.
342///
343/// Scans for directories containing `package.json` with an `openclaw` plugin entry.
344#[must_use]
345pub fn discover_plugins(plugins_dir: &Path) -> Vec<PluginInfo> {
346    let mut plugins = Vec::new();
347
348    let entries = match std::fs::read_dir(plugins_dir) {
349        Ok(entries) => entries,
350        Err(_) => return plugins,
351    };
352
353    for entry in entries.flatten() {
354        let path = entry.path();
355        if !path.is_dir() {
356            continue;
357        }
358
359        let pkg_json = path.join("package.json");
360        if !pkg_json.exists() {
361            continue;
362        }
363
364        if let Ok(content) = std::fs::read_to_string(&pkg_json) {
365            if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) {
366                // Check for openclaw plugin marker
367                if pkg.get("openclaw").is_some() || pkg.get("openclaw-plugin").is_some() {
368                    let name = pkg["name"].as_str().unwrap_or("unknown").to_string();
369                    let version = pkg["version"].as_str().unwrap_or("0.0.0").to_string();
370                    let description = pkg["description"].as_str().unwrap_or("").to_string();
371
372                    plugins.push(PluginInfo {
373                        name,
374                        version,
375                        description,
376                        path: path.clone(),
377                    });
378                }
379            }
380        }
381    }
382
383    plugins
384}
385
386/// Information about a discovered plugin.
387#[derive(Debug, Clone)]
388pub struct PluginInfo {
389    /// Plugin name (from package.json).
390    pub name: String,
391    /// Plugin version.
392    pub version: String,
393    /// Plugin description.
394    pub description: String,
395    /// Path to plugin directory.
396    pub path: PathBuf,
397}
398
399/// Check if a command exists on PATH.
400fn which_exists(cmd: &str) -> bool {
401    std::env::var_os("PATH").is_some_and(|paths| {
402        std::env::split_paths(&paths)
403            .any(|dir| dir.join(cmd).exists() || dir.join(format!("{cmd}.exe")).exists())
404    })
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use tempfile::tempdir;
411
412    #[test]
413    fn test_bridge_creation() {
414        let dir = tempdir().unwrap();
415        let bridge = TsPluginBridge::new(dir.path());
416        assert_eq!(bridge.id(), "ts-bridge");
417    }
418
419    #[test]
420    fn test_discover_no_plugins() {
421        let dir = tempdir().unwrap();
422        let plugins = discover_plugins(dir.path());
423        assert!(plugins.is_empty());
424    }
425
426    #[test]
427    fn test_discover_with_plugin() {
428        let dir = tempdir().unwrap();
429        let plugin_dir = dir.path().join("test-plugin");
430        std::fs::create_dir(&plugin_dir).unwrap();
431        std::fs::write(
432            plugin_dir.join("package.json"),
433            r#"{"name": "test-plugin", "version": "1.0.0", "openclaw": {}}"#,
434        )
435        .unwrap();
436
437        let plugins = discover_plugins(dir.path());
438        assert_eq!(plugins.len(), 1);
439        assert_eq!(plugins[0].name, "test-plugin");
440    }
441}