diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 60c91236..9bd22c25 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -18,6 +18,32 @@ In development, the desktop UI and Rust auth commands default to the local API a `http://localhost:8787`. Set `NEXT_PUBLIC_BACKEND_URL` / `SUPERMEMORY_API_URL` if you need a different API. +## Manual onboarding verification + +Run the API locally on `localhost:8787`, then start the desktop app: + +```sh +bun run tauri dev +``` + +Use this path before shipping auth or onboarding changes: + +1. Clear `localStorage["supermemory-desktop-onboarding-v1"]` if you need a + fresh first-run state. +2. Sign in with Google or email from the desktop login page. +3. Confirm the browser callback returns to the desktop app and the first + authenticated route is `/onboarding`, not the web console. +4. In the tools step, rescan and confirm Claude Code, Codex, and Cursor show + detected paths when installed or configured. +5. Preview each tool connection and verify the diff targets the expected config: + `~/.claude.json`, `$CODEX_HOME/config.toml` or `~/.codex/config.toml`, and + `~/.cursor/mcp.json`. +6. Apply one connection and confirm the app writes the MCP server, creates a + `.smbak` backup when the config already existed, and reports the tool as + connected after refresh. +7. Mount or skip the filesystem step, finish onboarding, relaunch the app, and + confirm completed or skipped onboarding does not appear again. + SMFS integration uses the first available binary in this order: 1. `SUPERMEMORY_DESKTOP_SMFS_BIN` diff --git a/apps/desktop/app/(app)/page.tsx b/apps/desktop/app/(app)/page.tsx index bc893b6c..d455f447 100644 --- a/apps/desktop/app/(app)/page.tsx +++ b/apps/desktop/app/(app)/page.tsx @@ -3,10 +3,7 @@ import { useQuery } from "@tanstack/react-query" import { ArrowRight, - Bot, - CheckCircle2, Clock, - Code2, FileText, Link2, Lightbulb, @@ -16,20 +13,11 @@ import { Search, SendHorizontal, Sparkles, - Terminal, } from "lucide-react" -import { useCallback, useEffect, useMemo, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { listDocuments, type DocumentWithMemories } from "@/lib/api" import type { SearchResult } from "@/lib/search" import { OPEN_MEMORY_EVENT, type SpotlightMemory } from "@/lib/spotlight" -import { - connectDesktopTool, - detectDesktopTools, - type DesktopToolId, - type DesktopToolPreview, - type DesktopToolStatus, - previewConnectDesktopTool, -} from "@/lib/tools" import { SearchCommand, useCommandK } from "@/components/search-command" type MemoryPreview = { @@ -160,9 +148,6 @@ export default function DashboardPage() { - - -

