mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-06 08:16:03 +00:00
Add desktop AI tool connectors
This commit is contained in:
parent
7249b287e9
commit
d20026a28a
6 changed files with 843 additions and 11 deletions
|
|
@ -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
51
apps/desktop/lib/tools.ts
Normal 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 })
|
||||
}
|
||||
39
apps/desktop/src-tauri/Cargo.lock
generated
39
apps/desktop/src-tauri/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
552
apps/desktop/src-tauri/src/tools.rs
Normal file
552
apps/desktop/src-tauri/src/tools.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue