@takara-ai/miru-code 1.9.0 → 1.10.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.
Files changed (59) 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/index-cache.ts +16 -3
  43. package/src/mcp/server.ts +42 -22
  44. package/src/mcp/stdio.ts +17 -2
  45. package/src/miru-index.ts +54 -18
  46. package/src/prompt.ts +51 -14
  47. package/src/ranking/boosting.ts +5 -6
  48. package/src/ranking/location.ts +6 -10
  49. package/src/ranking/penalties.ts +14 -22
  50. package/src/search.ts +4 -12
  51. package/src/setup.ts +25 -13
  52. package/src/snippet.ts +8 -26
  53. package/src/spinner.ts +26 -16
  54. package/src/tokenizer/bert-wordpiece.ts +0 -6
  55. package/src/utils.ts +0 -4
  56. package/src/version.ts +4 -4
  57. package/src/installer/hooks/install.ts +0 -549
  58. package/src/installer/hooks/opencode-plugin.ts +0 -54
  59. package/src/installer/hooks/search-guard.ts +0 -272
@@ -30,7 +30,7 @@ import {
30
30
  replaceOrAppendMarked,
31
31
  stripJsonComments,
32
32
  } from "./config.ts";
33
- import { mergeHooks, removeHooks } from "./hooks/install.ts";
33
+ import { removeLegacySearchHooks } from "./legacy-hooks.ts";
34
34
  import { promptConfirm, promptMultiSelect, requireInteractiveTerminal } from "./prompt.ts";
35
35
  import { CURSOR_RULES_MDC } from "./search-policy.ts";
