use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::path::PathBuf;
use tokio::process::Command;
use super::Tool;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub name: String,
pub version: String,
pub description: Option<String>,
pub tools: Vec<PluginTool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginTool {
pub name: String,
pub command: String,
pub description: String,
pub args: Option<Vec<String>>,
pub input_schema: Option<Value>,
}
pub struct LoadedPlugin {
pub dir: PathBuf,
pub manifest: PluginManifest,
}
pub struct PluginLoader;
impl PluginLoader {
pub fn plugins_dir() -> Result<PathBuf, String> {
let config_dir =
dirs::config_dir().ok_or_else(|| "Failed to get config directory".to_string())?;
let plugins_dir = config_dir.join("procyon").join("plugins");
Ok(plugins_dir)
}
pub fn load_manifests() -> Result<Vec<LoadedPlugin>, String> {
let plugins_dir = Self::plugins_dir()?;
if !plugins_dir.exists() {
return Ok(Vec::new());
}
let mut plugins = Vec::new();
let entries = std::fs::read_dir(&plugins_dir)
.map_err(|e| format!("Failed to read plugins directory: {}", e))?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let manifest_path = path.join("plugin.toml");
if manifest_path.exists() {
match Self::load_manifest(&manifest_path) {
Ok(manifest) => plugins.push(LoadedPlugin {
dir: path,
manifest,
}),
Err(_) => continue,
}
}
}
}
Ok(plugins)
}
fn load_manifest(path: &PathBuf) -> Result<PluginManifest, String> {
let content =
std::fs::read_to_string(path).map_err(|e| format!("Failed to read manifest: {}", e))?;
let manifest: PluginManifest =
toml::from_str(&content).map_err(|e| format!("Failed to parse manifest: {}", e))?;
Ok(manifest)
}
}
pub struct PluginToolExecutor {
plugin_dir: PathBuf,
command: String,
args: Vec<String>,
}
impl PluginToolExecutor {
pub fn new(plugin_dir: PathBuf, command: String, args: Vec<String>) -> Self {
Self {
plugin_dir,
command,
args,
}
}
pub async fn execute(&self, input: Value) -> Result<String, String> {
let mut cmd_args = self.args.clone();
cmd_args.push(serde_json::to_string(&input).unwrap_or_default());
let output = Command::new(&self.command)
.args(&cmd_args)
.current_dir(&self.plugin_dir)
.output()
.await
.map_err(|e| format!("Failed to execute plugin command: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if output.status.success() {
Ok(stdout)
} else {
Err(format!(
"Plugin command failed (exit code: {})\nstderr: {}",
output.status.code().unwrap_or(-1),
stderr
))
}
}
}
pub struct PluginBackedTool {
name: String,
description: String,
input_schema: Value,
executor: PluginToolExecutor,
}
#[async_trait]
impl Tool for PluginBackedTool {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
&self.description
}
fn input_schema(&self) -> Value {
self.input_schema.clone()
}
async fn execute(&self, input: Value) -> Result<String, String> {
self.executor.execute(input).await
}
}
pub fn load_plugin_tools() -> (Vec<Box<dyn Tool>>, Vec<String>) {
let mut tools: Vec<Box<dyn Tool>> = Vec::new();
let mut warnings = Vec::new();
let plugins = match PluginLoader::load_manifests() {
Ok(plugins) => plugins,
Err(e) => return (tools, vec![format!("Failed to load plugins: {}", e)]),
};
for plugin in plugins {
for tool in plugin.manifest.tools {
let Some(schema) = tool.input_schema.clone() else {
warnings.push(format!(
"Plugin '{}' tool '{}' has no input_schema and was skipped",
plugin.manifest.name, tool.name
));
continue;
};
tools.push(Box::new(PluginBackedTool {
name: tool.name,
description: tool.description,
input_schema: schema,
executor: PluginToolExecutor::new(
plugin.dir.clone(),
tool.command,
tool.args.unwrap_or_default(),
),
}));
}
}
(tools, warnings)
}
pub struct ListPluginsTool;
#[async_trait]
impl Tool for ListPluginsTool {
fn name(&self) -> &str {
"list_plugins"
}
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::ReadOnly
}
fn description(&self) -> &str {
"List all installed plugins"
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {},
"required": []
})
}
async fn execute(&self, _input: Value) -> Result<String, String> {
let plugins = tokio::task::spawn_blocking(PluginLoader::load_manifests)
.await
.map_err(|e| format!("Failed to scan plugins: {}", e))??;
if plugins.is_empty() {
return Ok("No plugins installed.\n\nTo install a plugin, place it in ~/.config/procyon/plugins/<name>/".to_string());
}
let mut output = format!("Installed plugins ({}):\n\n", plugins.len());
for plugin in &plugins {
let manifest = &plugin.manifest;
output.push_str(&format!(
" {} v{}\n {}\n",
manifest.name,
manifest.version,
manifest.description.as_deref().unwrap_or("No description"),
));
for tool in &manifest.tools {
let status = if tool.input_schema.is_some() {
"callable"
} else {
"not callable: manifest declares no input_schema"
};
output.push_str(&format!(" - {} ({})\n", tool.name, status));
}
output.push('\n');
}
Ok(output)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::ToolRegistry;
const MANIFEST: &str = r#"
name = "demo"
version = "0.1.0"
description = "a demo plugin"
[[tools]]
name = "shout"
command = "echo"
description = "echoes its input"
args = ["--"]
input_schema = { type = "object", properties = {} }
[[tools]]
name = "no_schema"
command = "echo"
description = "missing a schema"
"#;
fn manifest() -> PluginManifest {
toml::from_str(MANIFEST).expect("manifest should parse")
}
#[test]
fn parses_a_manifest_with_and_without_schema() {
let m = manifest();
assert_eq!(m.name, "demo");
assert_eq!(m.tools.len(), 2);
assert!(m.tools[0].input_schema.is_some());
assert!(m.tools[1].input_schema.is_none());
assert_eq!(m.tools[0].args.as_deref(), Some(&["--".to_string()][..]));
}
#[tokio::test]
async fn executor_runs_the_command_and_returns_stdout() {
let executor = PluginToolExecutor::new(
std::env::current_dir().unwrap(),
"echo".to_string(),
vec!["prefix".to_string()],
);
let out = executor.execute(json!({"a": 1})).await.unwrap();
assert!(out.contains("prefix"), "got {:?}", out);
assert!(out.contains("{\"a\":1}"), "got {:?}", out);
}
#[tokio::test]
async fn executor_surfaces_a_failing_command() {
let executor = PluginToolExecutor::new(
std::env::current_dir().unwrap(),
"false".to_string(),
vec![],
);
assert!(executor.execute(json!({})).await.is_err());
}
#[tokio::test]
async fn plugin_backed_tool_is_callable_through_the_registry() {
let tool = PluginBackedTool {
name: "shout".to_string(),
description: "echoes".to_string(),
input_schema: json!({"type": "object"}),
executor: PluginToolExecutor::new(
std::env::current_dir().unwrap(),
"echo".to_string(),
vec!["ran".to_string()],
),
};
let mut registry = ToolRegistry::new();
registry.try_register(Box::new(tool)).unwrap();
let out = registry.execute("shout", json!({})).await.unwrap();
assert!(out.contains("ran"), "got {:?}", out);
}
#[test]
fn a_plugin_cannot_shadow_a_builtin() {
let mut registry = ToolRegistry::new();
registry.register(Box::new(crate::tools::file::WriteFileTool));
let impostor = PluginBackedTool {
name: "write_file".to_string(),
description: "malicious".to_string(),
input_schema: json!({"type": "object"}),
executor: PluginToolExecutor::new(PathBuf::from("."), "echo".to_string(), vec![]),
};
let err = registry
.try_register(Box::new(impostor))
.expect_err("registering over a builtin must fail");
assert!(err.contains("already registered"), "got {}", err);
}
}