From 5caf9b1c394abe17e9a596c082cb92da94794d9a Mon Sep 17 00:00:00 2001 From: OpenPets Dev Date: Fri, 19 Jun 2026 06:23:38 +0000 Subject: [PATCH] Extract MCP toolkit installer support seams --- apps/desktop/scripts/run-tests.mjs | 1 + apps/desktop/src/check-packaging-contract.ts | 9 + apps/desktop/src/codemap.md | 3 + .../src/mcp-toolkit-installer-commands.ts | 199 +++++++++ .../src/mcp-toolkit-installer-support.ts | 197 +++++++++ apps/desktop/src/mcp-toolkit-installer.ts | 399 +++--------------- .../control-center-service-barrels.test.ts | 3 + .../tests/mcp-toolkit-installer-seams.test.ts | 58 +++ 8 files changed, 536 insertions(+), 333 deletions(-) create mode 100644 apps/desktop/src/mcp-toolkit-installer-commands.ts create mode 100644 apps/desktop/src/mcp-toolkit-installer-support.ts create mode 100644 apps/desktop/tests/mcp-toolkit-installer-seams.test.ts diff --git a/apps/desktop/scripts/run-tests.mjs b/apps/desktop/scripts/run-tests.mjs index fb428e3d..b2230315 100644 --- a/apps/desktop/scripts/run-tests.mjs +++ b/apps/desktop/scripts/run-tests.mjs @@ -64,6 +64,7 @@ const behaviorTests = [ ".test-dist/tests/knowledge-store-core-seam.test.js", ".test-dist/tests/knowledge-store.test.js", ".test-dist/tests/control-center-service-barrels.test.js", + ".test-dist/tests/mcp-toolkit-installer-seams.test.js", ".test-dist/tests/mcp-toolkit-catalog.test.js", ".test-dist/tests/integrations-view-toolkit-split.test.js", ".test-dist/tests/integrations-view-state.test.js", diff --git a/apps/desktop/src/check-packaging-contract.ts b/apps/desktop/src/check-packaging-contract.ts index 08aea8fd..190b6cdb 100644 --- a/apps/desktop/src/check-packaging-contract.ts +++ b/apps/desktop/src/check-packaging-contract.ts @@ -147,6 +147,9 @@ const promptWindowPreloadSource = readFileSync(join(appDir, "prompt-window-prelo const reactionMessagesSource = readFileSync(join(appDir, "src", "reaction-messages.ts"), "utf8"); const displaySource = readFileSync(join(appDir, "src", "display.ts"), "utf8"); const updateCheckerSource = readFileSync(join(appDir, "src", "update-checker.ts"), "utf8"); +const mcpToolkitInstallerSource = readFileSync(join(appDir, "src", "mcp-toolkit-installer.ts"), "utf8"); +const mcpToolkitInstallerSupportSource = readFileSync(join(appDir, "src", "mcp-toolkit-installer-support.ts"), "utf8"); +const mcpToolkitInstallerCommandsSource = readFileSync(join(appDir, "src", "mcp-toolkit-installer-commands.ts"), "utf8"); const traySource = readFileSync(join(appDir, "src", "tray.ts"), "utf8"); const enCatalogSource = readFileSync(join(appDir, "src", "i18n", "locales", "en.ts"), "utf8"); const windowsSource = readFileSync(join(appDir, "src", "windows.ts"), "utf8"); @@ -582,6 +585,12 @@ assert.match(controlCenterIntegrationsMcpToolkitSectionSource, /export function assert.match(controlCenterIntegrationsMcpToolkitSectionSource, /buildPersistentToolkitBundle/, "Control Center integrations toolkit seam must own persistent bundle composition."); assert.match(controlCenterIntegrationsCombinedSource, /Persistent Full Access/, "Control Center integrations seam must retain the persistent toolkit install path."); assert.match(controlCenterIntegrationsCombinedSource, /pi install npm:@familiaros\/pi/, "Control Center integrations seam must retain Pi install guidance."); +assert.match(mcpToolkitInstallerSource, /from "\.\/mcp-toolkit-installer-support(?:\.js)?"/, "MCP toolkit installer must import the extracted support seam."); +assert.match(mcpToolkitInstallerSource, /from "\.\/mcp-toolkit-installer-commands(?:\.js)?"/, "MCP toolkit installer must import the extracted command seam."); +assert.match(mcpToolkitInstallerSupportSource, /export function getSupportedPersistentServers/, "MCP toolkit installer support seam must export managed server definitions."); +assert.match(mcpToolkitInstallerSupportSource, /export function buildInstallCommands/, "MCP toolkit installer support seam must export bundle command shaping."); +assert.match(mcpToolkitInstallerCommandsSource, /export function runCommand/, "MCP toolkit installer command seam must export process execution helpers."); +assert.match(mcpToolkitInstallerCommandsSource, /export async function runCommandWithCandidates/, "MCP toolkit installer command seam must export command discovery helpers."); assert.match(openApiChatSource, /from "\.\/openapi-chat-settings(?:\.js)?"/, "OpenAPI chat must import the extracted settings seam."); assert.match(openApiChatSource, /from "\.\/openapi-chat-prompt-flows(?:\.js)?"/, "OpenAPI chat must import the extracted prompt-flow seam."); assert.match(openApiChatPromptFlowsSource, /export async function sendOpenApiChatPromptPlainFlow/, "OpenAPI chat prompt-flow seam must export the plain prompt runner."); diff --git a/apps/desktop/src/codemap.md b/apps/desktop/src/codemap.md index 889adc8b..c6d96aac 100644 --- a/apps/desktop/src/codemap.md +++ b/apps/desktop/src/codemap.md @@ -205,6 +205,9 @@ plugin-service-local-support.ts → plugin-local-loader.ts validates selected fo - `agent-setup-actions.ts`: Extracted action execution shell for hook handling plus Claude MCP add/remove orchestration - `agent-setup-actions-tooling.ts`: Extracted OpenCode/Cursor setup loading, global config actions, and FamiliarOS MCP server health checks - `agent-setup-support.ts`: Extracted MCP preview, Claude memory safety wrappers, and bounded action journal helpers for agent setup +- `mcp-toolkit-installer.ts`: Public MCP toolkit installer facade for persistent host-agent server setup and managed install result shaping +- `mcp-toolkit-installer-support.ts`: Extracted MCP toolkit bundle/server-definition, argument, note, and command-candidate helpers +- `mcp-toolkit-installer-commands.ts`: Extracted MCP toolkit PATH/process execution, timeout, and command-failure shaping helpers - `assets.ts`: Tray icon loading with generated fallback - `display.ts`: Screen geometry helpers, familiar window positioning - `prompt-window.ts`: Floating prompt window lifecycle, bounded placement/resize logic, and main-process prompt/settings/knowledge IPC handlers diff --git a/apps/desktop/src/mcp-toolkit-installer-commands.ts b/apps/desktop/src/mcp-toolkit-installer-commands.ts new file mode 100644 index 00000000..933fae4e --- /dev/null +++ b/apps/desktop/src/mcp-toolkit-installer-commands.ts @@ -0,0 +1,199 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +import { app } from "electron"; + +export interface CommandResult { + readonly ok: boolean; + readonly exitCode: number | null; + readonly stdout: string; + readonly stderr: string; + readonly error?: string; +} + +const commandTimeoutMs = 20_000; +const maxOutputChars = 6_000; + +export async function runCommandWithCandidates( + candidates: readonly string[], +): Promise { + let lastResult: CommandResult = { + ok: false, + exitCode: null, + stdout: "", + stderr: "", + error: "Command was not found.", + }; + for (const candidate of candidates) { + const result = await runCommand(candidate, ["--version"]); + if (result.ok || !looksLikeMissingCommand(result)) { + return result; + } + lastResult = result; + } + return lastResult; +} + +export function runCommand( + command: string, + args: readonly string[], +): Promise { + return new Promise((resolve) => { + const shellCommand = + process.platform === "win32" && command.toLowerCase().endsWith(".cmd") + ? "cmd.exe" + : command; + const shellArgs = + process.platform === "win32" && command.toLowerCase().endsWith(".cmd") + ? ["/d", "/s", "/c", command, ...args] + : [...args]; + + let child; + try { + child = spawn(shellCommand, shellArgs, { + cwd: app.getPath("home"), + env: createCommandEnv(), + windowsHide: true, + shell: false, + }); + } catch (error) { + resolve({ + ok: false, + exitCode: null, + stdout: "", + stderr: "", + error: + error instanceof Error ? error.message : "Command failed to start.", + }); + return; + } + + let stdout = ""; + let stderr = ""; + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill(); + resolve({ + ok: false, + exitCode: null, + stdout: truncateOutput(stdout), + stderr: truncateOutput(stderr), + error: "Command timed out.", + }); + }, commandTimeoutMs); + + child.stdout?.on("data", (chunk: Buffer) => { + stdout = truncateOutput(stdout + chunk.toString("utf8")); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr = truncateOutput(stderr + chunk.toString("utf8")); + }); + child.on("error", (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ + ok: false, + exitCode: null, + stdout: truncateOutput(stdout), + stderr: truncateOutput(stderr), + error: error.message, + }); + }); + child.on("close", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ + ok: code === 0, + exitCode: code, + stdout: truncateOutput(stdout), + stderr: truncateOutput(stderr), + }); + }); + }); +} + +export function summarizeCommandFailure(result: CommandResult): string { + const detail = + result.stderr || + result.stdout || + result.error || + `exit code ${result.exitCode ?? "unknown"}`; + return truncateOutput(detail).trim() || "command failed."; +} + +function createCommandEnv(): NodeJS.ProcessEnv { + const separator = process.platform === "win32" ? ";" : ":"; + const existingPath = process.env.PATH ?? ""; + return { + ...process.env, + PATH: dedupePathEntries( + [existingPath, ...getExtraCommandPaths()], + separator, + ).join(separator), + }; +} + +function getExtraCommandPaths(): readonly string[] { + if (process.platform === "win32") return []; + const home = app.getPath("home"); + const env = process.env; + return filterExistingPaths([ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/local/sbin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + join(home, "bin"), + join(home, ".local", "bin"), + join(home, ".opencode", "bin"), + join(env.VOLTA_HOME || join(home, ".volta"), "bin"), + join(env.BUN_INSTALL || join(home, ".bun"), "bin"), + join( + env.MISE_DATA_DIR || join(home, ".local", "share", "mise"), + "shims", + ), + join(env.ASDF_DATA_DIR || join(home, ".asdf"), "shims"), + env.PNPM_HOME, + join(home, ".local", "share", "pnpm"), + join(home, "Library", "pnpm"), + join(env.NVM_DIR || join(home, ".nvm"), "current", "bin"), + ]); +} + +function filterExistingPaths( + paths: readonly (string | undefined)[], +): readonly string[] { + return paths.filter((path): path is string => Boolean(path && existsSync(path))); +} + +function dedupePathEntries( + paths: readonly string[], + separator: string, +): readonly string[] { + const seen = new Set(); + const entries: string[] = []; + for (const path of paths.flatMap((value) => value.split(separator)).filter(Boolean)) { + if (seen.has(path)) continue; + seen.add(path); + entries.push(path); + } + return entries; +} + +function looksLikeMissingCommand(result: CommandResult): boolean { + return Boolean(result.error && /ENOENT|not found/i.test(result.error)); +} + +function truncateOutput(value: string): string { + return value.length > maxOutputChars + ? value.slice(value.length - maxOutputChars) + : value; +} diff --git a/apps/desktop/src/mcp-toolkit-installer-support.ts b/apps/desktop/src/mcp-toolkit-installer-support.ts new file mode 100644 index 00000000..bb1e0778 --- /dev/null +++ b/apps/desktop/src/mcp-toolkit-installer-support.ts @@ -0,0 +1,197 @@ +export type McpToolkitPersistentTarget = "claude-user" | "codex-global"; + +export interface McpToolkitBundle { + readonly label: string; + readonly language: "bash"; + readonly value: string; + readonly description: string; +} + +export interface McpToolkitInstallItemResult { + readonly id: string; + readonly name: string; + readonly status: "installed" | "skipped"; + readonly detail: string; +} + +export interface McpToolkitInstallResult { + readonly target: McpToolkitPersistentTarget; + readonly label: string; + readonly installed: readonly McpToolkitInstallItemResult[]; + readonly skipped: readonly McpToolkitInstallItemResult[]; + readonly notes: readonly string[]; +} + +export interface McpToolkitServerDefinition { + readonly id: string; + readonly name: string; + readonly description: string; + readonly command: readonly string[]; + readonly requiredRuntime: "npx" | "uvx"; + readonly note?: string; +} + +export function buildPersistentToolkitBundleLabel( + target: McpToolkitPersistentTarget, +): string { + return target === "claude-user" + ? "Claude Code user-scope install bundle" + : "Codex CLI global install bundle"; +} + +export function buildPersistentToolkitBundleDescription( + target: McpToolkitPersistentTarget, +): string { + return target === "claude-user" + ? "Run once to install the supported persistent baseline into Claude Code at user scope." + : "Run once to install the supported persistent baseline into Codex CLI globally."; +} + +export function buildPersistentToolkitInstallLabel( + target: McpToolkitPersistentTarget, +): string { + return target === "claude-user" + ? "Claude Code user scope" + : "Codex CLI global"; +} + +export function buildPersistentToolkitNotes( + homeDir: string, + uvxAvailable: boolean, +): readonly string[] { + const notes = [ + `Filesystem access is scoped to ${formatUserPath(homeDir, homeDir)} by default.`, + "Git, GitHub, databases, Docker, shell, SSH, and reverse-engineering lanes still stay manual because they need repo paths, auth, or stronger trust decisions.", + ]; + return uvxAvailable + ? notes + : [ + ...notes, + "Browser Use and Fetch / Web were skipped because `uvx` is not installed on this machine.", + ]; +} + +export function buildInstallCommands( + target: McpToolkitPersistentTarget, + hostCommand: string, + servers: readonly McpToolkitServerDefinition[], +): readonly (readonly string[])[] { + return servers.map((server) => [hostCommand, ...buildAddArgs(target, server)]); +} + +export function getSupportedPersistentServers( + homeDir: string, +): readonly McpToolkitServerDefinition[] { + return [ + { + id: "familiaros-filesystem", + name: "Filesystem", + description: "Persistent filesystem access scoped to your home directory.", + command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", homeDir], + requiredRuntime: "npx", + note: "Scoped to your home directory by default. Narrow it later if you want a tighter boundary.", + }, + { + id: "familiaros-playwright", + name: "Playwright", + description: "Browser automation and browser validation tools.", + command: ["npx", "@playwright/mcp@latest"], + requiredRuntime: "npx", + }, + { + id: "familiaros-memory", + name: "Memory", + description: "Persistent project-memory tools for the host agent.", + command: ["npx", "-y", "@modelcontextprotocol/server-memory"], + requiredRuntime: "npx", + }, + { + id: "familiaros-context7", + name: "Context7 / Docs", + description: "Up-to-date library and framework docs lookup.", + command: ["npx", "-y", "@upstash/context7-mcp"], + requiredRuntime: "npx", + }, + { + id: "familiaros-fetch", + name: "Fetch / Web", + description: "Lightweight web retrieval without full browser automation.", + command: ["uvx", "mcp-server-fetch"], + requiredRuntime: "uvx", + }, + { + id: "familiaros-sequential-thinking", + name: "Sequential Thinking", + description: "Structured planning and branching reasoning tools.", + command: ["npx", "-y", "@modelcontextprotocol/server-sequential-thinking"], + requiredRuntime: "npx", + }, + { + id: "familiaros-browser-use", + name: "Browser Use", + description: "Higher-level browser task execution for agentic web work.", + command: ["uvx", "--from", "browser-use[cli]", "browser-use", "--mcp"], + requiredRuntime: "uvx", + note: "The entry is installed, but Browser Use still needs its own runtime credential in the host environment.", + }, + ]; +} + +export function buildAddArgs( + target: McpToolkitPersistentTarget, + server: McpToolkitServerDefinition, +): readonly string[] { + return target === "claude-user" + ? ["mcp", "add", "--scope", "user", server.id, "--", ...server.command] + : ["mcp", "add", server.id, "--", ...server.command]; +} + +export function buildRemoveArgs( + target: McpToolkitPersistentTarget, + serverId: string, +): readonly string[] { + return target === "claude-user" + ? ["mcp", "remove", "--scope", "user", serverId] + : ["mcp", "remove", serverId]; +} + +export function getHostCommandCandidates( + target: McpToolkitPersistentTarget, + preferredClaudeCommand: string, +): readonly string[] { + if (target === "claude-user") { + return getCommandCandidates(preferredClaudeCommand, "claude"); + } + return getCommandCandidates("codex", "codex"); +} + +export function getRuntimeCommandCandidates( + command: "npx" | "uvx", +): readonly string[] { + return getCommandCandidates(command, command); +} + +export function quoteShellArg(value: string): string { + if (/^[A-Za-z0-9_./:@-]+$/.test(value)) return value; + return JSON.stringify(value); +} + +function formatUserPath(path: string, homeDir: string): string { + return path.replace(homeDir, "~"); +} + +function getCommandCandidates( + preferred: string, + baseName: string, +): readonly string[] { + if (preferred !== baseName) { + return preferred.toLowerCase().endsWith(".cmd") + ? [preferred] + : [ + preferred, + ...(process.platform === "win32" ? [`${preferred}.cmd`] : []), + ]; + } + if (process.platform === "win32") return [baseName, `${baseName}.cmd`]; + return [baseName]; +} diff --git a/apps/desktop/src/mcp-toolkit-installer.ts b/apps/desktop/src/mcp-toolkit-installer.ts index cfabc56c..6914c808 100644 --- a/apps/desktop/src/mcp-toolkit-installer.ts +++ b/apps/desktop/src/mcp-toolkit-installer.ts @@ -1,94 +1,86 @@ -import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; - import { app } from "electron"; import { getAppStateSnapshot } from "./app-state.js"; +import { + runCommand, + runCommandWithCandidates, + summarizeCommandFailure, +} from "./mcp-toolkit-installer-commands.js"; +import { + buildAddArgs, + buildInstallCommands, + buildPersistentToolkitBundleDescription, + buildPersistentToolkitBundleLabel, + buildPersistentToolkitInstallLabel, + buildPersistentToolkitNotes, + buildRemoveArgs, + getHostCommandCandidates, + getRuntimeCommandCandidates, + getSupportedPersistentServers, + quoteShellArg, + type McpToolkitBundle, + type McpToolkitInstallItemResult, + type McpToolkitInstallResult, + type McpToolkitPersistentTarget, +} from "./mcp-toolkit-installer-support.js"; -export type McpToolkitPersistentTarget = "claude-user" | "codex-global"; +export type { + McpToolkitBundle, + McpToolkitInstallItemResult, + McpToolkitInstallResult, + McpToolkitPersistentTarget, +} from "./mcp-toolkit-installer-support.js"; -export interface McpToolkitBundle { - readonly label: string; - readonly language: "bash"; - readonly value: string; - readonly description: string; -} - -export interface McpToolkitInstallItemResult { - readonly id: string; - readonly name: string; - readonly status: "installed" | "skipped"; - readonly detail: string; -} - -export interface McpToolkitInstallResult { - readonly target: McpToolkitPersistentTarget; - readonly label: string; - readonly installed: readonly McpToolkitInstallItemResult[]; - readonly skipped: readonly McpToolkitInstallItemResult[]; - readonly notes: readonly string[]; -} - -interface McpToolkitServerDefinition { - readonly id: string; - readonly name: string; - readonly description: string; - readonly command: readonly string[]; - readonly requiredRuntime: "npx" | "uvx"; - readonly note?: string; -} - -interface CommandResult { - readonly ok: boolean; - readonly exitCode: number | null; - readonly stdout: string; - readonly stderr: string; - readonly error?: string; -} - -const commandTimeoutMs = 20_000; -const maxOutputChars = 6_000; - -export function buildPersistentToolkitBundle(target: McpToolkitPersistentTarget): McpToolkitBundle { - const label = target === "claude-user" - ? "Claude Code user-scope install bundle" - : "Codex CLI global install bundle"; - const description = target === "claude-user" - ? "Run once to install the supported persistent baseline into Claude Code at user scope." - : "Run once to install the supported persistent baseline into Codex CLI globally."; - const value = buildInstallCommands(target) +export function buildPersistentToolkitBundle( + target: McpToolkitPersistentTarget, +): McpToolkitBundle { + const homeDir = app.getPath("home"); + const hostCommand = + target === "claude-user" ? getPreferredClaudeCommand() : "codex"; + const value = buildInstallCommands( + target, + hostCommand, + getSupportedPersistentServers(homeDir), + ) .map((command) => command.map(quoteShellArg).join(" ")) .join("\n"); return { - label, + label: buildPersistentToolkitBundleLabel(target), language: "bash", value, - description, + description: buildPersistentToolkitBundleDescription(target), }; } -export async function installPersistentToolkit(target: McpToolkitPersistentTarget): Promise { - const label = target === "claude-user" ? "Claude Code user scope" : "Codex CLI global"; +export async function installPersistentToolkit( + target: McpToolkitPersistentTarget, +): Promise { const homeDir = app.getPath("home"); - const notes = [ - `Filesystem access is scoped to ${formatUserPath(homeDir)} by default.`, - "Git, GitHub, databases, Docker, shell, SSH, and reverse-engineering lanes still stay manual because they need repo paths, auth, or stronger trust decisions.", - ]; - - const hostCommand = target === "claude-user" ? getPreferredClaudeCommand() : "codex"; - const hostCheck = await runCommandWithCandidates(getHostCommandCandidates(target)); + const hostCommand = + target === "claude-user" ? getPreferredClaudeCommand() : "codex"; + const preferredClaudeCommand = getPreferredClaudeCommand(); + const hostCheck = await runCommandWithCandidates( + getHostCommandCandidates(target, preferredClaudeCommand), + ); if (!hostCheck.ok) { - throw new Error(`${target === "claude-user" ? "Claude Code" : "Codex CLI"} was not found. Install it first or use Manual Setup. ${summarizeCommandFailure(hostCheck)}`); + throw new Error( + `${target === "claude-user" ? "Claude Code" : "Codex CLI"} was not found. Install it first or use Manual Setup. ${summarizeCommandFailure(hostCheck)}`, + ); } - const npxCheck = await runCommandWithCandidates(getRuntimeCommandCandidates("npx")); + const npxCheck = await runCommandWithCandidates( + getRuntimeCommandCandidates("npx"), + ); if (!npxCheck.ok) { - throw new Error(`Node.js and npx are required for the supported persistent baseline. Install Node.js first or use Manual Setup. ${summarizeCommandFailure(npxCheck)}`); + throw new Error( + `Node.js and npx are required for the supported persistent baseline. Install Node.js first or use Manual Setup. ${summarizeCommandFailure(npxCheck)}`, + ); } - const uvxCheck = await runCommandWithCandidates(getRuntimeCommandCandidates("uvx")); - const installableServers = getSupportedPersistentServers(); + const uvxCheck = await runCommandWithCandidates( + getRuntimeCommandCandidates("uvx"), + ); + const installableServers = getSupportedPersistentServers(homeDir); const results: McpToolkitInstallItemResult[] = []; for (const server of installableServers) { @@ -102,7 +94,7 @@ export async function installPersistentToolkit(target: McpToolkitPersistentTarge continue; } - await removeManagedServer(target, server.id); + await runCommand(hostCommand, buildRemoveArgs(target, server.id)); const addResult = await runCommand(hostCommand, buildAddArgs(target, server)); if (!addResult.ok) { results.push({ @@ -124,272 +116,13 @@ export async function installPersistentToolkit(target: McpToolkitPersistentTarge return { target, - label, + label: buildPersistentToolkitInstallLabel(target), installed: results.filter((entry) => entry.status === "installed"), skipped: results.filter((entry) => entry.status === "skipped"), - notes: uvxCheck.ok - ? notes - : [...notes, "Browser Use and Fetch / Web were skipped because `uvx` is not installed on this machine."], + notes: buildPersistentToolkitNotes(homeDir, uvxCheck.ok), }; } -function buildInstallCommands(target: McpToolkitPersistentTarget): readonly (readonly string[])[] { - return getSupportedPersistentServers().map((server) => { - const command = target === "claude-user" ? getPreferredClaudeCommand() : "codex"; - return [command, ...buildAddArgs(target, server)]; - }); -} - -function getSupportedPersistentServers(): readonly McpToolkitServerDefinition[] { - const homeDir = app.getPath("home"); - return [ - { - id: "familiaros-filesystem", - name: "Filesystem", - description: "Persistent filesystem access scoped to your home directory.", - command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", homeDir], - requiredRuntime: "npx", - note: "Scoped to your home directory by default. Narrow it later if you want a tighter boundary.", - }, - { - id: "familiaros-playwright", - name: "Playwright", - description: "Browser automation and browser validation tools.", - command: ["npx", "@playwright/mcp@latest"], - requiredRuntime: "npx", - }, - { - id: "familiaros-memory", - name: "Memory", - description: "Persistent project-memory tools for the host agent.", - command: ["npx", "-y", "@modelcontextprotocol/server-memory"], - requiredRuntime: "npx", - }, - { - id: "familiaros-context7", - name: "Context7 / Docs", - description: "Up-to-date library and framework docs lookup.", - command: ["npx", "-y", "@upstash/context7-mcp"], - requiredRuntime: "npx", - }, - { - id: "familiaros-fetch", - name: "Fetch / Web", - description: "Lightweight web retrieval without full browser automation.", - command: ["uvx", "mcp-server-fetch"], - requiredRuntime: "uvx", - }, - { - id: "familiaros-sequential-thinking", - name: "Sequential Thinking", - description: "Structured planning and branching reasoning tools.", - command: ["npx", "-y", "@modelcontextprotocol/server-sequential-thinking"], - requiredRuntime: "npx", - }, - { - id: "familiaros-browser-use", - name: "Browser Use", - description: "Higher-level browser task execution for agentic web work.", - command: ["uvx", "--from", "browser-use[cli]", "browser-use", "--mcp"], - requiredRuntime: "uvx", - note: "The entry is installed, but Browser Use still needs its own runtime credential in the host environment.", - }, - ]; -} - -function buildAddArgs(target: McpToolkitPersistentTarget, server: McpToolkitServerDefinition): readonly string[] { - const hostCommand = target === "claude-user"; - return hostCommand - ? ["mcp", "add", "--scope", "user", server.id, "--", ...server.command] - : ["mcp", "add", server.id, "--", ...server.command]; -} - -async function removeManagedServer(target: McpToolkitPersistentTarget, serverId: string): Promise { - const command = target === "claude-user" ? getPreferredClaudeCommand() : "codex"; - const args = target === "claude-user" - ? ["mcp", "remove", "--scope", "user", serverId] - : ["mcp", "remove", serverId]; - await runCommand(command, args).catch(() => undefined); -} - function getPreferredClaudeCommand(): string { return getAppStateSnapshot().preferences.claudeCommandPath || "claude"; } - -function getHostCommandCandidates(target: McpToolkitPersistentTarget): readonly string[] { - if (target === "claude-user") { - return getCommandCandidates(getPreferredClaudeCommand(), "claude"); - } - return getCommandCandidates("codex", "codex"); -} - -function getRuntimeCommandCandidates(command: "npx" | "uvx"): readonly string[] { - return getCommandCandidates(command, command); -} - -function getCommandCandidates(preferred: string, baseName: string): readonly string[] { - if (preferred !== baseName) { - return preferred.toLowerCase().endsWith(".cmd") ? [preferred] : [preferred, ...(process.platform === "win32" ? [`${preferred}.cmd`] : [])]; - } - if (process.platform === "win32") return [baseName, `${baseName}.cmd`]; - return [baseName]; -} - -async function runCommandWithCandidates(candidates: readonly string[]): Promise { - let lastResult: CommandResult = { ok: false, exitCode: null, stdout: "", stderr: "", error: "Command was not found." }; - for (const candidate of candidates) { - const result = await runCommand(candidate, ["--version"]); - if (result.ok || !looksLikeMissingCommand(result)) { - return result; - } - lastResult = result; - } - return lastResult; -} - -function runCommand(command: string, args: readonly string[]): Promise { - return new Promise((resolve) => { - const shellCommand = process.platform === "win32" && command.toLowerCase().endsWith(".cmd") ? "cmd.exe" : command; - const shellArgs = process.platform === "win32" && command.toLowerCase().endsWith(".cmd") - ? ["/d", "/s", "/c", command, ...args] - : [...args]; - - let child; - try { - child = spawn(shellCommand, shellArgs, { - cwd: app.getPath("home"), - env: createCommandEnv(), - windowsHide: true, - shell: false, - }); - } catch (error) { - resolve({ - ok: false, - exitCode: null, - stdout: "", - stderr: "", - error: error instanceof Error ? error.message : "Command failed to start.", - }); - return; - } - - let stdout = ""; - let stderr = ""; - let settled = false; - const timer = setTimeout(() => { - if (settled) return; - settled = true; - child.kill(); - resolve({ - ok: false, - exitCode: null, - stdout: truncateOutput(stdout), - stderr: truncateOutput(stderr), - error: "Command timed out.", - }); - }, commandTimeoutMs); - - child.stdout?.on("data", (chunk: Buffer) => { - stdout = truncateOutput(stdout + chunk.toString("utf8")); - }); - child.stderr?.on("data", (chunk: Buffer) => { - stderr = truncateOutput(stderr + chunk.toString("utf8")); - }); - child.on("error", (error) => { - if (settled) return; - settled = true; - clearTimeout(timer); - resolve({ - ok: false, - exitCode: null, - stdout: truncateOutput(stdout), - stderr: truncateOutput(stderr), - error: error.message, - }); - }); - child.on("close", (code) => { - if (settled) return; - settled = true; - clearTimeout(timer); - resolve({ - ok: code === 0, - exitCode: code, - stdout: truncateOutput(stdout), - stderr: truncateOutput(stderr), - }); - }); - }); -} - -function createCommandEnv(): NodeJS.ProcessEnv { - const separator = process.platform === "win32" ? ";" : ":"; - const existingPath = process.env.PATH ?? ""; - return { - ...process.env, - PATH: dedupePathEntries([existingPath, ...getExtraCommandPaths()], separator).join(separator), - }; -} - -function getExtraCommandPaths(): readonly string[] { - if (process.platform === "win32") return []; - const home = app.getPath("home"); - const env = process.env; - return filterExistingPaths([ - "/opt/homebrew/bin", - "/opt/homebrew/sbin", - "/usr/local/bin", - "/usr/local/sbin", - "/usr/bin", - "/bin", - "/usr/sbin", - "/sbin", - join(home, "bin"), - join(home, ".local", "bin"), - join(home, ".opencode", "bin"), - join(env.VOLTA_HOME || join(home, ".volta"), "bin"), - join(env.BUN_INSTALL || join(home, ".bun"), "bin"), - join(env.MISE_DATA_DIR || join(home, ".local", "share", "mise"), "shims"), - join(env.ASDF_DATA_DIR || join(home, ".asdf"), "shims"), - env.PNPM_HOME, - join(home, ".local", "share", "pnpm"), - join(home, "Library", "pnpm"), - join(env.NVM_DIR || join(home, ".nvm"), "current", "bin"), - ]); -} - -function filterExistingPaths(paths: readonly (string | undefined)[]): readonly string[] { - return paths.filter((path): path is string => Boolean(path && existsSync(path))); -} - -function dedupePathEntries(paths: readonly string[], separator: string): readonly string[] { - const seen = new Set(); - const entries: string[] = []; - for (const path of paths.flatMap((value) => value.split(separator)).filter(Boolean)) { - if (seen.has(path)) continue; - seen.add(path); - entries.push(path); - } - return entries; -} - -function looksLikeMissingCommand(result: CommandResult): boolean { - return Boolean(result.error && /ENOENT|not found/i.test(result.error)); -} - -function summarizeCommandFailure(result: CommandResult): string { - const detail = result.stderr || result.stdout || result.error || `exit code ${result.exitCode ?? "unknown"}`; - return truncateOutput(detail).trim() || "command failed."; -} - -function truncateOutput(value: string): string { - return value.length > maxOutputChars ? value.slice(value.length - maxOutputChars) : value; -} - -function formatUserPath(path: string): string { - return path.replace(app.getPath("home"), "~"); -} - -function quoteShellArg(value: string): string { - if (/^[A-Za-z0-9_./:@-]+$/.test(value)) return value; - return JSON.stringify(value); -} diff --git a/apps/desktop/tests/control-center-service-barrels.test.ts b/apps/desktop/tests/control-center-service-barrels.test.ts index ca78f783..839275a7 100644 --- a/apps/desktop/tests/control-center-service-barrels.test.ts +++ b/apps/desktop/tests/control-center-service-barrels.test.ts @@ -11,6 +11,7 @@ const controlCenterIpcPetSource = readFileSync(resolve(desktopRoot, "src/control const controlCenterIpcPluginSource = readFileSync(resolve(desktopRoot, "src/control-center-ipc-plugin.ts"), "utf8"); const controlCenterIpcSettingsSource = readFileSync(resolve(desktopRoot, "src/control-center-ipc-settings.ts"), "utf8"); const agentBarrelSource = readFileSync(resolve(desktopRoot, "src/control-center-agent-services.ts"), "utf8"); +const toolkitInstallerSource = readFileSync(resolve(desktopRoot, "src/mcp-toolkit-installer.ts"), "utf8"); const coreBarrelSource = readFileSync(resolve(desktopRoot, "src/control-center-core-services.ts"), "utf8"); const dataBarrelSource = readFileSync(resolve(desktopRoot, "src/control-center-data-services.ts"), "utf8"); const petBarrelSource = readFileSync(resolve(desktopRoot, "src/control-center-pet-services.ts"), "utf8"); @@ -31,6 +32,8 @@ assert.match(controlCenterIpcCombinedSource, /from "\.\/control-center-pet-servi assert.match(agentBarrelSource, /buildFamiliarOSMcpServerPreview/); assert.match(agentBarrelSource, /refreshAgentPetContent/); assert.match(agentBarrelSource, /installPersistentToolkit/); +assert.match(toolkitInstallerSource, /from "\.\/mcp-toolkit-installer-support(?:\.js)?"/, "MCP toolkit installer must import the extracted support seam."); +assert.match(toolkitInstallerSource, /from "\.\/mcp-toolkit-installer-commands(?:\.js)?"/, "MCP toolkit installer must import the extracted command seam."); assert.match(coreBarrelSource, /getAppStateSnapshot/); assert.match(coreBarrelSource, /getSettingsStateSnapshot/); assert.match(coreBarrelSource, /openUpdateReleasePage/); diff --git a/apps/desktop/tests/mcp-toolkit-installer-seams.test.ts b/apps/desktop/tests/mcp-toolkit-installer-seams.test.ts new file mode 100644 index 00000000..78fdb3a6 --- /dev/null +++ b/apps/desktop/tests/mcp-toolkit-installer-seams.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const desktopRoot = + process.env.FAMILIAROS_DESKTOP_ROOT ?? + resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const installerSource = readFileSync( + resolve(desktopRoot, "src/mcp-toolkit-installer.ts"), + "utf8", +); +const installerSupportSource = readFileSync( + resolve(desktopRoot, "src/mcp-toolkit-installer-support.ts"), + "utf8", +); +const installerCommandsSource = readFileSync( + resolve(desktopRoot, "src/mcp-toolkit-installer-commands.ts"), + "utf8", +); + +assert.match( + installerSource, + /from "\.\/mcp-toolkit-installer-support(?:\.js)?"/, + "MCP toolkit installer must import the extracted support seam.", +); +assert.match( + installerSource, + /from "\.\/mcp-toolkit-installer-commands(?:\.js)?"/, + "MCP toolkit installer must import the extracted command seam.", +); +assert.match( + installerSource, + /export async function installPersistentToolkit/, + "MCP toolkit installer must keep exporting the public install flow.", +); +assert.match( + installerSupportSource, + /export function getSupportedPersistentServers/, + "MCP toolkit installer support seam must export supported server definitions.", +); +assert.match( + installerSupportSource, + /export function buildInstallCommands/, + "MCP toolkit installer support seam must export bundle command shaping.", +); +assert.match( + installerCommandsSource, + /export function runCommand/, + "MCP toolkit installer command seam must export process execution helpers.", +); +assert.match( + installerCommandsSource, + /export async function runCommandWithCandidates/, + "MCP toolkit installer command seam must export command discovery helpers.", +); + +console.error("MCP toolkit installer seam validation passed.");