@takara-ai/miru-code 1.9.0 → 1.10.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 (60) hide show
  1. package/README.md +43 -48
  2. package/docs/coverage-badge.json +6 -0
  3. package/grammars/manifest.json +10 -2
  4. package/grammars/tree-sitter-astro.wasm +0 -0
  5. package/grammars/tree-sitter-php.wasm +0 -0
  6. package/grammars/tree-sitter-php_only.wasm +0 -0
  7. package/grammars/tree-sitter-sql.wasm +0 -0
  8. package/grammars/tree-sitter-svelte.wasm +0 -0
  9. package/grammars/tree-sitter-vue.wasm +0 -0
  10. package/package.json +10 -2
  11. package/src/agents.ts +4 -4
  12. package/src/auth/device.ts +26 -21
  13. package/src/auth/providers.ts +26 -11
  14. package/src/benchmark/compare.ts +3 -10
  15. package/src/benchmark/grep.ts +64 -37
  16. package/src/benchmark/locate-compare.ts +8 -1
  17. package/src/benchmark/rg-literal.ts +9 -4
  18. package/src/cache.ts +3 -3
  19. package/src/chunking/ast.ts +21 -3
  20. package/src/chunking/grammars.ts +8 -3
  21. package/src/chunking/structural.ts +13 -24
  22. package/src/cli-mode.ts +18 -0
  23. package/src/cli.ts +10 -45
  24. package/src/credentials.ts +8 -13
  25. package/src/embeddings/openai.ts +6 -3
  26. package/src/embeddings/sagemaker.ts +18 -3
  27. package/src/embeddings/validate.ts +10 -7
  28. package/src/git.ts +6 -2
  29. package/src/help.ts +5 -4
  30. package/src/index/bm25.ts +66 -6
  31. package/src/index/create.ts +5 -9
  32. package/src/index/files.ts +5 -1
  33. package/src/index/incremental.ts +48 -8
  34. package/src/index/sparse.ts +14 -2
  35. package/src/installer/agents.ts +85 -65
  36. package/src/installer/benchmark-mode.ts +40 -11
  37. package/src/installer/config.ts +105 -42
  38. package/src/installer/installer.ts +59 -34
  39. package/src/installer/legacy-hooks.ts +131 -0
  40. package/src/installer/prompt.ts +20 -22
  41. package/src/installer/search-policy.ts +8 -8
  42. package/src/mcp/auth-tool.ts +93 -33
  43. package/src/mcp/index-cache.ts +16 -3
  44. package/src/mcp/server.ts +42 -22
  45. package/src/mcp/stdio.ts +17 -2
  46. package/src/miru-index.ts +54 -18
  47. package/src/prompt.ts +51 -14
  48. package/src/ranking/boosting.ts +5 -6
  49. package/src/ranking/location.ts +6 -10
  50. package/src/ranking/penalties.ts +14 -22
  51. package/src/search.ts +4 -12
  52. package/src/setup.ts +25 -13
  53. package/src/snippet.ts +8 -26
  54. package/src/spinner.ts +26 -16
  55. package/src/tokenizer/bert-wordpiece.ts +0 -6
  56. package/src/utils.ts +0 -4
  57. package/src/version.ts +4 -4
  58. package/src/installer/hooks/install.ts +0 -549
  59. package/src/installer/hooks/opencode-plugin.ts +0 -54
  60. package/src/installer/hooks/search-guard.ts +0 -272
@@ -147,7 +147,7 @@ function normalizeRepoFile(repoRoot: string, filePath: string): string {
147
147
  return rel;
148
148
  }
149
149
 
