Add desktop AI tool connectors

This commit is contained in:
Sreeram Sreedhar 2026-06-22 17:47:44 -07:00
parent 7249b287e9
commit d20026a28a
6 changed files with 843 additions and 11 deletions

View file

@ -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<string | null>(null)
const [smfsLogs, setSmfsLogs] = useState<string | null>(null)
const [smfsProfile, setSmfsProfile] = useState<SmfsProfile | null>(null)
const [tools, setTools] = useState<DesktopToolStatus[]>([])
const [toolsError, setToolsError] = useState<string | null>(null)
const [toolsBusy, setToolsBusy] = useState<string | null>(null)
const [toolPreview, setToolPreview] = useState<DesktopToolPreview | null>(
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<AppInfo>("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 (
<div className="mx-auto w-full max-w-2xl p-8">
<h1 className="mb-6 font-semibold text-2xl">Settings</h1>
@ -391,6 +456,117 @@ export default function SettingsPage() {
</CardContent>
</Card>
<Card className="mb-4">
<CardHeader>
<CardTitle className="text-base">AI tools</CardTitle>
<CardDescription>
Detect local tools and connect them to the Supermemory MCP server.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div className="flex justify-end">
<Button
type="button"
variant="outline"
disabled={toolsBusy !== null}
onClick={() => refreshTools()}
>
Refresh
</Button>
</div>
<div className="space-y-3">
{tools.map((tool) => (
<div
key={tool.id}
className="rounded-md border border-border bg-muted/10 p-3"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium">{tool.name}</p>
<span className="rounded-full border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
{tool.connected
? "Connected"
: tool.detected
? "Detected"
: "Not detected"}
</span>
</div>
<p className="text-muted-foreground text-xs">
{tool.detail}
</p>
<p
className="truncate font-mono text-muted-foreground text-xs"
title={tool.configPath}
>
{tool.configPath}
</p>
</div>
<div className="flex shrink-0 gap-2">
<Button
type="button"
variant="outline"
disabled={toolsBusy !== null}
onClick={() => previewToolAction(tool.id, "connect")}
>
Preview
</Button>
<Button
type="button"
variant="outline"
disabled={toolsBusy !== null || !tool.connected}
onClick={() => previewToolAction(tool.id, "disconnect")}
>
Disconnect
</Button>
</div>
</div>
</div>
))}
</div>
{toolsError ? (
<p className="text-destructive text-sm">{toolsError}</p>
) : null}
{toolPreview ? (
<div className="space-y-3 rounded-md border border-border bg-muted/10 p-3">
<div className="space-y-1">
<p className="font-medium">
{toolPreview.action === "connect" ? "Connect" : "Disconnect"}{" "}
{toolPreview.tool.name}
</p>
<Row label="Config" value={toolPreview.configPath} />
<Row
label="Backup"
value={
toolPreview.backupPath ?? "Created only if file exists"
}
/>
</div>
<pre className="max-h-80 overflow-auto rounded-md border border-border bg-background p-3 whitespace-pre-wrap text-xs">
{toolPreview.diff}
</pre>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
disabled={toolsBusy !== null}
onClick={() => setToolPreview(null)}
>
Cancel
</Button>
<Button
type="button"
disabled={toolsBusy !== null}
onClick={() => applyToolPreview()}
>
{toolsBusy ? "Applying..." : "Apply"}
</Button>
</div>
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">About</CardTitle>

51
apps/desktop/lib/tools.ts Normal file
View file

@ -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<DesktopToolStatus[]>("tools_detect")
}
export function previewConnectDesktopTool(toolId: DesktopToolId) {
return invoke<DesktopToolPreview>("tools_preview_connect", { toolId })
}
export function connectDesktopTool(toolId: DesktopToolId) {
return invoke<DesktopToolConnectResult>("tools_connect", { toolId })
}
export function previewDisconnectDesktopTool(toolId: DesktopToolId) {
return invoke<DesktopToolPreview>("tools_preview_disconnect", { toolId })
}
export function disconnectDesktopTool(toolId: DesktopToolId) {
return invoke<DesktopToolConnectResult>("tools_disconnect", { toolId })
}

View file

@ -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"

View file

@ -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"

View file

@ -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<String>) -> Result<smfs::Smfs
smfs::profile(&app, tag)
}
#[tauri::command]
fn tools_detect() -> Result<Vec<tools::ToolStatus>, String> {
tools::detect()
}
#[tauri::command]
fn tools_preview_connect(tool_id: String) -> Result<tools::ToolPreview, String> {
tools::preview_connect(tool_id)
}
#[tauri::command]
fn tools_connect(tool_id: String) -> Result<tools::ToolConnectResult, String> {
tools::connect(tool_id)
}
#[tauri::command]
fn tools_preview_disconnect(tool_id: String) -> Result<tools::ToolPreview, String> {
tools::preview_disconnect(tool_id)
}
#[tauri::command]
fn tools_disconnect(tool_id: String) -> Result<tools::ToolConnectResult, String> {
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.

View file

@ -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<String>,
diff: String,
before: String,
after: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolConnectResult {
tool: ToolStatus,
backup_path: Option<String>,
}
pub fn detect() -> Result<Vec<ToolStatus>, String> {
let mut statuses = tool_ids()
.iter()
.map(|tool| status_for_tool(*tool))
.collect::<Result<Vec<_>, _>>()?;
statuses.sort_by_key(|status| (!status.detected, !status.connected, status.name));
Ok(statuses)
}
pub fn preview_connect(tool_id: String) -> Result<ToolPreview, String> {
preview(tool_from_id(&tool_id)?, "connect")
}
pub fn connect(tool_id: String) -> Result<ToolConnectResult, String> {
apply(tool_from_id(&tool_id)?, "connect")
}
pub fn preview_disconnect(tool_id: String) -> Result<ToolPreview, String> {
preview(tool_from_id(&tool_id)?, "disconnect")
}
pub fn disconnect(tool_id: String) -> Result<ToolConnectResult, String> {
apply(tool_from_id(&tool_id)?, "disconnect")
}
fn preview(tool: ToolId, action: &'static str) -> Result<ToolPreview, String> {
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<ToolConnectResult, String> {
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<String, String> {
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<ToolStatus, String> {
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<bool, String> {
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<String, String> {
let mut root = if before.trim().is_empty() {
Value::Object(Map::new())
} else {
serde_json::from_str::<Value>(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<String, String> {
if before.trim().is_empty() {
return Ok(String::new());
}
let mut root =
serde_json::from_str::<Value>(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<String, String> {
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<String, String> {
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<bool, String> {
let value = serde_json::from_str::<Value>(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<bool, String> {
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<DocumentMut, String> {
if contents.trim().is_empty() {
Ok(DocumentMut::new())
} else {
contents
.parse::<DocumentMut>()
.map_err(|error| format!("Invalid TOML: {error}"))
}
}
fn write_config_atomically(path: &Path, bytes: &[u8]) -> Result<Option<PathBuf>, 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<String, String> {
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<PathBuf, String> {
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<PathBuf, String> {
env::var("HOME")
.map(PathBuf::from)
.map_err(|_| "Could not resolve HOME".to_string())
}
fn find_binary(binary: &str) -> Option<PathBuf> {
shell_path_entries()
.into_iter()
.map(|path| path.join(binary))
.find(|candidate| candidate.is_file())
}
fn shell_path_entries() -> Vec<PathBuf> {
let mut paths: Vec<PathBuf> = 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<ToolId, String> {
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::<Value>(&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::<Value>(&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::<DocumentMut>().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::<DocumentMut>().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());
}
}