@takara-ai/miru-code 1.8.2 → 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 (60) hide show
  1. package/README.md +65 -53
  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 +54 -98
  24. package/src/credentials.ts +14 -14
  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 +7 -7
  30. package/src/index/bm25.ts +66 -6
  31. package/src/index/chunk-selector.ts +39 -11
  32. package/src/index/create.ts +5 -9
  33. package/src/index/files.ts +5 -1
  34. package/src/index/incremental.ts +48 -8
  35. package/src/index/sparse.ts +14 -2
  36. package/src/installer/agents.ts +85 -65
  37. package/src/installer/benchmark-mode.ts +40 -11
  38. package/src/installer/config.ts +105 -42
  39. package/src/installer/installer.ts +59 -34
  40. package/src/installer/legacy-hooks.ts +131 -0
  41. package/src/installer/prompt.ts +20 -22
  42. package/src/installer/search-policy.ts +10 -10
  43. package/src/mcp/index-cache.ts +16 -3
  44. package/src/mcp/server.ts +65 -46
  45. package/src/mcp/stdio.ts +17 -2
  46. package/src/miru-index.ts +90 -22
  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 +8 -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 +1 -15
  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
@@ -1,4 +1,9 @@
1
- import { AGENT_TARGETS, type AgentTarget, type InstallAction } from "./agents.ts";
1
+ import {
2
+ AGENT_TARGETS,
3
+ type AgentTarget,
4
+ type InstallAction,
5
+ MIRU_BUNX_PACKAGE,
6
+ } from "./agents.ts";
2
7
  import { removeTomlBlock, stripJsonComments } from "./config.ts";
3
8
 
4
9
  export const MCP_BENCHMARK_FLAG = "--benchmark";
@@ -7,9 +12,25 @@ const CODEX_MCP_HEADER = "[mcp_servers.miru]";
7
12
 
8
13
  function codexMcpBlock(enabled: boolean): string {
9
14
  const args = enabled
10
- ? `["@takara-ai/miru-code", "${MCP_BENCHMARK_FLAG}"]`
11
- : '["@takara-ai/miru-code"]';
12
- return `${CODEX_MCP_HEADER}\ncommand = "bunx"\nargs = ${args}\n`;
15
+ ? `["${MIRU_BUNX_PACKAGE}", "mcp", "${MCP_BENCHMARK_FLAG}"]`
16
+ : `["${MIRU_BUNX_PACKAGE}", "mcp"]`;
17
+ return `${CODEX_MCP_HEADER}\ncommand = "bunx"\nargs = ${args}\nstartup_timeout_sec = 60\n`;
18
+ }
19
+
20
+ function withMcpSubcommand(list: string[], command: unknown): string[] {
21
+ if (list.includes("mcp")) return list;
22
+ if (command === "miru") return ["mcp", ...list];
23
+
24
+ const packageIndex = list.findIndex((item) => /^@takara-ai\/miru-code(?:@.+)?$/.test(item));
25
+ if (packageIndex >= 0) {
26
+ return [...list.slice(0, packageIndex + 1), "mcp", ...list.slice(packageIndex + 1)];
27
+ }
28
+
29
+ const miruIndex = list.indexOf("miru");
30
+ if (miruIndex >= 0) {
31
+ return [...list.slice(0, miruIndex + 1), "mcp", ...list.slice(miruIndex + 1)];
32
+ }
33
+ return list;
13
34
  }
14
35
 
