procyon 0.1.1

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
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,
                        }),
                        // Writing to stderr would corrupt the TUI, so a broken manifest is
                        // reported through the returned warnings instead.
                        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
            ))
        }
    }
}

// Bridges a manifest-declared tool into the registry. The command comes from a manifest the user
// installed, never from the model; the model only controls `input`, which is passed as a single
// argv item, so there is no shell to inject into.
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> {
        // The loader is sync because boot also uses it; off-thread here so a slow plugins
        // directory cannot stall the runtime driving the TUI.
        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);
        // The model-supplied input is appended as one argv item.
        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);
    }
}