use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const MANIFEST_FILE: &str = "openclaw.plugin.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginManifest {
pub name: String,
pub version: Option<String>,
pub description: Option<String>,
#[serde(default = "default_runtime")]
pub runtime: String,
pub entry: String,
#[serde(default)]
pub slots: Vec<String>,
#[serde(default)]
pub hooks: Vec<String>,
#[serde(default)]
pub tools: Vec<PluginToolDef>,
pub requires_rsclaw: Option<String>,
#[serde(default, flatten)]
pub extra: HashMap<String, Value>,
#[serde(skip)]
pub dir: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginToolDef {
pub name: String,
pub description: String,
pub input_schema: Option<Value>,
}
fn default_runtime() -> String {
"node".to_owned()
}
pub fn load_manifest(plugin_dir: &Path) -> Result<PluginManifest> {
let path = plugin_dir.join(MANIFEST_FILE);
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("cannot read {}", path.display()))?;
let mut manifest: PluginManifest = serde_json::from_str(&raw)
.with_context(|| format!("JSON parse error in {}", path.display()))?;
manifest.dir = plugin_dir.to_path_buf();
Ok(manifest)
}
pub fn scan_plugins(plugins_dir: &Path) -> Result<Vec<PluginManifest>> {
if !plugins_dir.exists() {
return Ok(Vec::new());
}
let mut manifests = Vec::new();
for entry in std::fs::read_dir(plugins_dir)
.with_context(|| format!("read plugins dir: {}", plugins_dir.display()))?
.flatten()
{
let plugin_dir = entry.path();
if !plugin_dir.is_dir() {
continue;
}
let mf = plugin_dir.join(MANIFEST_FILE);
if !mf.exists() {
continue;
}
match load_manifest(&plugin_dir) {
Ok(m) => manifests.push(m),
Err(e) => {
tracing::warn!(
path = %plugin_dir.display(),
"failed to load plugin manifest: {e:#}"
);
}
}
}
Ok(manifests)
}
#[cfg(test)]
mod tests {
use super::*;
fn write_manifest(dir: &Path, json: &str) {
std::fs::write(dir.join(MANIFEST_FILE), json).expect("write manifest");
}
#[test]
fn parse_full_manifest() {
let tmp = tempfile::tempdir().expect("tempdir");
write_manifest(
tmp.path(),
r#"{
"name": "test-plugin",
"version": "2.0.0",
"description": "A test plugin",
"runtime": "bun",
"entry": "./index.js",
"slots": ["memory"],
"hooks": ["before_prompt_build"],
"tools": [
{
"name": "do_thing",
"description": "Does things",
"inputSchema": { "type": "object" }
}
]
}"#,
);
let m = load_manifest(tmp.path()).expect("load");
assert_eq!(m.name, "test-plugin");
assert_eq!(m.runtime, "bun");
assert_eq!(m.slots, vec!["memory"]);
assert_eq!(m.hooks, vec!["before_prompt_build"]);
assert_eq!(m.tools.len(), 1);
}
#[test]
fn parse_minimal_manifest() {
let tmp = tempfile::tempdir().expect("tempdir");
write_manifest(tmp.path(), r#"{"name": "minimal", "entry": "./main.js"}"#);
let m = load_manifest(tmp.path()).expect("load");
assert_eq!(m.name, "minimal");
assert_eq!(m.runtime, "node"); assert!(m.slots.is_empty());
}
#[test]
fn scan_plugins_dir() {
let tmp = tempfile::tempdir().expect("tempdir");
for slug in ["plugin-a", "plugin-b"] {
let dir = tmp.path().join(slug);
std::fs::create_dir_all(&dir).expect("mkdir");
write_manifest(
&dir,
&format!(r#"{{"name":"{slug}","entry":"./index.js"}}"#),
);
}
std::fs::create_dir_all(tmp.path().join("no-manifest")).expect("mkdir");
let plugins = scan_plugins(tmp.path()).expect("scan");
assert_eq!(plugins.len(), 2);
}
}