@esankhan3/code-search-mcp 1.0.5 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -54
- package/dist/__tests__/_harness.js +194 -0
- package/dist/__tests__/config-cli.test.js +110 -0
- package/dist/__tests__/config-resolver.test.js +1 -1
- package/dist/__tests__/daemon-rpc-protocol.test.js +95 -0
- package/dist/__tests__/daemon-runtime.test.js +145 -0
- package/dist/__tests__/graph-tools-store.test.js +314 -0
- package/dist/__tests__/graph-tools.test.js +11 -4
- package/dist/__tests__/http-auth.test.js +180 -0
- package/dist/__tests__/http-index.test.js +125 -0
- package/dist/__tests__/metrics-collect.test.js +48 -0
- package/dist/__tests__/observability.test.js +3 -4
- package/dist/__tests__/remote-proxy.test.js +105 -0
- package/dist/__tests__/server-cli-args.test.js +146 -0
- package/dist/__tests__/server-lifecycle.test.js +108 -0
- package/dist/__tests__/tool-inputs.test.js +178 -0
- package/dist/backends/in-process.js +5 -2
- package/dist/backends/index.js +2 -2
- package/dist/cli/index.js +143 -76
- package/dist/core/config.js +106 -35
- package/dist/core/env-config.js +3 -5
- package/dist/core/project-name.js +12 -0
- package/dist/core/version.js +5 -0
- package/dist/daemon/index.js +106 -65
- package/dist/daemon/rpc-server.js +38 -18
- package/dist/index.js +113 -89
- package/dist/middleware/auth.js +34 -66
- package/dist/middleware/rate-limit.js +37 -0
- package/dist/observability/logger.js +10 -2
- package/dist/observability/metrics.js +7 -6
- package/dist/projects/registry.js +13 -15
- package/dist/resources/resources.js +17 -0
- package/dist/server.js +216 -129
- package/dist/tools/graph.js +353 -133
- package/dist/tools/index-tools.js +3 -2
- package/dist/tools/profile.js +14 -8
- package/dist/tools/search.js +61 -51
- package/dist/tools/validate.js +48 -0
- package/dist/transports/http-transport.js +94 -55
- package/dist/transports/remote-proxy.js +58 -97
- package/package.json +1 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
|
|
6
|
+
import {
|
|
7
|
+
BIN,
|
|
8
|
+
tmp,
|
|
9
|
+
makeWorkspace,
|
|
10
|
+
baseEnv,
|
|
11
|
+
spawnProc,
|
|
12
|
+
getJson,
|
|
13
|
+
waitFor,
|
|
14
|
+
exitWithin,
|
|
15
|
+
startFakeEmbedder
|
|
16
|
+
} from "./_harness.js";
|
|
17
|
+
const PKG_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8")).version;
|
|
18
|
+
let root;
|
|
19
|
+
let ws;
|
|
20
|
+
let slow;
|
|
21
|
+
let fast;
|
|
22
|
+
before(async () => {
|
|
23
|
+
root = tmp("cs-life-");
|
|
24
|
+
ws = makeWorkspace(root);
|
|
25
|
+
slow = await startFakeEmbedder(47391, { delayMs: 2500 });
|
|
26
|
+
fast = await startFakeEmbedder(47392);
|
|
27
|
+
});
|
|
28
|
+
after(async () => {
|
|
29
|
+
await slow.close();
|
|
30
|
+
await fast.close();
|
|
31
|
+
rmSync(root, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
describe("HTTP server lifecycle", () => {
|
|
34
|
+
it("answers /health and /ready during the startup index, then serves", async () => {
|
|
35
|
+
const env = baseEnv({ home: join(root, "home"), dataDir: join(root, "data1"), embedderUrl: slow.url });
|
|
36
|
+
const p = spawnProc(BIN.mcp, ["--local", ws, "--serve", "--port", "47383"], env);
|
|
37
|
+
const base = "http://127.0.0.1:47383";
|
|
38
|
+
try {
|
|
39
|
+
await waitFor(async () => (await getJson(`${base}/health`)).status === 200, 2e4, `health
|
|
40
|
+
${p.stderr()}`);
|
|
41
|
+
const early = await getJson(`${base}/ready`);
|
|
42
|
+
assert.equal(early.status, 503, "not ready while the first index is still embedding");
|
|
43
|
+
assert.equal(early.body.ready, false);
|
|
44
|
+
await waitFor(async () => (await getJson(`${base}/ready`)).status === 200, 6e4, `ready
|
|
45
|
+
${p.stderr()}`);
|
|
46
|
+
const version = await getJson(`${base}/version`);
|
|
47
|
+
assert.equal(version.body.version, PKG_VERSION);
|
|
48
|
+
const admin = await getJson(`${base}/admin/api/status`);
|
|
49
|
+
assert.equal(admin.body.version, PKG_VERSION);
|
|
50
|
+
const client = new Client({ name: "lifecycle-test", version: "1.0.0" });
|
|
51
|
+
await client.connect(new StreamableHTTPClientTransport(new URL(`${base}/mcp`)));
|
|
52
|
+
assert.equal(client.getServerVersion()?.version, PKG_VERSION);
|
|
53
|
+
const templates = (await client.listResourceTemplates()).resourceTemplates.map((t) => t.uriTemplate).sort();
|
|
54
|
+
assert.deepEqual(templates, ["code-search://repo/{name}/graph", "code-search://repo/{name}/profile"]);
|
|
55
|
+
await client.close();
|
|
56
|
+
const metrics = await (await fetch(`${base}/metrics`)).text();
|
|
57
|
+
const age = metrics.match(/^code_search_index_age_seconds\{project="default"\} (\d+)$/m);
|
|
58
|
+
assert.ok(age, `index_age missing:
|
|
59
|
+
${metrics}`);
|
|
60
|
+
assert.ok(Number(age[1]) < 120, `index_age=${age[1]}`);
|
|
61
|
+
assert.doesNotMatch(metrics, /phase=/);
|
|
62
|
+
assert.match(metrics, /^code_search_index_chunks_total\{project="default",repo="orders"\} [1-9]\d*$/m);
|
|
63
|
+
p.child.kill("SIGTERM");
|
|
64
|
+
const exit = await exitWithin(p, 5e3);
|
|
65
|
+
assert.notEqual(exit, "timeout", "SIGTERM must terminate the HTTP server");
|
|
66
|
+
} finally {
|
|
67
|
+
p.kill();
|
|
68
|
+
await p.exited;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
it("structured logs + reindex interval from the config file", async () => {
|
|
72
|
+
const env = baseEnv({
|
|
73
|
+
home: join(root, "home"),
|
|
74
|
+
dataDir: join(root, "data2"),
|
|
75
|
+
embedderUrl: fast.url,
|
|
76
|
+
extra: { CODE_SEARCH_STRUCTURED_LOGS: "1" }
|
|
77
|
+
});
|
|
78
|
+
const p = spawnProc(BIN.mcp, ["--serve", ws, "--port", "47384", "--indexing.reindex-interval-ms", "1h"], env);
|
|
79
|
+
try {
|
|
80
|
+
await waitFor(async () => (await getJson("http://127.0.0.1:47384/ready")).status === 200, 6e4, `ready
|
|
81
|
+
${p.stderr()}`);
|
|
82
|
+
const lines = p.stderr().split("\n").filter(Boolean);
|
|
83
|
+
assert.ok(!lines.some((l) => l.startsWith("[code-search-mcp]")), `plain-text server lines:
|
|
84
|
+
${p.stderr()}`);
|
|
85
|
+
const json = lines.filter((l) => l.startsWith("{")).map((l) => JSON.parse(l));
|
|
86
|
+
assert.ok(json.some((e) => e.component === "code-search-mcp" && e.msg === "Auto-reindex every 60m"), p.stderr());
|
|
87
|
+
assert.ok(json.some((e) => e.component === "auto-index" && e.level === "info"), "index progress goes through the logger");
|
|
88
|
+
} finally {
|
|
89
|
+
p.kill();
|
|
90
|
+
await p.exited;
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe("stdio server lifecycle", () => {
|
|
95
|
+
it("SIGTERM terminates --local stdio mode", async () => {
|
|
96
|
+
const env = baseEnv({ home: join(root, "home"), dataDir: join(root, "data3"), embedderUrl: fast.url });
|
|
97
|
+
const p = spawnProc(BIN.mcp, ["--local", ws], env);
|
|
98
|
+
try {
|
|
99
|
+
await p.waitForStderr(/Server running for "ws" \(stdio\)/, 6e4);
|
|
100
|
+
p.child.kill("SIGTERM");
|
|
101
|
+
const exit = await exitWithin(p, 5e3);
|
|
102
|
+
assert.notEqual(exit, "timeout", "SIGTERM must terminate the stdio server");
|
|
103
|
+
} finally {
|
|
104
|
+
p.kill();
|
|
105
|
+
await p.exited;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, it, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { VectorStore, invalidateRetriever, resetLlmConfig } from "@esankhan3/anvil-knowledge-core";
|
|
7
|
+
import { handleGraphTool, registerGraphTools } from "../tools/graph.js";
|
|
8
|
+
import { handleSearchTool, registerSearchTools } from "../tools/search.js";
|
|
9
|
+
import { handleProfileTool } from "../tools/profile.js";
|
|
10
|
+
import { resetServerConfig } from "../core/env-config.js";
|
|
11
|
+
import { registry } from "../observability/metrics.js";
|
|
12
|
+
const PROJECT = "inputs";
|
|
13
|
+
let tmp = "";
|
|
14
|
+
const ENV_KEYS = [
|
|
15
|
+
"CODE_SEARCH_DATA_DIR",
|
|
16
|
+
"CODE_SEARCH_EMBEDDING_PROVIDER",
|
|
17
|
+
"CODE_SEARCH_OLLAMA_HOST",
|
|
18
|
+
"CODE_SEARCH_EMBEDDING_DIMENSIONS",
|
|
19
|
+
"CODE_SEARCH_RERANKER_PROVIDER",
|
|
20
|
+
"ANVIL_LLM_MODE",
|
|
21
|
+
"CODE_SEARCH_LLM_MODE"
|
|
22
|
+
];
|
|
23
|
+
const stash = {};
|
|
24
|
+
function ctx(indexReady = true) {
|
|
25
|
+
return {
|
|
26
|
+
projectName: PROJECT,
|
|
27
|
+
directoryPath: null,
|
|
28
|
+
indexReady,
|
|
29
|
+
startedAt: 0,
|
|
30
|
+
indexing: {
|
|
31
|
+
status: "idle",
|
|
32
|
+
phase: null,
|
|
33
|
+
message: null,
|
|
34
|
+
percent: 0,
|
|
35
|
+
startedAt: null,
|
|
36
|
+
error: null,
|
|
37
|
+
lastSuccess: null,
|
|
38
|
+
lastDurationMs: 0,
|
|
39
|
+
history: []
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const text = (r) => r?.content?.[0]?.text ?? "";
|
|
44
|
+
function chunk(id, entityName, content) {
|
|
45
|
+
return {
|
|
46
|
+
id,
|
|
47
|
+
filePath: `src/${id}.ts`,
|
|
48
|
+
repoName: "app",
|
|
49
|
+
project: PROJECT,
|
|
50
|
+
startLine: 1,
|
|
51
|
+
endLine: 3,
|
|
52
|
+
content,
|
|
53
|
+
contextPrefix: "",
|
|
54
|
+
contextualizedContent: content,
|
|
55
|
+
language: "typescript",
|
|
56
|
+
entityType: "function",
|
|
57
|
+
entityName,
|
|
58
|
+
tokens: 10,
|
|
59
|
+
imports: [],
|
|
60
|
+
exports: [],
|
|
61
|
+
embedding: [1, 0, 0, 0]
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
before(async () => {
|
|
65
|
+
for (const k of ENV_KEYS) stash[k] = process.env[k];
|
|
66
|
+
tmp = mkdtempSync(join(tmpdir(), "cs-inputs-"));
|
|
67
|
+
process.env.CODE_SEARCH_DATA_DIR = tmp;
|
|
68
|
+
process.env.CODE_SEARCH_EMBEDDING_PROVIDER = "ollama";
|
|
69
|
+
process.env.CODE_SEARCH_OLLAMA_HOST = "http://127.0.0.1:47361";
|
|
70
|
+
process.env.CODE_SEARCH_EMBEDDING_DIMENSIONS = "4";
|
|
71
|
+
process.env.CODE_SEARCH_RERANKER_PROVIDER = "none";
|
|
72
|
+
process.env.ANVIL_LLM_MODE = "none";
|
|
73
|
+
process.env.CODE_SEARCH_LLM_MODE = "none";
|
|
74
|
+
resetServerConfig();
|
|
75
|
+
resetLlmConfig();
|
|
76
|
+
mkdirSync(join(tmp, PROJECT), { recursive: true });
|
|
77
|
+
const store = new VectorStore(join(tmp, PROJECT, "lancedb"));
|
|
78
|
+
await store.init();
|
|
79
|
+
await store.upsertChunks([
|
|
80
|
+
chunk("a", "chargeCustomer", "export function chargeCustomer(amount: number) { return gateway.charge(amount); }"),
|
|
81
|
+
chunk("b", "refundCustomer", "export function refundCustomer(id: string) { return gateway.refund(id); }")
|
|
82
|
+
]);
|
|
83
|
+
await store.ensureFtsIndex();
|
|
84
|
+
});
|
|
85
|
+
after(async () => {
|
|
86
|
+
await invalidateRetriever();
|
|
87
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
88
|
+
for (const k of ENV_KEYS) {
|
|
89
|
+
if (stash[k] === void 0) delete process.env[k];
|
|
90
|
+
else process.env[k] = stash[k];
|
|
91
|
+
}
|
|
92
|
+
resetServerConfig();
|
|
93
|
+
resetLlmConfig();
|
|
94
|
+
});
|
|
95
|
+
describe("input validation", () => {
|
|
96
|
+
const cases = [
|
|
97
|
+
["search", "search_code", {}, /query/],
|
|
98
|
+
["search", "search_code", { query: " " }, /query/],
|
|
99
|
+
["search", "search_code", { query: 5 }, /query.*string/],
|
|
100
|
+
["search", "search_code", { query: "x", maxResults: 0 }, /maxResults/],
|
|
101
|
+
["search", "search_exact", { query: "x", repos: 5 }, /"repos" must be a non-empty array of strings or a non-empty string/],
|
|
102
|
+
["search", "search_exact", { query: "x", repos: { app: true } }, /repos/],
|
|
103
|
+
["search", "search_exact", { query: "x", repos: [] }, /repos/],
|
|
104
|
+
["search", "search_exact", { query: "x", repos: "" }, /repos/],
|
|
105
|
+
["search", "search_exact", { query: "x", repos: " , " }, /repos.*at least one/],
|
|
106
|
+
["search", "get_code_snippet", {}, /id|repo/],
|
|
107
|
+
["graph", "detect_changes", {}, /repo/],
|
|
108
|
+
["graph", "trace_path", { from: "a", direction: "sideways" }, /direction.*callees, callers, both/],
|
|
109
|
+
["graph", "trace_path", { from: "a", maxDepth: 0 }, /maxDepth/],
|
|
110
|
+
["graph", "trace_path", { from: "a", maxDepth: 11 }, /maxDepth/],
|
|
111
|
+
["graph", "find_callers", { function: "a", relations: ["friends"] }, /relations/],
|
|
112
|
+
["graph", "impact_analysis", { repo: "app" }, /file/],
|
|
113
|
+
["graph", "search_graph", { limit: "ten" }, /limit/],
|
|
114
|
+
["graph", "find_dead_code", { repo: "app", limit: -1 }, /limit/]
|
|
115
|
+
];
|
|
116
|
+
for (const [kind, name, args, re] of cases) {
|
|
117
|
+
it(`${name} ${JSON.stringify(args)} \u2192 isError`, async () => {
|
|
118
|
+
const r = kind === "search" ? await handleSearchTool(name, args, ctx()) : await handleGraphTool(name, args, ctx());
|
|
119
|
+
assert.equal(r?.isError, true, text(r));
|
|
120
|
+
assert.match(text(r), re);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
it("get_repo_profile without repo \u2192 isError", async () => {
|
|
124
|
+
const r = await handleProfileTool("get_repo_profile", {}, ctx());
|
|
125
|
+
assert.equal(r?.isError, true);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
describe("search_exact without an embedder", () => {
|
|
129
|
+
it("serves BM25 even while the index is not ready", async () => {
|
|
130
|
+
const r = await handleSearchTool("search_exact", { query: "refundCustomer" }, ctx(false));
|
|
131
|
+
assert.notEqual(r.isError, true, text(r));
|
|
132
|
+
assert.match(text(r), /refundCustomer/);
|
|
133
|
+
});
|
|
134
|
+
it("accepts repos as an array, one name, or a comma-separated list \u2014 and applies it", async () => {
|
|
135
|
+
for (const repos of [["app"], "app", "other, app"]) {
|
|
136
|
+
const r = await handleSearchTool("search_exact", { query: "refundCustomer", repos }, ctx());
|
|
137
|
+
assert.notEqual(r.isError, true, `${JSON.stringify(repos)}: ${text(r)}`);
|
|
138
|
+
assert.match(text(r), /refundCustomer/, JSON.stringify(repos));
|
|
139
|
+
}
|
|
140
|
+
assert.match(text(await handleSearchTool("search_exact", { query: "refundCustomer", repos: "other" }, ctx())), /No results/);
|
|
141
|
+
});
|
|
142
|
+
it("records the query metrics", async () => {
|
|
143
|
+
await handleSearchTool("search_exact", { query: "chargeCustomer" }, ctx());
|
|
144
|
+
const body = registry.render();
|
|
145
|
+
assert.match(body, /code_search_queries_total\{[^}]*mode="bm25"[^}]*\} \d+/);
|
|
146
|
+
assert.match(body, /code_search_query_duration_seconds_count\{mode="bm25"\} \d+/);
|
|
147
|
+
});
|
|
148
|
+
it("hybrid search reports an embedder failure as an error and counts it", async () => {
|
|
149
|
+
const r = await handleSearchTool("search_code", { query: "charge customer" }, ctx());
|
|
150
|
+
assert.equal(r.isError, true);
|
|
151
|
+
assert.match(registry.render(), /code_search_errors_total\{kind="search"\} \d+/);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
describe("hints", () => {
|
|
155
|
+
it("index-not-ready does not point at a reindex tool", async () => {
|
|
156
|
+
const t = text(await handleSearchTool("search_semantic", { query: "x" }, ctx(false)));
|
|
157
|
+
assert.match(t, /not ready/i);
|
|
158
|
+
assert.doesNotMatch(t, /reindex tool/);
|
|
159
|
+
});
|
|
160
|
+
it("missing profile with LLM off says profiles need an LLM", async () => {
|
|
161
|
+
const t = text(await handleProfileTool("get_repo_profile", { repo: "app" }, ctx()));
|
|
162
|
+
assert.match(t, /LLM/);
|
|
163
|
+
assert.doesNotMatch(t, /Run reindex/);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
describe("descriptors match behaviour", () => {
|
|
167
|
+
const search = Object.fromEntries(registerSearchTools().map((t) => [t.name, t]));
|
|
168
|
+
const graph = Object.fromEntries(registerGraphTools().map((t) => [t.name, t]));
|
|
169
|
+
it("search_semantic / search_exact advertise repos", () => {
|
|
170
|
+
assert.ok(search.search_semantic.inputSchema.properties.repos);
|
|
171
|
+
assert.ok(search.search_exact.inputSchema.properties.repos);
|
|
172
|
+
});
|
|
173
|
+
it("trace_path is not described as confidence-weighted; search_graph name is a regex", () => {
|
|
174
|
+
assert.doesNotMatch(graph.trace_path.description, /confidence-weighted/);
|
|
175
|
+
assert.match(graph.search_graph.inputSchema.properties.name.description, /regex/i);
|
|
176
|
+
assert.ok(graph.find_callers.inputSchema.properties.relations);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -2,6 +2,7 @@ import { join } from "node:path";
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import {
|
|
4
4
|
getRetriever,
|
|
5
|
+
invalidateRetriever,
|
|
5
6
|
indexFromPath,
|
|
6
7
|
getKnowledgeBasePath,
|
|
7
8
|
KnowledgeIndexer,
|
|
@@ -60,7 +61,7 @@ class InProcessBackend {
|
|
|
60
61
|
}
|
|
61
62
|
async status() {
|
|
62
63
|
const indexer = new KnowledgeIndexer();
|
|
63
|
-
const stats = await indexer.getStats(this.project);
|
|
64
|
+
const stats = await indexer.getStats(this.project, this.knowledge);
|
|
64
65
|
return {
|
|
65
66
|
totalChunks: stats.totalChunks,
|
|
66
67
|
repos: stats.repos,
|
|
@@ -79,6 +80,7 @@ class InProcessBackend {
|
|
|
79
80
|
force: opts?.force,
|
|
80
81
|
config: this.knowledge
|
|
81
82
|
});
|
|
83
|
+
await invalidateRetriever(this.project);
|
|
82
84
|
return this.status();
|
|
83
85
|
}
|
|
84
86
|
async invalidate(paths) {
|
|
@@ -91,10 +93,11 @@ class InProcessBackend {
|
|
|
91
93
|
const stats = await store.getStats();
|
|
92
94
|
if (!stats || stats.rowCount === 0) return;
|
|
93
95
|
const indexer = new KnowledgeIndexer();
|
|
94
|
-
const summary = await indexer.getStats(this.project);
|
|
96
|
+
const summary = await indexer.getStats(this.project, this.knowledge);
|
|
95
97
|
for (const r of summary.repos) {
|
|
96
98
|
await store.deleteFileChunks(this.project, r.name, paths);
|
|
97
99
|
}
|
|
100
|
+
await invalidateRetriever(this.project);
|
|
98
101
|
}
|
|
99
102
|
async close() {
|
|
100
103
|
}
|
package/dist/backends/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { InProcessBackend } from "./in-process.js";
|
|
4
4
|
import { DaemonBackend } from "./daemon-client.js";
|
|
5
|
+
import { getComponentLogger } from "../observability/logger.js";
|
|
5
6
|
export * from "./types.js";
|
|
6
7
|
import { InProcessBackend as InProcessBackend2 } from "./in-process.js";
|
|
7
8
|
import { DaemonBackend as DaemonBackend2 } from "./daemon-client.js";
|
|
@@ -16,8 +17,7 @@ async function pickBackend(cfg) {
|
|
|
16
17
|
if (cfg.preferDaemon && socketPath && existsSync(socketPath)) {
|
|
17
18
|
const daemon = new DaemonBackend({ ...cfg, socketPath });
|
|
18
19
|
if (await daemon.ping()) return daemon;
|
|
19
|
-
|
|
20
|
-
`);
|
|
20
|
+
getComponentLogger("code-search").warn(`daemon socket exists at ${socketPath} but ping failed; using in-process backend`);
|
|
21
21
|
}
|
|
22
22
|
return new InProcessBackend(cfg);
|
|
23
23
|
}
|
package/dist/cli/index.js
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { existsSync, rmSync } from "node:fs";
|
|
4
|
-
import {
|
|
4
|
+
import { constants as osConstants } from "node:os";
|
|
5
|
+
import { dirname, join, resolve as resolvePath, basename } from "node:path";
|
|
5
6
|
import {
|
|
6
7
|
resolveCodeSearchConfig,
|
|
7
8
|
parseCliFlags,
|
|
8
9
|
printConfig,
|
|
9
|
-
|
|
10
|
+
redactSecrets,
|
|
11
|
+
toKnowledgeConfig,
|
|
12
|
+
exportDataDir
|
|
10
13
|
} from "../core/config.js";
|
|
14
|
+
import { validateProjectName } from "../core/project-name.js";
|
|
11
15
|
import {
|
|
12
16
|
indexFromPath,
|
|
13
17
|
KnowledgeIndexer,
|
|
18
|
+
getBlobStore,
|
|
14
19
|
getKnowledgeBasePath,
|
|
15
20
|
loadProfile
|
|
16
21
|
} from "@esankhan3/anvil-knowledge-core";
|
|
17
|
-
import { pickBackend, daemonSocketPath } from "../backends/index.js";
|
|
22
|
+
import { pickBackend, daemonSocketPath, DaemonBackend } from "../backends/index.js";
|
|
23
|
+
import { configureLogger, getComponentLogger } from "../observability/logger.js";
|
|
24
|
+
const log = getComponentLogger("code-search");
|
|
25
|
+
const FORMATS = /* @__PURE__ */ new Set(["text", "json", "jsonl"]);
|
|
18
26
|
const HELP_TEXT = `code-search \u2014 multi-repo code intelligence
|
|
19
27
|
|
|
20
28
|
USAGE:
|
|
@@ -26,9 +34,14 @@ COMMANDS:
|
|
|
26
34
|
query <text> hybrid search; print top-K
|
|
27
35
|
status show index + daemon health
|
|
28
36
|
reset [--project <name>] drop the project's index
|
|
29
|
-
daemon --workspace <path>
|
|
30
|
-
serve [--port N] [--auth mode] HTTP MCP server
|
|
31
|
-
mcp
|
|
37
|
+
daemon --workspace <path> run the long-running daemon
|
|
38
|
+
serve [path] [--port N] [--auth mode] HTTP MCP server
|
|
39
|
+
mcp [path] stdio MCP server over local repos (default: cwd)
|
|
40
|
+
mcp --remote <url> stdio proxy to a remote code-search server
|
|
41
|
+
|
|
42
|
+
INDEX OPTIONS:
|
|
43
|
+
--project <name> project namespace (default: path basename)
|
|
44
|
+
--force full re-index
|
|
32
45
|
|
|
33
46
|
QUERY OPTIONS:
|
|
34
47
|
--mode hybrid|vector|bm25 retrieval mode (default: hybrid)
|
|
@@ -36,6 +49,7 @@ QUERY OPTIONS:
|
|
|
36
49
|
--repo <name> restrict to a repo (repeatable)
|
|
37
50
|
--format text|json|jsonl output format (default: text)
|
|
38
51
|
--project <name> project namespace (default: cwd basename)
|
|
52
|
+
--no-daemon search in-process even if a daemon is running
|
|
39
53
|
|
|
40
54
|
CONFIG:
|
|
41
55
|
Every leaf in CodeSearchConfig is settable via --<dotted-path>.
|
|
@@ -44,6 +58,11 @@ CONFIG:
|
|
|
44
58
|
--retrieval.max-chunks 20
|
|
45
59
|
--no-indexing.auto-index
|
|
46
60
|
`;
|
|
61
|
+
function usageError(msg) {
|
|
62
|
+
process.stderr.write(`code-search: ${msg}
|
|
63
|
+
`);
|
|
64
|
+
process.exit(2);
|
|
65
|
+
}
|
|
47
66
|
async function main() {
|
|
48
67
|
const argv = process.argv.slice(2);
|
|
49
68
|
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
|
|
@@ -51,8 +70,7 @@ async function main() {
|
|
|
51
70
|
process.exit(0);
|
|
52
71
|
}
|
|
53
72
|
if (argv[0] === "--print-config") {
|
|
54
|
-
const {
|
|
55
|
-
const cfg = resolveCodeSearchConfig({ cli: patch });
|
|
73
|
+
const { cfg } = parseCommand("--print-config", argv.slice(1), {}, process.cwd());
|
|
56
74
|
process.stdout.write(printConfig(cfg) + "\n");
|
|
57
75
|
process.exit(0);
|
|
58
76
|
}
|
|
@@ -79,68 +97,100 @@ ${HELP_TEXT}`);
|
|
|
79
97
|
process.exit(2);
|
|
80
98
|
}
|
|
81
99
|
}
|
|
82
|
-
function
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
85
|
-
|
|
100
|
+
function parseCommand(cmd, argv, spec, workspaceDir) {
|
|
101
|
+
const { patch, rest, unknown } = parseCliFlags(argv);
|
|
102
|
+
if (unknown.length) usageError(`${cmd}: unknown flag(s): ${unknown.join(", ")}`);
|
|
103
|
+
const values = /* @__PURE__ */ new Map();
|
|
104
|
+
const bools = /* @__PURE__ */ new Set();
|
|
105
|
+
const positionals = [];
|
|
106
|
+
for (let i = 0; i < rest.length; i++) {
|
|
107
|
+
const a = rest[i];
|
|
108
|
+
if (a === "--help" || a === "-h") {
|
|
109
|
+
process.stdout.write(HELP_TEXT);
|
|
110
|
+
process.exit(0);
|
|
111
|
+
}
|
|
112
|
+
if (spec.values?.includes(a)) {
|
|
113
|
+
const v = rest[i + 1];
|
|
114
|
+
if (v === void 0 || v.startsWith("--")) usageError(`${cmd}: ${a} needs a value`);
|
|
115
|
+
values.set(a, [...values.get(a) ?? [], v]);
|
|
116
|
+
i++;
|
|
117
|
+
} else if (spec.bools?.includes(a)) {
|
|
118
|
+
bools.add(a);
|
|
119
|
+
} else if (a.startsWith("-") && a.length > 1 && Number.isNaN(Number(a))) {
|
|
120
|
+
usageError(`${cmd}: unknown flag ${a}`);
|
|
121
|
+
} else {
|
|
122
|
+
positionals.push(a);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (spec.maxPositionals !== void 0 && positionals.length > spec.maxPositionals) {
|
|
126
|
+
usageError(`${cmd}: unexpected argument(s): ${positionals.slice(spec.maxPositionals).join(" ")}`);
|
|
127
|
+
}
|
|
128
|
+
const cfg = resolveCodeSearchConfig({ cli: patch, workspaceDir: workspaceDir ?? positionals[0] ?? process.cwd() });
|
|
129
|
+
configureLogger({ structured: cfg.telemetry.structuredLogs });
|
|
130
|
+
return { cfg, values, bools, positionals };
|
|
86
131
|
}
|
|
87
|
-
function
|
|
88
|
-
|
|
132
|
+
function flag(p, name) {
|
|
133
|
+
const v = p.values.get(name);
|
|
134
|
+
return v?.[v.length - 1];
|
|
135
|
+
}
|
|
136
|
+
function projectArg(p, fallbackDir) {
|
|
137
|
+
const name = flag(p, "--project") ?? basename(resolvePath(fallbackDir));
|
|
138
|
+
try {
|
|
139
|
+
return validateProjectName(name);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
usageError(err.message);
|
|
142
|
+
}
|
|
89
143
|
}
|
|
90
144
|
async function cmdIndex(rest) {
|
|
91
|
-
const
|
|
92
|
-
const path =
|
|
93
|
-
|
|
94
|
-
const project =
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
onProgress: (m) => process.stderr.write(`[code-search] ${m}
|
|
102
|
-
`)
|
|
145
|
+
const p = parseCommand("index", rest, { values: ["--project"], bools: ["--force"], maxPositionals: 1 });
|
|
146
|
+
const path = resolvePath(p.positionals[0] ?? process.cwd());
|
|
147
|
+
exportDataDir(p.cfg);
|
|
148
|
+
const project = projectArg(p, path);
|
|
149
|
+
const knowledge = toKnowledgeConfig(p.cfg);
|
|
150
|
+
log.info(`indexing ${path} as project "${project}"...`);
|
|
151
|
+
const stats = await indexFromPath(project, path, {
|
|
152
|
+
force: p.bools.has("--force"),
|
|
153
|
+
config: knowledge,
|
|
154
|
+
onProgress: (m) => log.info(m)
|
|
103
155
|
});
|
|
156
|
+
const rollup = await new KnowledgeIndexer().getStats(project, knowledge);
|
|
104
157
|
process.stdout.write(JSON.stringify({
|
|
105
158
|
project,
|
|
106
159
|
chunks: stats.totalChunks,
|
|
107
160
|
repos: stats.repos.length,
|
|
108
|
-
crossRepoEdges:
|
|
161
|
+
crossRepoEdges: rollup.crossRepoEdges,
|
|
109
162
|
durationMs: stats.indexDurationMs
|
|
110
163
|
}, null, 2) + "\n");
|
|
111
164
|
}
|
|
112
165
|
const VALID_MODES = /* @__PURE__ */ new Set(["hybrid", "vector", "bm25"]);
|
|
113
166
|
async function cmdQuery(rest) {
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const rawMode =
|
|
167
|
+
const p = parseCommand("query", rest, {
|
|
168
|
+
values: ["--project", "--mode", "--top-k", "--format", "--repo"],
|
|
169
|
+
bools: ["--no-daemon"]
|
|
170
|
+
}, process.cwd());
|
|
171
|
+
const queryText = p.positionals.join(" ");
|
|
172
|
+
if (!queryText) usageError("query: missing <text>");
|
|
173
|
+
const rawMode = flag(p, "--mode") ?? "hybrid";
|
|
121
174
|
if (!VALID_MODES.has(rawMode)) {
|
|
122
|
-
|
|
123
|
-
`code-search query: invalid --mode "${rawMode}". Valid modes: ${[...VALID_MODES].join(", ")}.
|
|
124
|
-
`
|
|
125
|
-
);
|
|
126
|
-
process.exit(2);
|
|
175
|
+
usageError(`query: invalid --mode "${rawMode}". Valid modes: ${[...VALID_MODES].join(", ")}.`);
|
|
127
176
|
}
|
|
128
177
|
const mode = rawMode;
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const format =
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
178
|
+
const rawTopK = flag(p, "--top-k") ?? "10";
|
|
179
|
+
if (!/^\d+$/.test(rawTopK) || parseInt(rawTopK, 10) < 1) {
|
|
180
|
+
usageError(`query: invalid --top-k "${rawTopK}" (a positive integer).`);
|
|
181
|
+
}
|
|
182
|
+
const topK = parseInt(rawTopK, 10);
|
|
183
|
+
const format = flag(p, "--format") ?? "text";
|
|
184
|
+
if (!FORMATS.has(format)) usageError(`query: invalid --format "${format}". Valid formats: ${[...FORMATS].join(", ")}.`);
|
|
185
|
+
const repos = p.values.get("--repo") ?? [];
|
|
186
|
+
exportDataDir(p.cfg);
|
|
187
|
+
const project = projectArg(p, process.cwd());
|
|
138
188
|
const backend = await pickBackend({
|
|
139
189
|
project,
|
|
140
190
|
workspaceDir: process.cwd(),
|
|
141
|
-
knowledge: toKnowledgeConfig(cfg),
|
|
142
|
-
preferDaemon: !cfg.daemon.disabled,
|
|
143
|
-
socketPath
|
|
191
|
+
knowledge: toKnowledgeConfig(p.cfg),
|
|
192
|
+
preferDaemon: !p.cfg.daemon.disabled && !p.bools.has("--no-daemon"),
|
|
193
|
+
socketPath: daemonSocketPath(p.cfg.storage.dataDir, project)
|
|
144
194
|
});
|
|
145
195
|
const result = await backend.search(queryText, {
|
|
146
196
|
mode,
|
|
@@ -174,37 +224,44 @@ function printSearchResult(result, format) {
|
|
|
174
224
|
}
|
|
175
225
|
}
|
|
176
226
|
async function cmdStatus(rest) {
|
|
177
|
-
const {
|
|
178
|
-
|
|
179
|
-
const project =
|
|
180
|
-
const
|
|
181
|
-
const
|
|
182
|
-
const
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
);
|
|
227
|
+
const p = parseCommand("status", rest, { values: ["--project"], maxPositionals: 0 }, process.cwd());
|
|
228
|
+
exportDataDir(p.cfg);
|
|
229
|
+
const project = projectArg(p, process.cwd());
|
|
230
|
+
const knowledge = toKnowledgeConfig(p.cfg);
|
|
231
|
+
const socketPath = daemonSocketPath(p.cfg.storage.dataDir, project);
|
|
232
|
+
const daemonAlive = await new DaemonBackend({ project, workspaceDir: null, knowledge, preferDaemon: true, socketPath }).ping();
|
|
233
|
+
const blobs = getBlobStore(project, knowledge);
|
|
234
|
+
const indexed = await blobs.exists("index_stats.json") || await blobs.exists("lancedb");
|
|
235
|
+
const stats = indexed ? await new KnowledgeIndexer().getStats(project, knowledge) : null;
|
|
236
|
+
const profilesAvailable = stats ? await Promise.all(stats.repos.map(async (r) => ({ name: r.name, hasProfile: !!await loadProfile(project, r.name, knowledge) }))) : [];
|
|
186
237
|
process.stdout.write(JSON.stringify({
|
|
187
238
|
project,
|
|
188
|
-
dataDir: cfg.storage.dataDir,
|
|
239
|
+
dataDir: p.cfg.storage.dataDir,
|
|
240
|
+
...p.cfg.storage.backends ? { storageBackends: redactSecrets(p.cfg.storage.backends) } : { kbPath: getKnowledgeBasePath(project) },
|
|
241
|
+
indexed,
|
|
189
242
|
daemonSocket: socketPath,
|
|
190
|
-
daemonAlive
|
|
191
|
-
totalChunks: stats
|
|
192
|
-
embeddingProvider: stats
|
|
193
|
-
lastIndexedAt: stats
|
|
194
|
-
repos: stats
|
|
243
|
+
daemonAlive,
|
|
244
|
+
totalChunks: stats?.totalChunks ?? 0,
|
|
245
|
+
embeddingProvider: stats?.embeddingProvider ?? null,
|
|
246
|
+
lastIndexedAt: stats?.lastIndexed || null,
|
|
247
|
+
repos: stats?.repos ?? [],
|
|
195
248
|
profilesAvailable
|
|
196
249
|
}, null, 2) + "\n");
|
|
197
250
|
}
|
|
198
251
|
async function cmdReset(rest) {
|
|
199
|
-
const
|
|
200
|
-
|
|
252
|
+
const p = parseCommand("reset", rest, { values: ["--project"], maxPositionals: 0 }, process.cwd());
|
|
253
|
+
exportDataDir(p.cfg);
|
|
254
|
+
const project = projectArg(p, process.cwd());
|
|
255
|
+
const dataDir = p.cfg.storage.dataDir;
|
|
256
|
+
const basePath = resolvePath(getKnowledgeBasePath(project));
|
|
257
|
+
if (dirname(basePath) !== dataDir) {
|
|
258
|
+
usageError(`reset: refusing to remove ${basePath} \u2014 not a project directory inside ${dataDir}`);
|
|
259
|
+
}
|
|
201
260
|
if (existsSync(basePath)) {
|
|
202
261
|
rmSync(basePath, { recursive: true, force: true });
|
|
203
|
-
|
|
204
|
-
`);
|
|
262
|
+
log.info(`removed ${basePath}`);
|
|
205
263
|
} else {
|
|
206
|
-
|
|
207
|
-
`);
|
|
264
|
+
log.info(`nothing to remove at ${basePath}`);
|
|
208
265
|
}
|
|
209
266
|
}
|
|
210
267
|
async function cmdDaemon(rest) {
|
|
@@ -214,18 +271,28 @@ async function cmdDaemon(rest) {
|
|
|
214
271
|
stdio: "inherit",
|
|
215
272
|
detached: false
|
|
216
273
|
});
|
|
217
|
-
|
|
274
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
275
|
+
process.on(sig, () => {
|
|
276
|
+
try {
|
|
277
|
+
child.kill(sig);
|
|
278
|
+
} catch {
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
child.on("exit", (code, signal) => {
|
|
283
|
+
process.exit(code ?? 128 + (signal ? osConstants.signals[signal] ?? 0 : 0));
|
|
284
|
+
});
|
|
218
285
|
}
|
|
219
286
|
async function cmdServe(rest) {
|
|
220
287
|
process.argv = [process.argv[0], process.argv[1], "--serve", ...rest];
|
|
221
288
|
await import("../index.js");
|
|
222
289
|
}
|
|
223
290
|
async function cmdMcp(rest) {
|
|
224
|
-
|
|
291
|
+
const remote = rest.includes("--remote") || rest.some((a) => /^https?:\/\//.test(a));
|
|
292
|
+
process.argv = [process.argv[0], process.argv[1], ...remote ? [] : ["--local"], ...rest];
|
|
225
293
|
await import("../index.js");
|
|
226
294
|
}
|
|
227
295
|
main().catch((err) => {
|
|
228
|
-
|
|
229
|
-
`);
|
|
296
|
+
log.error(`fatal: ${err instanceof Error ? err.stack : err}`);
|
|
230
297
|
process.exit(1);
|
|
231
298
|
});
|