sparrow-cli 0.5.1

A local-first Rust agent cockpit — route, run, replay, rewind
Documentation
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PluginManifest {
    pub name: String,
    #[serde(default)]
    pub version: String,
    #[serde(default)]
    pub description: String,
    #[serde(default)]
    pub commands: Vec<PluginCommand>,
    #[serde(default)]
    pub agents: Vec<String>,
    #[serde(default)]
    pub skills: Vec<PluginSkill>,
    #[serde(default)]
    pub hooks: Vec<PluginHook>,
    #[serde(default)]
    pub mcp_servers: Vec<crate::capabilities::mcp::McpServer>,
    #[serde(default)]
    pub themes: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PluginCommand {
    pub name: String,
    #[serde(default)]
    pub description: String,
    #[serde(default)]
    pub body: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PluginSkill {
    pub name: String,
    #[serde(default)]
    pub path: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PluginHook {
    pub name: String,
    #[serde(default)]
    pub kind: String,
    #[serde(default)]
    pub command: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Plugin {
    pub manifest: PluginManifest,
    pub root: PathBuf,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PluginAudit {
    pub allowed: bool,
    pub warnings: Vec<String>,
}

pub struct PluginScanner {
    allowlist: Vec<String>,
}

impl PluginScanner {
    pub fn new(allowlist: Vec<String>) -> Self {
        Self { allowlist }
    }

    pub fn scan(&self, plugin: &Plugin) -> PluginAudit {
        let mut warnings = Vec::new();
        if plugin.manifest.name.trim().is_empty() {
            warnings.push("plugin name is empty".into());
        }
        if !self.allowlist.is_empty() && !self.allowlist.contains(&plugin.manifest.name) {
            warnings.push(format!(
                "plugin '{}' is not in the allowlist",
                plugin.manifest.name
            ));
        }
        for hook in &plugin.manifest.hooks {
            if hook.kind.eq_ignore_ascii_case("command") && command_looks_dangerous(&hook.command) {
                warnings.push(format!("dangerous hook '{}' is blocked", hook.name));
            }
        }
        PluginAudit {
            allowed: warnings.is_empty(),
            warnings,
        }
    }
}

pub struct PluginRegistry {
    plugins_dir: PathBuf,
    allowlist: Vec<String>,
}

impl PluginRegistry {
    pub fn new(plugins_dir: PathBuf) -> Self {
        std::fs::create_dir_all(&plugins_dir).ok();
        Self {
            plugins_dir,
            allowlist: Vec::new(),
        }
    }

    pub fn with_allowlist(mut self, allowlist: Vec<String>) -> Self {
        self.allowlist = allowlist;
        self
    }

    pub fn scan(&self) -> Vec<Plugin> {
        let Ok(entries) = std::fs::read_dir(&self.plugins_dir) else {
            return Vec::new();
        };
        entries
            .flatten()
            .filter_map(|entry| load_plugin(&entry.path()).ok())
            .collect()
    }

    pub fn audit(&self, plugin: &Plugin) -> PluginAudit {
        PluginScanner::new(self.allowlist.clone()).scan(plugin)
    }

    pub fn install_local(&self, source: &Path) -> anyhow::Result<Plugin> {
        let plugin = load_plugin(source)?;
        let audit = self.audit(&plugin);
        if !audit.allowed {
            anyhow::bail!("plugin blocked: {}", audit.warnings.join("; "));
        }
        let dest = self.plugins_dir.join(&plugin.manifest.name);
        if dest.exists() {
            std::fs::remove_dir_all(&dest)?;
        }
        copy_dir_all(source, &dest)?;
        load_plugin(&dest)
    }

    pub fn install_github(&self, repo: &str) -> anyhow::Result<Plugin> {
        let tmp = std::env::temp_dir().join(format!("sparrow-plugin-{}", uuid::Uuid::new_v4()));
        let status = std::process::Command::new("git")
            .args([
                "clone",
                "--depth",
                "1",
                repo,
                tmp.to_string_lossy().as_ref(),
            ])
            .status()?;
        if !status.success() {
            anyhow::bail!("git clone failed for plugin repo {}", repo);
        }
        let plugin = self.install_local(&tmp);
        let _ = std::fs::remove_dir_all(&tmp);
        plugin
    }
}

pub fn load_plugin(root: &Path) -> anyhow::Result<Plugin> {
    let toml_path = root.join(".sparrow-plugin").join("plugin.toml");
    let json_path = root.join(".sparrow-plugin").join("plugin.json");
    let manifest = if toml_path.exists() {
        toml::from_str::<PluginManifest>(&std::fs::read_to_string(toml_path)?)?
    } else if json_path.exists() {
        serde_json::from_str::<PluginManifest>(&std::fs::read_to_string(json_path)?)?
    } else {
        anyhow::bail!("plugin manifest not found in {}", root.display());
    };
    Ok(Plugin {
        manifest,
        root: root.to_path_buf(),
    })
}

pub fn namespace(plugin: &str, item: &str) -> String {
    format!("{}:{}", plugin.trim(), item.trim())
}

fn command_looks_dangerous(command: &str) -> bool {
    let lower = command.to_ascii_lowercase();
    [
        "rm -rf",
        "remove-item",
        "format ",
        "del /s",
        "shutdown",
        "curl ",
        "invoke-webrequest",
        "powershell -enc",
    ]
    .iter()
    .any(|needle| lower.contains(needle))
}

fn copy_dir_all(src: &Path, dst: &Path) -> anyhow::Result<()> {
    std::fs::create_dir_all(dst)?;
    for entry in std::fs::read_dir(src)? {
        let entry = entry?;
        let ty = entry.file_type()?;
        let dest = dst.join(entry.file_name());
        if ty.is_dir() {
            copy_dir_all(&entry.path(), &dest)?;
        } else {
            std::fs::copy(entry.path(), dest)?;
        }
    }
    Ok(())
}