import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { stat } from "node:fs/promises"; import { homedir } from "node:os"; import { join, parse, resolve } from "node:path"; interface ExecResult { code: number; stdout: string; stderr: string; } export type PluginInitExecutor = ( command: string, args: string[], options: { cwd: string }, ) => Promise; interface InitTarget { name: "CodeGraph" | "Hippo"; command: "codegraph" | "hippo"; database: string; initArgs(cwd: string): string[]; } const INIT_TARGETS: InitTarget[] = [ { name: "CodeGraph", command: "codegraph", database: join(".codegraph", "codegraph.db"), initArgs: (cwd) => ["init", cwd], }, { name: "Hippo", command: "hippo", database: join(".hippo", "hippo.db"), initArgs: () => ["init"], }, ]; async function isFile(path: string): Promise { try { return (await stat(path)).isFile(); } catch (error) { if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return false; throw error; } } export function validatePluginInitCwd(cwd: string): string { const projectRoot = resolve(cwd); if (projectRoot === parse(projectRoot).root) { throw new Error("拒绝在文件系统根目录初始化项目插件。"); } if (projectRoot === resolve(homedir())) { throw new Error("拒绝直接在用户主目录初始化项目插件;请先进入具体项目目录。"); } return projectRoot; } export async function findMissingPluginInitializers(cwd: string): Promise { const projectRoot = validatePluginInitCwd(cwd); const states = await Promise.all( INIT_TARGETS.map(async (target) => ({ target, initialized: await isFile(join(projectRoot, target.database)), })), ); return states.filter(({ initialized }) => !initialized).map(({ target }) => target); } function compactFailure(result: ExecResult): string { const output = (result.stderr.trim() || result.stdout.trim()).replace(/\s+/gu, " "); return output ? output.slice(0, 400) : `退出码 ${result.code}`; } async function executeChecked( exec: PluginInitExecutor, target: InitTarget, args: string[], cwd: string, phase: "检测" | "初始化", ): Promise { let result: ExecResult; try { result = await exec(target.command, args, { cwd }); } catch (error) { const detail = error instanceof Error ? error.message : String(error); throw new Error(`${target.name} ${phase}失败:${detail}`, { cause: error }); } if (result.code !== 0) { throw new Error(`${target.name} ${phase}失败:${compactFailure(result)}`); } } export async function initializeMissingPlugins( cwd: string, targets: InitTarget[], exec: PluginInitExecutor, onStart?: (target: InitTarget) => void, ): Promise { const projectRoot = validatePluginInitCwd(cwd); // Preflight both CLIs before creating either project's state. for (const target of INIT_TARGETS) { await executeChecked(exec, target, ["--version"], projectRoot, "检测"); } const completed: InitTarget[] = []; for (const target of targets) { onStart?.(target); try { await executeChecked(exec, target, target.initArgs(projectRoot), projectRoot, "初始化"); completed.push(target); } catch (error) { if (completed.length === 0) throw error; const detail = error instanceof Error ? error.message : String(error); throw new Error( `${detail};已完成的 ${completed.map(({ name }) => name).join("、")} 初始化会保留,不会自动回滚。`, { cause: error }, ); } } } export default function pluginInitExtension(pi: ExtensionAPI): void { pi.registerCommand("plugin_init", { description: "Initialize CodeGraph and Hippo for the current project, then hot-reload Pi", handler: async (_args, ctx) => { if (!ctx.hasUI) return; let projectRoot: string; let missing: InitTarget[]; try { projectRoot = validatePluginInitCwd(ctx.cwd); missing = await findMissingPluginInitializers(projectRoot); } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); return; } const pending = missing.length > 0 ? missing.map(({ name }) => name).join("、") : "无(均已初始化)"; const confirmed = await ctx.ui.confirm( "初始化项目插件", `项目:${projectRoot}\n待初始化:${pending}\n\n完成后将热重载 Pi 扩展。是否继续?`, ); if (!confirmed) return; try { await initializeMissingPlugins( projectRoot, missing, (command, args, options) => pi.exec(command, args, options), (target) => ctx.ui.notify(`正在初始化 ${target.name}…`, "info"), ); ctx.ui.notify( missing.length > 0 ? "项目插件初始化完成,正在热重载 Pi…" : "项目插件均已初始化,正在热重载 Pi…", "info", ); await ctx.reload(); } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); } }, }); }