From e941e71ed87d170db024e5aa67920effbf507180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E6=9C=8D=E5=8A=A1=E9=83=A8-=E5=8F=B6=E6=9E=97?= =?UTF-8?q?=E7=AB=8B?= <1361666059@qq.com> Date: Mon, 24 Aug 2026 19:30:59 +0800 Subject: [PATCH] feat: add project plugin initializer command --- AGENTS.md | 3 +- README.md | 21 +++- extensions/plugin-init.ts | 164 +++++++++++++++++++++++++++ package.json | 1 + tests/extension-load.test.ts | 4 + tests/plugin-init.test.ts | 210 +++++++++++++++++++++++++++++++++++ 6 files changed, 396 insertions(+), 7 deletions(-) create mode 100644 extensions/plugin-init.ts create mode 100644 tests/plugin-init.test.ts diff --git a/AGENTS.md b/AGENTS.md index 9f7de1f..8d71674 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ - `@keenable/pi-search`:加载 `keenable_search` / `keenable_fetch` 及其 skill,默认 keyless,`KEENABLE_API_KEY` 仅用于提高速率限制。 - `search_config.sh` 与 `extensions/search-config.ts`:以交互式或参数方式把三家搜索 key 写入用户级 `search.env`(权限 `600`),并在搜索扩展初始化前加载;显式进程环境变量优先。 - `extensions/mcp.ts`:通过单个共享 `pi-mcp-adapter` 实例同时连接 Exa 托管 MCP 与机器现有的 `codegraph serve --mcp`,避免重复注册 Pi 的全局 MCP flag 与命令;Exa key 只通过 `x-api-key` 请求头发送,原始工具映射为统一的来源前缀形式,CodeGraph 只暴露 `codegraph_explore`。 +- `extensions/plugin-init.ts`:注册用户显式触发的 `/plugin_init`,在确认当前项目后只初始化缺失的 CodeGraph/Hippo 状态,并在全部成功后调用 Pi 官方热重载。 - `context-mode`:加载上游 Pi adapter 与 skills,提供 `ctx_*` 工具、隔离式大输出处理和会话连续性。 - `extensions/hashline.ts`:部署组合包权威 `config/pi-hashline-edit.json` 后加载 `pi-hashline-edit`,以行哈希锚点覆盖内置 `read` / `edit`;Hashline 自带 `grep` 强制关闭。 - `extensions/permission-system.ts`:直接加载仓库内 `pi-permission-system/src/index.ts`,并在权限扩展注册前将 `config/pi-permission-system.json` 同步为全局权威配置。 @@ -68,7 +69,7 @@ - 标准组合包工具全部命中预置目录时 Tool Search 不得调用模型、创建用户缓存或发送隐藏 schema。只有存在未识别第三方工具时才允许惰性模型增强;生成结果必须保留全部预置 assignment,模型元数据不得替代真实名称、参数或 schema。缓存必须位于 agent 目录且权限 `600`;模型失败必须继续使用预置目录 + 确定性未知工具分组。`groupOverrides` 优先级高于预置分组。 - Tool Search 必须直接使用当前 `@earendil-works/pi-coding-agent`、`typebox`、`ModelRegistry.complete()` 和 `setActiveTools()`;首次纯增加载使用当前 Pi 的增量结果传播,LRU 替换允许走宿主安全 fallback。不得安装或映射旧 `@mariozechner` runtime,不得恢复 provider payload 改写、代理分发或隐藏 `sendMessage` steer/retry 兼容循环。 - `.pi-debug/` 是 `/dump-system-prompt` 生成的本地诊断目录,不提交到仓库,也不作为组合包运行时配置源。 -- CodeGraph 扩展只配置 Pi 到外部 `codegraph` 命令的 MCP 连接。根包安装本身不安装 CodeGraph;便捷脚本仅在组合包安装完成且用户明确选择 `Y` 后调用官方安装器。仓库不执行 `codegraph init`,不创建或管理 `.codegraph/`,也不改动索引、更新或遥测设置。 +- CodeGraph MCP 扩展只配置 Pi 到外部 `codegraph` 命令的连接。根包安装本身不安装 CodeGraph;便捷脚本仅在组合包安装完成且用户明确选择 `Y` 后调用官方安装器。安装、升级和普通启动流程不得执行 `codegraph init`;只有用户在目标项目显式调用 `/plugin_init` 并确认后,命令才可以 `.codegraph/codegraph.db` 与 `.hippo/hippo.db` 为权威标志初始化缺失状态。命令必须先预检两个 CLI,任一步失败不得热重载,也不得隐瞒此前已完成的部分初始化;不得改动 CodeGraph 更新或遥测设置。 - CodeGraph MCP 使用 `keep-alive` 并只直接暴露 `codegraph_explore`;命令缺失、项目未初始化或连接失败时不得阻止其他扩展加载。 - 所有 MCP 服务必须合并到 `extensions/mcp.ts` 创建的唯一 `pi-mcp-adapter` 实例;不得新增独立 adapter 扩展入口,否则会重复注册 `--mcp-config`、`/mcp` 等全局接口。根测试必须扫描全部本地扩展入口并强制这一不变量。 - Exa MCP 使用托管 Streamable HTTP 端点和 `eager` lifecycle;`EXA_API_KEY` 通过 `x-api-key` 请求头发送,不得放入 URL、仓库文件或日志。所有在线搜索工具统一采用“来源名 + 原始语义工具名”的命名形式,例如 `tavily_web_search`、`exa_web_search`、`keenable_search`。Exa 高级搜索必须显式约束结果数量和文本长度;Tavily 输出默认控制 `max_results` 且非必要不请求 raw content;Keenable 优先利用中文、站点和日期筛选能力。 diff --git a/README.md b/README.md index 1a321d9..aea1c2b 100644 --- a/README.md +++ b/README.md @@ -109,15 +109,16 @@ Hippo 官方 Pi Extension 存在于上游 Git 仓库的 `extensions/pi-extension `hippo-memory@1.33.0` npm tarball 中。本仓库从官方 `v1.33.0` / `e928179a3b35e8fe5837878aed071d6025ced45c` 导入源码到 `hippo-memory-pi/` 并直接加载; 匹配的 CLI 版本由 `config/hippo-memory-version` 声明。`install.sh` 会在 CLI 缺失时询问是否执行 -`npm install -g hippo-memory@<固定版本>`,但不会选择项目或执行 `hippo init`。用户应进入需要记忆的 -项目目录自行运行 `hippo init`。 +`npm install -g hippo-memory@<固定版本>`,但安装脚本不会选择或初始化项目。进入目标项目后可在 Pi 中执行 +`/plugin_init`:命令会显示当前目录并要求确认,以 `.codegraph/codegraph.db` 和 `.hippo/hippo.db` 为 +初始化标志,只为缺失状态运行 `codegraph init` 与 `hippo init`;全部成功后通过 Pi 官方热重载重新连接 CodeGraph MCP 并重新触发 Hippo +session start。也可以继续在终端手动执行两个 CLI 的 `init` 命令。 -默认配置不需要系统 `rtk` CLI。只有以后在 `/rtk` 中主动开启 -`RTK command rewriting` 时,才需要另外安装 `rtk` 可执行文件。 +默认配置不需要系统 `rtk` CLI。只有以后在 `/rtk` 中主动开启 RTK command rewriting 时,才需要另外安装 `rtk` 可执行文件。 CodeGraph 本体不由组合包安装。需要使用 CodeGraph 的机器应自行确保 `codegraph` 在 -启动 Pi 的 `PATH` 中,并在目标项目执行过 `codegraph init`。组合包不会创建或维护 -`.codegraph/`,也不会修改 CodeGraph 的索引、更新或遥测设置。 +启动 Pi 的 `PATH` 中。组合包只有在用户显式调用 `/plugin_init` 并确认当前项目后才会创建 +`.codegraph/`;安装、升级和普通启动流程不会创建或维护索引,也不会修改 CodeGraph 的更新或遥测设置。 ## 组合行为 @@ -238,6 +239,14 @@ codegraph --version codegraph status ``` +在当前项目需要同时初始化 CodeGraph 和 Hippo 时,可直接执行: + +```text +/plugin_init +``` + +该命令拒绝文件系统根目录和用户主目录,先确认两个 CLI 均可用,再依次初始化缺失数据库状态。任一步失败都不会热重载,也不会回滚此前已成功完成的初始化;两者已初始化时仍可用该命令快速热重载扩展。 + ### 权限基线自动部署 `pi-permission-system/` 从上游 `pi-permission-system-v26.2.1` tag 导入,初始源码快照为 `ec4fdb11343dc94f7185b113e559a4cf9f8dc035`。根包通过本地 `file:` 依赖提供其运行时依赖,并由包装入口直接加载仓库源码,不再加载 npm 包中的实现。 diff --git a/extensions/plugin-init.ts b/extensions/plugin-init.ts new file mode 100644 index 0000000..16e24da --- /dev/null +++ b/extensions/plugin-init.ts @@ -0,0 +1,164 @@ +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"); + } + }, + }); +} diff --git a/package.json b/package.json index a7f2fe8..dbb53be 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "./extensions/tavily-override.ts", "./node_modules/@keenable/pi-search/src/index.ts", "./extensions/mcp.ts", + "./extensions/plugin-init.ts", "./pi-rtk-optimizer/index.ts", "./extensions/hashline.ts", "./node_modules/context-mode/build/adapters/pi/extension.js", diff --git a/tests/extension-load.test.ts b/tests/extension-load.test.ts index 859c75f..0907859 100644 --- a/tests/extension-load.test.ts +++ b/tests/extension-load.test.ts @@ -15,6 +15,10 @@ test("all package extensions load together without global registration conflicts files: string[]; pi: { extensions: string[] }; }; + assert.ok( + packageJson.pi.extensions.includes("./extensions/plugin-init.ts"), + "the bundle must expose the project plugin initializer command", + ); assert.ok( packageJson.pi.extensions.includes("./hippo-memory-pi/index.ts"), "the bundle must load the vendored official Hippo Memory extension", diff --git a/tests/plugin-init.test.ts b/tests/plugin-init.test.ts new file mode 100644 index 0000000..3b3003d --- /dev/null +++ b/tests/plugin-init.test.ts @@ -0,0 +1,210 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { join, parse } from "node:path"; +import test from "node:test"; + +import pluginInitExtension, { + findMissingPluginInitializers, + initializeMissingPlugins, + validatePluginInitCwd, +} from "../extensions/plugin-init.ts"; + +test("initializes missing CodeGraph and Hippo state after preflighting both CLIs", async () => { + const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-")); + const calls: Array<{ command: string; args: string[]; cwd: string }> = []; + try { + const targets = await findMissingPluginInitializers(cwd); + assert.deepEqual(targets.map(({ name }) => name), ["CodeGraph", "Hippo"]); + + await initializeMissingPlugins(cwd, targets, async (command, args, options) => { + calls.push({ command, args, cwd: options.cwd }); + return { code: 0, stdout: "ok", stderr: "" }; + }); + + assert.deepEqual(calls, [ + { command: "codegraph", args: ["--version"], cwd }, + { command: "hippo", args: ["--version"], cwd }, + { command: "codegraph", args: ["init", cwd], cwd }, + { command: "hippo", args: ["init"], cwd }, + ]); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("uses the CLIs' database files as authoritative initialization markers", async () => { + const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-")); + const calls: string[] = []; + try { + await mkdir(join(cwd, ".codegraph")); + await mkdir(join(cwd, ".hippo")); + assert.deepEqual( + (await findMissingPluginInitializers(cwd)).map(({ name }) => name), + ["CodeGraph", "Hippo"], + "bare state directories must not count as initialized", + ); + + await writeFile(join(cwd, ".codegraph", "codegraph.db"), ""); + assert.deepEqual((await findMissingPluginInitializers(cwd)).map(({ name }) => name), ["Hippo"]); + await writeFile(join(cwd, ".hippo", "hippo.db"), ""); + const targets = await findMissingPluginInitializers(cwd); + assert.deepEqual(targets, []); + + await initializeMissingPlugins(cwd, targets, async (command, args) => { + calls.push(`${command} ${args.join(" ")}`); + return { code: 0, stdout: "ok", stderr: "" }; + }); + assert.deepEqual(calls, ["codegraph --version", "hippo --version"]); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("rejects unsafe project roots", () => { + assert.throws(() => validatePluginInitCwd(parse(homedir()).root), /文件系统根目录/); + assert.throws(() => validatePluginInitCwd(homedir()), /用户主目录/); +}); + +test("preflights both CLIs before creating project state", async () => { + const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-")); + const calls: string[] = []; + try { + const targets = await findMissingPluginInitializers(cwd); + await assert.rejects( + initializeMissingPlugins(cwd, targets, async (command, args) => { + calls.push(`${command} ${args.join(" ")}`); + return command === "hippo" + ? { code: 1, stdout: "", stderr: "not installed" } + : { code: 0, stdout: "ok", stderr: "" }; + }), + /Hippo 检测失败:not installed/, + ); + assert.deepEqual(calls, ["codegraph --version", "hippo --version"]); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("plugin_init confirms the target and hot-reloads after initialization", async () => { + let command: { handler: (args: string, ctx: any) => Promise } | undefined; + const calls: string[] = []; + const pi = { + registerCommand(name: string, definition: typeof command) { + assert.equal(name, "plugin_init"); + command = definition; + }, + exec: async (program: string, args: string[]) => { + calls.push(`${program} ${args.join(" ")}`); + return { code: 0, stdout: "ok", stderr: "" }; + }, + } as unknown as ExtensionAPI; + pluginInitExtension(pi); + assert.ok(command); + const registeredCommand = command!; + + const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-")); + let reloaded = false; + let confirmation = ""; + try { + await registeredCommand.handler("", { + cwd, + hasUI: true, + ui: { + confirm: async (_title: string, message: string) => { + confirmation = message; + return true; + }, + notify() {}, + }, + reload: async () => { + reloaded = true; + }, + }); + assert.match(confirmation, new RegExp(cwd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.deepEqual(calls, [ + "codegraph --version", + "hippo --version", + `codegraph init ${cwd}`, + "hippo init", + ]); + assert.equal(reloaded, true); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("plugin_init reports retained partial state and does not reload when a later initializer fails", async () => { + let command: { handler: (args: string, ctx: any) => Promise } | undefined; + const pi = { + registerCommand(_name: string, definition: typeof command) { + command = definition; + }, + exec: async (program: string, args: string[]) => ({ + code: program === "hippo" && args[0] === "init" ? 1 : 0, + stdout: "", + stderr: "mock failure", + }), + } as unknown as ExtensionAPI; + pluginInitExtension(pi); + assert.ok(command); + const registeredCommand = command!; + + const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-")); + let reloaded = false; + let error = ""; + try { + await registeredCommand.handler("", { + cwd, + hasUI: true, + ui: { + confirm: async () => true, + notify(message: string, level: string) { + if (level === "error") error = message; + }, + }, + reload: async () => { + reloaded = true; + }, + }); + assert.match(error, /Hippo 初始化失败:mock failure/); + assert.match(error, /已完成的 CodeGraph 初始化会保留,不会自动回滚/); + assert.equal(reloaded, false); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("plugin_init stops without executing or reloading when confirmation is cancelled", async () => { + let command: { handler: (args: string, ctx: any) => Promise } | undefined; + let executed = false; + const pi = { + registerCommand(_name: string, definition: typeof command) { + command = definition; + }, + exec: async () => { + executed = true; + return { code: 0, stdout: "", stderr: "" }; + }, + } as unknown as ExtensionAPI; + pluginInitExtension(pi); + const registeredCommand = command!; + + const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-")); + let reloaded = false; + try { + await registeredCommand.handler("", { + cwd, + hasUI: true, + ui: { confirm: async () => false, notify() {} }, + reload: async () => { + reloaded = true; + }, + }); + assert.equal(executed, false); + assert.equal(reloaded, false); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +});