md-semantic-search 1.0.2 → 1.0.3

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/bin/cli.mjs CHANGED
@@ -18,9 +18,14 @@ import crypto from 'node:crypto';
18
18
  import { buildIndex } from '../dist/indexer.js';
19
19
  import { search } from '../dist/search.js';
20
20
  import { searchFederated } from '../dist/federation.js';
21
- import { createServe, DEFAULT_PORT, DEFAULT_HOST } from '../dist/serve.js';
21
+ import { createServe, DEFAULT_PORT, DEFAULT_HOST, isLoopbackHost, validateBindSecurity, loadApiKeyFile } from '../dist/serve.js';
22
22
  import { MODELS, DEFAULT_MODEL, resolveModel } from '../dist/models.js';
23
- import { decodeVec, walkMarkdown, SCHEMA_VERSION, assertSafePath } from '../dist/core.js';
23
+ import { decodeVec, walkMarkdown, SCHEMA_VERSION, assertSafePath, setDownloadProgressListener } from '../dist/core.js';
24
+ import { DownloadProgressAggregator } from '../dist/download-progress.js';
25
+ import { generateCompletion, COMPLETION_SHELLS } from '../dist/completions.js';
26
+ import { openHit, resolveOpenCommand } from '../dist/open.js';
27
+ import { planRepairs, applyRepairs } from '../dist/repair.js';
28
+ import { resolveExternalEmbedder } from '../dist/providers.js';
24
29
  import { inspectIndexSchema, validateCurrentChunk, validateIndexEnvelope, validateNumericVector } from '../dist/index-format.js';
25
30
 
