feat: codegraph_explore 改为原生 CLI 工具并移除 /plugin_init

codegraph 有一等公民 CLI 且上游保证其输出与 codegraph_explore MCP
工具一致,走共享 MCP adapter 只是多余的传输层。改为原生 Pi 工具后
每次调用以短命 codegraph explore 进程完成,去掉常驻 server/daemon;
未初始化项目经 ctx.ui.confirm 确认后 init 并自动重试,拒绝或失败时
打回并引导模型回退 find/grep/read/LSP;结果按 codegraph.db mtime
做有界缓存,索引一变即失效。共享 MCP adapter 只保留 Exa;工具名
不变,权限策略、Tool Search 常驻表、路由规则与分组不受影响。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
云服务部-叶林立
2026-10-19 19:42:11 -07:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 97c2e75dfe
commit 6fe6cb29ae
11 changed files with 242 additions and 410 deletions
+208
View File
@@ -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<void> }> {
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<boolean>) {
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<string, Promise<string | undefined>>();
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 });
}
});
+6 -1
View File
@@ -32,8 +32,13 @@ test("all package extensions load together without global registration conflicts
allowScripts: Record<string, boolean>;
};
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"),
+1 -11
View File
@@ -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,
},
},
});
});
-200
View File
@@ -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<void> } | 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<void> } | 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<void> } | 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 });
}
});