mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-11 07:13:43 +02:00
feat: more mcp integrations
This commit is contained in:
Generated
+1
@@ -1858,6 +1858,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-util",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tray-icon 0.24.0",
|
||||
|
||||
@@ -100,6 +100,7 @@ playwright = { git = "https://github.com/zhom/playwright-rust", branch = "master
|
||||
tokio-tungstenite = { version = "0.29", features = ["native-tls"] }
|
||||
rusqlite = { version = "0.39", features = ["bundled"] }
|
||||
serde_yaml = "0.9"
|
||||
toml = "0.9"
|
||||
thiserror = "2.0"
|
||||
regex-lite = "0.1"
|
||||
tempfile = "3"
|
||||
|
||||
+43
-89
@@ -52,6 +52,7 @@ pub mod daemon_client;
|
||||
mod daemon_spawn;
|
||||
pub mod daemon_ws;
|
||||
pub mod events;
|
||||
mod mcp_integrations;
|
||||
mod mcp_server;
|
||||
mod tag_manager;
|
||||
mod team_lock;
|
||||
@@ -504,20 +505,20 @@ fn claude_desktop_extension_dir() -> Option<std::path::PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn is_mcp_in_claude_desktop() -> Result<bool, String> {
|
||||
let dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?;
|
||||
Ok(dir.join("manifest.json").exists())
|
||||
fn is_mcp_in_claude_desktop_internal() -> bool {
|
||||
let Some(dir) = claude_desktop_extension_dir() else {
|
||||
return false;
|
||||
};
|
||||
dir.join("manifest.json").exists()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn add_mcp_to_claude_desktop(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
async fn add_mcp_to_claude_desktop_internal(app_handle: &tauri::AppHandle) -> Result<(), String> {
|
||||
let mcp_server = mcp_server::McpServer::instance();
|
||||
let port = mcp_server.get_port().ok_or("MCP server is not running")?;
|
||||
|
||||
let settings_manager = settings_manager::SettingsManager::instance();
|
||||
let token = settings_manager
|
||||
.get_mcp_token(&app_handle)
|
||||
.get_mcp_token(app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get MCP token: {e}"))?
|
||||
.ok_or("MCP token not found")?;
|
||||
@@ -606,8 +607,7 @@ rl.on("close", () => setTimeout(() => process.exit(0), 500));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn remove_mcp_from_claude_desktop() -> Result<(), String> {
|
||||
fn remove_mcp_from_claude_desktop_internal() -> Result<(), String> {
|
||||
let ext_dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?;
|
||||
if ext_dir.exists() {
|
||||
std::fs::remove_dir_all(&ext_dir).map_err(|e| format!("Failed to remove extension: {e}"))?;
|
||||
@@ -669,91 +669,48 @@ fn update_claude_extensions_registry(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_claude_cli() -> Option<std::path::PathBuf> {
|
||||
let mut candidates: Vec<std::path::PathBuf> = vec![
|
||||
std::path::PathBuf::from("/usr/local/bin/claude"),
|
||||
std::path::PathBuf::from("/opt/homebrew/bin/claude"),
|
||||
];
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
candidates.insert(0, home.join(".local/bin/claude"));
|
||||
candidates.push(home.join(".claude/local/claude"));
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||
candidates.insert(
|
||||
0,
|
||||
std::path::PathBuf::from(appdata).join("Claude/claude.exe"),
|
||||
);
|
||||
}
|
||||
for p in &candidates {
|
||||
if p.exists() {
|
||||
return Some(p.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn is_mcp_in_claude_code() -> Result<bool, String> {
|
||||
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
|
||||
// `claude mcp list` health-checks every registered MCP server, so a
|
||||
// missing or stalled server can hang the call for many seconds. Cap it
|
||||
// — for this dialog, a slow `claude` is treated the same as "not registered".
|
||||
let fut = tokio::process::Command::new(&cli)
|
||||
.args(["mcp", "list"])
|
||||
.output();
|
||||
let output = tokio::time::timeout(std::time::Duration::from_secs(2), fut)
|
||||
.await
|
||||
.map_err(|_| "claude mcp list timed out".to_string())?
|
||||
.map_err(|e| format!("Failed to run claude: {e}"))?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(stdout.contains("donut-browser"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn add_mcp_to_claude_code(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
|
||||
|
||||
async fn current_mcp_url(app_handle: &tauri::AppHandle) -> Result<String, String> {
|
||||
let mcp_server = mcp_server::McpServer::instance();
|
||||
let port = mcp_server.get_port().ok_or("MCP server is not running")?;
|
||||
|
||||
let settings_manager = settings_manager::SettingsManager::instance();
|
||||
let token = settings_manager
|
||||
.get_mcp_token(&app_handle)
|
||||
.get_mcp_token(app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get MCP token: {e}"))?
|
||||
.ok_or("MCP token not found")?;
|
||||
|
||||
let url = format!("http://127.0.0.1:{port}/mcp/{token}");
|
||||
|
||||
let _ = std::process::Command::new(&cli)
|
||||
.args(["mcp", "remove", "donut-browser"])
|
||||
.output();
|
||||
|
||||
let output = std::process::Command::new(&cli)
|
||||
.args(["mcp", "add", "--transport", "http", "donut-browser", &url])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run claude: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to add MCP to Claude Code: {stderr}"));
|
||||
}
|
||||
Ok(())
|
||||
Ok(format!("http://127.0.0.1:{port}/mcp/{token}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn remove_mcp_from_claude_code() -> Result<(), String> {
|
||||
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
|
||||
let output = std::process::Command::new(&cli)
|
||||
.args(["mcp", "remove", "donut-browser"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run claude: {e}"))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to remove MCP from Claude Code: {stderr}"));
|
||||
async fn list_mcp_agents() -> Result<Vec<mcp_integrations::McpAgentInfo>, String> {
|
||||
let claude_desktop_connected = is_mcp_in_claude_desktop_internal();
|
||||
Ok(mcp_integrations::list_agents_with_status(&[(
|
||||
"claude-desktop",
|
||||
claude_desktop_connected,
|
||||
)]))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn add_mcp_to_agent(app_handle: tauri::AppHandle, agent_id: String) -> Result<(), String> {
|
||||
if !mcp_integrations::agent_exists(&agent_id) {
|
||||
return Err(format!("Unknown agent: {agent_id}"));
|
||||
}
|
||||
Ok(())
|
||||
if agent_id == "claude-desktop" {
|
||||
return add_mcp_to_claude_desktop_internal(&app_handle).await;
|
||||
}
|
||||
let url = current_mcp_url(&app_handle).await?;
|
||||
mcp_integrations::install_generic(&agent_id, &url)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn remove_mcp_from_agent(agent_id: String) -> Result<(), String> {
|
||||
if !mcp_integrations::agent_exists(&agent_id) {
|
||||
return Err(format!("Unknown agent: {agent_id}"));
|
||||
}
|
||||
if agent_id == "claude-desktop" {
|
||||
return remove_mcp_from_claude_desktop_internal();
|
||||
}
|
||||
mcp_integrations::uninstall_generic(&agent_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -2131,12 +2088,9 @@ pub fn run() {
|
||||
stop_mcp_server,
|
||||
get_mcp_server_status,
|
||||
get_mcp_config,
|
||||
is_mcp_in_claude_desktop,
|
||||
add_mcp_to_claude_desktop,
|
||||
remove_mcp_from_claude_desktop,
|
||||
is_mcp_in_claude_code,
|
||||
add_mcp_to_claude_code,
|
||||
remove_mcp_from_claude_code,
|
||||
list_mcp_agents,
|
||||
add_mcp_to_agent,
|
||||
remove_mcp_from_agent,
|
||||
// VPN commands
|
||||
import_vpn_config,
|
||||
list_vpn_configs,
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
// MCP client integrations — installs/removes the donut-browser MCP server in
|
||||
// 14 popular AI assistant clients. Ports the add-mcp registry to Rust.
|
||||
//
|
||||
// Claude Desktop is managed via Claude's local extensions bundle
|
||||
// (manifest.json + node bridge), since the desktop app supports only stdio
|
||||
// servers via its plain JSON config but exposes HTTP through the extension
|
||||
// framework. See `add_mcp_to_claude_desktop_internal` in lib.rs. All other
|
||||
// agents (including Claude Code) use the generic config-file installer here.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const SERVER_NAME: &str = "donut-browser";
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum AgentCategory {
|
||||
DesktopApp,
|
||||
Cli,
|
||||
Editor,
|
||||
EditorExt,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum ConfigFormat {
|
||||
Json,
|
||||
Toml,
|
||||
Yaml,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct AgentSpec {
|
||||
id: &'static str,
|
||||
display_name: &'static str,
|
||||
category: AgentCategory,
|
||||
/// Top-level key (supports dot notation) where the server is written.
|
||||
config_key: &'static str,
|
||||
format: ConfigFormat,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct McpAgentInfo {
|
||||
pub id: String,
|
||||
pub display_name: String,
|
||||
pub category: AgentCategory,
|
||||
pub connected: bool,
|
||||
/// True when the underlying client appears to be installed on the system
|
||||
/// (its config directory exists), regardless of whether we have installed
|
||||
/// the donut-browser server into it.
|
||||
pub detected: bool,
|
||||
}
|
||||
|
||||
fn home() -> Option<PathBuf> {
|
||||
dirs::home_dir()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn vscode_user_dir() -> Option<PathBuf> {
|
||||
home().map(|h| {
|
||||
h.join("Library")
|
||||
.join("Application Support")
|
||||
.join("Code")
|
||||
.join("User")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn vscode_user_dir() -> Option<PathBuf> {
|
||||
std::env::var("APPDATA")
|
||||
.ok()
|
||||
.map(|a| PathBuf::from(a).join("Code").join("User"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn vscode_user_dir() -> Option<PathBuf> {
|
||||
let base = std::env::var("XDG_CONFIG_HOME")
|
||||
.ok()
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| home().map(|h| h.join(".config")))?;
|
||||
Some(base.join("Code").join("User"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn zed_config_dir() -> Option<PathBuf> {
|
||||
home().map(|h| h.join("Library").join("Application Support").join("Zed"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn zed_config_dir() -> Option<PathBuf> {
|
||||
std::env::var("APPDATA")
|
||||
.ok()
|
||||
.map(|a| PathBuf::from(a).join("Zed"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn zed_config_dir() -> Option<PathBuf> {
|
||||
let base = std::env::var("XDG_CONFIG_HOME")
|
||||
.ok()
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| home().map(|h| h.join(".config")))?;
|
||||
Some(base.join("zed"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn goose_config_path() -> Option<PathBuf> {
|
||||
std::env::var("APPDATA").ok().map(|a| {
|
||||
PathBuf::from(a)
|
||||
.join("Block")
|
||||
.join("goose")
|
||||
.join("config")
|
||||
.join("config.yaml")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn goose_config_path() -> Option<PathBuf> {
|
||||
let base = std::env::var("XDG_CONFIG_HOME")
|
||||
.ok()
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| home().map(|h| h.join(".config")))?;
|
||||
Some(base.join("goose").join("config.yaml"))
|
||||
}
|
||||
|
||||
/// Resolve the global config path for an agent. Returns `None` on unsupported
|
||||
/// platforms (none currently — every supported agent has a defined path on
|
||||
/// macOS/Linux/Windows).
|
||||
fn config_path_for(agent_id: &str) -> Option<PathBuf> {
|
||||
let h = home()?;
|
||||
match agent_id {
|
||||
"antigravity" => Some(
|
||||
h.join(".gemini")
|
||||
.join("antigravity")
|
||||
.join("mcp_config.json"),
|
||||
),
|
||||
"cline" => vscode_user_dir().map(|d| {
|
||||
d.join("globalStorage")
|
||||
.join("saoudrizwan.claude-dev")
|
||||
.join("settings")
|
||||
.join("cline_mcp_settings.json")
|
||||
}),
|
||||
"cline-cli" => {
|
||||
let base = std::env::var("CLINE_DIR")
|
||||
.ok()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| h.join(".cline"));
|
||||
Some(
|
||||
base
|
||||
.join("data")
|
||||
.join("settings")
|
||||
.join("cline_mcp_settings.json"),
|
||||
)
|
||||
}
|
||||
"claude-code" => Some(h.join(".claude.json")),
|
||||
"claude-desktop" => claude_desktop_config_path(),
|
||||
"codex" => {
|
||||
let base = std::env::var("CODEX_HOME")
|
||||
.ok()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| h.join(".codex"));
|
||||
Some(base.join("config.toml"))
|
||||
}
|
||||
"cursor" => Some(h.join(".cursor").join("mcp.json")),
|
||||
"gemini-cli" => Some(h.join(".gemini").join("settings.json")),
|
||||
"goose" => goose_config_path(),
|
||||
"github-copilot-cli" => Some(
|
||||
std::env::var("XDG_CONFIG_HOME")
|
||||
.ok()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| h.join(".copilot"))
|
||||
.join("mcp-config.json"),
|
||||
),
|
||||
"mcporter" => {
|
||||
// add-mcp's resolveMcporterConfigPath: prefer mcporter.json, fall back
|
||||
// to mcporter.jsonc if it already exists, else default to mcporter.json.
|
||||
let dir = h.join(".mcporter");
|
||||
let json_path = dir.join("mcporter.json");
|
||||
let jsonc_path = dir.join("mcporter.jsonc");
|
||||
if json_path.exists() {
|
||||
Some(json_path)
|
||||
} else if jsonc_path.exists() {
|
||||
Some(jsonc_path)
|
||||
} else {
|
||||
Some(json_path)
|
||||
}
|
||||
}
|
||||
"opencode" => Some(h.join(".config").join("opencode").join("opencode.json")),
|
||||
"vscode" => vscode_user_dir().map(|d| d.join("mcp.json")),
|
||||
"zed" => zed_config_dir().map(|d| d.join("settings.json")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn claude_desktop_config_path() -> Option<PathBuf> {
|
||||
home().map(|h| {
|
||||
h.join("Library")
|
||||
.join("Application Support")
|
||||
.join("Claude")
|
||||
.join("claude_desktop_config.json")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn claude_desktop_config_path() -> Option<PathBuf> {
|
||||
std::env::var("APPDATA").ok().map(|a| {
|
||||
PathBuf::from(a)
|
||||
.join("Claude")
|
||||
.join("claude_desktop_config.json")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn claude_desktop_config_path() -> Option<PathBuf> {
|
||||
let base = std::env::var("XDG_CONFIG_HOME")
|
||||
.ok()
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| home().map(|h| h.join(".config")))?;
|
||||
Some(base.join("Claude").join("claude_desktop_config.json"))
|
||||
}
|
||||
|
||||
const AGENT_SPECS: &[AgentSpec] = &[
|
||||
AgentSpec {
|
||||
id: "claude-desktop",
|
||||
display_name: "Claude Desktop",
|
||||
category: AgentCategory::DesktopApp,
|
||||
config_key: "mcpServers",
|
||||
format: ConfigFormat::Json,
|
||||
},
|
||||
AgentSpec {
|
||||
id: "claude-code",
|
||||
display_name: "Claude Code",
|
||||
category: AgentCategory::Cli,
|
||||
config_key: "mcpServers",
|
||||
format: ConfigFormat::Json,
|
||||
},
|
||||
AgentSpec {
|
||||
id: "cursor",
|
||||
display_name: "Cursor",
|
||||
category: AgentCategory::Editor,
|
||||
config_key: "mcpServers",
|
||||
format: ConfigFormat::Json,
|
||||
},
|
||||
AgentSpec {
|
||||
id: "vscode",
|
||||
display_name: "VS Code",
|
||||
category: AgentCategory::Editor,
|
||||
config_key: "servers",
|
||||
format: ConfigFormat::Json,
|
||||
},
|
||||
AgentSpec {
|
||||
id: "zed",
|
||||
display_name: "Zed",
|
||||
category: AgentCategory::Editor,
|
||||
config_key: "context_servers",
|
||||
format: ConfigFormat::Json,
|
||||
},
|
||||
AgentSpec {
|
||||
id: "cline-cli",
|
||||
display_name: "Cline CLI",
|
||||
category: AgentCategory::Cli,
|
||||
config_key: "mcpServers",
|
||||
format: ConfigFormat::Json,
|
||||
},
|
||||
AgentSpec {
|
||||
id: "cline",
|
||||
display_name: "Cline VSCode",
|
||||
category: AgentCategory::EditorExt,
|
||||
config_key: "mcpServers",
|
||||
format: ConfigFormat::Json,
|
||||
},
|
||||
AgentSpec {
|
||||
id: "codex",
|
||||
display_name: "Codex",
|
||||
category: AgentCategory::Cli,
|
||||
config_key: "mcp_servers",
|
||||
format: ConfigFormat::Toml,
|
||||
},
|
||||
AgentSpec {
|
||||
id: "gemini-cli",
|
||||
display_name: "Gemini CLI",
|
||||
category: AgentCategory::Cli,
|
||||
config_key: "mcpServers",
|
||||
format: ConfigFormat::Json,
|
||||
},
|
||||
AgentSpec {
|
||||
id: "github-copilot-cli",
|
||||
display_name: "GitHub Copilot CLI",
|
||||
category: AgentCategory::Cli,
|
||||
config_key: "mcpServers",
|
||||
format: ConfigFormat::Json,
|
||||
},
|
||||
AgentSpec {
|
||||
id: "goose",
|
||||
display_name: "Goose",
|
||||
category: AgentCategory::Cli,
|
||||
config_key: "extensions",
|
||||
format: ConfigFormat::Yaml,
|
||||
},
|
||||
AgentSpec {
|
||||
id: "antigravity",
|
||||
display_name: "Antigravity",
|
||||
category: AgentCategory::DesktopApp,
|
||||
config_key: "mcpServers",
|
||||
format: ConfigFormat::Json,
|
||||
},
|
||||
AgentSpec {
|
||||
id: "opencode",
|
||||
display_name: "OpenCode",
|
||||
category: AgentCategory::Cli,
|
||||
config_key: "mcp",
|
||||
format: ConfigFormat::Json,
|
||||
},
|
||||
AgentSpec {
|
||||
id: "mcporter",
|
||||
display_name: "MCPorter",
|
||||
category: AgentCategory::Cli,
|
||||
config_key: "mcpServers",
|
||||
format: ConfigFormat::Json,
|
||||
},
|
||||
];
|
||||
|
||||
fn spec_for(agent_id: &str) -> Option<&'static AgentSpec> {
|
||||
AGENT_SPECS.iter().find(|s| s.id == agent_id)
|
||||
}
|
||||
|
||||
fn detect_agent_directory(agent_id: &str) -> bool {
|
||||
// Mirrors add-mcp's `detectGlobalInstall` checks — typically the immediate
|
||||
// parent of the config file. Used only for UI annotation; install/uninstall
|
||||
// always operates on the resolved config path.
|
||||
let Some(h) = home() else {
|
||||
return false;
|
||||
};
|
||||
match agent_id {
|
||||
"antigravity" => h.join(".gemini").exists(),
|
||||
"cline" => config_path_for("cline")
|
||||
.and_then(|p| p.parent().map(|d| d.exists()))
|
||||
.unwrap_or(false),
|
||||
"cline-cli" => config_path_for("cline-cli")
|
||||
.and_then(|p| p.parent().map(|d| d.exists()))
|
||||
.unwrap_or(false),
|
||||
"claude-code" => h.join(".claude").exists(),
|
||||
"claude-desktop" => claude_desktop_config_path()
|
||||
.and_then(|p| p.parent().map(|d| d.exists()))
|
||||
.unwrap_or(false),
|
||||
"codex" => h.join(".codex").exists(),
|
||||
"cursor" => h.join(".cursor").exists(),
|
||||
"gemini-cli" => h.join(".gemini").exists(),
|
||||
"github-copilot-cli" => config_path_for("github-copilot-cli")
|
||||
.and_then(|p| p.parent().map(|d| d.exists()))
|
||||
.unwrap_or(false),
|
||||
"goose" => goose_config_path().is_some_and(|p| p.exists()),
|
||||
"mcporter" => h.join(".mcporter").exists(),
|
||||
"opencode" => h.join(".config").join("opencode").exists(),
|
||||
"vscode" => vscode_user_dir().is_some_and(|d| d.exists()),
|
||||
"zed" => zed_config_dir().is_some_and(|d| d.exists()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Transform the donut-browser HTTP server config into the per-agent shape.
|
||||
/// All agents speak HTTP except Claude Desktop, which uses a node stdio bridge
|
||||
/// (handled by the extension installer in lib.rs).
|
||||
fn transform_remote_config(agent_id: &str, url: &str) -> serde_json::Value {
|
||||
use serde_json::json;
|
||||
match agent_id {
|
||||
"zed" => json!({ "source": "custom", "type": "http", "url": url }),
|
||||
"opencode" => json!({ "type": "remote", "url": url, "enabled": true }),
|
||||
"antigravity" => json!({ "serverUrl": url }),
|
||||
"cursor" => json!({ "url": url }),
|
||||
"cline" | "cline-cli" => json!({
|
||||
"url": url,
|
||||
"type": "streamableHttp",
|
||||
"disabled": false,
|
||||
}),
|
||||
"codex" => json!({ "type": "http", "url": url }),
|
||||
"github-copilot-cli" => json!({ "type": "http", "url": url, "tools": ["*"] }),
|
||||
"goose" => json!({
|
||||
"name": SERVER_NAME,
|
||||
"description": "",
|
||||
"type": "streamable_http",
|
||||
"uri": url,
|
||||
"headers": {},
|
||||
"enabled": true,
|
||||
"timeout": 300,
|
||||
}),
|
||||
"vscode" => json!({ "type": "http", "url": url }),
|
||||
// claude-code, claude-desktop, gemini-cli, mcporter — passthrough
|
||||
_ => json!({ "type": "http", "url": url }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect whether a server config object looks like our donut-browser HTTP
|
||||
/// endpoint by URL prefix. Matches across the various per-agent key shapes
|
||||
/// (`url`, `uri`, `serverUrl`).
|
||||
fn config_matches_donut(value: &serde_json::Value) -> bool {
|
||||
for key in ["url", "uri", "serverUrl"] {
|
||||
if let Some(s) = value.get(key).and_then(|v| v.as_str()) {
|
||||
if s.contains("/mcp/")
|
||||
&& (s.starts_with("http://127.0.0.1") || s.starts_with("http://localhost"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn read_value(path: &Path, format: ConfigFormat) -> serde_json::Value {
|
||||
let Ok(content) = fs::read_to_string(path) else {
|
||||
return serde_json::Value::Null;
|
||||
};
|
||||
match format {
|
||||
ConfigFormat::Json => serde_json::from_str(&content).unwrap_or(serde_json::Value::Null),
|
||||
ConfigFormat::Toml => toml::from_str::<toml::Value>(&content)
|
||||
.ok()
|
||||
.and_then(|t| serde_json::to_value(t).ok())
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
ConfigFormat::Yaml => serde_yaml::from_str::<serde_yaml::Value>(&content)
|
||||
.ok()
|
||||
.and_then(|y| serde_json::to_value(y).ok())
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_value(path: &Path, value: &serde_json::Value, format: ConfigFormat) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("Failed to create config dir: {e}"))?;
|
||||
}
|
||||
let content = match format {
|
||||
ConfigFormat::Json => {
|
||||
serde_json::to_string_pretty(value).map_err(|e| format!("Failed to serialize JSON: {e}"))?
|
||||
}
|
||||
ConfigFormat::Toml => {
|
||||
let toml_val: toml::Value = serde_json::from_value(value.clone())
|
||||
.map_err(|e| format!("Failed to convert to TOML: {e}"))?;
|
||||
toml::to_string_pretty(&toml_val).map_err(|e| format!("Failed to serialize TOML: {e}"))?
|
||||
}
|
||||
ConfigFormat::Yaml => {
|
||||
let yaml_val: serde_yaml::Value = serde_yaml::from_str(
|
||||
&serde_json::to_string(value).map_err(|e| format!("Failed to serialize: {e}"))?,
|
||||
)
|
||||
.map_err(|e| format!("Failed to convert to YAML: {e}"))?;
|
||||
serde_yaml::to_string(&yaml_val).map_err(|e| format!("Failed to serialize YAML: {e}"))?
|
||||
}
|
||||
};
|
||||
fs::write(path, content).map_err(|e| format!("Failed to write config: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Navigate `config_key` (dot notation), creating object literals at each
|
||||
/// missing level. Returns a mutable reference to the bottom container so the
|
||||
/// caller can set/remove server entries.
|
||||
fn ensure_nested_object<'a>(
|
||||
root: &'a mut serde_json::Value,
|
||||
config_key: &str,
|
||||
) -> &'a mut serde_json::Map<String, serde_json::Value> {
|
||||
if !root.is_object() {
|
||||
*root = serde_json::Value::Object(serde_json::Map::new());
|
||||
}
|
||||
let mut current = root.as_object_mut().expect("just set to object");
|
||||
let parts: Vec<&str> = config_key.split('.').collect();
|
||||
for part in &parts {
|
||||
let entry = current
|
||||
.entry(part.to_string())
|
||||
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
|
||||
if !entry.is_object() {
|
||||
*entry = serde_json::Value::Object(serde_json::Map::new());
|
||||
}
|
||||
current = entry.as_object_mut().expect("just ensured object");
|
||||
}
|
||||
current
|
||||
}
|
||||
|
||||
fn nested_object<'a>(
|
||||
root: &'a serde_json::Value,
|
||||
config_key: &str,
|
||||
) -> Option<&'a serde_json::Map<String, serde_json::Value>> {
|
||||
let mut current = root.as_object()?;
|
||||
for part in config_key.split('.') {
|
||||
current = current.get(part)?.as_object()?;
|
||||
}
|
||||
Some(current)
|
||||
}
|
||||
|
||||
fn is_generic_agent_connected(agent_id: &str) -> bool {
|
||||
let Some(spec) = spec_for(agent_id) else {
|
||||
return false;
|
||||
};
|
||||
let Some(path) = config_path_for(agent_id) else {
|
||||
return false;
|
||||
};
|
||||
if !path.exists() {
|
||||
return false;
|
||||
}
|
||||
let root = read_value(&path, spec.format);
|
||||
let Some(servers) = nested_object(&root, spec.config_key) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(entry) = servers.get(SERVER_NAME) {
|
||||
return config_matches_donut(entry);
|
||||
}
|
||||
servers.values().any(config_matches_donut)
|
||||
}
|
||||
|
||||
/// Install or remove the donut-browser entry from a generic agent. Returns
|
||||
/// `true` if a write happened. Callers handle higher-level dispatch (Claude
|
||||
/// Desktop extension setup, Claude Code CLI invocation).
|
||||
pub fn install_generic(agent_id: &str, url: &str) -> Result<(), String> {
|
||||
let spec = spec_for(agent_id).ok_or_else(|| format!("Unknown agent: {agent_id}"))?;
|
||||
let path = config_path_for(agent_id)
|
||||
.ok_or_else(|| format!("Unable to resolve config path for {agent_id}"))?;
|
||||
|
||||
let mut root = if path.exists() {
|
||||
read_value(&path, spec.format)
|
||||
} else {
|
||||
serde_json::Value::Object(serde_json::Map::new())
|
||||
};
|
||||
if !root.is_object() {
|
||||
root = serde_json::Value::Object(serde_json::Map::new());
|
||||
}
|
||||
|
||||
let container = ensure_nested_object(&mut root, spec.config_key);
|
||||
container.insert(
|
||||
SERVER_NAME.to_string(),
|
||||
transform_remote_config(agent_id, url),
|
||||
);
|
||||
|
||||
write_value(&path, &root, spec.format)
|
||||
}
|
||||
|
||||
pub fn uninstall_generic(agent_id: &str) -> Result<(), String> {
|
||||
let spec = spec_for(agent_id).ok_or_else(|| format!("Unknown agent: {agent_id}"))?;
|
||||
let Some(path) = config_path_for(agent_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut root = read_value(&path, spec.format);
|
||||
if !root.is_object() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let container = ensure_nested_object(&mut root, spec.config_key);
|
||||
container.remove(SERVER_NAME);
|
||||
|
||||
write_value(&path, &root, spec.format)
|
||||
}
|
||||
|
||||
pub fn list_agents_with_status(connected_overrides: &[(&str, bool)]) -> Vec<McpAgentInfo> {
|
||||
AGENT_SPECS
|
||||
.iter()
|
||||
.map(|spec| {
|
||||
let connected = connected_overrides
|
||||
.iter()
|
||||
.find(|(id, _)| *id == spec.id)
|
||||
.map(|(_, c)| *c)
|
||||
.unwrap_or_else(|| is_generic_agent_connected(spec.id));
|
||||
McpAgentInfo {
|
||||
id: spec.id.to_string(),
|
||||
display_name: spec.display_name.to_string(),
|
||||
category: spec.category,
|
||||
connected,
|
||||
detected: detect_agent_directory(spec.id),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn agent_exists(agent_id: &str) -> bool {
|
||||
spec_for(agent_id).is_some()
|
||||
}
|
||||
@@ -12,6 +12,20 @@ use std::path::{Path, PathBuf};
|
||||
use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
|
||||
use url::Url;
|
||||
|
||||
fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
|
||||
let tmp = path.with_extension(match path.extension().and_then(|e| e.to_str()) {
|
||||
Some(ext) => format!("{ext}.tmp"),
|
||||
None => "tmp".to_string(),
|
||||
});
|
||||
{
|
||||
let mut f = fs::File::create(&tmp)?;
|
||||
use std::io::Write;
|
||||
f.write_all(data)?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
fs::rename(&tmp, path)
|
||||
}
|
||||
|
||||
pub struct ProfileManager {
|
||||
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
|
||||
wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
|
||||
@@ -396,7 +410,7 @@ impl ProfileManager {
|
||||
create_dir_all(&profile_uuid_dir)?;
|
||||
|
||||
let json = serde_json::to_string_pretty(profile)?;
|
||||
fs::write(profile_file, json)?;
|
||||
atomic_write(&profile_file, json.as_bytes())?;
|
||||
|
||||
// Update tag suggestions after any save
|
||||
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
|
||||
@@ -421,8 +435,26 @@ impl ProfileManager {
|
||||
if path.is_dir() {
|
||||
let metadata_file = path.join("metadata.json");
|
||||
if metadata_file.exists() {
|
||||
let content = fs::read_to_string(&metadata_file)?;
|
||||
let mut profile: BrowserProfile = serde_json::from_str(&content)?;
|
||||
let content = match fs::read_to_string(&metadata_file) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Skipping profile at {}: failed to read metadata.json: {e}",
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut profile: BrowserProfile = match serde_json::from_str(&content) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Skipping profile at {}: invalid metadata.json: {e}",
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Backfill host_os from browser config for profiles created before
|
||||
// the field existed (or synced without it).
|
||||
@@ -431,7 +463,7 @@ impl ProfileManager {
|
||||
if let Some(os) = inferred_os {
|
||||
profile.host_os = Some(os);
|
||||
if let Ok(json) = serde_json::to_string_pretty(&profile) {
|
||||
let _ = fs::write(&metadata_file, json);
|
||||
let _ = atomic_write(&metadata_file, json.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user