@esankhan3/code-search-mcp 0.1.0 → 0.3.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 +174 -19
- package/dist/__tests__/config-resolver.test.js +153 -0
- package/dist/__tests__/daemon-rpc.test.js +100 -0
- package/dist/__tests__/observability.test.js +69 -0
- package/dist/__tests__/projects.test.js +141 -0
- package/dist/backends/daemon-client.js +87 -0
- package/dist/backends/in-process.js +104 -0
- package/dist/backends/index.js +29 -0
- package/dist/backends/types.js +0 -0
- package/dist/cli/index.js +231 -0
- package/dist/core/config.js +372 -0
- package/dist/core/env-config.js +45 -43
- package/dist/daemon/index.js +119 -0
- package/dist/daemon/rpc-server.js +99 -0
- package/dist/daemon/watcher.js +105 -0
- package/dist/index.js +7 -0
- package/dist/observability/logger.js +66 -0
- package/dist/observability/metrics.js +138 -0
- package/dist/projects/registry.js +146 -0
- package/dist/server.js +4 -0
- package/dist/transports/http-transport.js +38 -0
- package/package.json +18 -6
- package/scripts/smoke/run-smoke.sh +167 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import {
|
|
7
|
+
ProjectRegistry,
|
|
8
|
+
projectAccessAllowed,
|
|
9
|
+
checkProjectQuota
|
|
10
|
+
} from "../projects/registry.js";
|
|
11
|
+
let dir = "";
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
dir = mkdtempSync(join(tmpdir(), "cs-projects-"));
|
|
14
|
+
});
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
rmSync(dir, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
function writeProject(name, body) {
|
|
19
|
+
const d = join(dir, "projects", name);
|
|
20
|
+
mkdirSync(d, { recursive: true });
|
|
21
|
+
writeFileSync(join(d, "project.yaml"), body, "utf-8");
|
|
22
|
+
}
|
|
23
|
+
describe("ProjectRegistry", () => {
|
|
24
|
+
it("reload() discovers all projects with project.yaml", () => {
|
|
25
|
+
writeProject("alpha", `workspace: /tmp/alpha
|
|
26
|
+
repos: [a, b]
|
|
27
|
+
scopes: [team-x]
|
|
28
|
+
`);
|
|
29
|
+
writeProject("beta", `workspace: /tmp/beta
|
|
30
|
+
`);
|
|
31
|
+
mkdirSync(join(dir, "projects", "ignored"), { recursive: true });
|
|
32
|
+
const reg = new ProjectRegistry(dir);
|
|
33
|
+
const list = reg.reload();
|
|
34
|
+
assert.equal(list.length, 2);
|
|
35
|
+
const names = new Set(list.map((p) => p.name));
|
|
36
|
+
assert.ok(names.has("alpha"));
|
|
37
|
+
assert.ok(names.has("beta"));
|
|
38
|
+
const alpha = reg.get("alpha");
|
|
39
|
+
assert.equal(alpha.workspaceDir, "/tmp/alpha");
|
|
40
|
+
assert.deepEqual(alpha.repos, ["a", "b"]);
|
|
41
|
+
assert.deepEqual(alpha.scopes, ["team-x"]);
|
|
42
|
+
});
|
|
43
|
+
it("reload() applies quotas defaults when omitted", () => {
|
|
44
|
+
writeProject("plain", `workspace: /tmp/plain
|
|
45
|
+
`);
|
|
46
|
+
const reg = new ProjectRegistry(dir);
|
|
47
|
+
reg.reload();
|
|
48
|
+
const p = reg.get("plain");
|
|
49
|
+
assert.equal(p.quotas.maxQueriesPerMinute, 100);
|
|
50
|
+
});
|
|
51
|
+
it("reload() reads custom quotas", () => {
|
|
52
|
+
writeProject("tenant", `workspace: /tmp/tenant
|
|
53
|
+
quotas:
|
|
54
|
+
max_queries_per_minute: 5
|
|
55
|
+
max_embedding_cost_usd: 1
|
|
56
|
+
`);
|
|
57
|
+
const reg = new ProjectRegistry(dir);
|
|
58
|
+
reg.reload();
|
|
59
|
+
const p = reg.get("tenant");
|
|
60
|
+
assert.equal(p.quotas.maxQueriesPerMinute, 5);
|
|
61
|
+
assert.equal(p.quotas.maxEmbeddingCostUsd, 1);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
describe("projectAccessAllowed", () => {
|
|
65
|
+
it("public projects allow any identity", () => {
|
|
66
|
+
const p = {
|
|
67
|
+
name: "public",
|
|
68
|
+
workspaceDir: "/p",
|
|
69
|
+
repos: [],
|
|
70
|
+
scopes: [],
|
|
71
|
+
config: {},
|
|
72
|
+
quotas: { maxQueriesPerMinute: 1, maxEmbeddingCostUsd: 0, maxLlmCostUsd: 0 }
|
|
73
|
+
};
|
|
74
|
+
assert.equal(projectAccessAllowed(p, []), true);
|
|
75
|
+
assert.equal(projectAccessAllowed(p, ["some-scope"]), true);
|
|
76
|
+
});
|
|
77
|
+
it("scoped projects deny unmatched identities", () => {
|
|
78
|
+
const p = {
|
|
79
|
+
name: "secret",
|
|
80
|
+
workspaceDir: "/p",
|
|
81
|
+
repos: [],
|
|
82
|
+
scopes: ["team-x"],
|
|
83
|
+
config: {},
|
|
84
|
+
quotas: { maxQueriesPerMinute: 1, maxEmbeddingCostUsd: 0, maxLlmCostUsd: 0 }
|
|
85
|
+
};
|
|
86
|
+
assert.equal(projectAccessAllowed(p, ["team-y"]), false);
|
|
87
|
+
assert.equal(projectAccessAllowed(p, ["team-x"]), true);
|
|
88
|
+
});
|
|
89
|
+
it("admin scope * grants access regardless of project scopes", () => {
|
|
90
|
+
const p = {
|
|
91
|
+
name: "secret",
|
|
92
|
+
workspaceDir: "/p",
|
|
93
|
+
repos: [],
|
|
94
|
+
scopes: ["team-x"],
|
|
95
|
+
config: {},
|
|
96
|
+
quotas: { maxQueriesPerMinute: 1, maxEmbeddingCostUsd: 0, maxLlmCostUsd: 0 }
|
|
97
|
+
};
|
|
98
|
+
assert.equal(projectAccessAllowed(p, ["*"]), true);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
describe("checkProjectQuota", () => {
|
|
102
|
+
it("blocks once burst exceeds the per-minute cap", () => {
|
|
103
|
+
const p = {
|
|
104
|
+
name: "q",
|
|
105
|
+
workspaceDir: "/p",
|
|
106
|
+
repos: [],
|
|
107
|
+
scopes: [],
|
|
108
|
+
config: {},
|
|
109
|
+
quotas: { maxQueriesPerMinute: 2, maxEmbeddingCostUsd: 0, maxLlmCostUsd: 0 }
|
|
110
|
+
};
|
|
111
|
+
assert.equal(checkProjectQuota(p, "user-1").ok, true);
|
|
112
|
+
assert.equal(checkProjectQuota(p, "user-1").ok, true);
|
|
113
|
+
const denied = checkProjectQuota(p, "user-1");
|
|
114
|
+
assert.equal(denied.ok, false);
|
|
115
|
+
if (denied.ok === false) assert.match(denied.reason, /quota exceeded/);
|
|
116
|
+
});
|
|
117
|
+
it("separate identities have independent buckets", () => {
|
|
118
|
+
const p = {
|
|
119
|
+
name: "q2",
|
|
120
|
+
workspaceDir: "/p",
|
|
121
|
+
repos: [],
|
|
122
|
+
scopes: [],
|
|
123
|
+
config: {},
|
|
124
|
+
quotas: { maxQueriesPerMinute: 1, maxEmbeddingCostUsd: 0, maxLlmCostUsd: 0 }
|
|
125
|
+
};
|
|
126
|
+
assert.equal(checkProjectQuota(p, "a").ok, true);
|
|
127
|
+
assert.equal(checkProjectQuota(p, "b").ok, true);
|
|
128
|
+
assert.equal(checkProjectQuota(p, "a").ok, false);
|
|
129
|
+
});
|
|
130
|
+
it("maxQueriesPerMinute=0 disables enforcement", () => {
|
|
131
|
+
const p = {
|
|
132
|
+
name: "q3",
|
|
133
|
+
workspaceDir: "/p",
|
|
134
|
+
repos: [],
|
|
135
|
+
scopes: [],
|
|
136
|
+
config: {},
|
|
137
|
+
quotas: { maxQueriesPerMinute: 0, maxEmbeddingCostUsd: 0, maxLlmCostUsd: 0 }
|
|
138
|
+
};
|
|
139
|
+
for (let i = 0; i < 1e3; i++) assert.equal(checkProjectQuota(p, "user").ok, true);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createConnection } from "node:net";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
let nextRpcId = 1;
|
|
4
|
+
class DaemonBackend {
|
|
5
|
+
kind = "daemon";
|
|
6
|
+
project;
|
|
7
|
+
socketPath;
|
|
8
|
+
startedAt = Date.now();
|
|
9
|
+
constructor(cfg) {
|
|
10
|
+
this.project = cfg.project;
|
|
11
|
+
this.socketPath = cfg.socketPath;
|
|
12
|
+
}
|
|
13
|
+
async search(query, opts) {
|
|
14
|
+
return this.rpc("search.code", { query, ...opts });
|
|
15
|
+
}
|
|
16
|
+
async status() {
|
|
17
|
+
return this.rpc("index.status", {});
|
|
18
|
+
}
|
|
19
|
+
async forceIndex(opts) {
|
|
20
|
+
return this.rpc("index.force", { force: opts?.force ?? false });
|
|
21
|
+
}
|
|
22
|
+
async invalidate(paths) {
|
|
23
|
+
await this.rpc("index.invalidate", { paths });
|
|
24
|
+
}
|
|
25
|
+
async close() {
|
|
26
|
+
}
|
|
27
|
+
/** Ping the daemon to check liveness. Returns true on success within ~100ms. */
|
|
28
|
+
async ping() {
|
|
29
|
+
try {
|
|
30
|
+
const r = await this.rpc("health", {}, 250);
|
|
31
|
+
return !!r.ok;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
rpc(method, params, timeoutMs = 3e4) {
|
|
37
|
+
return new Promise((resolveP, rejectP) => {
|
|
38
|
+
if (!existsSync(this.socketPath)) {
|
|
39
|
+
rejectP(new Error(`daemon socket not found at ${this.socketPath}`));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const id = nextRpcId++;
|
|
43
|
+
let buf = "";
|
|
44
|
+
let settled = false;
|
|
45
|
+
const sock = createConnection(this.socketPath);
|
|
46
|
+
const finish = (err, value) => {
|
|
47
|
+
if (settled) return;
|
|
48
|
+
settled = true;
|
|
49
|
+
sock.destroy();
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
if (err) rejectP(err);
|
|
52
|
+
else resolveP(value);
|
|
53
|
+
};
|
|
54
|
+
const timer = setTimeout(() => finish(new Error(`daemon RPC timeout: ${method}`)), timeoutMs);
|
|
55
|
+
sock.setNoDelay(true);
|
|
56
|
+
sock.on("error", (err) => finish(err));
|
|
57
|
+
sock.on("data", (chunk) => {
|
|
58
|
+
buf += chunk.toString("utf-8");
|
|
59
|
+
let idx = buf.indexOf("\n");
|
|
60
|
+
while (idx >= 0) {
|
|
61
|
+
const line = buf.slice(0, idx);
|
|
62
|
+
buf = buf.slice(idx + 1);
|
|
63
|
+
try {
|
|
64
|
+
const msg = JSON.parse(line);
|
|
65
|
+
if (msg.id !== id) {
|
|
66
|
+
idx = buf.indexOf("\n");
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (msg.error) finish(new Error(`daemon RPC error ${msg.error.code}: ${msg.error.message}`));
|
|
70
|
+
else finish(null, msg.result);
|
|
71
|
+
return;
|
|
72
|
+
} catch (err) {
|
|
73
|
+
finish(err instanceof Error ? err : new Error(String(err)));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
sock.on("connect", () => {
|
|
79
|
+
const req = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
|
|
80
|
+
sock.write(req);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export {
|
|
86
|
+
DaemonBackend
|
|
87
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
getRetriever,
|
|
5
|
+
indexFromPath,
|
|
6
|
+
getKnowledgeBasePath,
|
|
7
|
+
KnowledgeIndexer,
|
|
8
|
+
VectorStore
|
|
9
|
+
} from "@esankhan3/anvil-knowledge-core";
|
|
10
|
+
import { metrics } from "../observability/metrics.js";
|
|
11
|
+
class InProcessBackend {
|
|
12
|
+
kind = "in-process";
|
|
13
|
+
project;
|
|
14
|
+
workspaceDir;
|
|
15
|
+
knowledge;
|
|
16
|
+
startedAt = Date.now();
|
|
17
|
+
constructor(cfg) {
|
|
18
|
+
this.project = cfg.project;
|
|
19
|
+
this.workspaceDir = cfg.workspaceDir;
|
|
20
|
+
this.knowledge = cfg.knowledge;
|
|
21
|
+
}
|
|
22
|
+
async search(query, opts) {
|
|
23
|
+
const started = Date.now();
|
|
24
|
+
let outcome = "ok";
|
|
25
|
+
try {
|
|
26
|
+
const retriever = await getRetriever(this.project, this.knowledge);
|
|
27
|
+
const modeMap = {
|
|
28
|
+
hybrid: "vector+bm25+graph",
|
|
29
|
+
vector: "vector",
|
|
30
|
+
bm25: "bm25"
|
|
31
|
+
};
|
|
32
|
+
const result = await retriever.retrieve(query, {
|
|
33
|
+
maxChunks: opts.maxResults ?? this.knowledge.retrieval.maxChunks,
|
|
34
|
+
repoFilter: opts.repos,
|
|
35
|
+
mode: modeMap[opts.mode]
|
|
36
|
+
});
|
|
37
|
+
return {
|
|
38
|
+
query: result.query,
|
|
39
|
+
totalTokens: result.totalTokens,
|
|
40
|
+
chunks: result.chunks.map((sc) => ({
|
|
41
|
+
filePath: sc.chunk.filePath,
|
|
42
|
+
startLine: sc.chunk.startLine,
|
|
43
|
+
endLine: sc.chunk.endLine,
|
|
44
|
+
language: sc.chunk.language,
|
|
45
|
+
repoName: sc.chunk.repoName,
|
|
46
|
+
score: sc.score,
|
|
47
|
+
source: sc.source,
|
|
48
|
+
content: sc.chunk.content
|
|
49
|
+
}))
|
|
50
|
+
};
|
|
51
|
+
} catch (err) {
|
|
52
|
+
outcome = "error";
|
|
53
|
+
metrics.errors.inc({ kind: "search" });
|
|
54
|
+
throw err;
|
|
55
|
+
} finally {
|
|
56
|
+
const elapsed = (Date.now() - started) / 1e3;
|
|
57
|
+
metrics.queriesTotal.inc({ mode: opts.mode, outcome });
|
|
58
|
+
metrics.queryDuration.observe(elapsed, { mode: opts.mode });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async status() {
|
|
62
|
+
const indexer = new KnowledgeIndexer();
|
|
63
|
+
const stats = await indexer.getStats(this.project);
|
|
64
|
+
return {
|
|
65
|
+
totalChunks: stats.totalChunks,
|
|
66
|
+
repos: stats.repos,
|
|
67
|
+
embeddingProvider: stats.embeddingProvider,
|
|
68
|
+
lastIndexedAt: stats.lastIndexed || null,
|
|
69
|
+
watching: false,
|
|
70
|
+
queueDepth: 0,
|
|
71
|
+
uptimeSec: Math.floor((Date.now() - this.startedAt) / 1e3)
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
async forceIndex(opts) {
|
|
75
|
+
if (!this.workspaceDir) {
|
|
76
|
+
throw new Error("InProcessBackend.forceIndex requires a workspaceDir");
|
|
77
|
+
}
|
|
78
|
+
await indexFromPath(this.project, this.workspaceDir, {
|
|
79
|
+
force: opts?.force,
|
|
80
|
+
config: this.knowledge
|
|
81
|
+
});
|
|
82
|
+
return this.status();
|
|
83
|
+
}
|
|
84
|
+
async invalidate(paths) {
|
|
85
|
+
if (paths.length === 0) return;
|
|
86
|
+
const basePath = getKnowledgeBasePath(this.project);
|
|
87
|
+
const dbPath = join(basePath, "lancedb");
|
|
88
|
+
if (!existsSync(dbPath)) return;
|
|
89
|
+
const store = new VectorStore(dbPath);
|
|
90
|
+
await store.init();
|
|
91
|
+
const stats = await store.getStats();
|
|
92
|
+
if (!stats || stats.rowCount === 0) return;
|
|
93
|
+
const indexer = new KnowledgeIndexer();
|
|
94
|
+
const summary = await indexer.getStats(this.project);
|
|
95
|
+
for (const r of summary.repos) {
|
|
96
|
+
await store.deleteFileChunks(this.project, r.name, paths);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async close() {
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export {
|
|
103
|
+
InProcessBackend
|
|
104
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { InProcessBackend } from "./in-process.js";
|
|
4
|
+
import { DaemonBackend } from "./daemon-client.js";
|
|
5
|
+
export * from "./types.js";
|
|
6
|
+
import { InProcessBackend as InProcessBackend2 } from "./in-process.js";
|
|
7
|
+
import { DaemonBackend as DaemonBackend2 } from "./daemon-client.js";
|
|
8
|
+
function daemonSocketPath(dataDir, project) {
|
|
9
|
+
if (process.platform === "win32") {
|
|
10
|
+
return `\\\\.\\pipe\\code-search-${project}`;
|
|
11
|
+
}
|
|
12
|
+
return join(dataDir, "daemon", `${project}.sock`);
|
|
13
|
+
}
|
|
14
|
+
async function pickBackend(cfg) {
|
|
15
|
+
const socketPath = cfg.socketPath ?? daemonSocketPath(cfg.knowledge ? "" : "", cfg.project);
|
|
16
|
+
if (cfg.preferDaemon && socketPath && existsSync(socketPath)) {
|
|
17
|
+
const daemon = new DaemonBackend({ ...cfg, socketPath });
|
|
18
|
+
if (await daemon.ping()) return daemon;
|
|
19
|
+
process.stderr.write(`[code-search-mcp] daemon socket exists at ${socketPath} but ping failed; using in-process backend
|
|
20
|
+
`);
|
|
21
|
+
}
|
|
22
|
+
return new InProcessBackend(cfg);
|
|
23
|
+
}
|
|
24
|
+
export {
|
|
25
|
+
DaemonBackend2 as DaemonBackend,
|
|
26
|
+
InProcessBackend2 as InProcessBackend,
|
|
27
|
+
daemonSocketPath,
|
|
28
|
+
pickBackend
|
|
29
|
+
};
|
|
File without changes
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync, rmSync } from "node:fs";
|
|
4
|
+
import { join, resolve as resolvePath } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
resolveCodeSearchConfig,
|
|
7
|
+
parseCliFlags,
|
|
8
|
+
printConfig,
|
|
9
|
+
toKnowledgeConfig
|
|
10
|
+
} from "../core/config.js";
|
|
11
|
+
import {
|
|
12
|
+
indexFromPath,
|
|
13
|
+
KnowledgeIndexer,
|
|
14
|
+
getKnowledgeBasePath,
|
|
15
|
+
loadProfile
|
|
16
|
+
} from "@esankhan3/anvil-knowledge-core";
|
|
17
|
+
import { pickBackend, daemonSocketPath } from "../backends/index.js";
|
|
18
|
+
const HELP_TEXT = `code-search \u2014 multi-repo code intelligence
|
|
19
|
+
|
|
20
|
+
USAGE:
|
|
21
|
+
code-search <command> [options]
|
|
22
|
+
code-search --print-config print resolved configuration (secrets redacted)
|
|
23
|
+
|
|
24
|
+
COMMANDS:
|
|
25
|
+
index [path] index a directory (default: cwd)
|
|
26
|
+
query <text> hybrid search; print top-K
|
|
27
|
+
status show index + daemon health
|
|
28
|
+
reset [--project <name>] drop the project's index
|
|
29
|
+
daemon --workspace <path> spawn the long-running daemon
|
|
30
|
+
serve [--port N] [--auth mode] HTTP MCP server
|
|
31
|
+
mcp stdio MCP server (Claude Desktop)
|
|
32
|
+
|
|
33
|
+
QUERY OPTIONS:
|
|
34
|
+
--mode hybrid|vector|bm25 retrieval mode (default: hybrid)
|
|
35
|
+
--top-k N max results (default: 10)
|
|
36
|
+
--repo <name> restrict to a repo (repeatable)
|
|
37
|
+
--format text|json|jsonl output format (default: text)
|
|
38
|
+
--project <name> project namespace (default: cwd basename)
|
|
39
|
+
|
|
40
|
+
CONFIG:
|
|
41
|
+
Every leaf in CodeSearchConfig is settable via --<dotted-path>.
|
|
42
|
+
Examples:
|
|
43
|
+
--embedding.provider codestral
|
|
44
|
+
--retrieval.max-chunks 20
|
|
45
|
+
--no-indexing.auto-index
|
|
46
|
+
`;
|
|
47
|
+
async function main() {
|
|
48
|
+
const argv = process.argv.slice(2);
|
|
49
|
+
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
|
|
50
|
+
process.stdout.write(HELP_TEXT);
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
if (argv[0] === "--print-config") {
|
|
54
|
+
const { patch } = parseCliFlags(argv.slice(1));
|
|
55
|
+
const cfg = resolveCodeSearchConfig({ cli: patch });
|
|
56
|
+
process.stdout.write(printConfig(cfg) + "\n");
|
|
57
|
+
process.exit(0);
|
|
58
|
+
}
|
|
59
|
+
const sub = argv[0];
|
|
60
|
+
const rest = argv.slice(1);
|
|
61
|
+
switch (sub) {
|
|
62
|
+
case "index":
|
|
63
|
+
return await cmdIndex(rest);
|
|
64
|
+
case "query":
|
|
65
|
+
return await cmdQuery(rest);
|
|
66
|
+
case "status":
|
|
67
|
+
return await cmdStatus(rest);
|
|
68
|
+
case "reset":
|
|
69
|
+
return await cmdReset(rest);
|
|
70
|
+
case "daemon":
|
|
71
|
+
return await cmdDaemon(rest);
|
|
72
|
+
case "serve":
|
|
73
|
+
return await cmdServe(rest);
|
|
74
|
+
case "mcp":
|
|
75
|
+
return await cmdMcp(rest);
|
|
76
|
+
default:
|
|
77
|
+
process.stderr.write(`unknown subcommand: ${sub}
|
|
78
|
+
${HELP_TEXT}`);
|
|
79
|
+
process.exit(2);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function takeFlag(rest, name) {
|
|
83
|
+
const idx = rest.indexOf(name);
|
|
84
|
+
if (idx < 0) return void 0;
|
|
85
|
+
return rest[idx + 1];
|
|
86
|
+
}
|
|
87
|
+
function projectFromPath(p) {
|
|
88
|
+
return resolvePath(p).split("/").filter(Boolean).pop() || "project";
|
|
89
|
+
}
|
|
90
|
+
async function cmdIndex(rest) {
|
|
91
|
+
const { patch, rest: positional } = parseCliFlags(rest);
|
|
92
|
+
const path = positional.find((a) => !a.startsWith("--")) ?? process.cwd();
|
|
93
|
+
const cfg = resolveCodeSearchConfig({ cli: patch, workspaceDir: path });
|
|
94
|
+
const project = takeFlag(positional, "--project") ?? projectFromPath(path);
|
|
95
|
+
const force = positional.includes("--force");
|
|
96
|
+
process.stderr.write(`[code-search] indexing ${path} as project "${project}"...
|
|
97
|
+
`);
|
|
98
|
+
const stats = await indexFromPath(project, resolvePath(path), {
|
|
99
|
+
force,
|
|
100
|
+
config: toKnowledgeConfig(cfg),
|
|
101
|
+
onProgress: (m) => process.stderr.write(`[code-search] ${m}
|
|
102
|
+
`)
|
|
103
|
+
});
|
|
104
|
+
process.stdout.write(JSON.stringify({
|
|
105
|
+
project,
|
|
106
|
+
chunks: stats.totalChunks,
|
|
107
|
+
repos: stats.repos.length,
|
|
108
|
+
crossRepoEdges: stats.crossRepoEdges,
|
|
109
|
+
durationMs: stats.indexDurationMs
|
|
110
|
+
}, null, 2) + "\n");
|
|
111
|
+
}
|
|
112
|
+
const VALID_MODES = /* @__PURE__ */ new Set(["hybrid", "vector", "bm25"]);
|
|
113
|
+
async function cmdQuery(rest) {
|
|
114
|
+
const { patch, rest: positional } = parseCliFlags(rest);
|
|
115
|
+
const queryText = positional.find((a) => !a.startsWith("--") && !a.startsWith("-"));
|
|
116
|
+
if (!queryText) {
|
|
117
|
+
process.stderr.write("code-search query: missing <text>\n");
|
|
118
|
+
process.exit(2);
|
|
119
|
+
}
|
|
120
|
+
const rawMode = takeFlag(positional, "--mode") ?? "hybrid";
|
|
121
|
+
if (!VALID_MODES.has(rawMode)) {
|
|
122
|
+
process.stderr.write(
|
|
123
|
+
`code-search query: invalid --mode "${rawMode}". Valid modes: ${[...VALID_MODES].join(", ")}.
|
|
124
|
+
`
|
|
125
|
+
);
|
|
126
|
+
process.exit(2);
|
|
127
|
+
}
|
|
128
|
+
const mode = rawMode;
|
|
129
|
+
const topK = parseInt(takeFlag(positional, "--top-k") ?? "10", 10);
|
|
130
|
+
const repos = positional.reduce((acc, a, i) => {
|
|
131
|
+
if (a === "--repo" && positional[i + 1]) acc.push(positional[i + 1]);
|
|
132
|
+
return acc;
|
|
133
|
+
}, []);
|
|
134
|
+
const format = takeFlag(positional, "--format") ?? "text";
|
|
135
|
+
const project = takeFlag(positional, "--project") ?? projectFromPath(process.cwd());
|
|
136
|
+
const cfg = resolveCodeSearchConfig({ cli: patch });
|
|
137
|
+
const socketPath = daemonSocketPath(cfg.storage.dataDir, project);
|
|
138
|
+
const backend = await pickBackend({
|
|
139
|
+
project,
|
|
140
|
+
workspaceDir: process.cwd(),
|
|
141
|
+
knowledge: toKnowledgeConfig(cfg),
|
|
142
|
+
preferDaemon: !cfg.daemon.disabled,
|
|
143
|
+
socketPath
|
|
144
|
+
});
|
|
145
|
+
const result = await backend.search(queryText, {
|
|
146
|
+
mode,
|
|
147
|
+
maxResults: topK,
|
|
148
|
+
repos: repos.length ? repos : void 0
|
|
149
|
+
});
|
|
150
|
+
await backend.close();
|
|
151
|
+
printSearchResult(result, format);
|
|
152
|
+
}
|
|
153
|
+
function printSearchResult(result, format) {
|
|
154
|
+
if (format === "json") {
|
|
155
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (format === "jsonl") {
|
|
159
|
+
for (const c of result.chunks) process.stdout.write(JSON.stringify(c) + "\n");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (result.chunks.length === 0) {
|
|
163
|
+
process.stdout.write(`No results for "${result.query}"
|
|
164
|
+
`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
let i = 0;
|
|
168
|
+
for (const c of result.chunks) {
|
|
169
|
+
i++;
|
|
170
|
+
process.stdout.write(
|
|
171
|
+
`### ${i}. ${c.repoName}/${c.filePath}:${c.startLine}-${c.endLine} (score: ${c.score.toFixed(3)}, source: ${c.source})
|
|
172
|
+
\`\`\`` + c.language + "\n" + c.content + "\n```\n\n"
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async function cmdStatus(rest) {
|
|
177
|
+
const { patch } = parseCliFlags(rest);
|
|
178
|
+
const cfg = resolveCodeSearchConfig({ cli: patch });
|
|
179
|
+
const project = takeFlag(rest, "--project") ?? projectFromPath(process.cwd());
|
|
180
|
+
const indexer = new KnowledgeIndexer();
|
|
181
|
+
const stats = await indexer.getStats(project);
|
|
182
|
+
const socketPath = daemonSocketPath(cfg.storage.dataDir, project);
|
|
183
|
+
process.stdout.write(JSON.stringify({
|
|
184
|
+
project,
|
|
185
|
+
dataDir: cfg.storage.dataDir,
|
|
186
|
+
daemonSocket: socketPath,
|
|
187
|
+
daemonAlive: existsSync(socketPath),
|
|
188
|
+
totalChunks: stats.totalChunks,
|
|
189
|
+
embeddingProvider: stats.embeddingProvider,
|
|
190
|
+
lastIndexedAt: stats.lastIndexed || null,
|
|
191
|
+
repos: stats.repos,
|
|
192
|
+
profilesAvailable: stats.repos.map((r) => ({
|
|
193
|
+
name: r.name,
|
|
194
|
+
hasProfile: !!loadProfile(project, r.name)
|
|
195
|
+
}))
|
|
196
|
+
}, null, 2) + "\n");
|
|
197
|
+
}
|
|
198
|
+
async function cmdReset(rest) {
|
|
199
|
+
const project = takeFlag(rest, "--project") ?? projectFromPath(process.cwd());
|
|
200
|
+
const basePath = getKnowledgeBasePath(project);
|
|
201
|
+
if (existsSync(basePath)) {
|
|
202
|
+
rmSync(basePath, { recursive: true, force: true });
|
|
203
|
+
process.stderr.write(`[code-search] removed ${basePath}
|
|
204
|
+
`);
|
|
205
|
+
} else {
|
|
206
|
+
process.stderr.write(`[code-search] nothing to remove at ${basePath}
|
|
207
|
+
`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async function cmdDaemon(rest) {
|
|
211
|
+
const here = new URL(import.meta.url);
|
|
212
|
+
const daemonEntry = join(new URL("..", here).pathname, "daemon", "index.js");
|
|
213
|
+
const child = spawn(process.execPath, [daemonEntry, ...rest], {
|
|
214
|
+
stdio: "inherit",
|
|
215
|
+
detached: false
|
|
216
|
+
});
|
|
217
|
+
child.on("exit", (code) => process.exit(code ?? 0));
|
|
218
|
+
}
|
|
219
|
+
async function cmdServe(rest) {
|
|
220
|
+
process.argv = [process.argv[0], process.argv[1], "--serve", ...rest];
|
|
221
|
+
await import("../index.js");
|
|
222
|
+
}
|
|
223
|
+
async function cmdMcp(rest) {
|
|
224
|
+
process.argv = [process.argv[0], process.argv[1], ...rest];
|
|
225
|
+
await import("../index.js");
|
|
226
|
+
}
|
|
227
|
+
main().catch((err) => {
|
|
228
|
+
process.stderr.write(`code-search fatal: ${err instanceof Error ? err.stack : err}
|
|
229
|
+
`);
|
|
230
|
+
process.exit(1);
|
|
231
|
+
});
|