@@ -221,180 +206,6 @@ export default function DashboardPage() { ) } -function ToolOnboardingPanel() { - const [tools, setTools] = useState([]) - const [toolsError, setToolsError] = useState(null) - const [toolsBusy, setToolsBusy] = useState(null) - const [toolPreview, setToolPreview] = useState( - null, - ) - const [loading, setLoading] = useState(true) - - const connectedCount = tools.filter((tool) => tool.connected).length - const detectedCount = tools.filter((tool) => tool.detected).length - - const refreshTools = useCallback(async () => { - setToolsError(null) - setLoading(true) - try { - setTools(await detectDesktopTools()) - } catch (err) { - setToolsError(formatUnknownError(err, "Could not detect local tools")) - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { - refreshTools() - }, [refreshTools]) - - async function previewConnect(toolId: DesktopToolId) { - setToolsError(null) - setToolsBusy(toolId) - try { - setToolPreview(await previewConnectDesktopTool(toolId)) - } catch (err) { - setToolsError(formatUnknownError(err, "Could not prepare setup")) - } finally { - setToolsBusy(null) - } - } - - async function applyPreview() { - if (!toolPreview) return - - setToolsError(null) - setToolsBusy(toolPreview.tool.id) - try { - await connectDesktopTool(toolPreview.tool.id) - setToolPreview(null) - await refreshTools() - } catch (err) { - setToolsError(formatUnknownError(err, "Could not connect tool")) - } finally { - setToolsBusy(null) - } - } - - return ( -

-
-
-

- - Connect AI tools -

-

- Detected {detectedCount} local tools. {connectedCount} connected. -

-
- -
- -
- {tools.map((tool) => { - const Icon = toolIcon(tool.id) - return ( -
-
-
-
- - - -
-

- {tool.name} -

-

- {toolStatusLabel(tool)} -

-
-
- {tool.connected ? ( - - ) : null} -
-

- {tool.detail} -

-
- -
- ) - })} -
- - {toolsError ? ( -

{toolsError}

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

- Connect {toolPreview.tool.name} -

-

- {toolPreview.configPath} -

-
-
- - -
-
-
-						{toolPreview.diff}
-					
-
- ) : null} -
- ) -} - function ComposerAction({ icon: Icon, label, @@ -416,31 +227,6 @@ function ComposerAction({ ) } -function toolIcon(toolId: DesktopToolId) { - switch (toolId) { - case "claude-code": - return Bot - case "codex": - return Terminal - case "cursor": - return Code2 - } -} - -function toolStatusLabel(tool: DesktopToolStatus) { - if (tool.connected) return "Connected" - if (tool.detected) return "Detected" - return "Not detected" -} - -function formatUnknownError(error: unknown, fallback: string) { - return error instanceof Error - ? error.message - : typeof error === "string" - ? error - : fallback -} - function RecentMemoryRow({ document, active, diff --git a/apps/desktop/app/login/page.tsx b/apps/desktop/app/login/page.tsx index 18918034..1ce72f67 100644 --- a/apps/desktop/app/login/page.tsx +++ b/apps/desktop/app/login/page.tsx @@ -32,6 +32,7 @@ import { storeToken, verifyMagicLinkToken, } from "@/lib/auth" +import { postAuthRedirectPath } from "@/lib/onboarding" export default function LoginPage() { const router = useRouter() @@ -55,7 +56,7 @@ export default function LoginPage() { setIsSubmitting(false) try { await getSession() - router.replace("/") + router.replace(postAuthRedirectPath()) } catch (err) { setError(formatError(err, "Could not validate browser sign-in")) } @@ -92,7 +93,7 @@ export default function LoginPage() { try { await storeToken(token) - router.replace("/") + router.replace(postAuthRedirectPath()) } catch (err) { setError(formatError(err, "Could not sign in")) } finally { diff --git a/apps/desktop/app/onboarding/page.tsx b/apps/desktop/app/onboarding/page.tsx new file mode 100644 index 00000000..d1c464a0 --- /dev/null +++ b/apps/desktop/app/onboarding/page.tsx @@ -0,0 +1,809 @@ +"use client" + +import { LogoFull } from "@ui/assets/Logo" +import { cn } from "@lib/utils" +import { + ArrowRight, + Check, + CheckCircle2, + ChevronLeft, + FolderOpen, + Loader2, + RefreshCcw, + X, +} from "lucide-react" +import { useRouter } from "next/navigation" +import { useCallback, useEffect, useState, type ReactNode } from "react" +import { Titlebar } from "@/components/titlebar" +import { getSession, type AuthSession } from "@/lib/auth" +import { + completeDesktopOnboarding, + getDesktopOnboardingStatus, + skipDesktopOnboarding, + type DesktopOnboardingStep, + updateDesktopOnboardingStatus, +} from "@/lib/onboarding" +import { + getDefaultSmfsContainerTag, + getSmfsState, + mountSmfs, + type SmfsStatus, +} from "@/lib/smfs" +import { + sortDesktopToolCards, + toDesktopToolCard, + type DesktopToolCard, +} from "@/lib/tool-catalog" +import { + connectDesktopTool, + detectDesktopTools, + type DesktopToolId, + type DesktopToolPreview, + previewConnectDesktopTool, +} from "@/lib/tools" + +const STEPS: DesktopOnboardingStep[] = [ + "welcome", + "tools", + "filesystem", + "done", +] + +const STEP_LABELS: Record = { + welcome: "Welcome", + tools: "Tools", + filesystem: "Files", + done: "Done", +} + +export default function DesktopOnboardingPage() { + const router = useRouter() + const [session, setSession] = useState(null) + const [authChecked, setAuthChecked] = useState(false) + const [step, setStep] = useState("welcome") + const [connectedTools, setConnectedTools] = useState([]) + + useEffect(() => { + let cancelled = false + getSession() + .then((nextSession) => { + if (cancelled) return + const status = getDesktopOnboardingStatus() + setSession(nextSession) + setStep(status.lastStep === "done" ? "welcome" : status.lastStep) + setConnectedTools(status.connectedTools) + setAuthChecked(true) + }) + .catch(() => { + if (cancelled) return + router.replace("/login") + }) + return () => { + cancelled = true + } + }, [router]) + + const goToStep = useCallback((nextStep: DesktopOnboardingStep) => { + setStep(nextStep) + updateDesktopOnboardingStatus({ lastStep: nextStep }) + }, []) + + const complete = useCallback( + (nextConnectedTools = connectedTools) => { + completeDesktopOnboarding(nextConnectedTools) + router.replace("/") + }, + [connectedTools, router], + ) + + const skip = useCallback(() => { + skipDesktopOnboarding(step) + router.replace("/") + }, [router, step]) + + if (!authChecked || !session) { + return ( +
+ + Loading onboarding... +
+ ) + } + + return ( +
+ +
+ +
+ + + +
+ +
+ {step === "welcome" ? ( + goToStep("tools")} + onSkip={skip} + /> + ) : null} + {step === "tools" ? ( + goToStep("welcome")} + onContinue={() => goToStep("filesystem")} + /> + ) : null} + {step === "filesystem" ? ( + goToStep("tools")} + onContinue={() => goToStep("done")} + /> + ) : null} + {step === "done" ? ( + goToStep("filesystem")} + onFinish={() => complete()} + /> + ) : null} +
+
+
+ ) +} + +function WelcomeStep({ + session, + onContinue, + onSkip, +}: { + session: AuthSession + onContinue: () => void + onSkip: () => void +}) { + return ( +
+
+

+ + Authenticated +

+
+

+ Set up Supermemory on this Mac. +

+

+ Connect local AI tools, mount your memory folder, and make Nova + available everywhere you work. +

+
+
+ + +
+
+
+

Signed in as

+

+ {session.email ?? session.userId} +

+
+ + + + +
+
+
+ ) +} + +function ToolsStep({ + connectedTools, + onConnectedToolsChange, + onBack, + onContinue, +}: { + connectedTools: DesktopToolId[] + onConnectedToolsChange: (tools: DesktopToolId[]) => void + onBack: () => void + onContinue: () => void +}) { + const [tools, setTools] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [busyTool, setBusyTool] = useState(null) + const [preview, setPreview] = useState(null) + + const detectedCount = tools.filter((tool) => tool.detected).length + const connectedCount = tools.filter((tool) => tool.connected).length + + const refreshTools = useCallback(async () => { + setError(null) + setLoading(true) + try { + const statuses = await detectDesktopTools() + const cards = sortDesktopToolCards(statuses.map(toDesktopToolCard)) + setTools(cards) + const nextConnected = cards + .filter((tool) => tool.connected) + .map((tool) => tool.id) + onConnectedToolsChange(nextConnected) + updateDesktopOnboardingStatus({ connectedTools: nextConnected }) + } catch (err) { + setError(formatUnknownError(err, "Could not scan local tools")) + } finally { + setLoading(false) + } + }, [onConnectedToolsChange]) + + useEffect(() => { + refreshTools() + }, [refreshTools]) + + async function previewTool(toolId: DesktopToolId) { + setError(null) + setBusyTool(toolId) + try { + setPreview(await previewConnectDesktopTool(toolId)) + } catch (err) { + setError(formatUnknownError(err, "Could not preview setup")) + } finally { + setBusyTool(null) + } + } + + async function applyPreview() { + if (!preview) return + setError(null) + setBusyTool(preview.tool.id) + try { + await connectDesktopTool(preview.tool.id) + setPreview(null) + await refreshTools() + } catch (err) { + setError(formatUnknownError(err, "Could not connect tool")) + } finally { + setBusyTool(null) + } + } + + return ( +
+ + + Rescan + + } + /> + +
+ {tools.map((tool) => ( + previewTool(tool.id)} + /> + ))} +
+ + {error ?

{error}

: null} + + {preview ? ( + setPreview(null)} + onApply={applyPreview} + /> + ) : null} + + 0 ? "Continue" : "Skip tools"} + /> +
+ ) +} + +function FilesystemStep({ + onBack, + onContinue, +}: { + onBack: () => void + onContinue: () => void +}) { + const [tag, setTag] = useState("sm_fs_desktop") + const [status, setStatus] = useState(null) + const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + const refresh = useCallback(async () => { + setError(null) + setLoading(true) + try { + const [defaultTag, statuses] = await Promise.all([ + getDefaultSmfsContainerTag(), + getSmfsState(), + ]) + setTag(defaultTag) + setStatus(statuses[0] ?? null) + } catch (err) { + setError(formatUnknownError(err, "Could not load filesystem status")) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + refresh() + }, [refresh]) + + async function mount() { + setError(null) + setBusy(true) + try { + setStatus(await mountSmfs(tag)) + } catch (err) { + setError(formatUnknownError(err, "Could not mount memory folder")) + } finally { + setBusy(false) + } + } + + const mounted = status?.state === "mounted" || status?.state === "external" + + return ( +
+ +
+
+
+ + + +
+

Filesystem space

+

+ {status?.tag ?? tag} +

+
+
+
+ + + +
+ {error ?

{error}

: null} +
+
+

Recommended

+

+ Mount now if you want a local memory folder. You can also do this + later from Settings. +

+ +
+
+ +
+ ) +} + +function DoneStep({ + connectedCount, + onBack, + onFinish, +}: { + connectedCount: number + onBack: () => void + onFinish: () => void +}) { + return ( +
+
+ +
+

+ Desktop setup is ready. +

+

+ {connectedCount > 0 + ? `${connectedCount} tool${connectedCount === 1 ? "" : "s"} connected. You can add more from Settings anytime.` + : "No tools connected yet. You can come back from Settings whenever you are ready."} +

+
+ + +
+
+ ) +} + +function ToolCard({ + tool, + busy, + onPreview, +}: { + tool: DesktopToolCard + busy: boolean + onPreview: () => void +}) { + const Icon = tool.icon + return ( +
+
+
+ + + +
+

{tool.name}

+

{toolStatusLabel(tool)}

+
+
+ {tool.connected ? ( + + + Active + + ) : null} +
+

+ {tool.tagline} +

+
+ + + {tool.version ? : null} +
+

+ {tool.restartHint} +

+ +
+ ) +} + +function DiffPreview({ + preview, + busy, + onCancel, + onApply, +}: { + preview: DesktopToolPreview + busy: boolean + onCancel: () => void + onApply: () => void +}) { + return ( +
+
+
+

Review config change

+

+ {preview.configPath} +

+

+ Backup: {preview.backupPath ?? "created only if the file exists"} +

+
+
+ + +
+
+
+				{preview.diff}
+			
+
+ ) +} + +function StepHeader({ + eyebrow, + title, + description, + meta, + action, +}: { + eyebrow: string + title: string + description: string + meta?: string + action?: ReactNode +}) { + return ( +
+
+

+ {eyebrow} +

+

+ {title} +

+

+ {description} +

+ {meta ?

{meta}

: null} +
+ {action} +
+ ) +} + +function StepActions({ + onBack, + onContinue, + continueLabel, +}: { + onBack: () => void + onContinue: () => void + continueLabel: string +}) { + return ( +
+ + +
+ ) +} + +function StepIndicator({ step }: { step: DesktopOnboardingStep }) { + const currentIdx = STEPS.indexOf(step) + return ( +
+ {STEPS.map((item, index) => { + const isDone = index < currentIdx + const isCurrent = index === currentIdx + const isLast = index === STEPS.length - 1 + return ( +
+
+ + {isCurrent ? ( + + ) : null} + + + {STEP_LABELS[item]} + +
+ {!isLast ? ( + + ) : null} +
+ ) + })} +
+ ) +} + +function SetupBenefit({ label }: { label: string }) { + return ( +
+ + {label} +
+ ) +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+

+ {label} +

+

+ {value} +

+
+ ) +} + +function NovaBackground() { + return ( + <> +
+
+ + ) +} + +function toolStatusLabel(tool: DesktopToolCard) { + if (tool.connected) return "Connected" + if (tool.detected) return "Detected" + return "Not detected" +} + +function formatSmfsState(state?: SmfsStatus["state"]) { + switch (state) { + case "degraded": + return "Mounted with warnings" + case "external": + return "External mount" + case "error": + return "Error" + case "missing-binary": + return "Missing binary" + case "mounted": + return "Mounted" + case "unmounted": + return "Unmounted" + default: + return "Loading" + } +} + +function formatUnknownError(error: unknown, fallback: string) { + return error instanceof Error + ? error.message + : typeof error === "string" + ? error + : fallback +} diff --git a/apps/desktop/components/auth-guard.tsx b/apps/desktop/components/auth-guard.tsx index 206a5229..be2c2780 100644 --- a/apps/desktop/components/auth-guard.tsx +++ b/apps/desktop/components/auth-guard.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/navigation" import { type ReactNode, useEffect, useState } from "react" import { getSession } from "@/lib/auth" +import { shouldShowDesktopOnboarding } from "@/lib/onboarding" type AuthStatus = "loading" | "authenticated" | "unauthenticated" @@ -35,6 +36,11 @@ export function AuthGuard({ children }: { children: ReactNode }) { useEffect(() => { if (status === "unauthenticated") { router.replace("/login") + return + } + + if (status === "authenticated" && shouldShowDesktopOnboarding()) { + router.replace("/onboarding") } }, [status, router]) diff --git a/apps/desktop/lib/onboarding.ts b/apps/desktop/lib/onboarding.ts new file mode 100644 index 00000000..41a7237c --- /dev/null +++ b/apps/desktop/lib/onboarding.ts @@ -0,0 +1,103 @@ +"use client" + +import type { DesktopToolId } from "@/lib/tools" + +export type DesktopOnboardingStep = "welcome" | "tools" | "filesystem" | "done" + +export type DesktopOnboardingStatus = { + version: "v1" + completed: boolean + skipped: boolean + completedAt?: string + skippedAt?: string + lastStep: DesktopOnboardingStep + connectedTools: DesktopToolId[] +} + +const STORAGE_KEY = "supermemory-desktop-onboarding-v1" + +const DEFAULT_STATUS: DesktopOnboardingStatus = { + version: "v1", + completed: false, + skipped: false, + lastStep: "welcome", + connectedTools: [], +} + +function storageAvailable() { + return ( + typeof window !== "undefined" && typeof window.localStorage !== "undefined" + ) +} + +export function getDesktopOnboardingStatus(): DesktopOnboardingStatus { + if (!storageAvailable()) return DEFAULT_STATUS + + try { + const raw = window.localStorage.getItem(STORAGE_KEY) + if (!raw) return DEFAULT_STATUS + const parsed = JSON.parse(raw) as Partial + if (parsed.version !== "v1") return DEFAULT_STATUS + return { + ...DEFAULT_STATUS, + ...parsed, + connectedTools: Array.isArray(parsed.connectedTools) + ? parsed.connectedTools + : [], + } + } catch { + return DEFAULT_STATUS + } +} + +export function saveDesktopOnboardingStatus( + status: DesktopOnboardingStatus, +): void { + if (!storageAvailable()) return + + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(status)) + } catch {} +} + +export function updateDesktopOnboardingStatus( + patch: Partial, +): DesktopOnboardingStatus { + const next: DesktopOnboardingStatus = { + ...getDesktopOnboardingStatus(), + ...patch, + version: "v1", + } + saveDesktopOnboardingStatus(next) + return next +} + +export function shouldShowDesktopOnboarding() { + const status = getDesktopOnboardingStatus() + return !status.completed && !status.skipped +} + +export function postAuthRedirectPath() { + return shouldShowDesktopOnboarding() ? "/onboarding" : "/" +} + +export function completeDesktopOnboarding( + connectedTools: DesktopToolId[] = [], +) { + return updateDesktopOnboardingStatus({ + completed: true, + skipped: false, + completedAt: new Date().toISOString(), + lastStep: "done", + connectedTools, + }) +} + +export function skipDesktopOnboarding(lastStep: DesktopOnboardingStep) { + return updateDesktopOnboardingStatus({ + completed: false, + skipped: true, + skippedAt: new Date().toISOString(), + lastStep, + }) +} diff --git a/apps/desktop/lib/tool-catalog.ts b/apps/desktop/lib/tool-catalog.ts new file mode 100644 index 00000000..0b87dedf --- /dev/null +++ b/apps/desktop/lib/tool-catalog.ts @@ -0,0 +1,82 @@ +"use client" + +import { Bot, Code2, Terminal, type LucideIcon } from "lucide-react" +import type { DesktopToolId, DesktopToolStatus } from "@/lib/tools" + +export type DesktopToolCatalogEntry = { + id: DesktopToolId + catalogId: "claude_code" | "codex" | "cursor" + name: string + tagline: string + description: string + docsUrl: string + icon: LucideIcon + restartHint: string +} + +export type DesktopToolCard = DesktopToolCatalogEntry & + DesktopToolStatus & { + primaryAction: "connect" | "set-up" | "connected" + } + +export const DESKTOP_TOOL_CATALOG: Record< + DesktopToolId, + DesktopToolCatalogEntry +> = { + "claude-code": { + id: "claude-code", + catalogId: "claude_code", + name: "Claude Code", + tagline: "Remembers your conventions, decisions, and project context", + description: + "Connect the Supermemory MCP server so Claude Code can search and save memories while you work.", + docsUrl: "https://supermemory.ai/docs/integrations/claude-code", + icon: Bot, + restartHint: "Restart Claude Code after changing MCP config.", + }, + codex: { + id: "codex", + catalogId: "codex", + name: "Codex", + tagline: "Persistent memory for the Codex CLI", + description: + "Add Supermemory to Codex MCP servers so coding sessions can recall durable context.", + docsUrl: "https://supermemory.ai/docs/integrations/codex", + icon: Terminal, + restartHint: "Restart Codex after changing ~/.codex/config.toml.", + }, + cursor: { + id: "cursor", + catalogId: "cursor", + name: "Cursor", + tagline: "Persistent memory, session hooks, and MCP tools inside Cursor", + description: + "Write the Supermemory MCP server into Cursor's MCP config for one-click memory access.", + docsUrl: "https://supermemory.ai/docs/supermemory-mcp/setup", + icon: Code2, + restartHint: "Restart Cursor so MCP changes are loaded.", + }, +} + +export function toDesktopToolCard(status: DesktopToolStatus): DesktopToolCard { + const catalog = DESKTOP_TOOL_CATALOG[status.id] + return { + ...catalog, + ...status, + name: catalog.name, + primaryAction: status.connected + ? "connected" + : status.detected + ? "connect" + : "set-up", + } +} + +export function sortDesktopToolCards(cards: DesktopToolCard[]) { + return [...cards].sort((a, b) => { + const aRank = a.connected ? 0 : a.detected ? 1 : 2 + const bRank = b.connected ? 0 : b.detected ? 1 : 2 + if (aRank !== bRank) return aRank - bRank + return a.name.localeCompare(b.name) + }) +} diff --git a/apps/desktop/lib/tools.ts b/apps/desktop/lib/tools.ts index dfeb7932..18bebb97 100644 --- a/apps/desktop/lib/tools.ts +++ b/apps/desktop/lib/tools.ts @@ -11,6 +11,8 @@ export type DesktopToolStatus = { connected: boolean configPath: string configExists: boolean + detectedPath?: string | null + version?: string | null installHint: string detail: string } diff --git a/apps/desktop/src-tauri/src/tools.rs b/apps/desktop/src-tauri/src/tools.rs index 29af5ed7..510fae2c 100644 --- a/apps/desktop/src-tauri/src/tools.rs +++ b/apps/desktop/src-tauri/src/tools.rs @@ -27,6 +27,8 @@ pub struct ToolStatus { connected: bool, config_path: String, config_exists: bool, + detected_path: Option, + version: Option, install_hint: &'static str, detail: String, } @@ -127,7 +129,9 @@ 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); + let detected_path = detected_path(tool); + let detected = detected_path.is_some(); + let version = detected_path.as_deref().and_then(version_for_tool); Ok(ToolStatus { id: tool.id(), @@ -136,8 +140,16 @@ fn status_for_tool(tool: ToolId) -> Result { connected, config_path: path_to_string(&config_path), config_exists, + detected_path: detected_path.as_deref().map(path_to_string), + version, install_hint: tool.install_hint(), - detail: status_detail(tool, detected, connected, config_exists), + detail: status_detail( + tool, + detected, + connected, + config_exists, + detected_path.as_deref(), + ), }) } @@ -338,19 +350,59 @@ fn make_diff(before: &str, after: &str) -> String { diff } -fn is_detected(tool: ToolId) -> bool { +fn detected_path(tool: ToolId) -> Option { 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() - } + ToolId::ClaudeCode => find_binary("claude") + .or_else(|| existing_path(home_path(".claude.json"))) + .or_else(|| existing_path(home_path(".claude"))), + ToolId::Codex => find_binary("codex") + .or_else(|| { + env::var("CODEX_HOME") + .ok() + .map(PathBuf::from) + .and_then(existing_path) + }) + .or_else(|| existing_path(home_path(".codex"))), + ToolId::Cursor => existing_path(PathBuf::from("/Applications/Cursor.app")) + .or_else(|| find_binary("cursor")) + .or_else(|| existing_path(home_path(".cursor"))), } } -fn status_detail(tool: ToolId, detected: bool, connected: bool, config_exists: bool) -> String { +fn existing_path(path: PathBuf) -> Option { + path.exists().then_some(path) +} + +fn version_for_tool(detected_path: &Path) -> Option { + if !detected_path.is_file() { + return None; + } + + let output = Command::new(detected_path).arg("--version").output().ok()?; + if !output.status.success() { + return None; + } + + let raw = if output.stdout.is_empty() { + String::from_utf8_lossy(&output.stderr) + } else { + String::from_utf8_lossy(&output.stdout) + }; + let first_line = raw.lines().next()?.trim(); + if first_line.is_empty() { + return None; + } + + Some(first_line.to_string()) +} + +fn status_detail( + tool: ToolId, + detected: bool, + connected: bool, + config_exists: bool, + detected_path: Option<&Path>, +) -> String { if connected { return "Connected to the Supermemory MCP server.".to_string(); } @@ -360,6 +412,9 @@ fn status_detail(tool: ToolId, detected: bool, connected: bool, config_exists: b } if detected { + if let Some(path) = detected_path { + return format!("Detected at {}; ready to connect.", path_to_string(path)); + } return "Detected locally; ready to connect.".to_string(); }