15
36
  export function withBenchmarkFlag(list: string[], enabled: boolean): string[] {
@@ -31,7 +52,7 @@ export function applyBenchmarkFlagToMcpEntry(
31
52
 
32
53
  if (Array.isArray(next.args)) {
33
54
  const args = next.args.filter((item): item is string => typeof item === "string");
34
- const updated = withBenchmarkFlag(args, enabled);
55
+ const updated = withBenchmarkFlag(withMcpSubcommand(args, next.command), enabled);
35
56
  if (JSON.stringify(updated) !== JSON.stringify(args)) {
36
57
  next.args = updated;
37
58
  changed = true;
@@ -40,7 +61,7 @@ export function applyBenchmarkFlagToMcpEntry(
40
61
 
41
62
  if (Array.isArray(next.command)) {
42
63
  const command = next.command.filter((item): item is string => typeof item === "string");
43
- const updated = withBenchmarkFlag(command, enabled);
64
+ const updated = withBenchmarkFlag(withMcpSubcommand(command, next.command), enabled);
44
65
  if (JSON.stringify(updated) !== JSON.stringify(command)) {
45
66
  next.command = updated;
46
67
  changed = true;
@@ -122,7 +143,10 @@ async function setTomlBenchmark(
122
143
  return "missing";
123
144
  }
124
145
  const current = await statusForToml(path);
125
- if (current === enabled) {
146
+ const section =
147
+ text.slice(text.indexOf(CODEX_MCP_HEADER) + CODEX_MCP_HEADER.length).split(/^\[/m)[0] ?? "";
148
+ const hasMcpArg = /^args\s*=\s*\[[^\n]*["']mcp["']/m.test(section);
149
+ if (current === enabled && hasMcpArg) {
126
150
  return "unchanged";
127
151
  }
128
152
 
@@ -190,9 +214,11 @@ async function setJsonBenchmark(
190
214
  }
191
215
 
192
216
  /** Inspect installed Miru MCP configs for `--benchmark`. */
193
- export async function getBenchmarkModeStatus(): Promise<BenchmarkModeTargetResult[]> {
217
+ export async function getBenchmarkModeStatus(
218
+ targets: readonly AgentTarget[] = AGENT_TARGETS,
219
+ ): Promise<BenchmarkModeTargetResult[]> {
194
220
  const results: BenchmarkModeTargetResult[] = [];
195
- for (const agent of AGENT_TARGETS) {
221
+ for (const agent of targets) {
196
222
  const mcp = agent.mcp;
197
223
  if (!mcp) {
198
224
  continue;
@@ -219,9 +245,12 @@ export async function getBenchmarkModeStatus(): Promise<BenchmarkModeTargetResul
219
245
  }
220
246
 
221
247
  /** Enable or disable `--benchmark` on every installed Miru MCP entry. */
222
- export async function setBenchmarkMode(enabled: boolean): Promise<BenchmarkModeTargetResult[]> {
248
+ export async function setBenchmarkMode(
249
+ enabled: boolean,
250
+ targets: readonly AgentTarget[] = AGENT_TARGETS,
251
+ ): Promise<BenchmarkModeTargetResult[]> {
223
252
  const results: BenchmarkModeTargetResult[] = [];
224
- for (const agent of AGENT_TARGETS) {
253
+ for (const agent of targets) {
225
254
  const mcp = agent.mcp;
226
255
  if (!mcp) {
227
256
  continue;
@@ -1,15 +1,17 @@
1
1
  import { unlink } from "node:fs/promises";
2
- import { type InstallAction, MIRU_END, MIRU_START } from "./agents.ts";
2
+ import { type InstallAction, MIRU_BUNX_PACKAGE, MIRU_END, MIRU_START } from "./agents.ts";
3
3
 
4
4
  const CODEX_MCP_HEADER = "[mcp_servers.miru]";
5
5
  const CODEX_MCP_BLOCK = `[mcp_servers.miru]
6
6
  command = "bunx"
7
- args = ["@takara-ai/miru-code"]
7
+ args = ["${MIRU_BUNX_PACKAGE}", "mcp"]
8
+ startup_timeout_sec = 60
8
9
  `;
9
10
  /** Same Codex block with `--benchmark` preserved across reinstall. */
10
11
  const CODEX_MCP_BLOCK_BENCHMARK = `[mcp_servers.miru]
11
12
  command = "bunx"
12
- args = ["@takara-ai/miru-code", "--benchmark"]
13
+ args = ["${MIRU_BUNX_PACKAGE}", "mcp", "--benchmark"]
14
+ startup_timeout_sec = 60
13
15
  `;
14
16
 
15
17
  /** Strip line and block comments so JSONC configs can be parsed. */
@@ -81,14 +83,16 @@ function sectionObject(
81
83
  root: Record<string, unknown>,
82
84
  sectionKey: string,
83
85
  ): Record<string, unknown> | "error" {
84
- const section = root[sectionKey];
85
- if (section === undefined) {
86
- return {};
87
- }
88
- if (!section || typeof section !== "object" || Array.isArray(section)) {
89
- return "error";
90
- }
91
- return section as Record<string, unknown>;
86
+ let section: unknown = root;
87
+ for (const key of sectionKey.split(".")) {
88
+ if (!section || typeof section !== "object" || Array.isArray(section)) return "error";
89
+ const child = (section as Record<string, unknown>)[key];
90
+ if (child === undefined) return {};
91
+ section = child;
92
+ }
93
+ return section && typeof section === "object" && !Array.isArray(section)
94
+ ? (section as Record<string, unknown>)
95
+ : "error";
92
96
  }
93
97
 
94
98
  export async function mergeJsonMember(
@@ -143,11 +147,12 @@ export async function removeJsonMember(
143
147
  }
144
148
  const nextText = removeJsonMemberText(text, sectionKey, memberKey);
145
149
  const nextObject = withRemovedMember(parsed, sectionKey, memberKey);
146
- const next = nextText ?? JSON.stringify(nextObject, null, 2);
147
- if (ensureTrailingNewline(next) === ensureTrailingNewline(text)) {
148
- return "not-found";
150
+ let next = nextText ?? JSON.stringify(nextObject, null, 2);
151
+ let nextParsed = parseJsonObject(next);
152
+ if (nextParsed === "error") {
153
+ next = JSON.stringify(nextObject, null, 2);
154
+ nextParsed = parseJsonObject(next);
149
155
  }
150
- const nextParsed = parseJsonObject(next);
151
156
  if (!nextParsed || typeof nextParsed !== "object" || Object.keys(nextParsed).length === 0) {
152
157
  await unlink(path);
153
158
  return "removed";
@@ -162,14 +167,24 @@ function withMergedMember(
162
167
  memberKey: string,
163
168
  value: Record<string, unknown>,
164
169
  ): Record<string, unknown> {
165
- const next = { ...root };
166
- const section =
167
- next[sectionKey] && typeof next[sectionKey] === "object" && !Array.isArray(next[sectionKey])
168
- ? { ...(next[sectionKey] as Record<string, unknown>) }
169
- : {};
170
- section[memberKey] = value;
171
- next[sectionKey] = section;
172
- return next;
170
+ const keys = sectionKey.split(".");
171
+ const update = (object: Record<string, unknown>, depth: number): Record<string, unknown> => {
172
+ const next = { ...object };
173
+ if (depth === keys.length) {
174
+ next[memberKey] = value;
175
+ return next;
176
+ }
177
+ const key = keys[depth];
178
+ if (!key) return next;
179
+ const child = next[key];
180
+ const section =
181
+ child && typeof child === "object" && !Array.isArray(child)
182
+ ? (child as Record<string, unknown>)
183
+ : {};
184
+ next[key] = update(section, depth + 1);
185
+ return next;
186
+ };
187
+ return update(root, 0);
173
188
  }
174
189
 
175
190
  function withRemovedMember(
@@ -177,21 +192,29 @@ function withRemovedMember(
177
192
  sectionKey: string,
178
193
  memberKey: string,
179
194
  ): Record<string, unknown> {
180
- const next = { ...root };
181
- const section =
182
- next[sectionKey] && typeof next[sectionKey] === "object" && !Array.isArray(next[sectionKey])
183
- ? { ...(next[sectionKey] as Record<string, unknown>) }
184
- : null;
185
- if (!section || !(memberKey in section)) {
186
- return next;
187
- }
188
- delete section[memberKey];
189
- if (Object.keys(section).length === 0) {
190
- delete next[sectionKey];
191
- } else {
192
- next[sectionKey] = section;
193
- }
194
- return next;
195
+ const keys = sectionKey.split(".");
196
+ const remove = (
197
+ object: Record<string, unknown>,
198
+ depth: number,
199
+ ): { value: Record<string, unknown>; changed: boolean } => {
200
+ const next = { ...object };
201
+ if (depth === keys.length) {
202
+ if (!(memberKey in next)) return { value: next, changed: false };
203
+ delete next[memberKey];
204
+ return { value: next, changed: true };
205
+ }
206
+ const key = keys[depth];
207
+ const child = key ? next[key] : undefined;
208
+ if (!key || !child || typeof child !== "object" || Array.isArray(child)) {
209
+ return { value: next, changed: false };
210
+ }
211
+ const result = remove(child as Record<string, unknown>, depth + 1);
212
+ if (!result.changed) return { value: next, changed: false };
213
+ if (Object.keys(result.value).length === 0) delete next[key];
214
+ else next[key] = result.value;
215
+ return { value: next, changed: true };
216
+ };
217
+ return remove(root, 0).value;
195
218
  }
196
219
 
197
220
  function upsertJsonMemberText(
@@ -292,13 +315,38 @@ function hasJsoncSyntax(text: string): boolean {
292
315
  return false;
293
316
  }
294
317
 
295
- /**
296
- * Locate a section object at the document root only.
297
- * Nested keys with the same name (e.g. Claude Code projects.*.mcpServers) are ignored.
298
- */
318
+ /** Locate an object section by dotted path, matching each key among direct children. */
299
319
  function findSectionRange(
300
320
  text: string,
301
321
  sectionKey: string,
322
+ ): { openBrace: number; closeBrace: number; indent: string } | null {
323
+ const keys = sectionKey.split(".");
324
+ let container = text;
325
+ let offset = 0;
326
+ let result: { openBrace: number; closeBrace: number; indent: string } | null = null;
327
+ for (let index = 0; index < keys.length; index++) {
328
+ const key = keys[index];
329
+ if (!key) return null;
330
+ result = findDirectSectionRange(container, key);
331
+ if (!result) return null;
332
+ if (index < keys.length - 1) {
333
+ offset += result.openBrace;
334
+ container = container.slice(result.openBrace, result.closeBrace + 1);
335
+ }
336
+ }
337
+ return result
338
+ ? {
339
+ ...result,
340
+ openBrace: offset + result.openBrace,
341
+ closeBrace: offset + result.closeBrace,
342
+ }
343
+ : null;
344
+ }
345
+
346
+ /** Locate a single child object in a root object, ignoring similarly named nested keys. */
347
+ function findDirectSectionRange(
348
+ text: string,
349
+ sectionKey: string,
302
350
  ): { openBrace: number; closeBrace: number; indent: string } | null {
303
351
  const keyPrefix = new RegExp(`^"${escapeRegExp(sectionKey)}"\\s*:\\s*\\{`);
304
352
  let depth = 0;
@@ -501,6 +549,21 @@ function ensureTrailingNewline(text: string): string {
501
549
  return text.endsWith("\n") ? text : `${text}\n`;
502
550
  }
503
551
 
552
+ /** Internal parser helpers exposed for focused edge-case tests. */
553
+ export const jsoncTestUtils = {
554
+ hasJsoncSyntax,
555
+ findSectionRange,
556
+ findMatchingBrace,
557
+ parseJsonValueEnd,
558
+ findMatchingBracket,
559
+ findStringEnd,
560
+ skipWhitespace,
561
+ findLeadingComma,
562
+ withRemovedMember,
563
+ upsertJsonMemberText,
564
+ removeJsonMemberText,
565
+ };
566
+
504
567
  export async function replaceOrAppendMarked(path: string, content: string): Promise<InstallAction> {
505
568
  const existed = await Bun.file(path).exists();
506
569
  const existing = existed ? await Bun.file(path).text() : "";
@@ -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
+ }