Extract MCP toolkit installer support seams
This commit is contained in:
parent
13e23c60c9
commit
5caf9b1c39
8 changed files with 536 additions and 333 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
199
apps/desktop/src/mcp-toolkit-installer-commands.ts
Normal file
199
apps/desktop/src/mcp-toolkit-installer-commands.ts
Normal file
|
|
@ -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<CommandResult> {
|
||||
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<CommandResult> {
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
197
apps/desktop/src/mcp-toolkit-installer-support.ts
Normal file
197
apps/desktop/src/mcp-toolkit-installer-support.ts
Normal file
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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<McpToolkitInstallResult> {
|
||||
const label = target === "claude-user" ? "Claude Code user scope" : "Codex CLI global";
|
||||
export async function installPersistentToolkit(
|
||||
target: McpToolkitPersistentTarget,
|
||||
): Promise<McpToolkitInstallResult> {
|
||||
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<void> {
|
||||
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<CommandResult> {
|
||||
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<CommandResult> {
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
|
|
|
|||
58
apps/desktop/tests/mcp-toolkit-installer-seams.test.ts
Normal file
58
apps/desktop/tests/mcp-toolkit-installer-seams.test.ts
Normal file
|
|
@ -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.");
|
||||
Loading…
Add table
Reference in a new issue