From d20026a28a4a5369bb2c3dbfa52d5c64846cdcf7 Mon Sep 17 00:00:00 2001 From: Sreeram Sreedhar Date: Mon, 22 Jun 2026 17:47:44 -0700 Subject: [PATCH] Add desktop AI tool connectors --- apps/desktop/app/(app)/settings/page.tsx | 178 +++++++- apps/desktop/lib/tools.ts | 51 +++ apps/desktop/src-tauri/Cargo.lock | 39 +- apps/desktop/src-tauri/Cargo.toml | 1 + apps/desktop/src-tauri/src/lib.rs | 33 +- apps/desktop/src-tauri/src/tools.rs | 552 +++++++++++++++++++++++ 6 files changed, 843 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/lib/tools.ts create mode 100644 apps/desktop/src-tauri/src/tools.rs diff --git a/apps/desktop/app/(app)/settings/page.tsx b/apps/desktop/app/(app)/settings/page.tsx index 028bd840..71e3cc53 100644 --- a/apps/desktop/app/(app)/settings/page.tsx +++ b/apps/desktop/app/(app)/settings/page.tsx @@ -32,6 +32,16 @@ import { syncSmfs, unmountSmfs, } from "@/lib/smfs" +import { + connectDesktopTool, + detectDesktopTools, + disconnectDesktopTool, + type DesktopToolId, + type DesktopToolPreview, + type DesktopToolStatus, + previewConnectDesktopTool, + previewDisconnectDesktopTool, +} from "@/lib/tools" type AppInfo = { name: string @@ -52,6 +62,12 @@ export default function SettingsPage() { const [smfsBusy, setSmfsBusy] = useState(null) const [smfsLogs, setSmfsLogs] = useState(null) const [smfsProfile, setSmfsProfile] = useState(null) + const [tools, setTools] = useState([]) + const [toolsError, setToolsError] = useState(null) + const [toolsBusy, setToolsBusy] = useState(null) + const [toolPreview, setToolPreview] = useState( + null, + ) const refreshSmfsState = useCallback(async () => { setSmfsError(null) @@ -62,6 +78,15 @@ export default function SettingsPage() { } }, []) + const refreshTools = useCallback(async () => { + setToolsError(null) + try { + setTools(await detectDesktopTools()) + } catch (err) { + setToolsError(formatError(err, "Could not detect tools")) + } + }, []) + useEffect(() => { invoke("app_info") .then(setInfo) @@ -76,7 +101,8 @@ export default function SettingsPage() { .then(setSmfsTag) .catch(() => setSmfsTag("sm_fs_desktop")) refreshSmfsState() - }, [refreshSmfsState]) + refreshTools() + }, [refreshSmfsState, refreshTools]) useEffect(() => { let unlisten: (() => void) | undefined @@ -162,6 +188,45 @@ export default function SettingsPage() { } } + async function previewToolAction( + toolId: DesktopToolId, + action: "connect" | "disconnect", + ) { + setToolsError(null) + setToolsBusy(`${action}:${toolId}`) + try { + const preview = + action === "connect" + ? await previewConnectDesktopTool(toolId) + : await previewDisconnectDesktopTool(toolId) + setToolPreview(preview) + } catch (err) { + setToolsError(formatError(err, `Could not preview ${action}`)) + } finally { + setToolsBusy(null) + } + } + + async function applyToolPreview() { + if (!toolPreview) return + + setToolsError(null) + setToolsBusy(`${toolPreview.action}:${toolPreview.tool.id}`) + try { + if (toolPreview.action === "connect") { + await connectDesktopTool(toolPreview.tool.id) + } else { + await disconnectDesktopTool(toolPreview.tool.id) + } + setToolPreview(null) + await refreshTools() + } catch (err) { + setToolsError(formatError(err, `Could not ${toolPreview.action} tool`)) + } finally { + setToolsBusy(null) + } + } + return (

Settings

@@ -391,6 +456,117 @@ export default function SettingsPage() { + + + AI tools + + Detect local tools and connect them to the Supermemory MCP server. + + + +
+ +
+
+ {tools.map((tool) => ( +
+
+
+
+

{tool.name}

+ + {tool.connected + ? "Connected" + : tool.detected + ? "Detected" + : "Not detected"} + +
+

+ {tool.detail} +

+

+ {tool.configPath} +

+
+
+ + +
+
+
+ ))} +
+ {toolsError ? ( +

{toolsError}

+ ) : null} + {toolPreview ? ( +
+
+

+ {toolPreview.action === "connect" ? "Connect" : "Disconnect"}{" "} + {toolPreview.tool.name} +

+ + +
+
+								{toolPreview.diff}
+							
+
+ + +
+
+ ) : null} +
+
+ About diff --git a/apps/desktop/lib/tools.ts b/apps/desktop/lib/tools.ts new file mode 100644 index 00000000..dfeb7932 --- /dev/null +++ b/apps/desktop/lib/tools.ts @@ -0,0 +1,51 @@ +"use client" + +import { invoke } from "@tauri-apps/api/core" + +export type DesktopToolId = "claude-code" | "codex" | "cursor" + +export type DesktopToolStatus = { + id: DesktopToolId + name: string + detected: boolean + connected: boolean + configPath: string + configExists: boolean + installHint: string + detail: string +} + +export type DesktopToolPreview = { + tool: DesktopToolStatus + action: "connect" | "disconnect" + configPath: string + backupPath?: string | null + diff: string + before: string + after: string +} + +export type DesktopToolConnectResult = { + tool: DesktopToolStatus + backupPath?: string | null +} + +export function detectDesktopTools() { + return invoke("tools_detect") +} + +export function previewConnectDesktopTool(toolId: DesktopToolId) { + return invoke("tools_preview_connect", { toolId }) +} + +export function connectDesktopTool(toolId: DesktopToolId) { + return invoke("tools_connect", { toolId }) +} + +export function previewDisconnectDesktopTool(toolId: DesktopToolId) { + return invoke("tools_preview_disconnect", { toolId }) +} + +export function disconnectDesktopTool(toolId: DesktopToolId) { + return invoke("tools_disconnect", { toolId }) +} diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 24c8f9a0..ee430ec5 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -1113,7 +1113,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" dependencies = [ "heck 0.4.1", - "proc-macro-crate 2.0.2", + "proc-macro-crate 2.0.0", "proc-macro-error", "proc-macro2", "quote", @@ -2302,11 +2302,10 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "2.0.2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" dependencies = [ - "toml_datetime 0.6.3", "toml_edit 0.20.2", ] @@ -3119,6 +3118,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-global-shortcut", + "toml_edit 0.22.27", ] [[package]] @@ -3637,7 +3637,7 @@ checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" dependencies = [ "serde", "serde_spanned 0.6.9", - "toml_datetime 0.6.3", + "toml_datetime 0.6.11", "toml_edit 0.20.2", ] @@ -3673,9 +3673,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.3" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] @@ -3705,7 +3705,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap 2.14.0", - "toml_datetime 0.6.3", + "toml_datetime 0.6.11", "winnow 0.5.40", ] @@ -3718,10 +3718,22 @@ dependencies = [ "indexmap 2.14.0", "serde", "serde_spanned 0.6.9", - "toml_datetime 0.6.3", + "toml_datetime 0.6.11", "winnow 0.5.40", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.25.12+spec-1.1.0" @@ -3743,6 +3755,12 @@ dependencies = [ "winnow 1.0.3", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.1.1+spec-1.1.0" @@ -4664,6 +4682,9 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] [[package]] name = "winnow" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index a01b1f3c..5af75437 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -21,3 +21,4 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus serde = { version = "1", features = ["derive"] } serde_json = "1" tauri-plugin-global-shortcut = "2" +toml_edit = "0.22" diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 71057db0..cfd4d2eb 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod auth; mod smfs; mod spotlight; +mod tools; mod tray; use serde::Serialize; @@ -125,6 +126,31 @@ fn smfs_profile(app: tauri::AppHandle, tag: Option) -> Result Result, String> { + tools::detect() +} + +#[tauri::command] +fn tools_preview_connect(tool_id: String) -> Result { + tools::preview_connect(tool_id) +} + +#[tauri::command] +fn tools_connect(tool_id: String) -> Result { + tools::connect(tool_id) +} + +#[tauri::command] +fn tools_preview_disconnect(tool_id: String) -> Result { + tools::preview_disconnect(tool_id) +} + +#[tauri::command] +fn tools_disconnect(tool_id: String) -> Result { + tools::disconnect(tool_id) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -156,7 +182,12 @@ pub fn run() { smfs_reveal, smfs_logs, smfs_default_container_tag, - smfs_profile + smfs_profile, + tools_detect, + tools_preview_connect, + tools_connect, + tools_preview_disconnect, + tools_disconnect ]) // Bootstrap failure is unrecoverable (no window, no app), so we abort // loudly here. This is the one sanctioned `expect` — see roadmap quality bar. diff --git a/apps/desktop/src-tauri/src/tools.rs b/apps/desktop/src-tauri/src/tools.rs new file mode 100644 index 00000000..29af5ed7 --- /dev/null +++ b/apps/desktop/src-tauri/src/tools.rs @@ -0,0 +1,552 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, + process::Command, +}; + +use serde::Serialize; +use serde_json::{json, Map, Value}; +use toml_edit::{value, Array, DocumentMut, Table}; + +const MCP_URL: &str = "https://mcp.supermemory.ai/mcp"; +const SERVER_NAME: &str = "supermemory"; + +#[derive(Clone, Copy)] +enum ToolId { + ClaudeCode, + Codex, + Cursor, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolStatus { + id: &'static str, + name: &'static str, + detected: bool, + connected: bool, + config_path: String, + config_exists: bool, + install_hint: &'static str, + detail: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolPreview { + tool: ToolStatus, + action: &'static str, + config_path: String, + backup_path: Option, + diff: String, + before: String, + after: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolConnectResult { + tool: ToolStatus, + backup_path: Option, +} + +pub fn detect() -> Result, String> { + let mut statuses = tool_ids() + .iter() + .map(|tool| status_for_tool(*tool)) + .collect::, _>>()?; + statuses.sort_by_key(|status| (!status.detected, !status.connected, status.name)); + Ok(statuses) +} + +pub fn preview_connect(tool_id: String) -> Result { + preview(tool_from_id(&tool_id)?, "connect") +} + +pub fn connect(tool_id: String) -> Result { + apply(tool_from_id(&tool_id)?, "connect") +} + +pub fn preview_disconnect(tool_id: String) -> Result { + preview(tool_from_id(&tool_id)?, "disconnect") +} + +pub fn disconnect(tool_id: String) -> Result { + apply(tool_from_id(&tool_id)?, "disconnect") +} + +fn preview(tool: ToolId, action: &'static str) -> Result { + let config_path = config_path(tool)?; + let before = read_config_or_empty(&config_path)?; + let after = next_config(tool, action, &before)?; + let status = status_for_tool(tool)?; + let backup_path = if config_path.exists() { + Some(path_to_string(&backup_path(&config_path))) + } else { + None + }; + + Ok(ToolPreview { + tool: status, + action, + config_path: path_to_string(&config_path), + backup_path, + diff: make_diff(&before, &after), + before, + after, + }) +} + +fn apply(tool: ToolId, action: &'static str) -> Result { + let config_path = config_path(tool)?; + let before = read_config_or_empty(&config_path)?; + let after = next_config(tool, action, &before)?; + let backup = write_config_atomically(&config_path, after.as_bytes())?; + + Ok(ToolConnectResult { + tool: status_for_tool(tool)?, + backup_path: backup.map(|path| path_to_string(&path)), + }) +} + +fn next_config(tool: ToolId, action: &str, before: &str) -> Result { + match action { + "connect" => match tool { + ToolId::ClaudeCode | ToolId::Cursor => connect_json(before), + ToolId::Codex => connect_codex_toml(before), + }, + "disconnect" => match tool { + ToolId::ClaudeCode | ToolId::Cursor => disconnect_json(before), + ToolId::Codex => disconnect_codex_toml(before), + }, + _ => Err(format!("Unsupported tools action: {action}")), + } +} + +fn status_for_tool(tool: ToolId) -> Result { + let config_path = config_path(tool)?; + let config_exists = config_path.exists(); + let connected = is_connected(tool, &config_path)?; + let detected = is_detected(tool); + + Ok(ToolStatus { + id: tool.id(), + name: tool.name(), + detected, + connected, + config_path: path_to_string(&config_path), + config_exists, + install_hint: tool.install_hint(), + detail: status_detail(tool, detected, connected, config_exists), + }) +} + +fn is_connected(tool: ToolId, config_path: &Path) -> Result { + if !config_path.exists() { + return Ok(false); + } + + let contents = fs::read_to_string(config_path).map_err(|error| error.to_string())?; + match tool { + ToolId::ClaudeCode | ToolId::Cursor => json_has_supermemory_server(&contents), + ToolId::Codex => codex_toml_has_supermemory_server(&contents), + } +} + +fn connect_json(before: &str) -> Result { + let mut root = if before.trim().is_empty() { + Value::Object(Map::new()) + } else { + serde_json::from_str::(before).map_err(|error| format!("Invalid JSON: {error}"))? + }; + + if !root.is_object() { + return Err("MCP JSON config must be a JSON object".to_string()); + } + + let root_object = root + .as_object_mut() + .ok_or_else(|| "MCP JSON config must be a JSON object".to_string())?; + let servers = root_object + .entry("mcpServers") + .or_insert_with(|| Value::Object(Map::new())); + + if !servers.is_object() { + return Err("mcpServers must be a JSON object".to_string()); + } + + servers + .as_object_mut() + .ok_or_else(|| "mcpServers must be a JSON object".to_string())? + .insert(SERVER_NAME.to_string(), json!({ "url": MCP_URL })); + + serde_json::to_string_pretty(&root) + .map(|json| format!("{json}\n")) + .map_err(|error| error.to_string()) +} + +fn disconnect_json(before: &str) -> Result { + if before.trim().is_empty() { + return Ok(String::new()); + } + + let mut root = + serde_json::from_str::(before).map_err(|error| format!("Invalid JSON: {error}"))?; + + if let Some(servers) = root.get_mut("mcpServers").and_then(Value::as_object_mut) { + servers.remove(SERVER_NAME); + } + + serde_json::to_string_pretty(&root) + .map(|json| format!("{json}\n")) + .map_err(|error| error.to_string()) +} + +fn connect_codex_toml(before: &str) -> Result { + let mut doc = parse_toml(before)?; + let servers = doc + .entry("mcp_servers") + .or_insert(toml_edit::Item::Table(Table::new())); + + if !servers.is_table() { + return Err("mcp_servers must be a TOML table".to_string()); + } + + let servers_table = servers + .as_table_mut() + .ok_or_else(|| "mcp_servers must be a TOML table".to_string())?; + let server = servers_table + .entry(SERVER_NAME) + .or_insert(toml_edit::Item::Table(Table::new())); + + if !server.is_table() { + *server = toml_edit::Item::Table(Table::new()); + } + + let server_table = server + .as_table_mut() + .ok_or_else(|| "supermemory MCP server must be a TOML table".to_string())?; + server_table["command"] = value("npx"); + + let mut args = Array::new(); + args.push("-y"); + args.push("mcp-remote@latest"); + args.push(MCP_URL); + server_table["args"] = value(args); + + Ok(doc.to_string()) +} + +fn disconnect_codex_toml(before: &str) -> Result { + let mut doc = parse_toml(before)?; + + if let Some(servers) = doc + .get_mut("mcp_servers") + .and_then(toml_edit::Item::as_table_mut) + { + servers.remove(SERVER_NAME); + } + + Ok(doc.to_string()) +} + +fn json_has_supermemory_server(contents: &str) -> Result { + let value = serde_json::from_str::(contents) + .map_err(|error| format!("Invalid JSON: {error}"))?; + + Ok(value + .get("mcpServers") + .and_then(Value::as_object) + .and_then(|servers| servers.get(SERVER_NAME)) + .is_some()) +} + +fn codex_toml_has_supermemory_server(contents: &str) -> Result { + let doc = parse_toml(contents)?; + Ok(doc + .get("mcp_servers") + .and_then(toml_edit::Item::as_table) + .and_then(|servers| servers.get(SERVER_NAME)) + .is_some()) +} + +fn parse_toml(contents: &str) -> Result { + if contents.trim().is_empty() { + Ok(DocumentMut::new()) + } else { + contents + .parse::() + .map_err(|error| format!("Invalid TOML: {error}")) + } +} + +fn write_config_atomically(path: &Path, bytes: &[u8]) -> Result, String> { + let parent = path + .parent() + .ok_or_else(|| "Could not resolve config parent directory".to_string())?; + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + + let backup = if path.exists() { + let backup_path = backup_path(path); + fs::copy(path, &backup_path).map_err(|error| error.to_string())?; + Some(backup_path) + } else { + None + }; + + let temp_path = path.with_extension("tmp"); + fs::write(&temp_path, bytes).map_err(|error| error.to_string())?; + fs::rename(temp_path, path).map_err(|error| error.to_string())?; + Ok(backup) +} + +fn read_config_or_empty(path: &Path) -> Result { + match fs::read_to_string(path) { + Ok(contents) => Ok(contents), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), + Err(error) => Err(error.to_string()), + } +} + +fn backup_path(path: &Path) -> PathBuf { + let extension = path + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| format!("{extension}.smbak")) + .unwrap_or_else(|| "smbak".to_string()); + path.with_extension(extension) +} + +fn make_diff(before: &str, after: &str) -> String { + if before == after { + return "No changes.".to_string(); + } + + let mut diff = String::from("--- current\n+++ proposed\n"); + if !before.is_empty() { + for line in before.lines() { + diff.push('-'); + diff.push_str(line); + diff.push('\n'); + } + } + for line in after.lines() { + diff.push('+'); + diff.push_str(line); + diff.push('\n'); + } + diff +} + +fn is_detected(tool: ToolId) -> bool { + match tool { + ToolId::ClaudeCode => find_binary("claude").is_some() || home_path(".claude").exists(), + ToolId::Codex => find_binary("codex").is_some() || home_path(".codex").exists(), + ToolId::Cursor => { + PathBuf::from("/Applications/Cursor.app").exists() + || find_binary("cursor").is_some() + || home_path(".cursor").exists() + } + } +} + +fn status_detail(tool: ToolId, detected: bool, connected: bool, config_exists: bool) -> String { + if connected { + return "Connected to the Supermemory MCP server.".to_string(); + } + + if config_exists { + return "Config found; Supermemory is not connected yet.".to_string(); + } + + if detected { + return "Detected locally; ready to connect.".to_string(); + } + + format!("Not detected. {}", tool.install_hint()) +} + +fn config_path(tool: ToolId) -> Result { + let home = home_dir()?; + Ok(match tool { + ToolId::ClaudeCode => home.join(".claude.json"), + ToolId::Codex => env::var("CODEX_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| home.join(".codex")) + .join("config.toml"), + ToolId::Cursor => home.join(".cursor").join("mcp.json"), + }) +} + +fn home_path(path: &str) -> PathBuf { + home_dir() + .map(|home| home.join(path)) + .unwrap_or_else(|_| PathBuf::from(path)) +} + +fn home_dir() -> Result { + env::var("HOME") + .map(PathBuf::from) + .map_err(|_| "Could not resolve HOME".to_string()) +} + +fn find_binary(binary: &str) -> Option { + shell_path_entries() + .into_iter() + .map(|path| path.join(binary)) + .find(|candidate| candidate.is_file()) +} + +fn shell_path_entries() -> Vec { + let mut paths: Vec = env::var_os("PATH") + .map(|path| env::split_paths(&path).collect()) + .unwrap_or_default(); + + if let Ok(shell) = env::var("SHELL") { + if let Ok(output) = Command::new(shell) + .args(["-lc", "printf %s \"$PATH\""]) + .output() + { + if output.status.success() { + let shell_path = String::from_utf8_lossy(&output.stdout); + paths.extend(env::split_paths(shell_path.trim())); + } + } + } + + if let Ok(home) = home_dir() { + paths.push(home.join(".local").join("bin")); + paths.push(home.join(".bun").join("bin")); + } + paths.push(PathBuf::from("/opt/homebrew/bin")); + paths.push(PathBuf::from("/usr/local/bin")); + + paths.sort(); + paths.dedup(); + paths +} + +fn tool_from_id(id: &str) -> Result { + match id { + "claude-code" => Ok(ToolId::ClaudeCode), + "codex" => Ok(ToolId::Codex), + "cursor" => Ok(ToolId::Cursor), + _ => Err(format!("Unsupported tool: {id}")), + } +} + +fn tool_ids() -> [ToolId; 3] { + [ToolId::ClaudeCode, ToolId::Codex, ToolId::Cursor] +} + +impl ToolId { + fn id(self) -> &'static str { + match self { + ToolId::ClaudeCode => "claude-code", + ToolId::Codex => "codex", + ToolId::Cursor => "cursor", + } + } + + fn name(self) -> &'static str { + match self { + ToolId::ClaudeCode => "Claude Code", + ToolId::Codex => "Codex", + ToolId::Cursor => "Cursor", + } + } + + fn install_hint(self) -> &'static str { + match self { + ToolId::ClaudeCode => "Install Claude Code or create ~/.claude.json.", + ToolId::Codex => "Install Codex CLI or create ~/.codex/config.toml.", + ToolId::Cursor => "Install Cursor or create ~/.cursor/mcp.json.", + } + } +} + +fn path_to_string(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_connect_preserves_existing_servers() { + let before = r#"{ + "mcpServers": { + "other": { + "url": "https://example.com" + } + } +}"#; + + let after = connect_json(before).expect("json connect should succeed"); + let parsed = serde_json::from_str::(&after).expect("valid json"); + let servers = parsed + .get("mcpServers") + .and_then(Value::as_object) + .expect("mcpServers object"); + + assert!(servers.get("other").is_some()); + assert_eq!( + servers + .get(SERVER_NAME) + .and_then(|server| server.get("url")) + .and_then(Value::as_str), + Some(MCP_URL) + ); + } + + #[test] + fn json_disconnect_only_removes_supermemory() { + let before = connect_json(r#"{"mcpServers":{"other":{"url":"https://example.com"}}}"#) + .expect("json connect should succeed"); + let after = disconnect_json(&before).expect("json disconnect should succeed"); + let parsed = serde_json::from_str::(&after).expect("valid json"); + let servers = parsed + .get("mcpServers") + .and_then(Value::as_object) + .expect("mcpServers object"); + + assert!(servers.get("other").is_some()); + assert!(servers.get(SERVER_NAME).is_none()); + } + + #[test] + fn codex_connect_preserves_existing_toml() { + let before = r#"model = "gpt-5-codex" + +[mcp_servers.other] +command = "node" +"#; + + let after = connect_codex_toml(before).expect("toml connect should succeed"); + let parsed = after.parse::().expect("valid toml"); + + assert_eq!(parsed["model"].as_str(), Some("gpt-5-codex")); + assert!(parsed["mcp_servers"]["other"].is_table()); + assert_eq!( + parsed["mcp_servers"][SERVER_NAME]["command"].as_str(), + Some("npx") + ); + } + + #[test] + fn codex_disconnect_only_removes_supermemory() { + let before = connect_codex_toml( + r#"[mcp_servers.other] +command = "node" +"#, + ) + .expect("toml connect should succeed"); + let after = disconnect_codex_toml(&before).expect("toml disconnect should succeed"); + let parsed = after.parse::().expect("valid toml"); + let servers = parsed["mcp_servers"].as_table().expect("mcp_servers table"); + + assert!(servers.get("other").is_some_and(toml_edit::Item::is_table)); + assert!(servers.get(SERVER_NAME).is_none()); + } +}