1use 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
11pub 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 #[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 #[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 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 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 std::thread::sleep(std::time::Duration::from_millis(500));
84
85 self.connect(&self.ipc_address.clone())?;
87
88 self.manifest = self.load_skills().ok();
90
91 Ok(())
92 }
93
94 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 fn find_runtime(&self) -> String {
120 if which_exists("bun") {
121 "bun".to_string()
122 } else {
123 "node".to_string()
124 }
125 }
126
127 #[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 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 #[must_use]
148 pub const fn skill_manifest(&self) -> Option<&SkillManifest> {
149 self.manifest.as_ref()
150 }
151
152 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 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 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#[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 Ok(())
312 }
313
314 async fn deactivate(&self) -> Result<(), PluginError> {
315 Ok(())
317 }
318}
319
320#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
322pub struct SkillManifest {
323 pub skills: Vec<SkillEntry>,
325 pub prompt: String,
327}
328
329#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
331#[serde(rename_all = "camelCase")]
332pub struct SkillEntry {
333 pub name: String,
335 pub description: String,
337 pub slash_command: Option<String>,
339}
340
341#[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 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#[derive(Debug, Clone)]
388pub struct PluginInfo {
389 pub name: String,
391 pub version: String,
393 pub description: String,
395 pub path: PathBuf,
397}
398
399fn 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}