feat: 导出 SproutClaw .sproutclaw 配置

包含 extensions、skills、prompts、settings、auth、models、mcp 等配置。
排除 node_modules、npm 缓存、sessions 等运行时数据。
This commit is contained in:
2026-06-26 15:48:56 +08:00
commit 50edff80f5
13904 changed files with 411646 additions and 0 deletions

View File

@@ -0,0 +1,581 @@
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "./panel-keys.ts";
import type { ImportKind } from "./types.ts";
import type { ConfigWritePreview, McpDiscoverySummary } from "./config.ts";
import type { McpOnboardingState } from "./onboarding-state.ts";
interface SetupTheme {
border: string;
title: string;
selected: string;
hint: string;
success: string;
warning: string;
muted: string;
}
const DEFAULT_THEME: SetupTheme = {
border: "2",
title: "36",
selected: "32",
hint: "2",
success: "32",
warning: "33",
muted: "2;3",
};
function fg(code: string, text: string): string {
return code ? `\x1b[${code}m${text}\x1b[0m` : text;
}
function wrapText(text: string, width: number): string[] {
if (width <= 8) return [text];
const words = text.split(/\s+/).filter(Boolean);
const lines: string[] = [];
let current = "";
for (const word of words) {
const candidate = current ? `${current} ${word}` : word;
if (visibleWidth(candidate) <= width) {
current = candidate;
continue;
}
if (current) lines.push(current);
current = word;
}
if (current) lines.push(current);
return lines.length > 0 ? lines : [""];
}
export interface SetupPanelCallbacks {
previewImports: (imports: ImportKind[]) => ConfigWritePreview;
previewStarterProject: () => ConfigWritePreview;
previewRepoPrompt: () => ConfigWritePreview | null;
adoptImports: (imports: ImportKind[]) => Promise<{ added: ImportKind[]; path: string }>;
scaffoldProjectConfig: () => Promise<{ path: string }>;
addRepoPrompt: () => Promise<{ path: string; serverName: string }>;
openPath: (path: string) => Promise<void>;
markSetupCompleted: () => void;
}
export interface SetupPanelOptions {
mode: "empty" | "setup";
onboardingState: McpOnboardingState;
keybindings?: PanelKeybindings;
}
type Screen = "empty" | "setup" | "imports" | "paths";
type ActionId =
| "run-setup"
| "adopt-imports"
| "view-example"
| "show-precedence"
| "open-paths"
| "add-repoprompt"
| "scaffold-project"
| "close";
interface Action {
id: ActionId;
label: string;
description: string;
}
export class McpSetupPanel {
private screen: Screen;
private actionCursor = 0;
private importCursor = 0;
private pathCursor = 0;
private selectedImports = new Set<ImportKind>();
private busy = false;
private notice: { text: string; tone: "success" | "warning" | "muted" } | null = null;
private tui: { requestRender(): void };
private t = DEFAULT_THEME;
private keys: PanelKeys;
private inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
private static readonly INACTIVITY_MS = 60_000;
constructor(
private discovery: McpDiscoverySummary,
private callbacks: SetupPanelCallbacks,
private options: SetupPanelOptions,
tui: { requestRender(): void },
private done: () => void,
) {
this.tui = tui;
this.keys = createPanelKeys(options.keybindings);
this.screen = options.mode;
for (const entry of discovery.imports) {
this.selectedImports.add(entry.kind);
}
this.resetInactivityTimeout();
}
private resetInactivityTimeout(): void {
if (this.inactivityTimeout) clearTimeout(this.inactivityTimeout);
this.inactivityTimeout = setTimeout(() => {
this.cleanup();
this.done();
}, McpSetupPanel.INACTIVITY_MS);
}
private cleanup(): void {
if (this.inactivityTimeout) {
clearTimeout(this.inactivityTimeout);
this.inactivityTimeout = null;
}
}
private getActions(): Action[] {
const actions: Action[] = [];
if (this.screen === "empty") {
actions.push({ id: "run-setup", label: "Run setup", description: "Inspect detected configs, adopt imports, and scaffold a minimal `.mcp.json`." });
}
if (this.discovery.imports.length > 0) {
actions.push({ id: "adopt-imports", label: "Adopt detected compatibility imports", description: `Choose which host-specific MCP configs Pi should import into its own override file. ${this.discovery.imports.length} source${this.discovery.imports.length === 1 ? "" : "s"} found.` });
}
actions.push({ id: "view-example", label: "View example `.mcp.json`", description: "Preview a working shared MCP config you can paste or adapt." });
if (!this.discovery.sources.some((source) => source.id === "shared-project" && source.exists)) {
actions.push({ id: "scaffold-project", label: "Scaffold project `.mcp.json`", description: "Write a minimal project config using the standard shared MCP file path, then reload Pi." });
}
actions.push({ id: "show-precedence", label: "Explain config precedence", description: "Show the read order and where Pi writes compatibility settings." });
if (this.getDetectedPaths().length > 0) {
actions.push({ id: "open-paths", label: "Open detected config paths", description: "Browse the actual config files that Pi discovered on this machine." });
}
if (!this.discovery.repoPrompt.configured && this.discovery.repoPrompt.executablePath && this.discovery.repoPrompt.targetPath && this.discovery.repoPrompt.entry && this.discovery.repoPrompt.serverName) {
actions.push({ id: "add-repoprompt", label: "Add RepoPrompt to shared MCP config", description: "Write a standard MCP entry for RepoPrompt to the recommended shared target, then reload MCP in-session." });
}
actions.push({ id: "close", label: "Close", description: "Exit the onboarding flow." });
return actions;
}
private getDetectedPaths(): string[] {
const paths = [
...this.discovery.sources.filter((source) => source.exists).map((source) => source.path),
...this.discovery.imports.map((entry) => entry.path),
];
return [...new Set(paths)];
}
private getSelectedAction(): Action | null {
const actions = this.getActions();
return actions[this.actionCursor] ?? null;
}
handleInput(data: string): void {
this.resetInactivityTimeout();
if (!this.busy) this.notice = null;
if (matchesKey(data, "ctrl+c")) {
this.cleanup();
this.done();
return;
}
if (matchesKey(data, "escape")) {
if (this.screen === "imports" || this.screen === "paths") {
this.screen = this.discovery.hasAnyConfig ? "setup" : "empty";
this.tui.requestRender();
return;
}
this.cleanup();
this.done();
return;
}
if (this.busy) return;
if (this.screen === "imports") {
this.handleImportsInput(data);
return;
}
if (this.screen === "paths") {
this.handlePathsInput(data);
return;
}
const actions = this.getActions();
if (this.keys.selectUp(data)) {
this.actionCursor = Math.max(0, this.actionCursor - 1);
this.tui.requestRender();
return;
}
if (this.keys.selectDown(data)) {
this.actionCursor = Math.min(actions.length - 1, this.actionCursor + 1);
this.tui.requestRender();
return;
}
if (this.keys.selectConfirm(data)) {
const selected = this.getSelectedAction();
if (selected) void this.runAction(selected.id);
}
}
private handleImportsInput(data: string): void {
const imports = this.discovery.imports;
if (this.keys.selectUp(data)) {
this.importCursor = Math.max(0, this.importCursor - 1);
this.tui.requestRender();
return;
}
if (this.keys.selectDown(data)) {
this.importCursor = Math.min(imports.length - 1, this.importCursor + 1);
this.tui.requestRender();
return;
}
if (matchesKey(data, "space")) {
const current = imports[this.importCursor];
if (!current) return;
if (this.selectedImports.has(current.kind)) {
this.selectedImports.delete(current.kind);
} else {
this.selectedImports.add(current.kind);
}
this.tui.requestRender();
return;
}
if (this.keys.selectConfirm(data)) {
void this.applySelectedImports();
}
}
private handlePathsInput(data: string): void {
const paths = this.getDetectedPaths();
if (this.keys.selectUp(data)) {
this.pathCursor = Math.max(0, this.pathCursor - 1);
this.tui.requestRender();
return;
}
if (this.keys.selectDown(data)) {
this.pathCursor = Math.min(paths.length - 1, this.pathCursor + 1);
this.tui.requestRender();
return;
}
if (this.keys.selectConfirm(data)) {
const selected = paths[this.pathCursor];
if (!selected) return;
void this.runBusy(async () => {
await this.callbacks.openPath(selected);
this.notice = { text: `Opened ${selected}`, tone: "success" };
});
}
}
private async runAction(action: ActionId): Promise<void> {
if (action === "run-setup") {
this.screen = "setup";
this.actionCursor = 0;
this.tui.requestRender();
return;
}
if (action === "adopt-imports") {
this.screen = "imports";
this.importCursor = 0;
this.tui.requestRender();
return;
}
if (action === "open-paths") {
this.screen = "paths";
this.pathCursor = 0;
this.tui.requestRender();
return;
}
if (action === "scaffold-project") {
await this.runBusy(async () => {
const result = await this.callbacks.scaffoldProjectConfig();
this.callbacks.markSetupCompleted();
this.notice = { text: `Wrote starter config to ${result.path}. Pi will reload after this panel closes.`, tone: "success" };
});
return;
}
if (action === "add-repoprompt") {
await this.runBusy(async () => {
const result = await this.callbacks.addRepoPrompt();
this.callbacks.markSetupCompleted();
this.notice = { text: `Added ${result.serverName} to ${result.path}. Pi will reload after this panel closes.`, tone: "success" };
});
return;
}
if (action === "close") {
this.cleanup();
this.done();
return;
}
this.notice = { text: "Review the details below. Press Enter on an action with a side effect to apply it.", tone: "muted" };
this.tui.requestRender();
}
private async applySelectedImports(): Promise<void> {
const selected = this.discovery.imports.filter((entry) => this.selectedImports.has(entry.kind)).map((entry) => entry.kind);
if (selected.length === 0) {
this.notice = { text: "Select at least one compatibility import first.", tone: "warning" };
this.tui.requestRender();
return;
}
await this.runBusy(async () => {
const result = await this.callbacks.adoptImports(selected);
this.callbacks.markSetupCompleted();
this.notice = result.added.length > 0
? { text: `Added ${result.added.join(", ")} to ${result.path}. Pi will reload after this panel closes.`, tone: "success" }
: { text: `No changes needed in ${result.path}.`, tone: "muted" };
this.screen = this.discovery.hasAnyConfig ? "setup" : "empty";
this.actionCursor = 0;
});
}
private async runBusy(fn: () => Promise<void>): Promise<void> {
this.busy = true;
this.notice = { text: "Working...", tone: "muted" };
this.tui.requestRender();
try {
await fn();
} catch (error) {
this.notice = {
text: error instanceof Error ? error.message : String(error),
tone: "warning",
};
} finally {
this.busy = false;
this.tui.requestRender();
}
}
render(width: number): string[] {
const innerW = Math.max(40, width - 2);
const lines: string[] = [];
const border = fg(this.t.border, "─".repeat(innerW));
lines.push(`${border}`);
lines.push(this.padLine(fg(this.t.title, "MCP setup"), innerW));
lines.push(this.padLine(this.discoverySummaryLine(), innerW));
lines.push(this.padLine(fg(this.t.muted, this.secondarySummaryLine()), innerW));
lines.push(this.padLine("", innerW));
if (this.notice) {
const tone = this.notice.tone === "success" ? this.t.success : this.notice.tone === "warning" ? this.t.warning : this.t.hint;
for (const line of wrapText(this.notice.text, innerW - 6)) {
lines.push(this.padLine(fg(tone, line), innerW));
}
lines.push(this.padLine("", innerW));
}
lines.push(`${border}`);
if (this.screen === "imports") {
lines.push(...this.renderImports(innerW));
} else if (this.screen === "paths") {
lines.push(...this.renderPaths(innerW));
} else {
lines.push(...this.renderActions(innerW));
}
lines.push(`${border}`);
return lines;
}
private renderActions(innerW: number): string[] {
const lines: string[] = [];
const actions = this.getActions();
for (let index = 0; index < actions.length; index++) {
const action = actions[index];
const selected = index === this.actionCursor;
const cursor = selected ? fg(this.t.selected, "") : " ";
lines.push(this.padLine(`${cursor} ${truncateToWidth(action.label, innerW - 4)}`, innerW));
}
lines.push(this.padLine("", innerW));
const preview = this.getActionPreview(this.getSelectedAction()?.id ?? "view-example");
for (const line of preview) {
lines.push(this.padLine(line, innerW));
}
lines.push(this.padLine("", innerW));
lines.push(this.padLine(fg(this.t.muted, "Enter selects, Esc goes back, Ctrl+C closes."), innerW));
return lines;
}
private renderImports(innerW: number): string[] {
const lines: string[] = [];
lines.push(this.padLine("Select compatibility imports. Space toggles, Enter saves, Esc goes back.", innerW));
lines.push(this.padLine("", innerW));
for (let index = 0; index < this.discovery.imports.length; index++) {
const entry = this.discovery.imports[index];
const selected = this.selectedImports.has(entry.kind) ? "[x]" : "[ ]";
const cursor = index === this.importCursor ? fg(this.t.selected, "") : " ";
lines.push(this.padLine(`${cursor} ${selected} ${entry.kind} ${entry.path}`, innerW));
}
lines.push(this.padLine("", innerW));
const selected = this.discovery.imports.filter((entry) => this.selectedImports.has(entry.kind)).map((entry) => entry.kind);
const preview = this.callbacks.previewImports(selected);
for (const line of this.formatWritePreview("Compatibility import write preview", preview)) {
lines.push(this.padLine(line, innerW));
}
return lines;
}
private renderPaths(innerW: number): string[] {
const lines: string[] = [];
lines.push(this.padLine("Select a detected config path to open. Enter opens it, Esc goes back.", innerW));
lines.push(this.padLine("", innerW));
const paths = this.getDetectedPaths();
for (let index = 0; index < paths.length; index++) {
const cursor = index === this.pathCursor ? fg(this.t.selected, "") : " ";
lines.push(this.padLine(`${cursor} ${paths[index]}`, innerW));
}
return lines;
}
private discoverySummaryLine(): string {
if (!this.discovery.hasAnyConfig) {
return fg(this.t.warning, this.options.onboardingState.setupCompleted
? "No MCP servers are active right now."
: "No MCP config is active yet.");
}
if (this.discovery.totalServerCount === 0 && (this.discovery.imports.length > 0 || !!this.discovery.repoPrompt.executablePath)) {
return fg(this.t.warning, "Pi found MCP-related setup options, but none are active in Pi yet.");
}
const shared = this.discovery.sources.filter((source) => source.kind === "shared" && source.serverCount > 0).length;
const piOwned = this.discovery.sources.filter((source) => source.kind === "pi" && source.serverCount > 0).length;
return fg(this.t.hint, `Detected ${this.discovery.totalServerCount} configured servers across ${shared} shared and ${piOwned} Pi-owned source${shared + piOwned === 1 ? "" : "s"}.`);
}
private secondarySummaryLine(): string {
if (!this.discovery.hasAnyConfig) {
return "Create a shared `.mcp.json`, adopt host imports, or quick-add RepoPrompt from this screen.";
}
if (this.discovery.totalServerCount === 0 && this.discovery.imports.length > 0) {
return `Detected ${this.discovery.imports.length} compatibility import source${this.discovery.imports.length === 1 ? "" : "s"}. Adopt them into Pi or inspect the underlying files.`;
}
return "Shared MCP files are preferred. Pi-owned files are only for compatibility imports and adapter-specific overrides.";
}
private getActionPreview(action: ActionId): string[] {
switch (action) {
case "run-setup":
return this.formatPreview([
"Run setup to adopt host-specific imports, inspect detected paths, and scaffold a minimal `.mcp.json` if needed.",
]);
case "adopt-imports":
return this.formatWritePreview(
"Compatibility import write preview",
this.callbacks.previewImports(this.discovery.imports.filter((entry) => this.selectedImports.has(entry.kind)).map((entry) => entry.kind)),
[
`Detected imports: ${this.discovery.imports.map((entry) => `${entry.kind} (${entry.serverCount} servers)`).join(", ")}`,
"Selected imports are written into the Pi agent dir config as Pi-owned compatibility state.",
],
);
case "view-example":
return this.formatPreview([
"Example shared `.mcp.json`:",
"{",
' "mcpServers": {',
' "chrome-devtools": {',
' "command": "npx",',
' "args": ["-y", "chrome-devtools-mcp@latest"]',
" }",
" }",
"}",
"",
"Use Scaffold project `.mcp.json` when you want a safe empty shell instead of a live example server.",
]);
case "show-precedence":
return this.formatPreview([
"Read order:",
"1. ~/.config/mcp/mcp.json",
"2. <Pi agent dir>/mcp.json",
"3. .mcp.json",
`4. ${CONFIG_DIR_NAME}/mcp.json`,
"Pi writes compatibility imports and adapter-only overrides to Pi-owned files.",
]);
case "open-paths":
return this.formatPreview(this.getDetectedPaths().length > 0
? ["Detected paths:", ...this.getDetectedPaths()]
: ["No config paths were detected."]);
case "add-repoprompt": {
const repoPrompt = this.discovery.repoPrompt;
const preview = this.callbacks.previewRepoPrompt();
if (!preview) {
return this.formatPreview(["RepoPrompt is not available to add from this setup screen."]);
}
return this.formatWritePreview(
"RepoPrompt write preview",
preview,
[
`Executable: ${repoPrompt.executablePath ?? "not found"}`,
`Target: ${repoPrompt.targetPath ?? "n/a"}`,
`Server name: ${repoPrompt.serverName ?? "repoprompt"}`,
],
);
}
case "scaffold-project":
return this.formatWritePreview(
"Starter project `.mcp.json` write preview",
this.callbacks.previewStarterProject(),
[
"This writes a minimal `.mcp.json` in the current project using the shared MCP layout.",
"It intentionally avoids adding a fake placeholder server that would fail on first reload.",
],
);
case "close":
default:
return this.formatPreview(["Close the setup flow."]);
}
}
private formatPreview(lines: string[]): string[] {
const preview: string[] = [];
for (const line of lines) {
preview.push(...wrapText(line, 74));
}
return preview;
}
private formatWritePreview(title: string, preview: ConfigWritePreview, intro: string[] = []): string[] {
const lines: string[] = [];
for (const line of intro) {
lines.push(...wrapText(line, 74));
}
if (intro.length > 0) lines.push("");
lines.push(...wrapText(`${title}: ${preview.path}`, 74));
lines.push(...wrapText(preview.existed ? "Existing file detected. Showing exact before/after diff." : "New file will be created. Showing exact content diff.", 74));
lines.push("");
const diffLines = preview.diffText.split("\n");
const maxLines = 18;
const shown = diffLines.slice(0, maxLines);
for (const line of shown) {
lines.push(...wrapText(line, 74));
}
if (diffLines.length > maxLines) {
lines.push(...wrapText(`${diffLines.length - maxLines} more diff line${diffLines.length - maxLines === 1 ? "" : "s"}`, 74));
}
return lines;
}
private padLine(text: string, innerW: number): string {
const inset = 2;
const contentW = Math.max(0, innerW - inset * 2);
const fitted = truncateToWidth(text, contentW, "…", true);
const plainWidth = visibleWidth(fitted);
const padding = Math.max(0, contentW - plainWidth);
return `${" ".repeat(inset)}${fitted}${" ".repeat(padding)}${" ".repeat(inset)}`;
}
invalidate(): void {}
dispose(): void {
this.cleanup();
}
}
export function createMcpSetupPanel(
discovery: McpDiscoverySummary,
callbacks: SetupPanelCallbacks,
options: SetupPanelOptions,
tui: { requestRender(): void },
done: () => void,
): McpSetupPanel & { dispose(): void } {
return new McpSetupPanel(discovery, callbacks, options, tui, done);
}