26
31
  const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -49,6 +54,7 @@ function parseArgs(argv) {
49
54
  else if (a === '--semantic') opts.semantic = true;
50
55
  else if (a === '--offline') opts.offline = true;
51
56
  else if (a === '--watch') opts.watch = true;
57
+ else if (a === '--watch-debug') opts.watchDebug = true;
52
58
  else if (a === '--rerank') opts.rerank = true;
53
59
  else if (a === '--explain') opts.explain = true;
54
60
  else if (a === '--interactive' || a === '-i') opts.interactive = true;
@@ -72,9 +78,21 @@ function parseArgs(argv) {
72
78
  else if (a === '--port') opts.port = nextInt(argv, ++i, a);
73
79
  else if (a === '--host') opts.host = nextValue(argv, ++i, a);
74
80
  else if (a === '--api-key') opts.apiKey = nextValue(argv, ++i, a);
81
+ else if (a === '--api-key-file') opts.apiKeyFile = nextValue(argv, ++i, a);
82
+ else if (a === '--allow-unsecured') opts.allowUnsecured = true;
83
+ else if (a === '--allowed-host') {
84
+ const v = nextValue(argv, ++i, a);
85
+ opts.allowedHost = opts.allowedHost ? [...opts.allowedHost, v] : [v];
86
+ }
87
+ else if (a === '--cors-origin') {
88
+ const v = nextValue(argv, ++i, a);
89
+ opts.corsOrigin = opts.corsOrigin ? [...opts.corsOrigin, v] : [v];
90
+ }
75
91
  else if (a === '--health-public') opts.healthPublic = true;
76
92
  else if (a === '--watch-delay') opts.watchDelay = nextInt(argv, ++i, a);
77
93
  else if (a === '--watch-interval') opts.watchInterval = nextInt(argv, ++i, a);
94
+ else if (a === '--rate-limit') opts.rateLimit = nextNonNegInt(argv, ++i, a);
95
+ else if (a === '--max-concurrency') opts.maxConcurrency = nextNonNegInt(argv, ++i, a);
78
96
  else if (a === '--since') opts.since = nextValue(argv, ++i, a);
79
97
  else if (a === '--path') opts.path.push(nextValue(argv, ++i, a));
80
98
  else if (a === '--ignore') opts.ignore.push(nextValue(argv, ++i, a));
@@ -97,6 +115,37 @@ function parseArgs(argv) {
97
115
  else if (a === '--llm-endpoint') opts.llmEndpoint = nextValue(argv, ++i, a);
98
116
  else if (a === '--llm-model') opts.llmModel = nextValue(argv, ++i, a);
99
117
  else if (a === '--system-prompt') opts.systemPrompt = nextValue(argv, ++i, a);
118
+ else if (a === '--open') {
119
+ // `--open` opens the top hit; `--open N` opens the Nth hit (issue #110).
120
+ const next = argv[i + 1];
121
+ if (next !== undefined && /^\d+$/.test(next)) {
122
+ opts.openRank = Number.parseInt(next, 10);
123
+ i++;
124
+ } else {
125
+ opts.open = true;
126
+ }
127
+ }
128
+ else if (a === '--fix') opts.fix = true;
129
+ else if (a === '--dry-run') opts.dryRun = true;
130
+ else if (a === '--no-ui') opts.noUi = true;
131
+ else if (a === '--no-query-cache') opts.noQueryCache = true;
132
+ else if (a === '--mcp') opts.mcp = true;
133
+ else if (a === '--recency') {
134
+ opts.recency = nextFloat(argv, ++i, a);
135
+ if (!(opts.recency > 0)) die(`--recency must be a positive half-life in days, got "${opts.recency}"`);
136
+ }
137
+ else if (a === '--embedder') opts.embedder = nextValue(argv, ++i, a);
138
+ else if (a === '--embedder-model') opts.embedderModel = nextValue(argv, ++i, a);
139
+ else if (a === '--embedder-base-url') opts.embedderBaseUrl = nextValue(argv, ++i, a);
140
+ else if (a === '--embedder-key-file') opts.embedderKeyFile = nextValue(argv, ++i, a);
141
+ else if (a === '--expand') {
142
+ opts.expand = nextValue(argv, ++i, a);
143
+ if (!['prf', 'hyde'].includes(opts.expand)) die(`unknown --expand "${opts.expand}" - use prf|hyde`);
144
+ }
145
+ else if (a === '--expand-passages') {
146
+ opts.expandPassages = nextInt(argv, ++i, a);
147
+ if (opts.expandPassages > 20) die(`--expand-passages must be <= 20`);
148
+ }
100
149
  else if (a.startsWith('-')) die(`unknown option: ${a}. Try \`mdss --help\`.`);
101
150
  else opts._.push(a);
102
151
  }
@@ -126,15 +175,42 @@ function nextFloat(argv, i, flag) {
126
175
  return f;
127
176
  }
128
177
 
178
+ /** Like nextInt but allows 0 (used by serve limit flags where 0 = disabled). */
179
+ function nextNonNegInt(argv, i, flag) {
180
+ const v = nextValue(argv, i, flag);
181
+ const n = Number.parseInt(v, 10);
182
+ if (!Number.isInteger(n) || n < 0) die(`${flag} must be a non-negative integer (0 disables), got "${v}"`);
183
+ return n;
184
+ }
185
+
186
+ /** Non-negative integer from an env var; undefined when unset/invalid. */
187
+ function envNonNegInt(name) {
188
+ const raw = process.env[name];
189
+ if (!raw) return undefined;
190
+ const n = Number.parseInt(raw, 10);
191
+ return Number.isInteger(n) && n >= 0 ? n : undefined;
192
+ }
193
+
129
194
  const KNOWN_CONFIG_KEYS = new Set([
130
195
  'db', 'model', 'indexDir', 'index-dir', 'cacheDir', 'cache-dir',
131
196
  'ignore', 'path', 'k', 'maxPerFile', 'max-per-file', 'maxPerDoc', 'max-per-doc',
132
197
  'targetTokens', 'target-tokens', 'port', 'host', 'apiKey', 'api-key',
198
+ 'apiKeyFile', 'api-key-file', 'allowUnsecured', 'allow-unsecured',
199
+ 'allowedHost', 'allowed-host', 'allowedHosts', 'allowed-hosts',
200
+ 'corsOrigin', 'cors-origin',
201
+ 'rateLimit', 'rate-limit', 'maxConcurrency', 'max-concurrency',
133
202
  'healthPublic', 'health-public', 'watch', 'watchInterval', 'watch-interval',
134
203
  'watchDelay', 'watch-delay', 'offline', 'rerank', 'semantic', 'tag', 'project', 'type', 'status', 'canonical',
204
+ 'watchDebug', 'watch-debug',
135
205
  'format', 'noVectors', 'no-vectors', 'output', 'workers', 'ann', 'nprobe', 'graphBoost', 'graph-boost', 'filter', 'quantize',
136
206
  'vault', 'vaults', 'rag', 'autoTag', 'auto-tag', 'autoSummarize', 'auto-summarize',
137
- 'llmEndpoint', 'llm-endpoint', 'llmModel', 'llm-model', 'systemPrompt', 'system-prompt'
207
+ 'llmEndpoint', 'llm-endpoint', 'llmModel', 'llm-model', 'systemPrompt', 'system-prompt',
208
+ 'completions', 'open', 'dry-run', 'fix', 'yes', 'no-ui', 'noUi',
209
+ 'noQueryCache', 'no-query-cache',
210
+ 'mcp',
211
+ 'embedder', 'embedder-model', 'embedder-base-url', 'embedder-key-file',
212
+ 'embedderModel', 'embedderBaseUrl', 'embedderKeyFile',
213
+ 'expand', 'expandPassages', 'expand-passages', 'llm-endpoint', 'llmEndpoint'
138
214
  ]);
139
215
 
140
216
  function findConfigFile(explicitPath) {
@@ -255,6 +331,7 @@ Usage:
255
331
  mdss serve --db <dir> [--port <n>] [--host <ip>] [--watch] Daemon: warm model + index
256
332
  mdss mcp --db <dir> [--list-tools] Start MCP server over stdio for LLM agents / IDEs
257
333
  mdss models List available models
334
+ mdss completions <bash|zsh|fish|powershell> Print a self-contained shell completion script
258
335
 
259
336
  Options:
260
337
  --config <file> Path to config file (default: .mdssrc.json / mdss.config.json).
@@ -280,11 +357,64 @@ Options:
280
357
  --port <n> HTTP port for serve (default: ${DEFAULT_PORT}).
281
358
  --host <ip> Bind address for serve (default: ${DEFAULT_HOST} — loopback
282
359
  only; use 0.0.0.0 to expose on the LAN, env MDSS_HOST).
360
+ --api-key <key> Require Bearer auth on every request (env MDSS_API_KEY).
361
+ --api-key-file <p> Read the API key from a file instead of a literal value —
362
+ avoids shell-history/CI-log leaks (env MDSS_API_KEY_FILE;
363
+ trailing newline trimmed, POSIX chmod-checked).
364
+ --allow-unsecured Explicit opt-in to serve non-loopback WITHOUT auth — a loud
365
+ banner is printed; without it (and without an api key) a
366
+ non-loopback bind refuses to start (issue #121).
367
+ --allowed-host <h> Extra Host header value to accept (repeatable) — requests
368
+ naming any other Host get 403 (DNS-rebinding guard, issue #120).
369
+ --cors-origin <o> Allow cross-origin browser access from this exact origin
370
+ (repeatable); CORS is off unless set, and only exact
371
+ matches are reflected with Vary: Origin (issue #120).
372
+ --rate-limit <n> serve: max /search requests per minute per client, burst
373
+ 10 → 429 + Retry-After above it; 0 disables (default 60,
374
+ env MDSS_RATE_LIMIT; issue #119).
375
+ --max-concurrency <n> serve: max concurrent in-flight searches, queue cap
376
+ 2× → 503 when full; 0 disables (default = CPU count,
377
+ env MDSS_MAX_CONCURRENCY; issue #119).
283
378
  --watch serve: re-index incrementally on file changes (mtime poll).
379
+ --no-ui serve: disable the built-in web UI at the root path
380
+ (issue #111) - the JSON API help is served there instead.
381
+ --mcp serve: also mount Streamable HTTP MCP at /mcp
382
+ (issue #123) - same auth/host/rate-limit gates as
383
+ /search; stdio via the mcp command remains default.
384
+ --watch-debug serve --watch: verbose trace of polls, FS-error
385
+ classifications and retries (issue #116).
284
386
  --watch-interval <ms> serve --watch: poll every N ms (default 3000).
285
387
  --watch-delay <ms> serve --watch: quiet-period debounce before a burst of
286
388
  saves triggers ONE re-index (default 1000; issue #42).
287
389
  --offline Never download the model; require a cached one (env MDSS_OFFLINE=1).
390
+ --no-query-cache search: skip the persistent query-embedding cache
391
+ (<cacheDir>/query-cache.json, issue #114); in-memory
392
+ caching stays on.
393
+ --recency <days> search: time-decay half-life — fresher passages get
394
+ 0.5^(age/halfLife) boost post-fusion (frontmatter
395
+ created/updated, then file mtime; issue #127).
396
+ --embedder <name> Use an EXTERNAL embedding provider instead of local
397
+ transformers.js: "ollama" or "openai" (issue #124).
398
+ Local stays the zero-config default and the only
399
+ fully-offline path.
400
+ --embedder-model <m> Provider-side model (e.g. nomic-embed-text).
401
+ --embedder-base-url <u> Endpoint (default: OLLAMA_HOST /
402
+ http://127.0.0.1:11434 for ollama,
403
+ https://api.openai.com/v1 for openai).
404
+ --embedder-key-file <p> Bearer key file for openai-compatible endpoints
405
+ (or OPENAI_API_KEY env).
406
+ --expand <mode> search: query expansion - "prf" (offline pseudo-
407
+ relevance feedback: top passages feed salient terms,
408
+ ONE embed total; issue #125) or "hyde" (LLM-generated
409
+ passage via --llm-endpoint; degrades silently without it).
410
+ --expand-passages <n> prf feedback pool size (default 3, max 20).
411
+ --open [N] search: open the top (or Nth) hit in your editor at its
412
+ startLine — MDSS_EDITOR/VISUAL/EDITOR, then VS Code
413
+ --goto, then the GUI opener (issue #110). With --json on
414
+ a non-TTY prints what WOULD be opened instead of launching.
415
+ --fix check: apply safe auto-repairs (stale lock, broken
416
+ vectors.bin sidecar, stale ivf.json); never touches notes.
417
+ --dry-run check --fix: print planned repairs without mutating.
288
418
  --version Print the version and exit.
289
419
  -h, --help Show this help.
290
420
 
@@ -306,6 +436,35 @@ function formatDuration(sec) {
306
436
  return `${h}:${String(m).padStart(2, '0')}:${String(remainingS).padStart(2, '0')}`;
307
437
  }
308
438
 
439
+ /**
440
+ * External embedder wiring (issue #124): when --embedder is set, build the
441
+ * synthetic adapter + embedFn and pass BOTH into the pipeline. Provider
442
+ * mismatch across rebuilds surfaces through the existing adapter-fingerprint
443
+ * invalidation, with the index `model` field naming the provider explicitly.
444
+ */
445
+ async function resolveEmbedderOverrides(opts) {
446
+ if (!opts.embedder) return {};
447
+ if (!['ollama', 'openai'].includes(opts.embedder)) {
448
+ die(`unknown --embedder "${opts.embedder}" - use "ollama" or "openai"`);
449
+ }
450
+ if (!opts.embedderModel) {
451
+ die(`--embedder ${opts.embedder} requires --embedder-model <name>`);
452
+ }
453
+ const emb = resolveExternalEmbedder({
454
+ embedder: opts.embedder,
455
+ model: opts.embedderModel,
456
+ baseUrl: opts.embedderBaseUrl,
457
+ keyFile: opts.embedderKeyFile,
458
+ });
459
+ const descriptor = await emb.descriptor();
460
+ logErr(`embedder: ${emb.id}/${emb.model} (dim ${descriptor.dim} via ${opts.embedderBaseUrl || 'default endpoint'})`);
461
+ return { modelName: descriptor, embedFn: emb.embedFn };
462
+ }
463
+
464
+ function logErr(s) {
465
+ process.stderr.write(s + '\n');
466
+ }
467
+
309
468
  async function cmdIndex(opts) {
310
469
  const db = resolveDb(opts);
311
470
  const indexDir = resolveIndexDir(opts, db);
@@ -333,11 +492,14 @@ async function cmdIndex(opts) {
333
492
  }
334
493
  };
335
494
 
495
+ const embedderOverrides = await resolveEmbedderOverrides(opts);
496
+
336
497
  let r;
337
498
  try {
338
499
  r = await buildIndex({
339
500
  db, indexDir, cacheDir,
340
- modelName: opts.model || DEFAULT_MODEL,
501
+ modelName: embedderOverrides.modelName || opts.model || DEFAULT_MODEL,
502
+ ...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
341
503
  ignore: opts.ignore,
342
504
  offline: resolveOffline(opts),
343
505
  workers: opts.workers,
@@ -777,7 +939,7 @@ const modelId = index.model ?? null;
777
939
  * embedding model or touching the network. Exit code 0 when healthy, 1 with a
778
940
  * summary when not; `--json` for scripting.
779
941
  */
780
- function cmdCheck(opts) {
942
+ async function cmdCheck(opts) {
781
943
  const db = resolveDb(opts);
782
944
  const indexDir = resolveIndexDir(opts, db);
783
945
  const cacheDir = resolveCache(opts);
@@ -787,6 +949,46 @@ function cmdCheck(opts) {
787
949
  config: opts._config,
788
950
  });
789
951
 
952
+ // --fix (issue #117): plan safe repairs, optionally apply them. Never
953
+ // touches source Markdown; --dry-run prints the plan without mutating.
954
+ if (opts.fix) {
955
+ const plan = planRepairs(indexDir, { db: fs.existsSync(db) ? db : null });
956
+ const result = await applyRepairs(indexDir, plan, {
957
+ dryRun: !!opts.dryRun,
958
+ db: fs.existsSync(db) ? db : null,
959
+ cacheDir,
960
+ modelName: opts.model || undefined,
961
+ log: (s) => process.stderr.write(s + '\n'),
962
+ });
963
+ report.fix = {
964
+ dryRun: !!opts.dryRun,
965
+ planned: plan.actions,
966
+ performed: result.performed,
967
+ skipped: plan.skipped,
968
+ failed: result.failed,
969
+ };
970
+
971
+ if (opts.json) {
972
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
973
+ process.exitCode = result.failed.length > 0 ? 1 : 0;
974
+ return;
975
+ }
976
+
977
+ let out = '';
978
+ for (const a of (opts.dryRun ? plan.actions : result.performed)) {
979
+ out += ` ${opts.dryRun ? 'would' : 'fixed'} ${a.action}: ${a.detail}\n`;
980
+ }
981
+ for (const s of plan.skipped) out += ` skip ${s.action}: ${s.detail}\n`;
982
+ for (const f of result.failed) out += ` FAIL ${f.action}: ${f.error}\n`;
983
+ if (plan.actions.length === 0 && plan.skipped.length === 0) out += ' ok nothing to repair\n';
984
+ process.stdout.write(out);
985
+ process.exitCode = result.failed.length > 0 ? 1 : 0;
986
+ if (!opts.dryRun && result.performed.length > 0) {
987
+ process.stdout.write('check: repairs applied — run `mdss check` to verify (issue #117)\n');
988
+ }
989
+ return;
990
+ }
991
+
790
992
  if (opts.json) {
791
993
  process.stdout.write(JSON.stringify(report, null, 2) + '\n');
792
994
  process.exitCode = report.healthy ? 0 : 1;
@@ -933,17 +1135,23 @@ async function cmdSearch(opts) {
933
1135
  const query = opts._.join(' ').trim();
934
1136
  if (!query) die('Missing query text. e.g. mdss search --db ./docs "your question"');
935
1137
 
1138
+ const embedderOverrides = await resolveEmbedderOverrides(opts);
936
1139
  let results;
937
1140
  const isFederated = Array.isArray(opts.vaults) && opts.vaults.length > 0;
938
1141
 
939
1142
  if (isFederated) {
940
1143
  results = await searchFederated({
1144
+ ...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
941
1145
  vaults: opts.vaults,
942
1146
  cacheDir,
943
1147
  query,
944
1148
  k: opts.k || 6,
945
1149
  semanticOnly: !!opts.semantic,
946
1150
  offline: resolveOffline(opts),
1151
+ queryDiskCache: !opts.noQueryCache,
1152
+ recency: opts.recency,
1153
+ expand: opts.expand,
1154
+ expandPassages: opts.expandPassages,
947
1155
  path: opts.path.length > 0 ? opts.path : undefined,
948
1156
  since: opts.since,
949
1157
  rerank: !!opts.rerank,
@@ -964,10 +1172,16 @@ async function cmdSearch(opts) {
964
1172
  const indexDir = resolveIndexDir(opts, db);
965
1173
 
966
1174
  results = await search({
1175
+ ...(embedderOverrides.modelName ? { model: embedderOverrides.modelName } : {}),
1176
+ ...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
967
1177
  indexDir, cacheDir, query,
968
1178
  k: opts.k || 6,
969
1179
  semanticOnly: !!opts.semantic,
970
1180
  offline: resolveOffline(opts),
1181
+ queryDiskCache: !opts.noQueryCache,
1182
+ recency: opts.recency,
1183
+ expand: opts.expand,
1184
+ expandPassages: opts.expandPassages,
971
1185
  path: opts.path.length > 0 ? opts.path : undefined,
972
1186
  since: opts.since,
973
1187
  rerank: !!opts.rerank,
@@ -988,6 +1202,12 @@ async function cmdSearch(opts) {
988
1202
  if (opts.json) { process.stdout.write(JSON.stringify(results, null, 2) + '\n'); return; }
989
1203
  if (results.length === 0) { process.stdout.write('No matches.\n'); return; }
990
1204
 
1205
+ // --open (issue #110): jump into the editor at the hit's exact line.
1206
+ if (opts.open || opts.openRank) {
1207
+ await openSearchHit(opts, results);
1208
+ return;
1209
+ }
1210
+
991
1211
  process.stdout.write(`\nTop ${results.length} for: "${query}"\n\n`);
992
1212
 
993
1213
  // Group by file (issue #13): each file becomes a header with its hits below.
@@ -1016,9 +1236,50 @@ async function cmdSearch(opts) {
1016
1236
  }
1017
1237
  }
1018
1238
 
1239
+ /**
1240
+ * --open (issue #110): resolve the Nth hit to an absolute path and jump to it
1241
+ * in the user's editor at startLine. With --json on a non-TTY nothing is
1242
+ * launched — the intent is printed as a JSON line on stderr instead, keeping
1243
+ * stdout machine-readable.
1244
+ */
1245
+ async function openSearchHit(opts, results) {
1246
+ const rank = opts.openRank || 1;
1247
+ if (!Number.isInteger(rank) || rank < 1) die(`--open N must be a positive hit number, got ${rank}`);
1248
+ const hit = results[rank - 1];
1249
+ if (!hit) {
1250
+ die(`--open ${rank}: only ${results.length} hit(s) available`);
1251
+ }
1252
+
1253
+ // Resolve relative hit paths against the right base (db or the matched vault).
1254
+ let base = null;
1255
+ if (hit.vault && Array.isArray(opts.vaults)) {
1256
+ base = opts.vaults.find((v) => v === hit.vault || path.basename(v) === hit.vault) || null;
1257
+ }
1258
+ if (!base) base = resolveDb(opts);
1259
+ const absFile = path.isAbsolute(hit.file) ? hit.file : path.join(base, hit.file);
1260
+ const target = { file: absFile, line: hit.startLine };
1261
+ const envEditor = process.env.MDSS_EDITOR || process.env.VISUAL || process.env.EDITOR || undefined;
1262
+
1263
+ // --json on a non-TTY: print what WOULD be launched, launch nothing (issue #110).
1264
+ if (opts.json && !process.stdout.isTTY) {
1265
+ const would = resolveOpenCommand(target, { editor: envEditor, platform: process.platform });
1266
+ if (!would) die(`could not resolve an editor for ${absFile} — set $MDSS_EDITOR/$VISUAL/$EDITOR`);
1267
+ process.stderr.write(JSON.stringify({
1268
+ type: 'open-intent',
1269
+ file: absFile,
1270
+ line: hit.startLine ?? null,
1271
+ via: would.via,
1272
+ }) + '\n');
1273
+ return;
1274
+ }
1275
+
1276
+ const resolved = openHit(target, { editor: envEditor });
1277
+ if (!resolved) die(`could not resolve an editor for ${absFile} — set $MDSS_EDITOR/$VISUAL/$EDITOR`);
1278
+ process.stderr.write(`→ opened ${absFile}${hit.startLine ? `:${hit.startLine}` : ''} via ${resolved.via}\n`);
1279
+ }
1280
+
1019
1281
  /** Escape a string for use inside a RegExp. */
1020
- function escRe(s) {
1021
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1282
+ function escRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1022
1283
  }
1023
1284
 
1024
1285
  function cmdModels() {
@@ -1052,16 +1313,47 @@ async function cmdServe(opts) {
1052
1313
  const host = opts.host || process.env.MDSS_HOST || DEFAULT_HOST;
1053
1314
  const log = s => process.stderr.write(s + '\n');
1054
1315
 
1316
+ const embedderOverrides = await resolveEmbedderOverrides(opts);
1317
+
1318
+ // API key precedence (issue #121): literal flag/env first, then key file —
1319
+ // the file form avoids shell-history and CI-log leaks of the literal value.
1320
+ let apiKey = opts.apiKey || process.env.MDSS_API_KEY || undefined;
1321
+ const keyFile = opts.apiKeyFile || process.env.MDSS_API_KEY_FILE;
1322
+ if (!apiKey && keyFile) {
1323
+ apiKey = loadApiKeyFile(keyFile, log);
1324
+ log(`api-key-file: key loaded from ${keyFile}`);
1325
+ }
1326
+
1327
+ // Refuse to serve the whole knowledge base to the network unauthenticated (issue #121).
1328
+ validateBindSecurity({ host, hasApiKey: !!apiKey, allowUnsecured: !!opts.allowUnsecured });
1329
+
1055
1330
  const { server, state, close } = await createServe({
1056
1331
  db, indexDir, cacheDir,
1057
- modelName: opts.model || DEFAULT_MODEL,
1332
+ modelName: embedderOverrides.modelName || opts.model || DEFAULT_MODEL,
1058
1333
  ignore: opts.ignore,
1059
1334
  offline: resolveOffline(opts),
1335
+ ...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
1060
1336
  watch: !!opts.watch,
1061
1337
  watchInterval: opts.watchInterval,
1062
1338
  watchDelay: opts.watchDelay,
1063
- apiKey: opts.apiKey || process.env.MDSS_API_KEY,
1339
+ watchDebug: !!opts.watchDebug,
1340
+ apiKey,
1064
1341
  healthPublic: !!opts.healthPublic || process.env.MDSS_HEALTH_PUBLIC === 'true',
1342
+ // Web UI on by default at `/` (issue #111); --no-ui restores pure JSON.
1343
+ ui: !opts.noUi,
1344
+ mcp: !!opts.mcp,
1345
+ // DNS-rebinding allowlist (issue #120): loopback names are always allowed;
1346
+ // the configured bind host is added so `--host mybox.local` still answers.
1347
+ allowedHosts: [
1348
+ ...(opts.allowedHost || []),
1349
+ ...(opts.host ? [opts.host] : []),
1350
+ ...(process.env.MDSS_HOST ? [process.env.MDSS_HOST] : []),
1351
+ ],
1352
+ // CORS stays OFF unless explicitly opted in (issue #120).
1353
+ corsOrigins: opts.corsOrigin,
1354
+ // Fairness gates (issue #119); 0 explicitly disables a gate.
1355
+ rateLimit: opts.rateLimit ?? envNonNegInt('MDSS_RATE_LIMIT'),
1356
+ maxConcurrency: opts.maxConcurrency ?? envNonNegInt('MDSS_MAX_CONCURRENCY'),
1065
1357
  log,
1066
1358
  });
1067
1359
 
@@ -1073,6 +1365,14 @@ async function cmdServe(opts) {
1073
1365
  (state.watching ? ' · watching for changes' : ''));
1074
1366
  log(` POST /search {"query": "...", "k": 6}`);
1075
1367
  log(` GET /health`);
1368
+ if (!isLoopbackHost(host) && !state.apiKey) {
1369
+ log('');
1370
+ log(' ⚠⚠⚠ UNSECURED NETWORK BIND — EXPLICIT OPT-IN (--allow-unsecured) ⚠⚠⚠');
1371
+ log(' ⚠⚠⚠ The ENTIRE knowledge base is readable by anyone who can ⚠⚠⚠');
1372
+ log(' ⚠⚠⚠ reach this port. Put a reverse proxy with TLS + auth in ⚠⚠⚠');
1373
+ log(' ⚠⚠⚠ front, or set an API key (--api-key / --api-key-file). ⚠⚠⚠');
1374
+ log('');
1375
+ }
1076
1376
  log(` Ctrl+C to stop.`);
1077
1377
  });
1078
1378
 
@@ -1098,6 +1398,8 @@ async function cmdMcp(opts) {
1098
1398
  const { startMcpServer } = await import('../dist/mcp.js');
1099
1399
  await startMcpServer({
1100
1400
  db, indexDir, cacheDir,
1401
+ // NOTE: external embedders (--embedder) are not yet wired into the MCP
1402
+ // stdio server; it keeps the local transformers.js path (issue #124).
1101
1403
  modelName: opts.model || DEFAULT_MODEL,
1102
1404
  ignore: opts.ignore,
1103
1405
  offline: resolveOffline(opts),
@@ -1105,6 +1407,68 @@ async function cmdMcp(opts) {
1105
1407
  });
1106
1408
  }
1107
1409
 
1410
+ // ---------------------------------------------------------------------------
1411
+ // First-run model download progress (issue #108): the embedding model (~280 MB)
1412
+ // and the reranker (~280 MB) stream in silently otherwise, which reads as a
1413
+ // hang on slow links. TTY → one aggregated bar; --json → throttled JSON events
1414
+ // on STDERR so stdout stays machine-readable; warm cache emits nothing at all.
1415
+ // ---------------------------------------------------------------------------
1416
+
1417
+ const DOWNLOAD_COMMANDS = new Set(['index', 'search', 'ask', 'serve']);
1418
+
1419
+ function formatMb(bytes) {
1420
+ if (!Number.isFinite(bytes) || bytes <= 0) return '0 MB';
1421
+ return bytes >= 1024 * 1024 * 1024
1422
+ ? `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
1423
+ : `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1424
+ }
1425
+
1426
+ function renderDownloadBar(snap) {
1427
+ const barWidth = 10;
1428
+ const filled = snap.percent === null ? 0 : Math.min(barWidth, Math.max(0, Math.round((snap.percent / 100) * barWidth)));
1429
+ const bar = '[' + '#'.repeat(filled) + '-'.repeat(barWidth - filled) + ']';
1430
+ const pct = snap.percent === null ? '…' : `${Math.floor(snap.percent)}%`;
1431
+ const size = `${formatMb(snap.loadedBytes)}${snap.totalBytes > 0 ? `/${formatMb(snap.totalBytes)}` : ''}`;
1432
+ const speed = snap.mbps !== null ? `${snap.mbps.toFixed(1)} MB/s` : '—';
1433
+ const eta = snap.etaSec !== null && Number.isFinite(snap.etaSec) ? formatDuration(snap.etaSec) : '—';
1434
+ process.stderr.write(`\r${bar} ${pct} │ ${size} │ ${speed} │ ETA ${eta}`);
1435
+ if (snap.complete) process.stderr.write('\n');
1436
+ }
1437
+
1438
+ /** Install the progress listener; returns a detach fn for the caller's finally. */
1439
+ function installDownloadProgress(opts) {
1440
+ const agg = new DownloadProgressAggregator();
1441
+ setDownloadProgressListener((e) => {
1442
+ agg.update(e);
1443
+ // JSON consumers poll slower than eyes; throttle accordingly.
1444
+ if (!agg.shouldRender(Date.now(), opts.json ? 1000 : 400)) return;
1445
+ const snap = agg.snapshot();
1446
+ if (!opts.json && process.stderr.isTTY) {
1447
+ renderDownloadBar(snap);
1448
+ } else if (opts.json) {
1449
+ process.stderr.write(JSON.stringify({
1450
+ type: snap.complete ? 'download-complete' : 'download-progress',
1451
+ percent: snap.percent === null ? null : Math.round(snap.percent * 10) / 10,
1452
+ loadedBytes: snap.loadedBytes,
1453
+ totalBytes: snap.totalBytes > 0 ? snap.totalBytes : null,
1454
+ mbps: snap.mbps === null ? null : Math.round(snap.mbps * 100) / 100,
1455
+ etaSec: snap.etaSec === null || !Number.isFinite(snap.etaSec) ? null : Math.round(snap.etaSec),
1456
+ doneFiles: snap.doneFiles,
1457
+ activeFiles: snap.activeFiles,
1458
+ }) + '\n');
1459
+ }
1460
+ });
1461
+ return () => setDownloadProgressListener(null);
1462
+ }
1463
+
1464
+ async function cmdCompletions(opts) {
1465
+ const shell = (opts._.shift() || '').toLowerCase();
1466
+ if (!COMPLETION_SHELLS.includes(shell)) {
1467
+ die(`unknown shell "${shell || '(none)'}" for completions — use one of: ${COMPLETION_SHELLS.join('|')}`);
1468
+ }
1469
+ process.stdout.write(generateCompletion(shell));
1470
+ }
1471
+
1108
1472
  async function main() {
1109
1473
  const argv = process.argv.slice(2);
1110
1474
  const opts = parseArgs(argv);
@@ -1120,19 +1484,28 @@ async function main() {
1120
1484
 
1121
1485
  if (opts.version) { process.stdout.write(`${VERSION}\n`); return; }
1122
1486
  if (opts.help || !cmd) { process.stdout.write(HELP); return; }
1123
- switch (cmd) {
1124
- case 'index': return cmdIndex(opts);
1125
- case 'stats': return cmdStats(opts);
1126
- case 'check':
1127
- case 'doctor': return cmdCheck(opts);
1128
- case 'export': return cmdExport(opts);
1129
- case 'search': return cmdSearch(opts);
1130
- case 'ask': return cmdAsk(opts);
1131
- case 'tui': return cmdTui(opts);
1132
- case 'serve': return cmdServe(opts);
1133
- case 'mcp': return cmdMcp(opts);
1134
- case 'models': return cmdModels();
1135
- default: die(`unknown command: ${cmd}. Try \`mdss --help\`.`);
1487
+ // Model downloads stream through this listener only for commands that may
1488
+ // load a model in-process (issue #108). `mcp` is excluded — its stderr is an
1489
+ // IDE log; `tui` draws its own full-screen UI.
1490
+ const detachProgress = DOWNLOAD_COMMANDS.has(cmd) ? installDownloadProgress(opts) : null;
1491
+ try {
1492
+ switch (cmd) {
1493
+ case 'index': return await cmdIndex(opts);
1494
+ case 'stats': return await cmdStats(opts);
1495
+ case 'check':
1496
+ case 'doctor': return await cmdCheck(opts);
1497
+ case 'export': return await cmdExport(opts);
1498
+ case 'search': return await cmdSearch(opts);
1499
+ case 'ask': return await cmdAsk(opts);
1500
+ case 'tui': return await cmdTui(opts);
1501
+ case 'serve': return await cmdServe(opts);
1502
+ case 'mcp': return await cmdMcp(opts);
1503
+ case 'models': return await cmdModels();
1504
+ case 'completions': return await cmdCompletions(opts);
1505
+ default: die(`unknown command: ${cmd}. Try \`mdss --help\`.`);
1506
+ }
1507
+ } finally {
1508
+ detachProgress?.();
1136
1509
  }
1137
1510
  }
1138
1511
 
@@ -1172,3 +1545,11 @@ export {
1172
1545
  checkHealth, cmdExport,
1173
1546
  die, main, HELP, VERSION,
1174
1547
  };
1548
+
1549
+
1550
+
1551
+
1552
+
1553
+
1554
+
1555
+
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Shell completions generator (issue #107).
3
+ *
4
+ * Single source of truth: the FLAGS table below drives every shell script AND
5
+ * the drift-guard test (test/completions.test.mjs) that diffs it against the
6
+ * flags actually parsed in bin/cli.mjs. Adding a flag in parseArgs without
7
+ * updating this table fails the test — and vice versa.
8
+ *
9
+ * Static generation only: no runtime dependency, `mdss completions <shell>`
10
+ * prints a self-contained script to stdout.
11
+ */
12
+ export type FlagKind = 'flag' | 'value' | 'file' | 'dir' | 'number';
13
+ export interface FlagSpec {
14
+ /** Primary long form, e.g. "--index-dir". */
15
+ name: string;
16
+ alias?: string;
17
+ kind: FlagKind;
18
+ }
19
+ /** Every option bin/cli.mjs parseArgs understands (issue #107 drift guard). */
20
+ export declare const FLAGS: FlagSpec[];
21
+ export declare const COMMANDS: string[];
22
+ export declare const COMPLETION_SHELLS: readonly ['bash', 'zsh', 'fish', 'powershell'];
23
+ export type CompletionShell = (typeof COMPLETION_SHELLS)[number];
24
+ /** Generate a self-contained completion script; throws on an unknown shell. */
25
+ export declare function generateCompletion(shell: string): string;