150
- function parsePathLinePrefix(line: string): { path: string; line: number } | null {
150
+ export function parsePathLinePrefix(line: string): { path: string; line: number } | null {
151
151
  for (let i = 0; i < line.length; i++) {
152
152
  const delim = line[i];
153
153
  if (delim !== ":" && delim !== "-") {
@@ -161,14 +161,12 @@ function parsePathLinePrefix(line: string): { path: string; line: number } | nul
161
161
  continue;
162
162
  }
163
163
  const trailing = line[j];
164
- if (trailing !== ":" && trailing !== "-") {
165
- continue;
166
- }
167
- const lineNo = Number(line.slice(i + 1, j));
168
- if (!Number.isFinite(lineNo)) {
169
- continue;
164
+ if (trailing === ":" || trailing === "-") {
165
+ const lineNo = Number(line.slice(i + 1, j));
166
+ if (Number.isFinite(lineNo)) {
167
+ return { path: line.slice(0, i), line: lineNo };
168
+ }
170
169
  }
171
- return { path: line.slice(0, i), line: lineNo };
172
170
  }
173
171
  return null;
174
172
  }
@@ -177,6 +175,10 @@ export async function grepSearch(
177
175
  repoRoot: string,
178
176
  query: string,
179
177
  topK: number,
178
+ options: {
179
+ tool?: BenchmarkSearchTool | null;
180
+ spawn?: typeof spawnBenchmarkSearch;
181
+ } = {},
180
182
  ): Promise<GrepSearchResult> {
181
183
  const keywords = queryKeywords(query);
182
184
  const pattern = buildGrepPattern(keywords);
@@ -184,17 +186,18 @@ export async function grepSearch(
184
186
  return { files: [], hits: [], tokens: 0, pattern: null, keywords };
185
187
  }
186
188
 
187
- const tool = selectBenchmarkSearchTool();
189
+ const tool = Object.hasOwn(options, "tool") ? options.tool : selectBenchmarkSearchTool();
188
190
  if (!tool) {
189
191
  throw new Error("No search tool found in PATH (tried rg, grep, and findstr on Windows)");
190
192
  }
193
+ const spawn = options.spawn ?? spawnBenchmarkSearch;
191
194
 
192
195
  const ranked =
193
196
  tool === "rg"
194
- ? await rgRankedMatches(repoRoot, pattern, topK)
197
+ ? await rgRankedMatches(repoRoot, pattern, topK, spawn)
195
198
  : tool === "grep"
196
- ? await grepRankedMatches(repoRoot, pattern, topK)
197
- : await findstrRankedMatches(repoRoot, keywords, topK);
199
+ ? await grepRankedMatches(repoRoot, pattern, topK, spawn)
200
+ : await findstrRankedMatches(repoRoot, keywords, topK, spawn);
198
201
 
199
202
  const hits: GrepFileHit[] = [];
200
203
  let tokens = 0;
@@ -202,10 +205,10 @@ export async function grepSearch(
202
205
  for (const row of ranked) {
203
206
  const output =
204
207
  tool === "rg"
205
- ? await rgFilePreview(row.absPath, pattern)
208
+ ? await rgFilePreview(row.absPath, pattern, spawn)
206
209
  : tool === "grep"
207
- ? await grepFilePreview(row.absPath, pattern)
208
- : await findstrFilePreview(row.absPath, keywords);
210
+ ? await grepFilePreview(row.absPath, pattern, spawn)
211
+ : await findstrFilePreview(row.absPath, keywords, spawn);
209
212
  hits.push({ file: row.file, matchCount: row.matchCount, output });
210
213
  tokens += countTokens(output);
211
214
  }
@@ -219,8 +222,13 @@ export async function grepSearch(
219
222
  };
220
223
  }
221
224
 
222
- async function rgRankedMatches(repoRoot: string, pattern: string, topK: number) {
223
- const countText = await spawnBenchmarkSearch([
225
+ async function rgRankedMatches(
226
+ repoRoot: string,
227
+ pattern: string,
228
+ topK: number,
229
+ spawn: typeof spawnBenchmarkSearch,
230
+ ) {
231
+ const countText = await spawn([
224
232
  "rg",
225
233
  "-i",
226
234
  "--count-matches",
@@ -231,8 +239,13 @@ async function rgRankedMatches(repoRoot: string, pattern: string, topK: number)
231
239
  return parseCountMatches(repoRoot, countText, topK);
232
240
  }
233
241
 
234
- async function grepRankedMatches(repoRoot: string, pattern: string, topK: number) {
235
- const countText = await spawnBenchmarkSearch([
242
+ async function grepRankedMatches(
243
+ repoRoot: string,
244
+ pattern: string,
245
+ topK: number,
246
+ spawn: typeof spawnBenchmarkSearch,
247
+ ) {
248
+ const countText = await spawn([
236
249
  "grep",
237
250
  "-R",
238
251
  "-I",
@@ -285,13 +298,15 @@ function splitSearchToolLine(line: string): { path: string; rest: string } | nul
285
298
  return { path: line.slice(0, colon), rest: line.slice(colon + 1) };
286
299
  }
287
300
 
288
- async function findstrRankedMatches(repoRoot: string, keywords: string[], topK: number) {
301
+ async function findstrRankedMatches(
302
+ repoRoot: string,
303
+ keywords: string[],
304
+ topK: number,
305
+ spawn: typeof spawnBenchmarkSearch,
306
+ ) {
289
307
  const counts = new Map<string, number>();
290
308
  for (const keyword of keywords) {
291
- const text = await spawnBenchmarkSearch(
292
- ["findstr", "/S", "/N", "/I", "/P", `/C:${keyword}`, "*"],
293
- repoRoot,
294
- );
309
+ const text = await spawn(["findstr", "/S", "/N", "/I", "/P", `/C:${keyword}`, "*"], repoRoot);
295
310
  for (const line of text.split("\n")) {
296
311
  const parsed = parsePathLinePrefix(line);
297
312
  if (!parsed?.path) {
@@ -346,8 +361,19 @@ function parseCountMatches(repoRoot: string, countText: string, topK: number) {
346
361
  .slice(0, topK);
347
362
  }
348
363
 
349
- async function rgFilePreview(absPath: string, pattern: string): Promise<string> {
350
- return spawnBenchmarkSearch([
364
+ export const grepTestUtils = {
365
+ buildGrepPattern,
366
+ normalizeRepoFile,
367
+ splitSearchToolLine,
368
+ parseCountMatches,
369
+ };
370
+
371
+ async function rgFilePreview(
372
+ absPath: string,
373
+ pattern: string,
374
+ spawn: typeof spawnBenchmarkSearch,
375
+ ): Promise<string> {
376
+ return spawn([
351
377
  "rg",
352
378
  "-i",
353
379
  "-n",
@@ -360,8 +386,12 @@ async function rgFilePreview(absPath: string, pattern: string): Promise<string>
360
386
  ]);
361
387
  }
362
388
 
363
- async function grepFilePreview(absPath: string, pattern: string): Promise<string> {
364
- return spawnBenchmarkSearch([
389
+ async function grepFilePreview(
390
+ absPath: string,
391
+ pattern: string,
392
+ spawn: typeof spawnBenchmarkSearch,
393
+ ): Promise<string> {
394
+ return spawn([
365
395
  "grep",
366
396
  "-I",
367
397
  "-i",
@@ -376,13 +406,10 @@ async function grepFilePreview(absPath: string, pattern: string): Promise<string
376
406
  ]);
377
407
  }
378
408
 
379
- async function findstrFilePreview(absPath: string, keywords: string[]): Promise<string> {
380
- return spawnBenchmarkSearch([
381
- "findstr",
382
- "/N",
383
- "/I",
384
- "/P",
385
- ...keywords.map((k) => `/C:${k}`),
386
- absPath,
387
- ]);
409
+ async function findstrFilePreview(
410
+ absPath: string,
411
+ keywords: string[],
412
+ spawn: typeof spawnBenchmarkSearch,
413
+ ): Promise<string> {
414
+ return spawn(["findstr", "/N", "/I", "/P", ...keywords.map((k) => `/C:${k}`), absPath]);
388
415
  }
@@ -42,13 +42,20 @@ export async function benchmarkLocateComparison(options: {
42
42
  repoPath: string;
43
43
  index: MiruIndex;
44
44
  locate?: LiteralLocateOptions;
45
+ dependencies?: {
46
+ selectComparableLiteralSearchTool?: typeof selectComparableLiteralSearchTool;
47
+ };
45
48
  }): Promise<LocateBenchmarkComparison> {
46
49
  if (options.locate?.limit != null) {
47
50
  throw new Error(
48
51
  "Cannot benchmark a limited locate response against native grep: rg/grep limits are per file, while locate.limit is global. Omit limit to keep token and recall comparisons valid.",
49
52
  );
50
53
  }
51
- if (!selectComparableLiteralSearchTool()) {
54
+ if (
55
+ !(
56
+ options.dependencies?.selectComparableLiteralSearchTool ?? selectComparableLiteralSearchTool
57
+ )()
58
+ ) {
52
59
  throw new Error(
53
60
  "A comparable literal benchmark requires rg or compatible grep; findstr is not equivalent for scoped locate output.",
54
61
  );
@@ -135,11 +135,16 @@ export async function rgLiteralOutput(
135
135
  repoRoot: string,
136
136
  literal: string | readonly string[],
137
137
  options: RgLiteralOptions = {},
138
+ dependencies: {
139
+ tool?: "rg" | "grep" | null;
140
+ spawn?: (args: string[]) => Promise<string>;
141
+ } = {},
138
142
  ): Promise<RgLiteralOutput> {
139
143
  const literals = Array.isArray(literal) ? literal : [literal as string];
140
144
  const context = options.context ?? 0;
141
145
  const maxCount = options.maxCount ?? 20;
142
- const tool = selectComparableLiteralSearchTool();
146
+ const tool =
147
+ dependencies.tool === undefined ? selectComparableLiteralSearchTool() : dependencies.tool;
143
148
  if (!tool) {
144
149
  throw new Error(
145
150
  "A comparable literal benchmark requires rg or compatible grep; findstr cannot preserve locate scope, context, and count semantics.",
@@ -150,7 +155,7 @@ export async function rgLiteralOutput(
150
155
  const output: string[] = [];
151
156
  for (const paths of pathBatches) {
152
157
  output.push(
153
- await spawnBenchmarkSearch(
158
+ await (dependencies.spawn ?? spawnBenchmarkSearch)(
154
159
  buildLiteralArgs(
155
160
  tool,
156
161
  repoRoot,
@@ -178,8 +183,8 @@ export async function rgLiteralOutput(
178
183
  * OR-matches every literal, mirroring what an agent without `locate` would have to run
179
184
  * to get the same recall as `literals`/`match_variants` (one pattern isn't equivalent).
180
185
  */
181
- function buildLiteralArgs(
182
- tool: "rg" | "grep",
186
+ export function buildLiteralArgs(
187
+ tool: string,
183
188
  repoRoot: string,
184
189
  literals: readonly string[],
185
190
  context: number,
package/src/cache.ts CHANGED
@@ -23,7 +23,7 @@ import { computeSourceCacheKey } from "./utils.ts";
23
23
  import { indexCacheEpoch } from "./version.ts";
24
24
 
25
25
  /** Platform cache root; override with `MIRU_CACHE_HOME`. */
26
- export function resolveCacheFolder(): string {
26
+ export function resolveCacheFolder(platform: NodeJS.Platform = process.platform): string {
27
27
  const override = process.env.MIRU_CACHE_HOME?.trim();
28
28
  if (override) {
29
29
  return override;
@@ -31,10 +31,10 @@ export function resolveCacheFolder(): string {
31
31
  const name = "miru";
32
32
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
33
33
  let base: string;
34
- if (process.platform === "win32") {
34
+ if (platform === "win32") {
35
35
  base = process.env.LOCALAPPDATA ?? process.env.APPDATA ?? join(home, "AppData", "Local");
36
36
  base = join(base, name, "Cache");
37
- } else if (process.platform === "darwin") {
37
+ } else if (platform === "darwin") {
38
38
  base = join(home, "Library", "Caches", name);
39
39
  } else {
40
40
  base = process.env.XDG_CACHE_HOME
@@ -1,4 +1,4 @@
1
- import type { Node } from "web-tree-sitter";
1
+ import type { Language, Node } from "web-tree-sitter";
2
2
  import { Parser } from "web-tree-sitter";
3
3
  import { getLanguageForFile } from "./grammars.ts";
4
4
  import { type ChunkBoundary, mergeAdjacentChunks } from "./lines.ts";
@@ -87,17 +87,35 @@ export async function chunkAst(
87
87
  filePath: string,
88
88
  language: string | null,
89
89
  desiredLength: number,
90
+ dependencies: {
91
+ getLanguage?: typeof getLanguageForFile;
92
+ createParser?: () => Pick<Parser, "setLanguage" | "parse"> & Partial<Pick<Parser, "delete">>;
93
+ } = {},
90
94
  ): Promise<ChunkBoundary[] | null> {
91
95
  if (!source.trim() || !astChunkingEnabled()) {
92
96
  return null;
93
97
  }
94
98
 
95
- const languageObj = await getLanguageForFile(filePath, language);
99
+ const languageObj = await (dependencies.getLanguage ?? getLanguageForFile)(filePath, language);
96
100
  if (!languageObj) {
97
101
  return null;
98
102
  }
99
103
 
100
- const parser = new Parser();
104
+ // A Parser owns wasm memory the JS GC can't free, so it must be deleted explicitly.
105
+ const parser = dependencies.createParser?.() ?? new Parser();
106
+ try {
107
+ return parseBoundaries(parser, languageObj, source, desiredLength);
108
+ } finally {
109
+ parser.delete?.();
110
+ }
111
+ }
112
+
113
+ function parseBoundaries(
114
+ parser: Pick<Parser, "setLanguage" | "parse">,
115
+ languageObj: Language,
116
+ source: string,
117
+ desiredLength: number,
118
+ ): ChunkBoundary[] | null {
101
119
  parser.setLanguage(languageObj);
102
120
 
103
121
  let tree: ReturnType<Parser["parse"]>;
@@ -36,13 +36,17 @@ export function wasmPathForLanguage(language: string | null): string | null {
36
36
  }
37
37
 
38
38
  /** Pick grammar wasm for a file (e.g. tsx vs typescript). */
39
- export function wasmPathForFile(filePath: string, language: string | null): string | null {
39
+ export function wasmPathForFile(
40
+ filePath: string,
41
+ language: string | null,
42
+ pathExists: (path: string) => boolean = existsSync,
43
+ ): string | null {
40
44
  if (!language) {
41
45
  return null;
42
46
  }
43
47
  if (language === "typescript" && filePath.toLowerCase().endsWith(".tsx")) {
44
48
  const tsxPath = join(grammarsDir(), "tree-sitter-tsx.wasm");
45
- if (existsSync(tsxPath)) {
49
+ if (pathExists(tsxPath)) {
46
50
  return tsxPath;
47
51
  }
48
52
  }
@@ -86,13 +90,14 @@ async function loadLanguage(wasmPath: string): Promise<Language> {
86
90
  export async function getLanguageForFile(
87
91
  filePath: string,
88
92
  language: string | null,
93
+ load: typeof loadLanguage = loadLanguage,
89
94
  ): Promise<Language | null> {
90
95
  const wasmPath = wasmPathForFile(filePath, language);
91
96
  if (!wasmPath || !existsSync(wasmPath)) {
92
97
  return null;
93
98
  }
94
99
  try {
95
- return await loadLanguage(wasmPath);
100
+ return await load(wasmPath);
96
101
  } catch {
97
102
  return null;
98
103
  }
@@ -14,8 +14,6 @@ const SUPPORTED_STRUCTURAL_LANGUAGES = new Set([
14
14
  "c",
15
15
  ]);
16
16
 
17
- const BRACE_FAMILY_LANGUAGES = new Set(["cpp", "c"]);
18
-
19
17
  export function chunkStructural(
20
18
  source: string,
21
19
  language: string | null,
@@ -55,18 +53,6 @@ function stripLine(text: string): string {
55
53
  return text.replace(/[\r\n]+$/, "");
56
54
  }
57
55
 
58
- function _isBlank(text: string): boolean {
59
- return stripLine(text).trim().length === 0;
60
- }
61
-
62
- function _isCommentLine(text: string, language: string): boolean {
63
- const trimmed = stripLine(text).trim();
64
- if (language === "python") {
65
- return trimmed.startsWith("#");
66
- }
67
- return trimmed.startsWith("//");
68
- }
69
-
70
56
  function pythonUnits(lines: LineGroup[]): ChunkBoundary[] {
71
57
  const units: ChunkBoundary[] = [];
72
58
  let firstDeclStart: number | null = null;
@@ -182,16 +168,21 @@ function declPattern(language: string): RegExp {
182
168
  return /^\s*(export\s+)?(async\s+function\b|function\b|class\b|interface\b|type\b|(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)(?:\s*:\s*[^=]+)?\s*=>)/;
183
169
  }
184
170
 
185
- function isBraceFamilyTypeDecl(text: string, language: string): boolean {
171
+ function isBraceFamilyTypeDecl(text: string, language: "cpp" | "c"): boolean {
186
172
  if (language === "cpp") {
187
173
  return /^\s*(?:(?:template\s*<[^>]*>\s*)?(?:class|struct|namespace|enum(?:\s+class)?)\b)/.test(
188
174
  text,
189
175
  );
190
176
  }
191
- if (language === "c") {
192
- return /^\s*(?:struct|enum|union)\b/.test(text);
193
- }
194
- return false;
177
+ return /^\s*(?:struct|enum|union)\b/.test(text);
178
+ }
179
+
180
+ function shouldSkipNestedBraceDecl(text: string, language: string): boolean {
181
+ return (
182
+ (language === "cpp" || language === "c") &&
183
+ lineIndent(text) > 0 &&
184
+ !isBraceFamilyTypeDecl(text, language)
185
+ );
195
186
  }
196
187
 
197
188
  function braceUnits(lines: LineGroup[], language: string): ChunkBoundary[] {
@@ -205,11 +196,7 @@ function braceUnits(lines: LineGroup[], language: string): ChunkBoundary[] {
205
196
  if (!re.test(text)) {
206
197
  continue;
207
198
  }
208
- if (
209
- BRACE_FAMILY_LANGUAGES.has(language) &&
210
- lineIndent(text) > 0 &&
211
- !isBraceFamilyTypeDecl(text, language)
212
- ) {
199
+ if (shouldSkipNestedBraceDecl(text, language)) {
213
200
  continue;
214
201
  }
215
202
  if (firstDeclStart === null) {
@@ -263,6 +250,8 @@ function braceUnits(lines: LineGroup[], language: string): ChunkBoundary[] {
263
250
  return dedupeAndSort(units);
264
251
  }
265
252
 
253
+ export const structuralTestUtils = { shouldSkipNestedBraceDecl };
254
+
266
255
  function dedupeAndSort(units: ChunkBoundary[]): ChunkBoundary[] {
267
256
  const out: ChunkBoundary[] = [];
268
257
  const seen = new Set<string>();
@@ -0,0 +1,18 @@
1
+ /** Keep older stdio MCP configs working when they launch Miru without a subcommand. */
2
+ export function shouldRunMcp(
3
+ argv: readonly string[],
4
+ stdinIsTTY: boolean,
5
+ stdoutIsTTY: boolean,
6
+ ): boolean {
7
+ if (argv[0] === "mcp") return argv[1] !== "-h" && argv[1] !== "--help";
8
+ if (stdinIsTTY || stdoutIsTTY) return false;
9
+ const first = argv[0];
10
+ return (
11
+ first === undefined ||
12
+ first === "--benchmark" ||
13
+ first === "--benchmark=true" ||
14
+ first === "--benchmark=false" ||
15
+ first === "--ref" ||
16
+ first === "--content"
17
+ );
18
+ }
package/src/cli.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import { type AgentId, writeAgentFile } from "./agents.ts";
3
3
  import { clearBenchmarkHistory, resolveBenchmarkHistoryPath } from "./benchmark/history.ts";
4
4
  import { clearCache } from "./cache.ts";
5
+ import { shouldRunMcp } from "./cli-mode.ts";
5
6
  import {
6
7
  fail,
7
8
  formatRelatedHeader,
@@ -26,7 +27,6 @@ import {
26
27
  printSageMakerHelp,
27
28
  } from "./help.ts";
28
29
  import { getBenchmarkModeStatus, setBenchmarkMode } from "./installer/benchmark-mode.ts";
29
- import { runSearchGuardFromStdin } from "./installer/hooks/search-guard.ts";
30
30
  import { runInstaller } from "./installer/installer.ts";
31
31
  import { promptConfirm } from "./installer/prompt.ts";
32
32
  import {
@@ -72,27 +72,6 @@ try {
72
72
  // Leave recovery to ensureCredentials / setup.
73
73
  }
74
74
 
75
- const CLI_COMMANDS = new Set([
76
- "search",
77
- "locate",
78
- "expand",
79
- "find-related",
80
- "init",
81
- "install",
82
- "uninstall",
83
- "setup",
84
- "clear",
85
- "benchmark",
86
- "env",
87
- "environment",
88
- "hook-guard",
89
- "help",
90
- "-h",
91
- "--help",
92
- "-v",
93
- "--version",
94
- ]);
95
-
96
75
  const AGENTS = new Set<AgentId>(AGENT_IDS);
97
76
 
98
77
  function parseFlagArgv(argv: string[], flag: string): { present: boolean; rest: string[] } {
@@ -392,21 +371,11 @@ async function runBenchmarkCommand(rest: string[]): Promise<void> {
392
371
  async function runCli(argv: string[]): Promise<void> {
393
372
  const [command, ...rest] = argv;
394
373
 
395
- if (command === undefined) {
374
+ if (command === undefined || command === "-h" || command === "--help") {
396
375
  printMainHelp();
397
376
  return;
398
377
  }
399
378
 
400
- if (command === "-h" || command === "--help") {
401
- printMainHelp();
402
- return;
403
- }
404
-
405
- if (command === "-v" || command === "--version") {
406
- console.log(miruVersion());
407
- return;
408
- }
409
-
410
379
  if (command === "help") {
411
380
  const topic = rest[0];
412
381
  if (!topic || topic === "-h" || topic === "--help") {
@@ -436,10 +405,6 @@ async function runCli(argv: string[]): Promise<void> {
436
405
  return;
437
406
  }
438
407
 
439
- if (command === "hook-guard") {
440
- process.exit(await runSearchGuardFromStdin());
441
- }
442
-
443
408
  if (command === "install" || command === "uninstall") {
444
409
  await runInstaller(command);
445
410
  return;
@@ -725,24 +690,24 @@ async function main(): Promise<void> {
725
690
  const argv = process.argv.slice(2);
726
691
  const first = argv[0];
727
692
 
693
+ // Old hook configs may survive until the next install/uninstall; let their
694
+ // search commands proceed normally while the installer removes the hooks.
695
+ if (first === "hook-guard") return;
696
+
728
697
  if (first === "-v" || first === "--version") {
729
698
  console.log(miruVersion());
730
699
  return;
731
700
  }
732
701
 
733
- if (first === "hook-guard") {
734
- process.exit(await runSearchGuardFromStdin());
735
- }
736
-
737
702
  const updateNotice = maybeNotifyUpdate();
738
703
 
739
- if (first && CLI_COMMANDS.has(first)) {
740
- await Promise.all([runCli(argv), updateNotice]);
704
+ if (shouldRunMcp(argv, Boolean(process.stdin.isTTY), Boolean(process.stdout.isTTY))) {
705
+ await updateNotice;
706
+ await runMcpWithCredentials(first === "mcp" ? argv.slice(1) : argv);
741
707
  return;
742
708
  }
743
709
 
744
- await updateNotice;
745
- await runMcpWithCredentials(argv);
710
+ await Promise.all([runCli(argv), updateNotice]);
746
711
  }
747
712
 
748
713
  main().catch((err) => {
@@ -25,17 +25,17 @@ function isUnsubstitutedPlaceholder(value: string): boolean {
25
25
  return value.startsWith("${") && value.endsWith("}");
26
26
  }
27
27
 
28
- export function resolveCredentialsDir(): string {
28
+ export function resolveCredentialsDir(platform: NodeJS.Platform = process.platform): string {
29
29
  const override = process.env.MIRU_CREDENTIALS_DIR;
30
30
  if (override && !isUnsubstitutedPlaceholder(override)) {
31
31
  return override;
32
32
  }
33
33
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
34
- if (process.platform === "win32") {
34
+ if (platform === "win32") {
35
35
  const base = process.env.APPDATA ?? join(home, "AppData", "Roaming");
36
36
  return join(base, "miru");
37
37
  }
38
- if (process.platform === "darwin") {
38
+ if (platform === "darwin") {
39
39
  return join(home, "Library", "Application Support", "miru");
40
40
  }
41
41
  const xdg = process.env.XDG_CONFIG_HOME ?? join(home, ".config");
@@ -46,12 +46,12 @@ export function resolveCredentialsDir(): string {
46
46
  * Global Miru state directory (credentials, benchmark history, etc.).
47
47
  * Same location as `resolveCredentialsDir()`; prefer this name for non-secret files.
48
48
  */
49
- export function resolveMiruStateDir(): string {
50
- return resolveCredentialsDir();
49
+ export function resolveMiruStateDir(platform: NodeJS.Platform = process.platform): string {
50
+ return resolveCredentialsDir(platform);
51
51
  }
52
52
 
53
- export function resolveCredentialsPath(): string {
54
- return join(resolveCredentialsDir(), CREDENTIALS_FILENAME);
53
+ export function resolveCredentialsPath(platform: NodeJS.Platform = process.platform): string {
54
+ return join(resolveCredentialsDir(platform), CREDENTIALS_FILENAME);
55
55
  }
56
56
 
57
57
  export async function readStoredCredentials(): Promise<StoredCredentials | null> {
@@ -267,12 +267,7 @@ export async function clearStoredCredentials(): Promise<{ cleared: boolean; path
267
267
  }
268
268
 
269
269
  // Read for env cleanup, but never let parse errors block deletion.
270
- let stored: StoredCredentials | null = null;
271
- try {
272
- stored = await readStoredCredentials();
273
- } catch {
274
- // Ignore parse/read errors — removal still proceeds.
275
- }
270
+ const stored = await readStoredCredentials();
276
271
  await rm(path, { force: true });
277
272
 
278
273
  if (stored) {
@@ -353,7 +353,9 @@ export class EmbeddingApiError extends Error {
353
353
  }
354
354
  }
355
355
 
356
- function createClient(): EmbeddingClient {
356
+ type EmbeddingFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
357
+
358
+ function createClient(fetchImpl: EmbeddingFetch = fetch): EmbeddingClient {
357
359
  const baseUrl = resolveEmbeddingBaseUrl();
358
360
  const endpoint = `${baseUrl}/embeddings`;
359
361
 
@@ -369,7 +371,7 @@ function createClient(): EmbeddingClient {
369
371
  if (dimensions != null) {
370
372
  body.dimensions = dimensions;
371
373
  }
372
- const response = await fetch(endpoint, {
374
+ const response = await fetchImpl(endpoint, {
373
375
  method: "POST",
374
376
  headers: {
375
377
  Authorization: `Bearer ${apiKey}`,
@@ -407,9 +409,10 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
407
409
  maxEmbedChars?: number;
408
410
  dimensions?: number;
409
411
  client?: EmbeddingClient;
412
+ fetchImpl?: EmbeddingFetch;
410
413
  }) {
411
414
  this.model = options?.model ?? resolveEmbeddingModel();
412
- this.client = options?.client ?? createClient();
415
+ this.client = options?.client ?? createClient(options?.fetchImpl);
413
416
  this.batchSize = options?.batchSize ?? resolveEmbeddingBatchSize();
414
417
  this.maxEmbedChars = options?.maxEmbedChars ?? resolveMaxEmbedChars();
415
418
  this.requestedDimensions = options?.dimensions ?? resolveEmbeddingDimensions(this.model);