36
36
  import {
@@ -56,7 +56,7 @@ export type IntegrationId =
56
56
  | "mcp"
57
57
  | "instructions"
58
58
  | "subagent"
59
- | "hooks"
59
+ | "legacy-hooks"
60
60
  | "rules"
61
61
  | "caveman"
62
62
  | "ste";
@@ -72,6 +72,21 @@ export interface ApplyCtx {
72
72
  isDetected?: (agent: AgentTarget) => Promise<boolean>;
73
73
  }
74
74
 
75
+ type InstallerMultiSelect = <T>(
76
+ title: string,
77
+ items: Array<{ label: string; value: T; checked: boolean }>,
78
+ ) => Promise<T[] | null>;
79
+
80
+ export interface RunInstallerDependencies {
81
+ requireInteractiveTerminal?: (command: string) => void;
82
+ ensureCredentials?: typeof ensureCredentials;
83
+ agents?: AgentTarget[];
84
+ isAgentDetected?: (agent: AgentTarget) => Promise<boolean>;
85
+ promptMultiSelect?: InstallerMultiSelect;
86
+ promptConfirm?: typeof promptConfirm;
87
+ removeUninstallLocalData?: typeof removeUninstallLocalData;
88
+ }
89
+
75
90
  interface Integration {
76
91
  id: IntegrationId;
77
92
  label: string;
@@ -110,10 +125,12 @@ async function readExistingJsonMcpEntry(
110
125
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
111
126
  return null;
112
127
  }
113
- const section = (parsed as Record<string, unknown>)[sectionKey];
114
- if (!section || typeof section !== "object" || Array.isArray(section)) {
115
- return null;
128
+ let section: unknown = parsed;
129
+ for (const key of sectionKey.split(".")) {
130
+ if (!section || typeof section !== "object" || Array.isArray(section)) return null;
131
+ section = (section as Record<string, unknown>)[key];
116
132
  }
133
+ if (!section || typeof section !== "object" || Array.isArray(section)) return null;
117
134
  const entry = (section as Record<string, unknown>)[memberKey];
118
135
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
119
136
  return null;
@@ -161,18 +178,13 @@ async function applyInstructions(
161
178
  return { path, action };
162
179
  }
163
180
 
164
- async function applyHooks(agent: AgentTarget, mode: InstallMode): Promise<WriteResult | null> {
165
- const path = agent.hooksPath;
166
- const format = agent.hooksFormat;
181
+ async function removeLegacyHooks(agent: AgentTarget): Promise<WriteResult | null> {
182
+ const path = agent.legacyHooksPath;
183
+ const format = agent.legacyHooksFormat;
167
184
  if (!path || !format) {
168
185
  return null;
169
186
  }
170
-
171
- const action =
172
- mode === "install"
173
- ? await mergeHooks(format, path, agent.id)
174
- : await removeHooks(format, path, agent.id);
175
-
187
+ const action = await removeLegacySearchHooks(format, path, agent.id);
176
188
  return { path, action };
177
189
  }
178
190
 
@@ -311,9 +323,6 @@ async function applyCaveman(
311
323
 
312
324
  async function stePackMatches(skillDir: string): Promise<boolean> {
313
325
  const skillPath = join(skillDir, "SKILL.md");
314
- if (!(await Bun.file(skillPath).exists())) {
315
- return false;
316
- }
317
326
  if ((await Bun.file(skillPath).text()) !== STE_SKILL_MD) {
318
327
  return false;
319
328
  }
@@ -449,13 +458,12 @@ const INTEGRATIONS: Integration[] = [
449
458
  apply: applyCursorRules,
450
459
  },
451
460
  {
452
- id: "hooks",
453
- label: "Search hooks",
454
- description: "blocks built-in search; routes to Miru MCP",
455
- experimental: true,
456
- defaultChecked: false,
457
- planPath: (agent) => agent.hooksPath,
458
- apply: applyHooks,
461
+ id: "legacy-hooks",
462
+ label: "Remove old Miru hooks",
463
+ description: "clean up stale search hooks from older releases",
464
+ defaultChecked: true,
465
+ planPath: (agent) => agent.legacyHooksPath,
466
+ apply: removeLegacyHooks,
459
467
  },
460
468
  {
461
469
  id: "caveman",
@@ -593,12 +601,25 @@ export async function removeUninstallLocalData(): Promise<{
593
601
  };
594
602
  }
595
603
 
596
- export async function runInstaller(mode: InstallMode): Promise<void> {
604
+ export async function runInstaller(
605
+ mode: InstallMode,
606
+ dependencies: RunInstallerDependencies = {},
607
+ ): Promise<void> {
608
+ const deps = {
609
+ requireInteractiveTerminal,
610
+ ensureCredentials,
611
+ agents: AGENT_TARGETS,
612
+ isAgentDetected,
613
+ promptMultiSelect,
614
+ promptConfirm,
615
+ removeUninstallLocalData,
616
+ ...dependencies,
617
+ };
597
618
  const install = mode === "install";
598
- requireInteractiveTerminal(`miru ${mode}`);
619
+ deps.requireInteractiveTerminal(`miru ${mode}`);
599
620
 
600
621
  if (install) {
601
- await ensureCredentials({ interactive: true });
622
+ await deps.ensureCredentials({ interactive: true });
602
623
  }
603
624
 
604
625
  writeStdout("");
@@ -607,9 +628,9 @@ export async function runInstaller(mode: InstallMode): Promise<void> {
607
628
  hint("↑↓ move space select enter confirm");
608
629
 
609
630
  const detected = await Promise.all(
610
- AGENT_TARGETS.map(async (agent) => ({
631
+ deps.agents.map(async (agent) => ({
611
632
  agent,
612
- detected: await isAgentDetected(agent),
633
+ detected: await deps.isAgentDetected(agent),
613
634
  })),
614
635
  );
615
636
 
@@ -619,7 +640,7 @@ export async function runInstaller(mode: InstallMode): Promise<void> {
619
640
  checked: isDetected && install,
620
641
  }));
621
642
 
622
- const chosenAgents = await promptMultiSelect(
643
+ const chosenAgents = await deps.promptMultiSelect(
623
644
  `Agents to ${install ? "configure" : "clean up"}`,
624
645
  agentItems,
625
646
  );
@@ -641,7 +662,7 @@ export async function runInstaller(mode: InstallMode): Promise<void> {
641
662
  checked: install ? (integration.defaultChecked ?? true) : true,
642
663
  }));
643
664
 
644
- const chosenIntegrations = await promptMultiSelect(
665
+ const chosenIntegrations = await deps.promptMultiSelect(
645
666
  `Integrations to ${install ? "enable" : "remove"}`,
646
667
  integrationItems,
647
668
  );
@@ -653,7 +674,10 @@ export async function runInstaller(mode: InstallMode): Promise<void> {
653
674
 
654
675
  await printPlan(mode, chosenAgents, chosenIntegrations);
655
676
 
656
- const proceed = await promptConfirm(install ? "Proceed?" : "Remove miru configuration?", install);
677
+ const proceed = await deps.promptConfirm(
678
+ install ? "Proceed?" : "Remove miru configuration?",
679
+ install,
680
+ );
657
681
  if (!proceed) {
658
682
  hint("Cancelled.");
659
683
  return;
@@ -662,7 +686,7 @@ export async function runInstaller(mode: InstallMode): Promise<void> {
662
686
  await apply(mode, chosenAgents, chosenIntegrations);
663
687
 
664
688
  if (!install) {
665
- const local = await removeUninstallLocalData();
689
+ const local = await deps.removeUninstallLocalData();
666
690
  if (local.benchmarkHistoryCleared) {
667
691
  info(`Removed benchmark report ${local.benchmarkHistoryPath}`);
668
692
  }
@@ -674,7 +698,6 @@ export async function runInstaller(mode: InstallMode): Promise<void> {
674
698
 
675
699
  export {
676
700
  applyCaveman,
677
- applyHooks,
678
701
  applyInstructions,
679
702
  applyMcp,
680
703
  applySte,
@@ -684,3 +707,5 @@ export {
684
707
  mergeJsonMember as mergeMcpJson,
685
708
  removeJsonMember as removeMcpJson,
686
709
  };
710
+
711
+ export const installerTestUtils = { applyCursorRules, printPlan };
@@ -0,0 +1,131 @@
1
+ import { unlink } from "node:fs/promises";
2
+ import type { InstallAction, LegacyHooksFormat } from "./agents.ts";
3
+ import { stripJsonComments } from "./config.ts";
4
+ import { isCopilotFamilyId } from "./copilot-family.ts";
5
+
6
+ const LEGACY_MARKERS = ["hook-guard", "miru-search"];
7
+ const VSCODE_OWNERS_KEY = "miruOwners";
8
+
9
+ function isLegacyCommand(value: unknown): boolean {
10
+ return typeof value === "string" && LEGACY_MARKERS.some((marker) => value.includes(marker));
11
+ }
12
+
13
+ function parseObject(text: string): Record<string, unknown> | null {
14
+ try {
15
+ const value: unknown = JSON.parse(stripJsonComments(text));
16
+ return value && typeof value === "object" && !Array.isArray(value)
17
+ ? (value as Record<string, unknown>)
18
+ : null;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ function object(value: unknown): Record<string, unknown> | null {
25
+ return value && typeof value === "object" && !Array.isArray(value)
26
+ ? (value as Record<string, unknown>)
27
+ : null;
28
+ }
29
+
30
+ function hasOnlyMiruConfig(data: Record<string, unknown>): boolean {
31
+ const keys = Object.keys(data);
32
+ if (keys.length === 0 || keys.every((key) => key === "version")) return true;
33
+ const hooks = object(data.hooks);
34
+ return (
35
+ !!hooks &&
36
+ Object.keys(data).every((key) => key === "version" || key === "hooks") &&
37
+ Object.keys(hooks).length === 0
38
+ );
39
+ }
40
+
41
+ async function writeResult(path: string, data: Record<string, unknown>): Promise<void> {
42
+ if (hasOnlyMiruConfig(data)) {
43
+ await unlink(path);
44
+ } else {
45
+ await Bun.write(path, `${JSON.stringify(data, null, 2)}\n`);
46
+ }
47
+ }
48
+
49
+ function isMiruEntry(format: LegacyHooksFormat, entry: Record<string, unknown>): boolean {
50
+ if (format === "claude") {
51
+ const nested = Array.isArray(entry.hooks) ? entry.hooks : [];
52
+ return nested.some((hook) => isLegacyCommand(object(hook)?.command));
53
+ }
54
+ return isLegacyCommand(entry.command);
55
+ }
56
+
57
+ /** Remove search hooks installed by older Miru releases; Miru no longer installs hooks. */
58
+ export async function removeLegacySearchHooks(
59
+ format: LegacyHooksFormat,
60
+ path: string,
61
+ owner?: string,
62
+ ): Promise<InstallAction> {
63
+ if (!(await Bun.file(path).exists())) return "not-found";
64
+
65
+ if (format === "opencode") {
66
+ await unlink(path);
67
+ return "removed";
68
+ }
69
+
70
+ const text = await Bun.file(path).text();
71
+ const data = parseObject(text);
72
+ if (!data) return "error";
73
+
74
+ const hooks = object(data.hooks);
75
+
76
+ let ownerChanged = false;
77
+ if (format === "vscode") {
78
+ const owners = Array.isArray(data[VSCODE_OWNERS_KEY])
79
+ ? (data[VSCODE_OWNERS_KEY] as unknown[]).filter(
80
+ (value): value is string => typeof value === "string" && isCopilotFamilyId(value),
81
+ )
82
+ : null;
83
+ if (owners && owner) {
84
+ const remaining = owners.filter((value) => value !== owner);
85
+ if (remaining.length > 0) {
86
+ data[VSCODE_OWNERS_KEY] = remaining;
87
+ await Bun.write(path, `${JSON.stringify(data, null, 2)}\n`);
88
+ return "updated";
89
+ }
90
+ delete data[VSCODE_OWNERS_KEY];
91
+ ownerChanged = true;
92
+ }
93
+ }
94
+
95
+ if (!hooks) {
96
+ if (!ownerChanged) return "not-found";
97
+ await writeResult(path, data);
98
+ return "removed";
99
+ }
100
+
101
+ const eventKey = {
102
+ claude: "PreToolUse",
103
+ cursor: "preToolUse",
104
+ gemini: "BeforeTool",
105
+ vscode: "PreToolUse",
106
+ kiro: "preToolUse",
107
+ windsurf: "pre_run_command",
108
+ opencode: "",
109
+ }[format];
110
+ const entries = Array.isArray(hooks[eventKey]) ? (hooks[eventKey] as unknown[]) : null;
111
+ if (!entries) {
112
+ if (!ownerChanged) return "not-found";
113
+ await writeResult(path, data);
114
+ return "removed";
115
+ }
116
+
117
+ const retained = entries.filter((entry) => {
118
+ const record = object(entry);
119
+ return !record || !isMiruEntry(format, record);
120
+ });
121
+ if (retained.length === entries.length) {
122
+ if (!ownerChanged) return "not-found";
123
+ await writeResult(path, data);
124
+ return "removed";
125
+ }
126
+ if (retained.length === 0) delete hooks[eventKey];
127
+ else hooks[eventKey] = retained;
128
+ if (Object.keys(hooks).length === 0) delete data.hooks;
129
+ await writeResult(path, data);
130
+ return "removed";
131
+ }
@@ -113,18 +113,9 @@ function parseArrowKey(chunk: Buffer, text: string): string | null {
113
113
  const introducer = text.charAt(1);
114
114
  if (introducer === "[" || introducer === "O") {
115
115
  const arrow = text.charAt(text.length - 1);
116
- if (arrow === "A") {
117
- return "up";
118
- }
119
- if (arrow === "B") {
120
- return "down";
121
- }
122
- if (arrow === "C") {
123
- return "right";
124
- }
125
- if (arrow === "D") {
126
- return "left";
127
- }
116
+ return (
117
+ ({ A: "up", B: "down", C: "right", D: "left" } as Record<string, string>)[arrow] ?? null
118
+ );
128
119
  }
129
120
  }
130
121
 
@@ -308,9 +299,13 @@ function updateConfirmPromptLine(question: string, yesSelected: boolean): void {
308
299
  );
309
300
  }
310
301
 
311
- export async function promptConfirm(question: string, defaultYes = true): Promise<boolean> {
302
+ export async function promptConfirm(
303
+ question: string,
304
+ defaultYes = true,
305
+ io: { input?: typeof input; output?: typeof output } = {},
306
+ ): Promise<boolean> {
312
307
  if (!input.isTTY) {
313
- const rl = readline.createInterface({ input, output });
308
+ const rl = readline.createInterface({ input: io.input ?? input, output: io.output ?? output });
314
309
  try {
315
310
  const hintText = defaultYes ? dim("[Y/n]") : dim("[y/N]");
316
311
  const answer = (await rl.question(`${question} ${hintText} `)).trim().toLowerCase();
@@ -354,10 +349,13 @@ export async function promptConfirm(question: string, defaultYes = true): Promis
354
349
  export async function promptMultiSelectLegacy<T>(
355
350
  title: string,
356
351
  items: Array<{ label: string; value: T; checked: boolean }>,
352
+ io: { input?: typeof input; output?: typeof output } = {},
357
353
  ): Promise<T[] | null> {
358
- output.write("\n");
359
- output.write(`${bold(title)}\n`);
360
- divider("─", 48, output);
354
+ const promptInput = io.input ?? input;
355
+ const promptOutput = io.output ?? output;
356
+ promptOutput.write("\n");
357
+ promptOutput.write(`${bold(title)}\n`);
358
+ divider("─", 48, promptOutput);
361
359
 
362
360
  for (let i = 0; i < items.length; i++) {
363
361
  const item = items[i];
@@ -365,13 +363,13 @@ export async function promptMultiSelectLegacy<T>(
365
363
  continue;
366
364
  }
367
365
  const mark = item.checked ? cyan("[x]") : dim("[ ]");
368
- output.write(` ${dim(String(i + 1).padStart(2))}. ${mark} ${item.label}\n`);
366
+ promptOutput.write(` ${dim(String(i + 1).padStart(2))}. ${mark} ${item.label}\n`);
369
367
  }
370
368
 
371
- output.write("\n");
372
- hint("Enter numbers (1,3), 'all', or press Enter for defaults", output);
369
+ promptOutput.write("\n");
370
+ hint("Enter numbers (1,3), 'all', or press Enter for defaults", promptOutput);
373
371
 
374
- const rl = readline.createInterface({ input, output });
372
+ const rl = readline.createInterface({ input: promptInput, output: promptOutput });
375
373
  try {
376
374
  const answer = (await rl.question(`${dim("> ")}`)).toLowerCase();
377
375
  if (!answer) {
@@ -390,7 +388,7 @@ export async function promptMultiSelectLegacy<T>(
390
388
  }
391
389
 
392
390
  if (indices.size === 0) {
393
- warn("No valid selection.", output);
391
+ warn("No valid selection.", promptOutput);
394
392
  return null;
395
393
  }
396
394
 
@@ -28,15 +28,17 @@ export function buildSearchPolicyBody(native: NativeToolNames): string {
28
28
 
29
29
  ${SNIPPET_GUIDANCE}
30
30
 
31
+ \`repo\` is optional, set it for another repo.
32
+
31
33
  Use Miru MCP tools:
32
34
  - \`locate\` — exact substring (env var, symbol, error code); prefer \`mode=count\` or \`locations\`
33
- - \`search\` — one call per conceptual question; pass project root as \`repo\`; use \`include\` / \`exclude\` Gitignore-style globs to scope files (same syntax as \`locate\`)
35
+ - \`search\` — one call per conceptual question; use \`include\` / \`exclude\` Gitignore-style globs to scope files (same syntax as \`locate\`)
34
36
  - \`expand\` — more context in the same file when \`truncated: true\` (\`file_path\` + \`anchor_line\`)
35
37
  - \`find_related\` — similar code in other files (hits may also be snippets; use \`expand\` if truncated)
36
38
 
37
39
  Call shapes:
38
- - \`locate(literal="<exact substring>", repo="<project root>")\` — prefer \`mode="locations"\`/\`"count"\`
39
- - \`search(query="<natural language>", repo="<project root>", include=["src/auth/**"])\` — use the same \`include\` / \`exclude\` globs on \`locate\` when scoping an exact-string search
40
+ - \`locate(literal="<exact substring>")\` — prefer \`mode="locations"\`/\`"count"\`
41
+ - \`search(query="<natural language>", include=["src/auth/**"])\` — use the same \`include\` / \`exclude\` globs on \`locate\` when scoping an exact-string search
40
42
 
41
43
  Stop rules:
42
44
  - Literal in the request → \`locate\`, not \`search\`
@@ -54,7 +56,7 @@ ${buildSearchPolicyTable(native)}`;
54
56
  export function buildMcpWorkflow(native: NativeToolNames): string {
55
57
  return `### MCP workflow
56
58
 
57
- 1. Call \`search\` with \`repo\` set to the project root (local path or https:// git URL).
59
+ 1. Call \`search\`; add \`repo\` only for another repo.
58
60
  2. For exact literals, call \`locate\` (prefer \`mode=count\` or \`locations\`).
59
61
  3. On \`truncated: true\`, call \`expand\` — only if the snippet doesn't already answer.
60
62
  4. Use \`find_related\` to trace similar code in other files — not for more context in the same file.
@@ -71,7 +73,7 @@ miru find-related src/auth.ts 42 .
71
73
  \`\`\`
72
74
 
73
75
  After large refactors or CLI-only use, run \`miru clear <path>\` to rebuild the disk cache.
74
- If \`miru\` is not on \`$PATH\`, use \`bunx @takara-ai/miru-code\`.`;
76
+ If \`miru\` is not on \`$PATH\`, use \`bunx @takara-ai/miru-code@latest\`.`;
75
77
 
76
78
  export function buildSubagentBody(native: NativeToolNames): string {
77
79
  return `When Miru MCP is available, use MCP \`search\`, \`locate\`, \`expand\`, and \`find_related\` — not ${native.explorationDenied} for exploration.
@@ -118,7 +120,7 @@ export const MCP_SERVER_INSTRUCTIONS =
118
120
  "Exact token (identifier, quoted string, env var, error code) → `locate`; everything else → `search` once, no paraphrase retries. " +
119
121
  `${SNIPPET_GUIDANCE} ` +
120
122
  "`find_related` traces similar code elsewhere, not more context in the same file. " +
121
- "Always pass the project root as `repo`; local repos return `absolute_path` — use Read only to edit. " +
123
+ "`repo` is optional, good for multi repo workflows. " +
122
124
  "Native Grep/Glob only outside the indexed repo or for non-code tasks. " +
123
125
  "Default indexed scope is code, config, and docs; an empty result can mean out-of-scope, not nonexistent. " +
124
126
  "On credential errors, call this server's `auth` tool — not the host `mcp_auth` tool.";
@@ -161,5 +163,3 @@ export const MCP_FIND_RELATED_TOOL_DESCRIPTION =
161
163
  export const MCP_READ_BENCHMARK_TOOL_DESCRIPTION =
162
164
  "Cumulative Miru vs Grep token savings from saved `search` and `locate` calls. Returns compact totals {n,saved,save_pct,miru,grep}. " +
163
165
  "Do not call unless the user asks about savings.";
164
-
165
- export const SEARCH_GUARD_EXPAND_HINT = SNIPPET_GUIDANCE;
@@ -49,6 +49,11 @@ type CacheEntry = {
49
49
  lastError: Error | null;
50
50
  };
51
51
 
52
+ export interface IndexCacheDependencies {
53
+ fromSource?: typeof MiruIndex.fromSource;
54
+ watch?: typeof watch;
55
+ }
56
+
52
57
  export function mcpWatchEnabled(): boolean {
53
58
  const raw = process.env.MIRU_MCP_WATCH;
54
59
  return raw !== "0" && raw !== "false";
@@ -76,12 +81,20 @@ export function shouldIgnoreWatchPath(relativePath: string | null | undefined):
76
81
  export class IndexCache {
77
82
  private readonly content: ContentType[];
78
83
  private readonly defaultRef: string | null;
84
+ private readonly fromSource: typeof MiruIndex.fromSource;
85
+ private readonly watchSource: typeof watch;
79
86
  private readonly entries = new Map<string, CacheEntry>();
80
87
  readonly watchers = new Map<string, WatcherHandle>();
81
88
 
82
- constructor(content: ContentType[] = defaultContentTypes(), defaultRef: string | null = null) {
89
+ constructor(
90
+ content: ContentType[] = defaultContentTypes(),
91
+ defaultRef: string | null = null,
92
+ dependencies: IndexCacheDependencies = {},
93
+ ) {
83
94
  this.content = content;
84
95
  this.defaultRef = defaultRef;
96
+ this.fromSource = dependencies.fromSource ?? MiruIndex.fromSource;
97
+ this.watchSource = dependencies.watch ?? watch;
85
98
  }
86
99
 
87
100
  private ensureEntry(cacheKey: string, source: string): CacheEntry {
@@ -128,7 +141,7 @@ export class IndexCache {
128
141
  ): Promise<MiruIndex> {
129
142
  const entry = this.ensureEntry(cacheKey, source);
130
143
  const task = (async () => {
131
- const index = await MiruIndex.fromSource(source, this.content, undefined, ref);
144
+ const index = await this.fromSource(source, this.content, undefined, ref);
132
145
  if (!isGitUrl(source)) {
133
146
  await index.saveToCache(resolve(source));
134
147
  }
@@ -342,7 +355,7 @@ export class IndexCache {
342
355
 
343
356
  let nativeWatcher: ReturnType<typeof watch> | null = null;
344
357
  try {
345
- nativeWatcher = watch(resolved, { recursive: true }, (_event, filename) => {
358
+ nativeWatcher = this.watchSource(resolved, { recursive: true }, (_event, filename) => {
346
359
  this.noteFileChange(path, filename);
347
360
  });
348
361
  } catch {
package/src/mcp/server.ts CHANGED
@@ -44,8 +44,7 @@ import { getIndexForRepo, type IndexCache, toolText } from "./index-cache.ts";
44
44
  import { MiruMcpServer } from "./runtime.ts";
45
45
 
46
46
  const REPO_DESCRIPTION =
47
- "https:// or http:// git URL (e.g. https://github.com/org/repo) or local directory path to index and search. " +
48
- "Pass the project root for local workspaces. " +
47
+ "`repo` is optional. Set a local path or Git URL for another repo. " +
49
48
  "The index is built on the first tool call and cached for the session.";
50
49
 
51
50
  const BENCHMARK_SKIP_NOTES = {
@@ -121,9 +120,26 @@ async function persistBenchmarkQuery(
121
120
 
122
121
  export function createMcpServer(
123
122
  cache: IndexCache,
124
- options?: { benchmark?: boolean },
123
+ options?: {
124
+ benchmark?: boolean;
125
+ dependencies?: {
126
+ withGrepTimeoutFallback?: typeof withGrepTimeoutFallback;
127
+ benchmarkSearchComparison?: typeof benchmarkSearchComparison;
128
+ benchmarkLocateComparison?: typeof benchmarkLocateComparison;
129
+ selectComparableLiteralSearchTool?: typeof selectComparableLiteralSearchTool;
130
+ readBenchmarkRollup?: typeof readBenchmarkRollup;
131
+ };
132
+ },
125
133
  ): MiruMcpServer {
134
+ const defaultRepo = process.cwd();
126
135
  const benchmark = options?.benchmark ?? false;
136
+ const dependencies = options?.dependencies;
137
+ const runWithGrepFallback = dependencies?.withGrepTimeoutFallback ?? withGrepTimeoutFallback;
138
+ const compareSearch = dependencies?.benchmarkSearchComparison ?? benchmarkSearchComparison;
139
+ const compareLocate = dependencies?.benchmarkLocateComparison ?? benchmarkLocateComparison;
140
+ const selectLiteralBaseline =
141
+ dependencies?.selectComparableLiteralSearchTool ?? selectComparableLiteralSearchTool;
142
+ const readRollup = dependencies?.readBenchmarkRollup ?? readBenchmarkRollup;
127
143
  const server = new MiruMcpServer(
128
144
  {
129
145
  name: "miru",
@@ -139,14 +155,14 @@ export function createMcpServer(
139
155
  server.registerTool(
140
156
  "search",
141
157
  {
142
- description: `${MCP_SEARCH_TOOL_DESCRIPTION} Indexes \`repo\` on first call; later calls reuse the session cache.`,
158
+ description: `${MCP_SEARCH_TOOL_DESCRIPTION} Indexes the selected directory or URL on first call; later calls reuse the session cache.`,
143
159
  inputSchema: {
144
160
  query: z
145
161
  .string()
146
162
  .describe(
147
163
  "Natural language or code query — your default for all code search in this repo.",
148
164
  ),
149
- repo: z.string().describe(REPO_DESCRIPTION),
165
+ repo: z.string().optional().describe(REPO_DESCRIPTION),
150
166
  include: z
151
167
  .array(z.string().min(1))
152
168
  .min(1)
@@ -169,15 +185,16 @@ export function createMcpServer(
169
185
  },
170
186
  async ({ query, repo, include, exclude, dedupe_by_file: dedupeByFile }) => {
171
187
  try {
172
- const index = await getIndexForRepo(repo, cache);
173
- const repoRoot = localRepoRoot(repo);
188
+ const source = repo ?? defaultRepo;
189
+ const index = await getIndexForRepo(source, cache);
190
+ const repoRoot = localRepoRoot(source);
174
191
  const k = SEARCH_RESULT_COUNT;
175
192
  let skip: BenchmarkSkipReason | undefined;
176
193
  if (benchmark && (include || exclude)) {
177
194
  skip = "filtered_search";
178
195
  } else if (benchmark && repoRoot) {
179
- const comparison = await withGrepTimeoutFallback(() =>
180
- benchmarkSearchComparison({
196
+ const comparison = await runWithGrepFallback(() =>
197
+ compareSearch({
181
198
  query,
182
199
  repoPath: repoRoot,
183
200
  index,
@@ -245,7 +262,7 @@ export function createMcpServer(
245
262
  .describe(
246
263
  "Alias for `literal` — accepted so a call shaped like `search(query=...)` still resolves as an exact-substring lookup.",
247
264
  ),
248
- repo: z.string().describe(REPO_DESCRIPTION),
265
+ repo: z.string().optional().describe(REPO_DESCRIPTION),
249
266
  mode: z
250
267
  .enum(["count", "locations", "lines"])
251
268
  .optional()
@@ -291,19 +308,20 @@ export function createMcpServer(
291
308
  return toolErrorText(new Error("locate requires `literal` (exact substring to find)."));
292
309
  }
293
310
  try {
294
- const index = await getIndexForRepo(repo, cache);
311
+ const source = repo ?? defaultRepo;
312
+ const index = await getIndexForRepo(source, cache);
295
313
 
296
314
  let skip: BenchmarkSkipReason | undefined;
297
- const repoRoot = localRepoRoot(repo);
315
+ const repoRoot = localRepoRoot(source);
298
316
  if (benchmark && repoRoot && typeof lit === "string") {
299
317
  if (locateOpts.limit != null) {
300
318
  skip = "limited_locate";
301
- } else if (!selectComparableLiteralSearchTool()) {
319
+ } else if (!selectLiteralBaseline()) {
302
320
  skip = "incompatible_literal_baseline";
303
321
  }
304
322
  if (!skip) {
305
- const comparison = await withGrepTimeoutFallback(() =>
306
- benchmarkLocateComparison({
323
+ const comparison = await runWithGrepFallback(() =>
324
+ compareLocate({
307
325
  literal: lit,
308
326
  repoPath: repoRoot,
309
327
  index,
@@ -343,7 +361,7 @@ export function createMcpServer(
343
361
  .number()
344
362
  .int()
345
363
  .describe("Line from the search hit (`anchor_line` when truncated, else `start_line`)."),
346
- repo: z.string().describe(REPO_DESCRIPTION),
364
+ repo: z.string().optional().describe(REPO_DESCRIPTION),
347
365
  before: z
348
366
  .number()
349
367
  .int()
@@ -360,8 +378,9 @@ export function createMcpServer(
360
378
  },
361
379
  async ({ file_path: filePath, anchor_line: anchorLine, repo, before, after }) => {
362
380
  try {
363
- const index = await getIndexForRepo(repo, cache);
364
- const repoRoot = localRepoRoot(repo);
381
+ const source = repo ?? defaultRepo;
382
+ const index = await getIndexForRepo(source, cache);
383
+ const repoRoot = localRepoRoot(source);
365
384
  const beforeCount = before ?? DEFAULT_EXPAND_BEFORE;
366
385
  const afterCount = after ?? DEFAULT_EXPAND_AFTER;
367
386
  const { anchor, chunks: expanded } = expandChunksAtLine(
@@ -404,13 +423,14 @@ export function createMcpServer(
404
423
  .number()
405
424
  .int()
406
425
  .describe("Line from the search hit (`anchor_line` when truncated, else `start_line`)."),
407
- repo: z.string().describe(REPO_DESCRIPTION),
426
+ repo: z.string().optional().describe(REPO_DESCRIPTION),
408
427
  },
409
428
  },
410
429
  async ({ file_path: filePath, anchor_line: anchorLine, repo }) => {
411
430
  try {
412
- const index = await getIndexForRepo(repo, cache);
413
- const repoRoot = localRepoRoot(repo);
431
+ const source = repo ?? defaultRepo;
432
+ const index = await getIndexForRepo(source, cache);
433
+ const repoRoot = localRepoRoot(source);
414
434
  const chunk = resolveChunk(index.chunks, filePath, anchorLine, repoRoot);
415
435
  if (!chunk) {
416
436
  return toolText(
@@ -452,7 +472,7 @@ export function createMcpServer(
452
472
  },
453
473
  async ({ repo }) => {
454
474
  try {
455
- const rollup = await readBenchmarkRollup({ repo });
475
+ const rollup = await readRollup({ repo });
456
476
  return toolText(JSON.stringify(rollup));
457
477
  } catch (err) {
458
478
  return toolText(err instanceof Error ? err.message : String(err));