@esankhan3/code-search-mcp 1.0.6 → 1.1.1

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.
Files changed (42) hide show
  1. package/README.md +114 -54
  2. package/dist/__tests__/_harness.js +194 -0
  3. package/dist/__tests__/config-cli.test.js +110 -0
  4. package/dist/__tests__/config-resolver.test.js +1 -1
  5. package/dist/__tests__/daemon-rpc-protocol.test.js +95 -0
  6. package/dist/__tests__/daemon-runtime.test.js +145 -0
  7. package/dist/__tests__/graph-tools-store.test.js +314 -0
  8. package/dist/__tests__/graph-tools.test.js +11 -4
  9. package/dist/__tests__/graph-traversal.test.js +262 -0
  10. package/dist/__tests__/http-auth.test.js +180 -0
  11. package/dist/__tests__/http-index.test.js +125 -0
  12. package/dist/__tests__/metrics-collect.test.js +48 -0
  13. package/dist/__tests__/observability.test.js +3 -4
  14. package/dist/__tests__/remote-proxy.test.js +105 -0
  15. package/dist/__tests__/server-cli-args.test.js +146 -0
  16. package/dist/__tests__/server-lifecycle.test.js +108 -0
  17. package/dist/__tests__/tool-inputs.test.js +178 -0
  18. package/dist/backends/in-process.js +5 -2
  19. package/dist/backends/index.js +2 -2
  20. package/dist/cli/index.js +143 -76
  21. package/dist/core/config.js +106 -35
  22. package/dist/core/env-config.js +3 -5
  23. package/dist/core/project-name.js +12 -0
  24. package/dist/core/version.js +5 -0
  25. package/dist/daemon/index.js +106 -65
  26. package/dist/daemon/rpc-server.js +38 -18
  27. package/dist/index.js +113 -89
  28. package/dist/middleware/auth.js +34 -66
  29. package/dist/middleware/rate-limit.js +37 -0
  30. package/dist/observability/logger.js +10 -2
  31. package/dist/observability/metrics.js +7 -6
  32. package/dist/projects/registry.js +13 -15
  33. package/dist/resources/resources.js +17 -0
  34. package/dist/server.js +216 -129
  35. package/dist/tools/graph.js +448 -165
  36. package/dist/tools/index-tools.js +6 -2
  37. package/dist/tools/profile.js +14 -8
  38. package/dist/tools/search.js +61 -51
  39. package/dist/tools/validate.js +48 -0
  40. package/dist/transports/http-transport.js +94 -55
  41. package/dist/transports/remote-proxy.js +58 -97
  42. package/package.json +2 -2
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 { join, resolve as resolvePath } from "node:path";
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
- toKnowledgeConfig
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> spawn the long-running daemon
30
- serve [--port N] [--auth mode] HTTP MCP server
31
- mcp stdio MCP server (Claude Desktop)
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 { patch } = parseCliFlags(argv.slice(1));
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 takeFlag(rest, name) {
83
- const idx = rest.indexOf(name);
84
- if (idx < 0) return void 0;
85
- return rest[idx + 1];
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 projectFromPath(p) {
88
- return resolvePath(p).split("/").filter(Boolean).pop() || "project";
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 { 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
- `)
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: stats.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 { 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";
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
- process.stderr.write(
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 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);
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 { 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
- const profilesAvailable = await Promise.all(
184
- stats.repos.map(async (r) => ({ name: r.name, hasProfile: !!await loadProfile(project, r.name) }))
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: existsSync(socketPath),
191
- totalChunks: stats.totalChunks,
192
- embeddingProvider: stats.embeddingProvider,
193
- lastIndexedAt: stats.lastIndexed || null,
194
- repos: stats.repos,
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 project = takeFlag(rest, "--project") ?? projectFromPath(process.cwd());
200
- const basePath = getKnowledgeBasePath(project);
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
- process.stderr.write(`[code-search] removed ${basePath}
204
- `);
262
+ log.info(`removed ${basePath}`);
205
263
  } else {
206
- process.stderr.write(`[code-search] nothing to remove at ${basePath}
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
- child.on("exit", (code) => process.exit(code ?? 0));
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
- process.argv = [process.argv[0], process.argv[1], ...rest];
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
- process.stderr.write(`code-search fatal: ${err instanceof Error ? err.stack : err}
229
- `);
296
+ log.error(`fatal: ${err instanceof Error ? err.stack : err}`);
230
297
  process.exit(1);
231
298
  });
@@ -1,8 +1,9 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { join, resolve } from "node:path";
3
3
  import { homedir, tmpdir } from "node:os";
4
+ import { Logger } from "../observability/logger.js";
4
5
  function defaultDataDir() {
5
- return process.env.CODE_SEARCH_DATA_DIR ?? join(homedir(), ".code-search", "index");
6
+ return process.env.CODE_SEARCH_DATA_DIR ?? join(process.env.ANVIL_HOME || join(homedir(), ".anvil"), "knowledge-base");
6
7
  }
7
8
  const DEFAULTS = {
8
9
  server: {
@@ -59,7 +60,6 @@ const DEFAULTS = {
59
60
  recordQueries: false
60
61
  },
61
62
  daemon: {
62
- autoSpawn: false,
63
63
  disabled: false
64
64
  }
65
65
  };
@@ -159,14 +159,16 @@ function parseHeaders(s) {
159
159
  }
160
160
  return Object.keys(out).length ? out : void 0;
161
161
  }
162
- function parseReindexMs(raw) {
163
- if (!raw || raw === "0" || raw === "none") return 0;
164
- const m = raw.match(/^(\d+)(m|h)$/);
165
- if (!m) {
166
- const n = parseInt(raw, 10);
167
- return Number.isFinite(n) ? n : 0;
162
+ function parseReindexInterval(raw) {
163
+ if (raw === void 0 || raw === null || raw === "" || raw === 0 || raw === "0" || raw === "none") return 0;
164
+ const s = String(raw).trim();
165
+ const unit = s.match(/^(\d+)(m|h)$/);
166
+ if (unit) return parseInt(unit[1], 10) * (unit[2] === "h" ? 36e5 : 6e4);
167
+ if (/^\d+$/.test(s)) {
168
+ const ms = parseInt(s, 10);
169
+ return ms >= 6e4 ? ms : null;
168
170
  }
169
- return m[2] === "h" ? parseInt(m[1], 10) * 60 * 6e4 : parseInt(m[1], 10) * 6e4;
171
+ return null;
170
172
  }
171
173
  function envStorageBackends(env) {
172
174
  const blobBackend = env("STORAGE_BLOB_BACKEND");
@@ -220,7 +222,7 @@ function envLayer() {
220
222
  server: {
221
223
  transport,
222
224
  port: port ? parseInt(port, 10) : void 0,
223
- host: env("HOST") ?? (authMode === "none" || !authMode ? void 0 : "0.0.0.0")
225
+ host: env("HOST")
224
226
  },
225
227
  auth: {
226
228
  mode: authMode,
@@ -253,7 +255,8 @@ function envLayer() {
253
255
  },
254
256
  indexing: {
255
257
  autoIndex: env("AUTO_INDEX") !== void 0 ? env("AUTO_INDEX") !== "false" && env("AUTO_INDEX") !== "0" : void 0,
256
- reindexIntervalMs: env("REINDEX_INTERVAL") !== void 0 ? parseReindexMs(env("REINDEX_INTERVAL")) : void 0,
258
+ // Raw string; normalised (and validated) by the resolver for every layer.
259
+ reindexIntervalMs: env("REINDEX_INTERVAL"),
257
260
  chunking: {
258
261
  maxTokens: chunkMaxTokens ? parseInt(chunkMaxTokens, 10) : void 0
259
262
  },
@@ -278,7 +281,6 @@ function envLayer() {
278
281
  recordQueries: env("TELEMETRY_RECORD_QUERIES") === "1" || env("TELEMETRY_RECORD_QUERIES") === "true" ? true : void 0
279
282
  },
280
283
  daemon: {
281
- autoSpawn: env("DAEMON_AUTO_SPAWN") === "1" || env("DAEMON_AUTO_SPAWN") === "true" ? true : void 0,
282
284
  disabled: env("DAEMON_DISABLED") === "1" || env("DAEMON_DISABLED") === "true" ? true : void 0
283
285
  }
284
286
  };
@@ -296,30 +298,64 @@ const RESERVED_FLAGS = /* @__PURE__ */ new Set([
296
298
  "--force",
297
299
  "--workspace",
298
300
  "--no-daemon",
299
- "--auto-spawn-daemon",
300
- "--config",
301
301
  "--format",
302
302
  "--top-k",
303
303
  "--mode",
304
- "--repo",
305
- "--language"
304
+ "--repo"
306
305
  ]);
306
+ const ROOT_ALIASES = {
307
+ "--port": ["server", "port"],
308
+ "--host": ["server", "host"],
309
+ "--transport": ["server", "transport"],
310
+ "--auth": ["auth", "mode"]
311
+ };
312
+ const OPTIONAL_LEAVES = [
313
+ "auth.jwtSecret",
314
+ "embedding.model",
315
+ "embedding.apiKey",
316
+ "embedding.baseUrl",
317
+ "embedding.ollamaHost",
318
+ "reranker.model",
319
+ "reranker.apiKey",
320
+ "reranker.baseUrl",
321
+ "reranker.timeoutMs",
322
+ "llm.apiKey",
323
+ "llm.baseUrl",
324
+ "github.token",
325
+ "telemetry.otelEndpoint"
326
+ ];
327
+ const OPEN_SUBTREES = ["storage.backends.", "telemetry.otelHeaders."];
328
+ function leafPaths(obj, prefix, out) {
329
+ for (const [k, v] of Object.entries(obj)) {
330
+ if (isPlainObject(v)) leafPaths(v, `${prefix}${k}.`, out);
331
+ else out.add(`${prefix}${k}`);
332
+ }
333
+ return out;
334
+ }
335
+ const KNOWN_LEAVES = leafPaths(DEFAULTS, "", new Set(OPTIONAL_LEAVES));
336
+ function isKnownPath(path) {
337
+ return KNOWN_LEAVES.has(path) || OPEN_SUBTREES.some((p) => path.startsWith(p));
338
+ }
307
339
  function parseCliFlags(argv) {
308
340
  const patch = {};
309
341
  const rest = [];
342
+ const unknown = [];
310
343
  for (let i = 0; i < argv.length; i++) {
311
344
  const arg = argv[i];
312
345
  if (!arg.startsWith("--") || RESERVED_FLAGS.has(arg)) {
313
346
  rest.push(arg);
314
347
  continue;
315
348
  }
316
- let key = arg.slice(2);
349
+ let path = ROOT_ALIASES[arg];
317
350
  let negate = false;
318
- if (key.startsWith("no-")) {
319
- negate = true;
320
- key = key.slice(3);
351
+ if (!path) {
352
+ let key = arg.slice(2);
353
+ if (key.startsWith("no-")) {
354
+ negate = true;
355
+ key = key.slice(3);
356
+ }
357
+ path = key.split(".").map((seg) => seg.replace(/-([a-z])/g, (_, c) => c.toUpperCase()));
321
358
  }
322
- const path = key.split(".").map((seg) => seg.replace(/-([a-z])/g, (_, c) => c.toUpperCase()));
323
359
  let val;
324
360
  if (negate) {
325
361
  val = false;
@@ -328,9 +364,13 @@ function parseCliFlags(argv) {
328
364
  } else {
329
365
  val = true;
330
366
  }
367
+ if (!isKnownPath(path.join("."))) {
368
+ unknown.push(arg);
369
+ continue;
370
+ }
331
371
  setDeep(patch, path, val);
332
372
  }
333
- return { patch, rest };
373
+ return { patch, rest, unknown };
334
374
  }
335
375
  function setDeep(obj, path, value) {
336
376
  let cur = obj;
@@ -347,10 +387,33 @@ function resolveCodeSearchConfig(opts = {}) {
347
387
  const yaml2 = opts.workspaceDir ? loadYamlFile(join(opts.workspaceDir, ".code-search.yaml")) ?? {} : {};
348
388
  const env = envLayer();
349
389
  const cli = opts.cli ?? {};
350
- const cfg = deepMerge(DEFAULTS, yaml1, yaml2, env, cli);
390
+ const layers = [yaml1, yaml2, env, cli];
391
+ const cfg = deepMerge(
392
+ structuredClone(DEFAULTS),
393
+ ...layers
394
+ );
395
+ if (cfg.auth.mode !== "none" && !layers.some((l) => l.server?.host !== void 0)) {
396
+ cfg.server.host = "0.0.0.0";
397
+ }
398
+ const rawInterval = cfg.indexing.reindexIntervalMs;
399
+ const intervalMs = parseReindexInterval(rawInterval);
400
+ if (intervalMs === null) {
401
+ warnOnce(cfg, `[code-search] invalid reindex interval ${JSON.stringify(String(rawInterval))} (use 30m, 1h, \u2026 \u2014 at least one minute \u2014 or 0); auto-reindex disabled`);
402
+ }
403
+ cfg.indexing.reindexIntervalMs = intervalMs ?? 0;
404
+ cfg.storage.dataDir = resolve(cfg.storage.dataDir);
351
405
  bridgeLegacyEnvVars(cfg);
352
406
  return cfg;
353
407
  }
408
+ const warned = /* @__PURE__ */ new Set();
409
+ function warnOnce(cfg, msg) {
410
+ if (warned.has(msg)) return;
411
+ warned.add(msg);
412
+ new Logger({ structured: cfg.telemetry.structuredLogs }).warn(msg);
413
+ }
414
+ function exportDataDir(cfg) {
415
+ process.env.CODE_SEARCH_DATA_DIR = cfg.storage.dataDir;
416
+ }
354
417
  function bridgeLegacyEnvVars(cfg) {
355
418
  process.env.ANVIL_LLM_MODE ??= process.env.CODE_SEARCH_LLM_MODE ?? cfg.llm.mode;
356
419
  process.env.ANVIL_LLM_PROVIDER ??= process.env.CODE_SEARCH_LLM_PROVIDER ?? cfg.llm.provider;
@@ -362,7 +425,7 @@ function bridgeLegacyEnvVars(cfg) {
362
425
  function toKnowledgeConfig(c) {
363
426
  return {
364
427
  embedding: { ...c.embedding },
365
- chunking: { ...c.indexing.chunking },
428
+ chunking: { ...c.indexing.chunking, ignorePatterns: c.indexing.ignorePatterns },
366
429
  retrieval: {
367
430
  maxChunks: c.retrieval.maxChunks,
368
431
  maxTokens: c.retrieval.maxTokens,
@@ -373,23 +436,29 @@ function toKnowledgeConfig(c) {
373
436
  ...c.storage.backends ? { storage: c.storage.backends } : {}
374
437
  };
375
438
  }
376
- const SECRET_KEYS = /* @__PURE__ */ new Set([
377
- "apiKey",
378
- "jwtSecret",
379
- "token",
380
- "githubToken"
381
- ]);
439
+ const SECRET_KEY = /pass(word)?|secret|token|authorization|api[-_]?keys?|credential/i;
440
+ const URI_USERINFO = /([a-z][a-z0-9+.-]*:\/\/)([^/\s@]+)@/gi;
441
+ function redactString(v) {
442
+ return `***redacted (${v.length} chars)`;
443
+ }
382
444
  function redactSecrets(value, parentKey = "") {
445
+ if (typeof value === "string") {
446
+ return value.replace(URI_USERINFO, (_m, scheme, info) => {
447
+ const colon = info.indexOf(":");
448
+ return `${scheme}${colon >= 0 ? info.slice(0, colon + 1) : ""}***@`;
449
+ });
450
+ }
383
451
  if (Array.isArray(value)) {
384
452
  return value.map((v) => redactSecrets(v, parentKey));
385
453
  }
386
454
  if (isPlainObject(value)) {
387
455
  const out = {};
388
456
  for (const [k, v] of Object.entries(value)) {
389
- if (SECRET_KEYS.has(k) && typeof v === "string" && v.length > 0) {
390
- out[k] = `***redacted (${v.length} chars)`;
391
- } else if (k === "apiKeys" && Array.isArray(v)) {
392
- out[k] = v.map((s) => `***redacted (${s.length} chars)`);
457
+ const secret = SECRET_KEY.test(k) || parentKey === "otelHeaders";
458
+ if (secret && typeof v === "string" && v.length > 0) {
459
+ out[k] = redactString(v);
460
+ } else if (secret && Array.isArray(v)) {
461
+ out[k] = v.map((s) => typeof s === "string" ? redactString(s) : redactSecrets(s, k));
393
462
  } else {
394
463
  out[k] = redactSecrets(v, k);
395
464
  }
@@ -403,7 +472,9 @@ function printConfig(c) {
403
472
  }
404
473
  export {
405
474
  DEFAULTS,
475
+ exportDataDir,
406
476
  parseCliFlags,
477
+ parseReindexInterval,
407
478
  printConfig,
408
479
  redactSecrets,
409
480
  resolveCodeSearchConfig,
@@ -1,13 +1,12 @@
1
1
  import { execSync as _execSync } from "node:child_process";
2
2
  import {
3
3
  resolveCodeSearchConfig,
4
- parseCliFlags,
5
4
  toKnowledgeConfig
6
5
  } from "./config.js";
7
6
  import { normalizeRerankerConfig } from "@esankhan3/anvil-knowledge-core";
8
7
  let _config = null;
9
8
  function resolveLlmMode(explicit, apiKey, claudeBin) {
10
- if (explicit && explicit !== "none") {
9
+ if (explicit === "cli" || explicit === "api" || explicit === "none") {
11
10
  if (explicit === "api" && !apiKey) {
12
11
  console.error(
13
12
  `[code-search-mcp] WARNING: LLM_MODE=api but no API key found. Set CODE_SEARCH_LLM_API_KEY or ANTHROPIC_API_KEY. Falling back to LLM_MODE=none.`
@@ -32,10 +31,9 @@ function resolvedKnowledgeConfig() {
32
31
  }
33
32
  function loadServerConfig(opts) {
34
33
  if (_config) return _config;
35
- const cli = opts?.argv ? parseCliFlags(opts.argv).patch : void 0;
36
- const c = resolveCodeSearchConfig({ cli, workspaceDir: opts?.workspaceDir });
34
+ const c = resolveCodeSearchConfig({ cli: opts?.cli, workspaceDir: opts?.workspaceDir });
37
35
  const rerankerStruct = normalizeRerankerConfig(c.reranker);
38
- const llmMode = resolveLlmMode(c.llm.mode, c.llm.apiKey, c.llm.claudeBin);
36
+ const llmMode = resolveLlmMode(process.env.ANVIL_LLM_MODE ?? c.llm.mode, c.llm.apiKey, c.llm.claudeBin);
39
37
  _config = {
40
38
  port: c.server.port,
41
39
  host: c.server.host,
@@ -0,0 +1,12 @@
1
+ const RESERVED = /* @__PURE__ */ new Set(["daemon", "projects"]);
2
+ function validateProjectName(name) {
3
+ if (!name || name === "." || name === ".." || /[/\\\0]/.test(name) || RESERVED.has(name)) {
4
+ throw new Error(
5
+ `Invalid project name ${JSON.stringify(name)}: a project name must be a single path segment (no "/" or "\\", not "." or "..") and not one of the reserved names: ${[...RESERVED].join(", ")}.`
6
+ );
7
+ }
8
+ return name;
9
+ }
10
+ export {
11
+ validateProjectName
12
+ };
@@ -0,0 +1,5 @@
1
+ import { readFileSync } from "node:fs";
2
+ const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8")).version;
3
+ export {
4
+ PACKAGE_VERSION
5
+ };