Files
sproutclaw-data/agent/extensions/pi-mcp-adapter/commands.ts
shumengya 50edff80f5 feat: 导出 SproutClaw .sproutclaw 配置
包含 extensions、skills、prompts、settings、auth、models、mcp 等配置。
排除 node_modules、npm 缓存、sessions 等运行时数据。
2026-06-26 15:48:56 +08:00

422 lines
15 KiB
TypeScript

import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import type { McpExtensionState } from "./state.ts";
import type { McpAuthResult, McpConfig, ServerEntry, McpPanelCallbacks, McpPanelResult, ImportKind } from "./types.ts";
import {
ensureCompatibilityImports,
getMcpDiscoverySummary,
getServerProvenance,
previewCompatibilityImports,
previewSharedServerEntry,
previewStarterProjectConfig,
writeDirectToolsConfig,
writeSharedServerEntry,
writeStarterProjectConfig,
} from "./config.ts";
import { lazyConnect, updateMetadataCache, updateStatusBar, getFailureAgeSeconds } from "./init.ts";
import { loadMetadataCache } from "./metadata-cache.ts";
import { buildToolMetadata } from "./tool-metadata.ts";
import { supportsOAuth, authenticate, removeAuth } from "./mcp-auth-flow.ts";
import { getAuthForUrl } from "./mcp-auth.ts";
import { loadOnboardingState, markSetupCompleted as persistSetupCompleted, markSharedConfigHintShown } from "./onboarding-state.ts";
import { openPath } from "./utils.ts";
export async function showStatus(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
if (!ctx.hasUI) return;
const lines: string[] = ["MCP Server Status:", ""];
for (const name of Object.keys(state.config.mcpServers)) {
const connection = state.manager.getConnection(name);
const metadata = state.toolMetadata.get(name);
const toolCount = metadata?.length ?? 0;
const failedAgo = getFailureAgeSeconds(state, name);
let status = "not connected";
let statusIcon = "○";
let failed = false;
if (connection?.status === "connected") {
status = "connected";
statusIcon = "✓";
} else if (connection?.status === "needs-auth") {
status = "needs auth";
statusIcon = "⚠";
} else if (failedAgo !== null) {
status = `failed ${failedAgo}s ago`;
statusIcon = "✗";
failed = true;
} else if (metadata !== undefined) {
status = "cached";
}
const toolSuffix = failed ? "" : ` (${toolCount} tools${status === "cached" ? ", cached" : ""})`;
lines.push(`${statusIcon} ${name}: ${status}${toolSuffix}`);
}
if (Object.keys(state.config.mcpServers).length === 0) {
lines.push("No MCP servers configured");
lines.push("Run /mcp setup to adopt imports or scaffold a starter .mcp.json");
}
ctx.ui.notify(lines.join("\n"), "info");
}
export async function showTools(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
if (!ctx.hasUI) return;
const allTools = [...state.toolMetadata.values()].flat().map(m => m.name);
if (allTools.length === 0) {
ctx.ui.notify("No MCP tools available", "info");
return;
}
const lines = [
"MCP Tools:",
"",
...allTools.map(t => ` ${t}`),
"",
`Total: ${allTools.length} tools`,
];
ctx.ui.notify(lines.join("\n"), "info");
}
export async function reconnectServers(
state: McpExtensionState,
ctx: ExtensionContext,
targetServer?: string
): Promise<void> {
if (targetServer && !state.config.mcpServers[targetServer]) {
if (ctx.hasUI) {
ctx.ui.notify(`Server "${targetServer}" not found in config`, "error");
}
return;
}
const entries = targetServer
? [[targetServer, state.config.mcpServers[targetServer]] as [string, ServerEntry]]
: Object.entries(state.config.mcpServers);
for (const [name, definition] of entries) {
try {
await state.manager.close(name);
const connection = await state.manager.connect(name, definition);
if (connection.status === "needs-auth") {
if (ctx.hasUI) {
ctx.ui.notify(`MCP: ${name} requires OAuth. Run /mcp-auth ${name} first.`, "warning");
}
continue;
}
const prefix = state.config.settings?.toolPrefix ?? "server";
const { metadata, failedTools } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
state.toolMetadata.set(name, metadata);
updateMetadataCache(state, name);
state.failureTracker.delete(name);
if (ctx.hasUI) {
ctx.ui.notify(
`MCP: Reconnected to ${name} (${connection.tools.length} tools, ${connection.resources.length} resources)`,
"info"
);
if (failedTools.length > 0) {
ctx.ui.notify(`MCP: ${name} - ${failedTools.length} tools skipped`, "warning");
}
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
state.failureTracker.set(name, Date.now());
if (ctx.hasUI) {
ctx.ui.notify(`MCP: Failed to reconnect to ${name}: ${message}`, "error");
}
}
}
updateStatusBar(state);
}
export async function authenticateServer(
serverName: string,
config: McpConfig,
ctx: ExtensionContext
): Promise<McpAuthResult> {
if (!ctx.hasUI) return { ok: false, message: "OAuth authentication requires an interactive session." };
const definition = config.mcpServers[serverName];
if (!definition) {
const message = `Server "${serverName}" not found in config`;
ctx.ui.notify(message, "error");
return { ok: false, message };
}
if (!supportsOAuth(definition)) {
const message = `Server "${serverName}" does not use OAuth authentication. Set "auth": "oauth" or omit auth for auto-detection.`;
ctx.ui.notify(
`Server "${serverName}" does not use OAuth authentication.\n` +
`Set "auth": "oauth" or omit auth for auto-detection.`,
"error"
);
return { ok: false, message };
}
if (!definition.url) {
const message = `Server "${serverName}" has no URL configured (OAuth requires HTTP transport)`;
ctx.ui.notify(message, "error");
return { ok: false, message };
}
try {
ctx.ui.setStatus("mcp-auth", `Authenticating ${serverName}...`);
const status = await authenticate(serverName, definition.url, definition);
if (status === "authenticated") {
const message = `OAuth authentication successful for "${serverName}"! Run /mcp reconnect ${serverName} to connect with the new token.`;
ctx.ui.notify(
`OAuth authentication successful for "${serverName}"!\n` +
`Run /mcp reconnect ${serverName} to connect with the new token.`,
"info"
);
return { ok: true, message };
}
const message = `OAuth authentication failed for "${serverName}".`;
ctx.ui.notify(message, "error");
return { ok: false, message };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
ctx.ui.notify(`Failed to authenticate "${serverName}": ${message}`, "error");
return { ok: false, message };
} finally {
ctx.ui.setStatus("mcp-auth", undefined);
}
}
export async function logoutServer(
serverName: string,
state: McpExtensionState,
ctx: ExtensionContext
): Promise<{ ok: boolean; message: string }> {
const definition = state.config.mcpServers[serverName];
if (!definition) {
const message = `Server "${serverName}" not found in config`;
if (ctx.hasUI) ctx.ui.notify(message, "error");
return { ok: false, message };
}
await removeAuth(serverName);
await state.manager.close(serverName);
updateStatusBar(state);
const message = `OAuth credentials cleared for "${serverName}". Run /mcp-auth ${serverName} to authenticate again.`;
if (ctx.hasUI) ctx.ui.notify(message, "info");
return { ok: true, message };
}
export interface PanelFlowResult {
configChanged: boolean;
}
function buildSharedConfigNoticeLines(configOverridePath: string | undefined, cwd: string): { lines: string[]; fingerprint: string | null } {
const discovery = getMcpDiscoverySummary(configOverridePath, cwd);
const onboardingState = loadOnboardingState();
if (!discovery.hasSharedServers || onboardingState.sharedConfigHintShown) {
return { lines: [], fingerprint: null };
}
const sharedSources = discovery.sources.filter((source) => source.kind === "shared" && source.serverCount > 0);
const sourceList = sharedSources.map((source) => source.path).join(", ");
return {
lines: [
`Using standard MCP config from ${sourceList}.`,
"Pi only writes compatibility imports and adapter-specific overrides into Pi-owned files when needed.",
],
fingerprint: discovery.fingerprint,
};
}
export async function openMcpSetup(
_state: McpExtensionState,
pi: ExtensionAPI,
ctx: ExtensionContext,
configOverridePath?: string,
mode: "empty" | "setup" = "setup",
): Promise<PanelFlowResult> {
if (!ctx.hasUI) return { configChanged: false };
const discovery = getMcpDiscoverySummary(configOverridePath, ctx.cwd);
const onboardingState = loadOnboardingState();
const { createMcpSetupPanel } = await import("./mcp-setup-panel.ts");
let configChanged = false;
const callbacks = {
previewImports: (imports: ImportKind[]) => previewCompatibilityImports(imports, configOverridePath),
previewStarterProject: () => previewStarterProjectConfig(ctx.cwd),
previewRepoPrompt: () => {
const repoPrompt = getMcpDiscoverySummary(configOverridePath, ctx.cwd).repoPrompt;
if (!repoPrompt.entry || !repoPrompt.targetPath || !repoPrompt.serverName) return null;
return previewSharedServerEntry(repoPrompt.targetPath, repoPrompt.serverName, repoPrompt.entry);
},
adoptImports: async (imports: ImportKind[]) => {
const result = ensureCompatibilityImports(imports, configOverridePath);
if (result.added.length > 0) configChanged = true;
return result;
},
scaffoldProjectConfig: async () => {
const path = writeStarterProjectConfig(ctx.cwd);
configChanged = true;
return { path };
},
addRepoPrompt: async () => {
const repoPrompt = getMcpDiscoverySummary(configOverridePath, ctx.cwd).repoPrompt;
if (!repoPrompt.entry || !repoPrompt.targetPath || !repoPrompt.serverName) {
throw new Error("RepoPrompt is not available to add from this setup screen.");
}
const path = writeSharedServerEntry(repoPrompt.targetPath, repoPrompt.serverName, repoPrompt.entry);
configChanged = true;
return { path, serverName: repoPrompt.serverName };
},
openPath: async (targetPath: string) => {
await openPath(pi, targetPath);
},
markSetupCompleted: () => {
persistSetupCompleted(discovery.fingerprint);
},
};
return new Promise<PanelFlowResult>((resolve) => {
ctx.ui.custom(
(tui, _theme, keybindings, done) => {
return createMcpSetupPanel(discovery, callbacks, { mode, onboardingState, keybindings }, tui, () => {
done(undefined);
resolve({ configChanged });
});
},
{ overlay: true, overlayOptions: { anchor: "center", width: 92 } },
);
});
}
function buildMcpPanelCallbacks(
state: McpExtensionState,
config: McpConfig,
ctx: ExtensionContext,
): McpPanelCallbacks {
return {
reconnect: (serverName: string) => lazyConnect(state, serverName),
canAuthenticate: (serverName: string) => {
const definition = config.mcpServers[serverName];
return definition ? supportsOAuth(definition) : false;
},
authenticate: (serverName: string) => authenticateServer(serverName, config, ctx),
getConnectionStatus: (serverName: string) => {
const definition = config.mcpServers[serverName];
const connection = state.manager.getConnection(serverName);
if (connection?.status === "needs-auth") {
return "needs-auth";
}
if (
definition?.auth === "oauth"
&& definition.url
&& definition.oauth !== false
&& definition.oauth?.grantType !== "client_credentials"
&& !getAuthForUrl(serverName, definition.url)?.tokens
) {
return "needs-auth";
}
if (connection?.status === "connected") return "connected";
if (getFailureAgeSeconds(state, serverName) !== null) return "failed";
return "idle";
},
refreshCacheAfterReconnect: (serverName: string) => {
const freshCache = loadMetadataCache();
return freshCache?.servers?.[serverName] ?? null;
},
};
}
export async function openMcpPanel(
state: McpExtensionState,
pi: ExtensionAPI,
ctx: ExtensionContext,
configOverridePath?: string,
): Promise<PanelFlowResult> {
if (Object.keys(state.config.mcpServers).length === 0) {
return openMcpSetup(state, pi, ctx, configOverridePath, "empty");
}
const config = state.config;
const cache = loadMetadataCache();
const configPath = pi.getFlag("mcp-config") as string | undefined ?? configOverridePath;
const provenanceMap = getServerProvenance(configPath, ctx.cwd);
const { lines: noticeLines, fingerprint } = buildSharedConfigNoticeLines(configPath, ctx.cwd);
const callbacks = buildMcpPanelCallbacks(state, config, ctx);
const { createMcpPanel } = await import("./mcp-panel.ts");
let configChanged = false;
await new Promise<void>((resolve) => {
ctx.ui.custom(
(tui, _theme, keybindings, done) => {
return createMcpPanel(config, cache, provenanceMap, callbacks, tui, (result: McpPanelResult) => {
if (!result.cancelled && result.changes.size > 0) {
writeDirectToolsConfig(result.changes, provenanceMap, config);
configChanged = true;
ctx.ui.notify("Direct tools updated. Pi will reload after this panel closes.", "info");
}
done(undefined);
resolve();
}, { noticeLines, keybindings });
},
{ overlay: true, overlayOptions: { anchor: "center", width: 82 } },
);
});
if (noticeLines.length > 0 && fingerprint) {
markSharedConfigHintShown(fingerprint);
}
return { configChanged };
}
export async function openMcpAuthPanel(
state: McpExtensionState,
pi: ExtensionAPI,
ctx: ExtensionContext,
configOverridePath?: string,
): Promise<PanelFlowResult> {
if (!ctx.hasUI) return { configChanged: false };
const config = state.config;
const oauthServers = Object.entries(config.mcpServers).filter(([, definition]) => supportsOAuth(definition));
if (oauthServers.length === 0) {
ctx.ui.notify("No OAuth-capable MCP servers are configured.", "warning");
return { configChanged: false };
}
const cache = loadMetadataCache();
const configPath = pi.getFlag("mcp-config") as string | undefined ?? configOverridePath;
const provenanceMap = getServerProvenance(configPath, ctx.cwd);
const callbacks = buildMcpPanelCallbacks(state, config, ctx);
const { createMcpPanel } = await import("./mcp-panel.ts");
await new Promise<void>((resolve) => {
ctx.ui.custom(
(tui, _theme, keybindings, done) => {
return createMcpPanel(config, cache, provenanceMap, callbacks, tui, () => {
done(undefined);
resolve();
}, {
authOnly: true,
keybindings,
noticeLines: ["Select an OAuth MCP server and press Enter or ctrl+a to authenticate."],
});
},
{ overlay: true, overlayOptions: { anchor: "center", width: 82 } },
);
});
return { configChanged: false };
}