tandem-core 0.4.16

Core types and helpers for the Tandem engine
Documentation
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;

use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::fs;
use tokio::sync::RwLock;

use crate::permissions::PermissionAction;

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PluginManifest {
    pub name: String,
    #[serde(default = "default_true")]
    pub enabled: bool,
    pub system_prompt_prefix: Option<String>,
    pub system_prompt_suffix: Option<String>,
    #[serde(default)]
    pub allow_tools: Vec<String>,
    #[serde(default)]
    pub deny_tools: Vec<String>,
    #[serde(default)]
    pub shell_env: HashMap<String, String>,
    pub tool_output_suffix: Option<String>,
}

#[derive(Clone)]
pub struct PluginRegistry {
    plugins: Arc<RwLock<Vec<PluginManifest>>>,
}

impl PluginRegistry {
    pub async fn new(workspace_root: impl Into<PathBuf>) -> anyhow::Result<Self> {
        let root: PathBuf = workspace_root.into();
        let plugins = load_plugins(root.join(".tandem").join("plugins")).await?;
        Ok(Self {
            plugins: Arc::new(RwLock::new(plugins)),
        })
    }

    pub async fn list(&self) -> Vec<PluginManifest> {
        self.plugins.read().await.clone()
    }

    pub async fn transform_prompt(&self, prompt: String) -> String {
        let plugins = self.plugins.read().await;
        let mut transformed = prompt;
        for plugin in plugins.iter().filter(|p| p.enabled) {
            if let Some(prefix) = &plugin.system_prompt_prefix {
                transformed = format!("{prefix}\n\n{transformed}");
            }
            if let Some(suffix) = &plugin.system_prompt_suffix {
                transformed = format!("{transformed}\n\n{suffix}");
            }
        }
        transformed
    }

    pub async fn permission_override(&self, tool_name: &str) -> Option<PermissionAction> {
        let plugins = self.plugins.read().await;
        let mut action = None;
        for plugin in plugins.iter().filter(|p| p.enabled) {
            if plugin.deny_tools.iter().any(|t| t == tool_name) {
                action = Some(PermissionAction::Deny);
            }
            if plugin.allow_tools.iter().any(|t| t == tool_name) {
                action = Some(PermissionAction::Allow);
            }
        }
        action
    }

    pub async fn inject_tool_args(&self, tool_name: &str, mut args: Value) -> Value {
        if tool_name != "bash" {
            return args;
        }

        let plugins = self.plugins.read().await;
        let mut merged_env = serde_json::Map::new();
        for plugin in plugins.iter().filter(|p| p.enabled) {
            for (k, v) in &plugin.shell_env {
                merged_env.insert(k.clone(), Value::String(v.clone()));
            }
        }
        if !merged_env.is_empty() {
            args["env"] = Value::Object(merged_env);
        }
        args
    }

    pub async fn transform_tool_output(&self, output: String) -> String {
        let plugins = self.plugins.read().await;
        let mut transformed = output;
        for plugin in plugins.iter().filter(|p| p.enabled) {
            if let Some(suffix) = &plugin.tool_output_suffix {
                transformed = format!("{transformed}\n{suffix}");
            }
        }
        transformed
    }
}

fn default_true() -> bool {
    true
}

async fn load_plugins(dir: PathBuf) -> anyhow::Result<Vec<PluginManifest>> {
    let mut out = Vec::new();
    let mut entries = match fs::read_dir(&dir).await {
        Ok(rd) => rd,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(out),
        Err(err) => return Err(err.into()),
    };

    while let Some(entry) = entries.next_entry().await? {
        let path = entry.path();
        let Some(ext) = path.extension().and_then(|v| v.to_str()) else {
            continue;
        };
        if ext != "json" {
            continue;
        }
        let raw = fs::read_to_string(&path).await?;
        if let Ok(parsed) = serde_json::from_str::<PluginManifest>(&raw) {
            out.push(parsed);
        }
    }
    out.sort_by(|a, b| a.name.cmp(&b.name));
    Ok(out)
}