use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const MANIFEST_FILE: &str = "plugin.json5";
pub const LEGACY_MANIFEST_FILE: &str = "openclaw.plugin.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginManifest {
#[serde(default)]
pub name: String,
#[serde(default)]
pub id: Option<String>,
pub version: Option<String>,
pub description: Option<String>,
#[serde(default = "default_runtime")]
pub runtime: String,
#[serde(default = "default_entry")]
pub entry: String,
#[serde(default)]
pub channels: Vec<String>,
#[serde(default)]
pub slots: Vec<String>,
#[serde(default)]
pub hooks: Vec<String>,
#[serde(default)]
pub tools: Vec<PluginToolDef>,
#[serde(default)]
pub min_call_interval_ms: u32,
pub timeout_ms: Option<u64>,
pub requires_rsclaw: Option<String>,
#[serde(default)]
pub browser_cdn: BrowserCdnConfig,
#[serde(default, flatten)]
pub extra: HashMap<String, Value>,
#[serde(skip)]
pub dir: PathBuf,
}
fn default_entry() -> String {
"./dist/index.js".to_owned()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BrowserCdnConfig {
#[serde(default)]
pub download_rules: Vec<CdnDownloadRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CdnDownloadRule {
pub match_hosts: Vec<String>,
pub referer: String,
}
#[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()
}
impl PluginManifest {
fn normalize(&mut self) {
if self.name.is_empty() {
if let Some(ref id) = self.id {
self.name = id.clone();
}
}
}
pub fn is_wasm(&self) -> bool {
self.runtime == "wasm"
}
pub fn is_channel_extension(&self) -> bool {
!self.channels.is_empty()
}
pub fn entry_path(&self) -> PathBuf {
self.dir.join(&self.entry)
}
}
pub fn load_manifest(plugin_dir: &Path) -> Result<PluginManifest> {
let json5_path = plugin_dir.join(MANIFEST_FILE);
let legacy_path = plugin_dir.join(LEGACY_MANIFEST_FILE);
if json5_path.exists() {
load_manifest_json5(&json5_path, plugin_dir)
} else if legacy_path.exists() {
load_manifest_json(&legacy_path, plugin_dir)
} else {
anyhow::bail!(
"no manifest found in {} (expected {} or {})",
plugin_dir.display(),
MANIFEST_FILE,
LEGACY_MANIFEST_FILE,
)
}
}
fn load_manifest_json5(path: &Path, plugin_dir: &Path) -> Result<PluginManifest> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("cannot read {}", path.display()))?;
let mut manifest: PluginManifest = json5::from_str(&raw)
.with_context(|| format!("json5 parse error in {}", path.display()))?;
manifest.dir = plugin_dir.to_path_buf();
manifest.normalize();
Ok(manifest)
}
fn load_manifest_json(path: &Path, plugin_dir: &Path) -> Result<PluginManifest> {
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();
manifest.normalize();
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 has_manifest = plugin_dir.join(MANIFEST_FILE).exists()
|| plugin_dir.join(LEGACY_MANIFEST_FILE).exists();
if !has_manifest {
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_file(dir: &Path, name: &str, content: &str) {
std::fs::write(dir.join(name), content).expect("write file");
}
#[test]
fn parse_json5_manifest() {
let tmp = tempfile::tempdir().expect("tempdir");
write_file(
tmp.path(),
MANIFEST_FILE,
r#"{
name: "test-wasm",
version: "2.0.0",
description: "A WASM plugin",
runtime: "wasm",
entry: "./plugin.wasm",
tools: [
{
name: "do_thing",
description: "Does things",
inputSchema: { type: "object" }
}
]
}"#,
);
let m = load_manifest(tmp.path()).expect("load");
assert_eq!(m.name, "test-wasm");
assert_eq!(m.version.as_deref(), Some("2.0.0"));
assert_eq!(m.runtime, "wasm");
assert!(m.is_wasm());
assert_eq!(m.tools.len(), 1);
}
#[test]
fn parse_legacy_manifest() {
let tmp = tempfile::tempdir().expect("tempdir");
write_file(
tmp.path(),
LEGACY_MANIFEST_FILE,
r#"{"name": "legacy", "entry": "./index.js"}"#,
);
let m = load_manifest(tmp.path()).expect("load");
assert_eq!(m.name, "legacy");
assert_eq!(m.runtime, "node"); assert!(!m.is_wasm());
}
#[test]
fn json5_takes_priority_over_legacy() {
let tmp = tempfile::tempdir().expect("tempdir");
write_file(
tmp.path(),
MANIFEST_FILE,
r#"{ name: "native", entry: "./plugin.wasm", runtime: "wasm" }"#,
);
write_file(
tmp.path(),
LEGACY_MANIFEST_FILE,
r#"{"name": "legacy", "entry": "./index.js"}"#,
);
let m = load_manifest(tmp.path()).expect("load");
assert_eq!(m.name, "native");
assert!(m.is_wasm());
}
#[test]
fn parse_minimal_manifest() {
let tmp = tempfile::tempdir().expect("tempdir");
write_file(
tmp.path(),
MANIFEST_FILE,
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");
let dir_a = tmp.path().join("plugin-a");
std::fs::create_dir_all(&dir_a).expect("mkdir");
write_file(
&dir_a,
MANIFEST_FILE,
r#"{ name: "plugin-a", entry: "./a.wasm", runtime: "wasm" }"#,
);
let dir_b = tmp.path().join("plugin-b");
std::fs::create_dir_all(&dir_b).expect("mkdir");
write_file(
&dir_b,
LEGACY_MANIFEST_FILE,
&format!(r#"{{"name":"plugin-b","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);
}
}