@@ -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.
-
-
-
- {loading ? "Scanning..." : "Rescan"}
-
-
-
-
- {tools.map((tool) => {
- const Icon = toolIcon(tool.id)
- return (
-
-
-
-
-
-
-
-
-
- {tool.name}
-
-
- {toolStatusLabel(tool)}
-
-
-
- {tool.connected ? (
-
- ) : null}
-
-
- {tool.detail}
-
-
-
previewConnect(tool.id)}
- disabled={toolsBusy !== null || tool.connected}
- className="mt-4 inline-flex h-8 items-center justify-center rounded-lg bg-white px-3 font-medium text-[#0B1018] text-[12px] transition-transform hover:scale-[1.02] disabled:cursor-default disabled:bg-white/[0.08] disabled:text-fg-faint disabled:hover:scale-100"
- >
- {tool.connected
- ? "Connected"
- : toolsBusy === tool.id
- ? "Preparing..."
- : tool.detected
- ? "Connect"
- : "Set up"}
-
-
- )
- })}
-
-
- {toolsError ? (
- {toolsError}
- ) : null}
-
- {toolPreview ? (
-
-
-
-
- Connect {toolPreview.tool.name}
-
-
- {toolPreview.configPath}
-
-
-
- setToolPreview(null)}
- disabled={toolsBusy !== null}
- className="h-8 rounded-lg border border-white/[0.08] px-3 text-[12px] text-fg-muted transition-colors hover:bg-white/[0.05] hover:text-white disabled:opacity-60"
- >
- Cancel
-
-
- {toolsBusy ? "Applying..." : "Apply"}
-
-
-
-
- {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.
+
+
+
+
+ Start setup
+
+
+
+ Skip for now
+
+
+
+
+
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.
+
+
+ {busy || loading ? (
+
+ ) : mounted ? (
+
+ ) : null}
+ {mounted ? "Mounted" : "Mount folder"}
+
+
+
+
+
+ )
+}
+
+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."}
+
+
+
+
+ Back
+
+
+ Open Supermemory
+
+
+
+
+ )
+}
+
+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}
+
+
+ {tool.connected
+ ? "Connected"
+ : busy
+ ? "Preparing..."
+ : tool.primaryAction === "connect"
+ ? "Connect"
+ : "Set up"}
+
+
+ )
+}
+
+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"}
+
+
+
+
+
+
+
+ {busy ? : null}
+ Apply
+
+
+
+
+ {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 (
+
+
+
+ Back
+
+
+ {continueLabel}
+
+
+
+ )
+}
+
+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();
}