diff --git a/AGENTS.md b/AGENTS.md index f9cea55..f5e79f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,8 +24,9 @@ - `extensions/tavily-override.ts`:加载 `@tavily/pi-extension`,把上游工具重命名为 `tavily_web_search` / `tavily_web_fetch`,并同步改写 label 与工具提示中的内部名称。 - `@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 状态,并在全部成功后调用 Pi 官方热重载。 +- `extensions/mcp.ts`:通过单个共享 `pi-mcp-adapter` 实例连接 Exa 托管 MCP,避免重复注册 Pi 的全局 MCP flag 与命令;Exa key 只通过 `x-api-key` 请求头发送,原始工具映射为统一的来源前缀形式。CodeGraph 不走 MCP。 +- `extensions/codegraph.ts`:注册原生 `codegraph_explore` 工具,以 `execFile` argv 形式调用机器级 `codegraph` CLI(每次调用为短命进程,无常驻 server/daemon);未初始化项目先经 `ctx.ui.confirm` 确认再 `codegraph init` 并自动重试,拒绝或失败则把结果打回让模型改用 `find`/`grep`/`read`/LSP;结果按 `codegraph.db` mtime 做有界缓存,索引一变即失效。 +- `extensions/plugin-init.ts`:已移除;原 `/plugin_init` 的 CodeGraph 初始化职责由 `extensions/codegraph.ts` 在首次 `codegraph_explore` 调用内按需确认完成。 - `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-policy.jsonc` / `config/pi-permission-hard-guards.jsonc` 由 `install.sh` / `update.sh` 经 `scripts/rebuild-bundle-config.mjs` 部署到 `/my-pi/permission-system/`(policy.jsonc 与 hard-guards.jsonc 且权限 owner-only);旧 `extensions/pi-permission-system/` 位置的残留文件被忽略,不迁移也不清理。项目级 policy 不读取。 @@ -88,8 +89,8 @@ - Tool Search 必须直接使用当前 `@earendil-works/pi-coding-agent`、`typebox`、`ModelRegistry.complete()` 和 `setActiveTools()`;首次纯增加载使用当前 Pi 的增量结果传播,LRU 替换允许走宿主安全 fallback。不得安装或映射旧 `@mariozechner` runtime,不得恢复 provider payload 改写、代理分发或隐藏 `sendMessage` steer/retry 兼容循环。Tool Search 是唯一可调用 `getActiveTools()` / `setActiveTools()` 管理工具显隐的组合包扩展;`pi-permission-system` 只能在调用时裁决,不能读写 active tool 列表。 - `pi-worktree` 只注册 `/wt` / `/worktree` 斜杠命令,不暴露任何模型可调用工具;其 git 操作由用户显式触发,经 `execFile` argv 直接调用(带超时、不经 shell),按设计绕过 Bash 工具权限策略,不新增权限审批层。 - `.pi-debug/` 是 `/dump-system-prompt` 生成的本地诊断目录,不提交到仓库,也不作为组合包运行时配置源。 -- CodeGraph MCP 扩展只配置 Pi 到外部 `codegraph` 命令的连接。根包安装本身不安装 CodeGraph;便捷脚本仅在组合包安装完成且用户明确选择 `Y` 后调用官方安装器。安装、升级和普通启动流程不得执行 `codegraph init`;只有用户在目标项目显式调用 `/plugin_init` 并确认后,命令才可以 `.codegraph/codegraph.db` 为权威标志初始化缺失状态。命令必须先预检 CLI,失败不得热重载,也不得隐瞒此前已完成的部分初始化;不得改动 CodeGraph 更新或遥测设置。 -- CodeGraph MCP 使用 `keep-alive` 并只直接暴露 `codegraph_explore`;命令缺失、项目未初始化或连接失败时不得阻止其他扩展加载。 +- CodeGraph 是机器级依赖:`install.sh`/`update.sh` 经官方安装器管理 `codegraph` CLI,组合包不内置其运行时。安装、升级和普通启动流程不得执行 `codegraph init`;只有 `codegraph_explore` 在未初始化项目上被调用、且用户在 `ctx.ui.confirm` 中明确确认后,扩展才可以 `codegraph init` 当前目录。init 失败必须如实报告并让模型回退内置工具;不得改动 CodeGraph 更新或遥测设置。 +- `codegraph_explore` 为原生 Pi 工具而非 MCP 工具:每次调用通过 `execFile`/`pi.exec` 以 argv 形式运行 `codegraph explore`(带超时、不经 shell),结果按 `.codegraph/codegraph.db` mtime 缓存;CLI 缺失、init 被拒绝或执行失败都不得抛出或阻止其他工具,必须以文本结果打回并引导模型改用 `find`/`grep`/`read`/LSP。 - 所有 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 优先利用中文、站点和日期筛选能力。 - 搜索 key 默认保存在 `${XDG_CONFIG_HOME:-$HOME/.config}/my-pi/search.env`,文件必须为 `600` 且不得提交;`search_config.sh` 只输出配置状态,禁止回显 key。 diff --git a/README.md b/README.md index 0f1ed1d..ae1185c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ - `context-mode@1.0.169`:通过 Pi adapter 提供 `ctx_*` 工具、上下文隔离和会话连续性。 - `@tavily/pi-extension@0.1.2`:提供 Tavily 广泛搜索与页面提取;通过本地 override 暴露为 `tavily_web_search` / `tavily_web_fetch`。 - `@keenable/pi-search@0.1.2`:提供紧凑的 `keenable_search` / `keenable_fetch`,并加载其搜索 skill;API key 可选。 -- `pi-mcp-adapter@2.26.0`:通过单个共享 adapter 实例接入机器现有的 CodeGraph 和 Exa 托管 MCP,避免重复注册 Pi 的全局 MCP flag 与命令。 +- `pi-mcp-adapter@2.26.0`:通过单个共享 adapter 实例接入 Exa 托管 MCP,避免重复注册 Pi 的全局 MCP flag 与命令。 +- `extensions/codegraph.ts`:注册原生 `codegraph_explore` 工具,每次调用以短命 `codegraph explore` CLI 进程完成,无常驻 server/daemon;未初始化项目经确认后才 `codegraph init`,结果按索引库 mtime 缓存。 - `extensions/context-view.ts`:通过 `/context` 只读显示 `pi-compaction` 的 effective context,不建立第二套上下文口径。 - `pi-lsp@0.1.7`:为 TypeScript/JavaScript、Kotlin 和 Java 提供 LSP 诊断、跳转与符号工具;TypeScript 后端由组合包内置。 - `pi-hermes-memory/`:从官方 `pi-hermes-memory v0.9.10` 源码导入的持久记忆扩展,提供全局/项目/用户/失败四类记忆、SQLite FTS5 会话与记忆检索、`SKILL.md` 过程记忆、correction 检测与自动 consolidation。 @@ -52,7 +53,7 @@ pi install git:git@bitbucket.org:siakitem/my-pi.git `.zshrc` 的 `plugins` 数组中启用。后两个插件缺少源码时会先克隆到 Oh My Zsh 的 custom plugins 目录;语法高亮按最后一个插件处理。 - 最后继续检查 `codegraph`、`kotlin-lsp`、Java 21+ 和 `jdtls`。Kotlin、Java 和 JDT LS - 的自动安装使用 Homebrew。 + 的自动安装使用 Homebrew,CodeGraph 使用官方安装脚本。 脚本首次实际改动 `.zshrc` 前会在同目录创建带时间戳的备份。Oh My Zsh loader、 Powerlevel10k 主题/配置和各 Zsh 插件使用 `# >>> my-pi: >>>` 到 @@ -120,15 +121,15 @@ Tool Search 常驻,保证 policy 指示与模型可见工具一致。 (项目级),可选配置为 `~/.pi/agent/hermes-memory-config.json`。这些是用户数据,安装、升级与卸载脚本 不会创建、迁移或删除。 -进入目标项目后可在 Pi 中执行 `/plugin_init`:命令会显示当前目录并要求确认,以 -`.codegraph/codegraph.db` 为初始化标志,只为缺失状态运行 `codegraph init`;全部成功后通过 Pi 官方热重载 -重新连接 CodeGraph MCP。也可以继续在终端手动执行 `codegraph init`。 +CodeGraph 采用惰性初始化:`codegraph_explore` 在未建立索引的项目上被调用时会先向用户确认, +确认后才运行 `codegraph init` 并自动重试本次探索;不需要手动执行任何初始化命令。 默认配置不需要系统 `rtk` CLI。只有以后在 `/rtk` 中主动开启 RTK command rewriting 时,才需要另外安装 `rtk` 可执行文件。 CodeGraph 本体不由组合包安装。需要使用 CodeGraph 的机器应自行确保 `codegraph` 在 -启动 Pi 的 `PATH` 中。组合包只有在用户显式调用 `/plugin_init` 并确认当前项目后才会创建 -`.codegraph/`;安装、升级和普通启动流程不会创建或维护索引,也不会修改 CodeGraph 的更新或遥测设置。 +启动 Pi 的 `PATH` 中(`install.sh` 会交互式提供官方安装)。扩展只有在用户于确认对话框中 +明确同意后才会创建 `.codegraph/`;安装、升级和普通启动流程不会创建或维护索引,也不会修改 +CodeGraph 的更新或遥测设置。 ## 组合行为 @@ -265,33 +266,28 @@ Tavily 通过 `extensions/tavily-override.ts` 加载官方扩展。override 只 Keenable 直接加载 npm 包的扩展和 skill,默认无需 key;设置 `KEENABLE_API_KEY` 可提高速率限制。它适合中文内容、`site` 限定和发布日期筛选。搜索结果保持紧凑,只对选中的 URL 调用 `keenable_fetch`。 -`extensions/mcp.ts` 通过单个 `pi-mcp-adapter` 实例同时连接 Exa 与 CodeGraph,避免两个 adapter 重复注册 `--mcp-config`、`/mcp` 和 `/mcp-auth`。其中 Exa 连接 `https://mcp.exa.ai/mcp`,并把 MCP 原始工具映射为统一的来源前缀形式:`exa_web_search`、`exa_web_fetch` 和 `exa_web_search_advanced`。配置 `EXA_API_KEY` 后通过 `x-api-key` 请求头认证,不把 key 放进 URL。Exa 适合官方文档、技术资料、版本信息和论文;高级搜索必须显式限制结果数量与返回文本长度,避免响应过大。 +`extensions/mcp.ts` 通过单个 `pi-mcp-adapter` 实例连接 Exa,避免多个 adapter 重复注册 `--mcp-config`、`/mcp` 和 `/mcp-auth`。Exa 连接 `https://mcp.exa.ai/mcp`,并把 MCP 原始工具映射为统一的来源前缀形式:`exa_web_search`、`exa_web_fetch` 和 `exa_web_search_advanced`。配置 `EXA_API_KEY` 后通过 `x-api-key` 请求头认证,不把 key 放进 URL。Exa 适合官方文档、技术资料、版本信息和论文;高级搜索必须显式限制结果数量与返回文本长度,避免响应过大。 根测试会扫描所有本地扩展入口,强制只有 `extensions/mcp.ts` 可以创建或导入 `pi-mcp-adapter`。新增 MCP 服务必须合并进这个共享实例,不能再增加独立 adapter 入口;隔离 Pi 加载仍作为发布前的运行时验证。 -### CodeGraph 只负责连接 +### CodeGraph 是原生工具 -同一个 `extensions/mcp.ts` adapter 配置启动 -`codegraph serve --mcp`,不会读取或覆盖用户已有的 MCP 配置。连接采用 -`keep-alive`,并只把官方当前主工具 `codegraph_explore` 作为同名 Pi 工具暴露; -CodeGraph 的文件 watcher 由该 MCP 进程自身维护。 +`extensions/codegraph.ts` 注册原生 `codegraph_explore` 工具,不走 MCP:每次调用以 argv 形式 +直接执行机器级 `codegraph explore`(上游保证其输出与 `codegraph_explore` MCP 工具一致), +进程随调用结束,不维护常驻 server 或 daemon。`projectPath` 缺省为会话 cwd,沿目录向上寻找 +最近的 `.codegraph/codegraph.db`;`maxFiles` 透传 `--max-files`。 -若机器没有 `codegraph`,或当前项目尚未建立索引,CodeGraph 会报告连接/初始化错误, -其他扩展仍可正常工作。安装与索引状态可在机器上自行检查: +结果按 `(索引根, query, maxFiles)` 缓存在扩展内,并以索引库 mtime 作为有效性判据:任何 +重建或同步都会更新 mtime 使旧缓存自动失效,模型对同一区域反复探索时重复调用立即返回。 + +若目标目录没有索引,工具先弹出确认;用户同意后运行 `codegraph init` 并自动重试本次调用, +拒绝或 CLI 不可用/执行失败时把结果打回并提示改用 `find`/`grep`/`read`/LSP,不影响其他扩展。 +索引状态可在项目内自行检查: ```bash -codegraph --version codegraph status ``` -在当前项目需要初始化 CodeGraph 时,可直接执行: - -```text -/plugin_init -``` - -该命令拒绝文件系统根目录和用户主目录,先确认 CLI 可用,再初始化缺失数据库状态。失败时不会热重载,也不会回滚此前已成功完成的初始化;已初始化时仍可用该命令快速热重载扩展。 - ### 统一权限系统、硬防御与 AutoReview `pi-permission-system/` 从上游 `pi-permission-system-v26.2.1` tag 导入,初始源码快照为 `ec4fdb11343dc94f7185b113e559a4cf9f8dc035`。原 `pi-permission-auto-review` 的 reviewer、可信证据边界和 Codex Guardian 风格策略已整合进同一个扩展;其 MIT 许可和来源记录保留在 `pi-permission-system/LICENSES/` 与 `docs/auto-review/`。根组合包不再加载第二个 AutoReview 扩展。 diff --git a/extensions/codegraph.ts b/extensions/codegraph.ts new file mode 100644 index 0000000..0d9b2a9 Binary files /dev/null and b/extensions/codegraph.ts differ diff --git a/extensions/mcp-config.ts b/extensions/mcp-config.ts index ba39300..38f080f 100644 --- a/extensions/mcp-config.ts +++ b/extensions/mcp-config.ts @@ -21,16 +21,6 @@ export function createMcpConfig(env: Record) { approveTools: false, exposeResources: false, }, - codegraph: { - command: "codegraph", - args: ["serve", "--mcp"], - lifecycle: "keep-alive" as const, - directTools: ["codegraph_explore"], - includeTools: ["codegraph_explore"], - toolPrefix: "none" as const, - approveTools: false, - exposeResources: false, - }, }, }; } diff --git a/extensions/mcp.ts b/extensions/mcp.ts index 47a5240..a688421 100644 --- a/extensions/mcp.ts +++ b/extensions/mcp.ts @@ -4,7 +4,7 @@ import { createExaToolApiOverride } from "./exa-tool-prefix.ts"; import { createMcpConfig } from "./mcp-config.ts"; import { loadSearchConfigEnv } from "./search-config.ts"; -/** Connect Pi to Exa and CodeGraph through one shared MCP adapter instance. */ +/** Connect Pi to Exa's hosted MCP through the shared adapter instance. CodeGraph is a native tool (see codegraph.ts), not an MCP server. */ export default function mcpExtension(pi: ExtensionAPI): void { const searchEnvironment = loadSearchConfigEnv(); const mcpAdapter = createMcpAdapter({ config: createMcpConfig(searchEnvironment) }); diff --git a/extensions/plugin-init.ts b/extensions/plugin-init.ts deleted file mode 100644 index 0a3e3f1..0000000 --- a/extensions/plugin-init.ts +++ /dev/null @@ -1,158 +0,0 @@ -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"; - command: "codegraph"; - database: string; - initArgs(cwd: string): string[]; -} - -const INIT_TARGETS: InitTarget[] = [ - { - name: "CodeGraph", - command: "codegraph", - database: join(".codegraph", "codegraph.db"), - initArgs: (cwd) => ["init", cwd], - }, -]; - -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 every CLI before creating project 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 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 2d0b506..b87ed13 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "./extensions/tavily-override.ts", "./node_modules/@keenable/pi-search/src/index.ts", "./extensions/mcp.ts", - "./extensions/plugin-init.ts", + "./extensions/codegraph.ts", "./pi-rtk-optimizer/index.ts", "./extensions/hashline.ts", "./node_modules/context-mode/build/adapters/pi/extension.js", diff --git a/tests/codegraph.test.ts b/tests/codegraph.test.ts new file mode 100644 index 0000000..fbf2cba --- /dev/null +++ b/tests/codegraph.test.ts @@ -0,0 +1,208 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + buildExploreArgs, + createCodegraphExploreHandler, + findIndexedRoot, + isNotIndexedOutput, + type CodegraphExec, + type ExploreParams, +} from "../extensions/codegraph.ts"; + +async function makeIndexedProject(): Promise<{ root: string; cleanup: () => Promise }> { + const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); + await mkdir(join(root, ".codegraph")); + await writeFile(join(root, ".codegraph", "codegraph.db"), ""); + return { root, cleanup: () => rm(root, { recursive: true, force: true }) }; +} + +function mockCtx(cwd: string, confirm?: () => Promise) { + return { + cwd, + hasUI: confirm !== undefined, + ui: { confirm: confirm ?? (async () => false) }, + } as never; +} + +function mockExec(handler: (command: string, args: string[]) => { code?: number; stdout?: string; stderr?: string }) { + const calls: Array<{ command: string; args: string[] }> = []; + const exec: CodegraphExec = async (command, args) => { + calls.push({ command, args }); + const result = handler(command, args); + return { code: result.code ?? 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "", killed: false }; + }; + return { exec, calls }; +} + +function makeHandler(exec: CodegraphExec) { + return { + handle: createCodegraphExploreHandler(exec, new Map(), new Map()), + }; +} + +test("findIndexedRoot locates the nearest ancestor index", async () => { + const { root, cleanup } = await makeIndexedProject(); + try { + const nested = join(root, "src", "deep"); + await mkdir(nested, { recursive: true }); + assert.equal(await findIndexedRoot(nested), root); + assert.equal(await findIndexedRoot(root), root); + } finally { + await cleanup(); + } +}); + +test("findIndexedRoot returns undefined when no index exists", async () => { + const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); + try { + assert.equal(await findIndexedRoot(root), undefined); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("buildExploreArgs maps params to CLI argv", () => { + assert.deepEqual(buildExploreArgs({ query: "auth flow" }, "/repo"), ["explore", "-p", "/repo", "auth flow"]); + assert.deepEqual( + buildExploreArgs({ query: "q", maxFiles: 5, projectPath: "/ignored" }, "/repo"), + ["explore", "-p", "/repo", "--max-files", "5", "q"], + ); +}); + +test("isNotIndexedOutput detects the upstream marker", () => { + assert.equal(isNotIndexedOutput("✗ CodeGraph isn't available here — no .codegraph/ index exists in /x."), true); + assert.equal(isNotIndexedOutput("Found 3 symbols"), false); +}); + +test("explore runs the CLI and caches by database mtime", async () => { + const { root, cleanup } = await makeIndexedProject(); + const { exec, calls } = mockExec(() => ({ stdout: "explore output" })); + const { handle } = makeHandler(exec); + try { + const first = await handle({ query: "auth" }, mockCtx(root)); + assert.equal(first.content[0].text, "explore output"); + const second = await handle({ query: "auth" }, mockCtx(root)); + assert.equal(second.content[0].text, "explore output"); + assert.equal(calls.length, 1, "identical query on unchanged index must hit the cache"); + + // Simulate a reindex: bump the db mtime. + await utimes(join(root, ".codegraph", "codegraph.db"), new Date(), new Date(Date.now() + 5000)); + await handle({ query: "auth" }, mockCtx(root)); + assert.equal(calls.length, 2, "mtime change must invalidate the cached result"); + } finally { + await cleanup(); + } +}); + +test("uninitialized project asks the user and indexes on approval", async () => { + const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); + const { exec, calls } = mockExec((_command, args) => { + if (args[0] === "init") { + // Emulate codegraph init creating the index. + return { stdout: "indexed" }; + } + return { stdout: "explore output" }; + }); + const { handle } = makeHandler(exec); + try { + const result = await handle({ query: "auth" }, mockCtx(root, async () => true)); + assert.equal(result.content[0].text, "explore output"); + assert.deepEqual( + calls.map(({ args }) => args[0]), + ["init", "explore"], + ); + assert.deepEqual(calls[0].args, ["init", root]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("uninitialized project bounces to built-in tools when declined", async () => { + const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); + const { exec, calls } = mockExec(() => ({ stdout: "unreachable" })); + const { handle } = makeHandler(exec); + try { + const result = await handle({ query: "auth" }, mockCtx(root, async () => false)); + assert.match(result.content[0].text ?? "", /find\/grep\/read\/LSP/); + assert.equal(calls.length, 0, "declined init must not spawn the CLI at all"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("concurrent calls on an uninitialized project share one init decision", async () => { + const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); + let confirmCalls = 0; + const { exec, calls } = mockExec(() => ({ stdout: "ok" })); + const pending = new Map>(); + const handle = createCodegraphExploreHandler(exec, new Map(), pending); + try { + const [a, b] = await Promise.all([ + handle({ query: "one" }, mockCtx(root, async () => { + confirmCalls += 1; + return true; + })), + handle({ query: "two" }, mockCtx(root, async () => { + confirmCalls += 1; + return true; + })), + ]); + assert.equal(a.content[0].text, "ok"); + assert.equal(b.content[0].text, "ok"); + assert.equal(confirmCalls, 1, "parallel callers must not double-prompt"); + assert.equal( + calls.filter(({ args }) => args[0] === "init").length, + 1, + "parallel callers must share a single init", + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("init failure is reported instead of exploring", async () => { + const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); + const { exec } = mockExec((_command, args) => + args[0] === "init" ? { code: 1, stderr: "boom" } : { stdout: "unreachable" }, + ); + const { handle } = makeHandler(exec); + try { + const result = await handle({ query: "auth" }, mockCtx(root, async () => true)); + assert.match(result.content[0].text ?? "", /codegraph init 失败/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("missing CLI surfaces an install hint instead of throwing", async () => { + const { root, cleanup } = await makeIndexedProject(); + const exec: CodegraphExec = async () => { + throw new Error("spawn codegraph ENOENT"); + }; + const { handle } = makeHandler(exec); + try { + const result = await handle({ query: "auth" }, mockCtx(root)); + assert.match(result.content[0].text ?? "", /无法执行 codegraph CLI/); + } finally { + await cleanup(); + } +}); + +test("projectPath overrides the session cwd", async () => { + const { root, cleanup } = await makeIndexedProject(); + const other = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); + const { exec, calls } = mockExec(() => ({ stdout: "ok" })); + const { handle } = makeHandler(exec); + try { + const params: ExploreParams = { query: "auth", projectPath: root }; + await handle(params, mockCtx(other)); + assert.deepEqual(calls[0].args, ["explore", "-p", root, "auth"]); + } finally { + await cleanup(); + await rm(other, { recursive: true, force: true }); + } +}); diff --git a/tests/extension-load.test.ts b/tests/extension-load.test.ts index dc55615..de5197e 100644 --- a/tests/extension-load.test.ts +++ b/tests/extension-load.test.ts @@ -32,8 +32,13 @@ test("all package extensions load together without global registration conflicts allowScripts: Record; }; assert.ok( + packageJson.pi.extensions.includes("./extensions/codegraph.ts"), + "the bundle must expose the native codegraph_explore tool", + ); + assert.equal( packageJson.pi.extensions.includes("./extensions/plugin-init.ts"), - "the bundle must expose the project plugin initializer command", + false, + "the retired plugin initializer command must not be loaded", ); assert.ok( packageJson.pi.extensions.includes("./pi-hermes-memory/src/index.ts"), diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 0614acc..fda20e6 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -4,7 +4,7 @@ import test from "node:test"; import { createMcpConfig, EXA_TOOLS } from "../extensions/mcp-config.ts"; -test("one MCP adapter config contains both Exa and CodeGraph", () => { +test("the shared MCP adapter config only connects Exa", () => { assert.deepEqual(createMcpConfig({}), { settings: { disableProxyTool: true, @@ -21,16 +21,6 @@ test("one MCP adapter config contains both Exa and CodeGraph", () => { approveTools: false, exposeResources: false, }, - codegraph: { - command: "codegraph", - args: ["serve", "--mcp"], - lifecycle: "keep-alive", - directTools: ["codegraph_explore"], - includeTools: ["codegraph_explore"], - toolPrefix: "none", - approveTools: false, - exposeResources: false, - }, }, }); }); diff --git a/tests/plugin-init.test.ts b/tests/plugin-init.test.ts deleted file mode 100644 index b93f8fe..0000000 --- a/tests/plugin-init.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -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 state after preflighting the CLI", 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"]); - - 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: "codegraph", args: ["init", cwd], cwd }, - ]); - } finally { - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("uses the CLI's database file as the authoritative initialization marker", async () => { - const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-")); - const calls: string[] = []; - try { - await mkdir(join(cwd, ".codegraph")); - assert.deepEqual( - (await findMissingPluginInitializers(cwd)).map(({ name }) => name), - ["CodeGraph"], - "a bare state directory must not count as initialized", - ); - - await writeFile(join(cwd, ".codegraph", "codegraph.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"]); - } 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 the CLI 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 { code: 1, stdout: "", stderr: "not installed" }; - }), - /CodeGraph 检测失败:not installed/, - ); - assert.deepEqual(calls, ["codegraph --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", - `codegraph init ${cwd}`, - ]); - assert.equal(reloaded, true); - } finally { - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("plugin_init reports the failure and does not reload when initialization 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 === "codegraph" && 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, /CodeGraph 初始化失败:mock failure/); - 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 }); - } -});