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 });
}
});