butterfly-bot 0.7.0

Butterfly Bot is an opinionated personal-ops AI assistant built for people who want results, not setup overhead.
Documentation
use async_trait::async_trait;
use serde_json::{json, Value};
use std::collections::HashMap;
use tokio::sync::RwLock;

use crate::error::{ButterflyBotError, Result};
use crate::interfaces::plugins::{Tool, ToolSecret};
use crate::tools::mcp::McpTool;
use crate::vault;

#[derive(Clone, Debug)]
struct GitHubConfig {
    url: String,
    headers: HashMap<String, String>,
    pat: Option<String>,
}

impl Default for GitHubConfig {
    fn default() -> Self {
        Self {
            url: "https://api.githubcopilot.com/mcp/".to_string(),
            headers: HashMap::new(),
            pat: None,
        }
    }
}

pub struct GitHubTool {
    config: RwLock<GitHubConfig>,
}

impl Default for GitHubTool {
    fn default() -> Self {
        Self::new()
    }
}

impl GitHubTool {
    pub fn new() -> Self {
        Self {
            config: RwLock::new(GitHubConfig::default()),
        }
    }

    fn get_tool_config(config: &Value) -> Option<&Value> {
        config.get("tools").and_then(|tools| tools.get("github"))
    }

    fn insert_pat_header(headers: &mut HashMap<String, String>, pat: &str) {
        if !headers.contains_key("Authorization") {
            headers.insert("Authorization".to_string(), format!("Bearer {pat}"));
        }
    }

    fn parse_headers(value: &Value) -> HashMap<String, String> {
        value
            .as_object()
            .map(|map| {
                map.iter()
                    .filter_map(|(key, value)| value.as_str().map(|v| (key.clone(), v.to_string())))
                    .collect::<HashMap<String, String>>()
            })
            .unwrap_or_default()
    }

    fn build_mcp_config(&self, config: &GitHubConfig) -> Value {
        json!({
            "tools": {
                "mcp": {
                    "servers": [
                        {
                            "name": "github",
                            "url": config.url.clone(),
                            "headers": config.headers.clone()
                        }
                    ]
                }
            }
        })
    }
}

#[async_trait]
impl Tool for GitHubTool {
    fn name(&self) -> &str {
        "github"
    }

    fn description(&self) -> &str {
        "Access GitHub MCP tools with a single GitHub PAT."
    }

    fn parameters(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "action": {
                    "type": "string",
                    "enum": ["list_tools", "call_tool"]
                },
                "tool": { "type": "string", "description": "GitHub MCP tool name" },
                "arguments": { "type": "object", "description": "Arguments for the GitHub MCP tool" }
            },
            "required": ["action"],
            "additionalProperties": false
        })
    }

    fn required_secrets_for_config(&self, config: &Value) -> Vec<ToolSecret> {
        let tool_cfg = match Self::get_tool_config(config) {
            Some(cfg) => cfg,
            None => return Vec::new(),
        };
        let has_pat = tool_cfg.get("pat").and_then(|v| v.as_str()).is_some();
        let has_auth_header = tool_cfg
            .get("headers")
            .and_then(|v| v.as_object())
            .and_then(|headers| headers.get("Authorization"))
            .and_then(|v| v.as_str())
            .is_some();
        if has_pat || has_auth_header {
            Vec::new()
        } else {
            vec![ToolSecret::new(
                "github_pat",
                "GitHub PAT (for MCP GitHub tool)",
            )]
        }
    }

    fn configure(&self, config: &Value) -> Result<()> {
        let mut next = GitHubConfig::default();

        if let Some(tool_cfg) = Self::get_tool_config(config) {
            if let Some(url) = tool_cfg.get("url").and_then(|v| v.as_str()) {
                if !url.trim().is_empty() {
                    next.url = url.to_string();
                }
            }
            if let Some(pat) = tool_cfg.get("pat").and_then(|v| v.as_str()) {
                if !pat.trim().is_empty() {
                    next.pat = Some(pat.to_string());
                }
            }
            if let Some(headers) = tool_cfg.get("headers") {
                next.headers = Self::parse_headers(headers);
            }
        }

        if let Some(pat) = next.pat.clone() {
            Self::insert_pat_header(&mut next.headers, &pat);
        }

        let mut guard = self
            .config
            .try_write()
            .map_err(|_| ButterflyBotError::Runtime("GitHub tool lock busy".to_string()))?;
        *guard = next;
        Ok(())
    }

    async fn execute(&self, params: Value) -> Result<Value> {
        let action = params
            .get("action")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();

        let mut config = self.config.read().await.clone();

        if !config.headers.contains_key("Authorization") {
            if let Some(secret) = vault::get_secret("github_pat")? {
                if !secret.trim().is_empty() {
                    Self::insert_pat_header(&mut config.headers, &secret);
                }
            }
        }

        if !config.headers.contains_key("Authorization") {
            return Err(ButterflyBotError::Runtime(
                "Missing GitHub PAT (set tools.github.pat or vault github_pat)".to_string(),
            ));
        }

        let mcp_config = self.build_mcp_config(&config);
        let mcp_tool = McpTool::new();
        mcp_tool.configure(&mcp_config)?;

        match action.as_str() {
            "list_tools" => {
                let result = mcp_tool
                    .execute(json!({"action": "list_tools", "server": "github"}))
                    .await?;
                Ok(result)
            }
            "call_tool" => {
                let tool_name = params
                    .get("tool")
                    .and_then(|v| v.as_str())
                    .ok_or_else(|| ButterflyBotError::Runtime("Missing tool name".to_string()))?;
                let args = params.get("arguments").cloned();
                let result = mcp_tool
                    .execute(json!({
                        "action": "call_tool",
                        "server": "github",
                        "tool": tool_name,
                        "arguments": args
                    }))
                    .await?;
                Ok(result)
            }
            _ => Err(ButterflyBotError::Runtime("Unsupported action".to_string())),
        }
    }
}