@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
@@ -4,14 +4,16 @@ export type ChunkIndexMappings = {
4
4
  };
5
5
 
6
6
  /**
7
- * Union of chunk indices matching language and/or path filters.
7
+ * Union of chunk indices matching language and/or path filters. Path filters
8
+ * match an exact indexed file path or every file below a directory prefix.
8
9
  *
9
10
  * Filters combine with OR semantics: a chunk is included if it matches any
10
11
  * requested language or path. Returns `undefined` when no filters are given
11
- * (search all chunks) or when no chunks match.
12
+ * (search all chunks), and an empty array when filters match no chunks.
12
13
  *
13
- * Single-filter queries return the prebuilt map array directly (read-only for
14
- * callers). Multi-filter queries allocate a deduped index list.
14
+ * Exact-file and single-language queries return the prebuilt map array
15
+ * directly (read-only for callers). Directory and multi-filter queries
16
+ * allocate an index list.
15
17
  */
16
18
  export function buildChunkSelector(
17
19
  mappings: ChunkIndexMappings,
@@ -28,19 +30,28 @@ export function buildChunkSelector(
28
30
  if (langCount === 1 && pathCount === 0) {
29
31
  const lang = filterLanguages?.[0];
30
32
  if (!lang) {
31
- return undefined;
33
+ return [];
32
34
  }
33
35
  const indices = mappings.languageMapping.get(lang);
34
- return indices && indices.length > 0 ? indices : undefined;
36
+ return indices && indices.length > 0 ? indices : [];
35
37
  }
36
38
 
37
39
  if (pathCount === 1 && langCount === 0) {
38
40
  const fp = filterPaths?.[0];
39
41
  if (!fp) {
40
- return undefined;
42
+ return [];
41
43
  }
42
- const indices = mappings.fileMapping.get(fp);
43
- return indices && indices.length > 0 ? indices : undefined;
44
+ const normalized = normalizePathFilter(fp);
45
+ const exact = [...mappings.fileMapping.entries()].find(
46
+ ([filePath]) => filePath.replace(/\\/g, "/") === normalized,
47
+ )?.[1];
48
+ if (exact) {
49
+ return exact;
50
+ }
51
+ const prefix = `${normalized}/`;
52
+ return [...mappings.fileMapping.entries()]
53
+ .filter(([filePath]) => filePath.replace(/\\/g, "/").startsWith(prefix))
54
+ .flatMap(([, indices]) => indices);
44
55
  }
45
56
 
46
57
  const selector: number[] = [];
@@ -48,10 +59,27 @@ export function buildChunkSelector(
48
59
  selector.push(...(mappings.languageMapping.get(lang) ?? []));
49
60
  }
50
61
  for (const fp of filterPaths ?? []) {
51
- selector.push(...(mappings.fileMapping.get(fp) ?? []));
62
+ const normalized = normalizePathFilter(fp);
63
+ const exact = [...mappings.fileMapping.entries()].find(
64
+ ([filePath]) => filePath.replace(/\\/g, "/") === normalized,
65
+ )?.[1];
66
+ if (exact) {
67
+ selector.push(...exact);
68
+ continue;
69
+ }
70
+ const prefix = `${normalized}/`;
71
+ for (const [filePath, indices] of mappings.fileMapping) {
72
+ if (filePath.replace(/\\/g, "/").startsWith(prefix)) {
73
+ selector.push(...indices);
74
+ }
75
+ }
52
76
  }
53
77
  if (selector.length === 0) {
54
- return undefined;
78
+ return [];
55
79
  }
56
80
  return [...new Set(selector)];
57
81
  }
82
+
83
+ function normalizePathFilter(filePath: string): string {
84
+ return filePath.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "");
85
+ }
@@ -136,9 +136,6 @@ export async function createIndexFromPath(
136
136
  }
137
137
 
138
138
  const take = Math.min(embedBatchSize, pendingWindows.length);
139
- if (!force && take < embedBatchSize) {
140
- break;
141
- }
142
139
 
143
140
  const batch = pendingWindows.splice(0, take);
144
141
  emittedApiBatches++;
@@ -158,9 +155,10 @@ export async function createIndexFromPath(
158
155
  })();
159
156
  embedPromises.push(promise);
160
157
  embedInFlight.add(promise);
161
- promise.finally(() => {
162
- embedInFlight.delete(promise);
163
- });
158
+ promise.then(
159
+ () => embedInFlight.delete(promise),
160
+ () => embedInFlight.delete(promise),
161
+ );
164
162
  }
165
163
  };
166
164
 
@@ -209,9 +207,7 @@ export async function createIndexFromPath(
209
207
  await maybeScheduleEmbed(true);
210
208
  await Promise.all(embedPromises);
211
209
  // Finish any BM25 docs not yet processed during idle gaps.
212
- while (bm25Cursor < emittedChunks.length) {
213
- progressBm25(50);
214
- }
210
+ progressBm25(Number.POSITIVE_INFINITY);
215
211
  const embedsDoneAt = performance.now();
216
212
 
217
213
  const chunks = emittedChunks;
@@ -52,6 +52,9 @@ const EXTENSION_TO_LANGUAGE: Record<string, string> = {
52
52
  ".zig": "zig",
53
53
  ".vue": "vue",
54
54
  ".svelte": "svelte",
55
+ ".astro": "astro",
56
+ ".erb": "embeddedtemplate",
57
+ ".ejs": "embeddedtemplate",
55
58
  ".md": "markdown",
56
59
  ".mdx": "markdown",
57
60
  ".rst": "rst",
@@ -64,6 +67,7 @@ const EXTENSION_TO_LANGUAGE: Record<string, string> = {
64
67
  ".cfg": "ini",
65
68
  ".xml": "xml",
66
69
  ".html": "html",
70
+ ".htm": "html",
67
71
  ".css": "css",
68
72
  ".scss": "scss",
69
73
  ".less": "less",
@@ -74,7 +78,7 @@ const CODE_EXTENSIONS = new Set(
74
78
  Object.entries(EXTENSION_TO_LANGUAGE)
75
79
  .filter(
76
80
  ([, lang]) =>
77
- !["markdown", "rst", "text", "json", "yaml", "toml", "ini", "xml", "html"].includes(lang),
81
+ !["markdown", "rst", "text", "json", "yaml", "toml", "ini", "xml"].includes(lang),
78
82
  )
79
83
  .map(([ext]) => ext),
80
84
  );
@@ -5,7 +5,7 @@ import type { Chunk, ContentType } from "../types.ts";
5
5
  import type { BM25Index } from "./bm25.ts";
6
6
  import { detectLanguage, getExtensions, getFileStatus, readFileText } from "./files.ts";
7
7
  import type { SemanticIndex } from "./semantic-index.ts";
8
- import { buildBm25FromChunks } from "./sparse.ts";
8
+ import { buildBm25FromChunks, patchBm25 } from "./sparse.ts";
9
9
  import { buildSemanticIndex } from "./vector-storage.ts";
10
10
  import { vectorAt } from "./vectors.ts";
11
11
 
@@ -40,9 +40,34 @@ function isIndexableRelativePath(relativePath: string, extensions: Set<string>):
40
40
  return extensions.has(ext);
41
41
  }
42
42
 
43
+ /** Vector for every text: reused from `known`, otherwise embedded once (duplicates share a call). */
44
+ async function vectorsByText(
45
+ embeddings: EmbeddingBackend,
46
+ texts: readonly string[],
47
+ known: ReadonlyMap<string, Float32Array>,
48
+ ): Promise<Map<string, Float32Array>> {
49
+ const result = new Map(known);
50
+ const missing = [...new Set(texts)].filter((text) => !result.has(text));
51
+ if (missing.length === 0) {
52
+ return result;
53
+ }
54
+ const embedded = await embeddings.embedDocuments(missing);
55
+ if (embedded.length !== missing.length) {
56
+ throw new Error(
57
+ `Vector count ${embedded.length} does not match chunk count ${missing.length}.`,
58
+ );
59
+ }
60
+ for (const [i, text] of missing.entries()) {
61
+ result.set(text, embedded[i] as Float32Array);
62
+ }
63
+ return result;
64
+ }
65
+
43
66
  /**
44
67
  * Replace chunks for the given repo-relative paths only: remove old chunks,
45
- * embed new ones, rebuild BM25 + semantic indexes from the merged vector set.
68
+ * embed new ones, rebuild the semantic index from the merged vector set, and update
69
+ * BM25 by dropping the old docs and indexing only the new chunks. Pass `bm25` (the
70
+ * index matching `chunks`) to enable that; without it BM25 is rebuilt from scratch.
46
71
  */
47
72
  export async function applyIncrementalFileChanges(options: {
48
73
  root: string;
@@ -50,6 +75,7 @@ export async function applyIncrementalFileChanges(options: {
50
75
  embeddings: EmbeddingBackend;
51
76
  chunks: Chunk[];
52
77
  semanticIndex: SemanticIndex;
78
+ bm25?: BM25Index;
53
79
  relativePaths: readonly string[];
54
80
  }): Promise<{ chunks: Chunk[]; bm25: BM25Index; semantic: SemanticIndex }> {
55
81
  const root = resolve(options.root);
@@ -60,16 +86,24 @@ export async function applyIncrementalFileChanges(options: {
60
86
  .filter((p) => p.length > 0 && isIndexableRelativePath(p, extensions)),
61
87
  );
62
88
 
89
+ // BM25 doc i is chunk i; an index of another size can't be patched safely.
90
+ const baseBm25 = options.bm25?.size === options.chunks.length ? options.bm25 : undefined;
91
+
63
92
  if (targets.size === 0) {
64
93
  return {
65
94
  chunks: options.chunks,
66
- bm25: buildBm25FromChunks(options.chunks),
95
+ bm25: baseBm25 ?? buildBm25FromChunks(options.chunks),
67
96
  semantic: options.semanticIndex,
68
97
  };
69
98
  }
70
99
 
71
100
  const keptChunks: Chunk[] = [];
72
101
  const keptVectors: Float32Array[] = [];
102
+ const removedDocs = new Set<number>();
103
+ // An embedding is a pure function of chunk text, so a re-chunked file can reuse
104
+ // the vector of any old chunk with byte-identical content (keyed on the exact
105
+ // string, never a hash, so a collision can't leave a stale vector).
106
+ const reusableVectors = new Map<string, Float32Array>();
73
107
 
74
108
  for (let i = 0; i < options.chunks.length; i++) {
75
109
  const chunk = options.chunks[i];
@@ -78,6 +112,10 @@ export async function applyIncrementalFileChanges(options: {
78
112
  }
79
113
  const rel = normalizeRelativePath(chunk.file_path);
80
114
  if (targets.has(rel)) {
115
+ removedDocs.add(i);
116
+ if (!reusableVectors.has(chunk.content)) {
117
+ reusableVectors.set(chunk.content, vectorAt(options.semanticIndex, i));
118
+ }
81
119
  continue;
82
120
  }
83
121
  keptChunks.push(chunk);
@@ -89,10 +127,12 @@ export async function applyIncrementalFileChanges(options: {
89
127
  addedChunks.push(...(await chunksForFile(root, rel)));
90
128
  }
91
129
 
92
- let addedVectors: Float32Array[] = [];
93
- if (addedChunks.length > 0) {
94
- addedVectors = await options.embeddings.embedDocuments(addedChunks.map((c) => c.content));
95
- }
130
+ const vectorFor = await vectorsByText(
131
+ options.embeddings,
132
+ addedChunks.map((c) => c.content),
133
+ reusableVectors,
134
+ );
135
+ const addedVectors = addedChunks.map((c) => vectorFor.get(c.content) as Float32Array);
96
136
 
97
137
  const chunks = [...keptChunks, ...addedChunks];
98
138
  const vectors = [...keptVectors, ...addedVectors];
@@ -107,7 +147,7 @@ export async function applyIncrementalFileChanges(options: {
107
147
 
108
148
  return {
109
149
  chunks,
110
- bm25: buildBm25FromChunks(chunks),
150
+ bm25: baseBm25 ? patchBm25(baseBm25, removedDocs, addedChunks) : buildBm25FromChunks(chunks),
111
151
  semantic: buildSemanticIndex(vectors),
112
152
  };
113
153
  }
@@ -30,10 +30,22 @@ export function addChunkToBm25(bm25: BM25Index, chunk: Chunk): void {
30
30
  bm25.addDocument(tokenize(enrichForBm25(chunk)));
31
31
  }
32
32
 
33
- export function buildBm25FromChunks(chunks: readonly Chunk[]): BM25Index {
34
- const bm25 = new BM25Index();
33
+ function appendChunksToBm25(bm25: BM25Index, chunks: readonly Chunk[]): BM25Index {
35
34
  for (const chunk of chunks) {
36
35
  addChunkToBm25(bm25, chunk);
37
36
  }
38
37
  return bm25;
39
38
  }
39
+
40
+ export function buildBm25FromChunks(chunks: readonly Chunk[]): BM25Index {
41
+ return appendChunksToBm25(new BM25Index(), chunks);
42
+ }
43
+
44
+ /** Drop docs `removedDocs` from `base` and append `added`; `base` is left untouched. */
45
+ export function patchBm25(
46
+ base: BM25Index,
47
+ removedDocs: ReadonlySet<number>,
48
+ added: readonly Chunk[],
49
+ ): BM25Index {
50
+ return appendChunksToBm25(base.withoutDocuments(removedDocs), added);
51
+ }
@@ -5,11 +5,7 @@ import { join } from "node:path";
5
5
  import type { AgentId } from "../agents.ts";
6
6
  import { INSTRUCTIONS_MARKDOWN } from "./search-policy.ts";
7
7
 
8
- function copilotHooksPath(home: string): string {
9
- return join(home, ".copilot", "hooks", "miru-search.json");
10
- }
11
-
12
- /** Shared Copilot / VS Code / Visual Studio config root (MCP, hooks, agents). */
8
+ /** Shared Copilot / VS Code / Visual Studio config root (MCP, agents). */
13
9
  export function copilotHomeDir(home: string): string {
14
10
  return join(home, ".copilot");
15
11
  }
@@ -55,22 +51,15 @@ export function nativeSteSkillDir(home: string, vendor: NativeSkillVendor): stri
55
51
  return skillDir(home, `.${vendor}`, "ste");
56
52
  }
57
53
 
58
- function kiroHooksPath(home: string): string {
59
- return join(home, ".kiro", "settings", "hooks.json");
60
- }
61
-
62
- function windsurfHooksPath(home: string): string {
63
- return join(home, ".codeium", "windsurf", "hooks.json");
64
- }
65
-
66
54
  /** OpenCode config root (`$XDG_CONFIG_HOME/opencode` or `~/.config/opencode`). */
67
55
  export function opencodeConfigDir(home: string): string {
68
56
  const xdg = process.env.XDG_CONFIG_HOME;
69
57
  return xdg ? join(xdg, "opencode") : join(home, ".config", "opencode");
70
58
  }
71
59
 
72
- function opencodePluginPath(home: string): string {
73
- return join(opencodeConfigDir(home), "plugins", "miru-search-guard.ts");
60
+ /** OpenCode's first-class global skill location for Miru code-search routing. */
61
+ export function opencodeMiruSkillPath(home: string): string {
62
+ return join(opencodeConfigDir(home), "skills", "miru-code-search", "SKILL.md");
74
63
  }
75
64
 
76
65
  export type InstallAction =
@@ -86,24 +75,24 @@ export type InstallMode = "install" | "uninstall";
86
75
 
87
76
  export const MIRU_START = "<!-- miru:start -->";
88
77
  export const MIRU_END = "<!-- miru:end -->";
78
+ export const MIRU_BUNX_PACKAGE = "@takara-ai/miru-code@latest";
89
79
 
90
80
  const HOME = homedir();
91
81
 
92
82
  const STDIO_SERVER_CONFIG: Record<string, unknown> = {
93
83
  command: "bunx",
94
- args: ["@takara-ai/miru-code"],
84
+ args: [MIRU_BUNX_PACKAGE, "mcp"],
95
85
  type: "stdio",
96
86
  };
97
87
 
98
88
  const BARE_STDIO_SERVER_CONFIG: Record<string, unknown> = {
99
89
  command: "bunx",
100
- args: ["@takara-ai/miru-code"],
90
+ args: [MIRU_BUNX_PACKAGE, "mcp"],
101
91
  };
102
92
 
103
93
  const OPENCODE_SERVER_CONFIG: Record<string, unknown> = {
104
- command: ["bunx", "@takara-ai/miru-code"],
94
+ command: ["bunx", MIRU_BUNX_PACKAGE, "mcp"],
105
95
  type: "local",
106
- enabled: true,
107
96
  };
108
97
 
109
98
  export const INSTRUCTIONS = `${MIRU_START}
@@ -121,7 +110,7 @@ export interface McpConfig {
121
110
  format: McpConfigFormat;
122
111
  }
123
112
 
124
- export type HooksFormat =
113
+ export type LegacyHooksFormat =
125
114
  | "claude"
126
115
  | "cursor"
127
116
  | "gemini"
@@ -138,8 +127,9 @@ export interface AgentTarget {
138
127
  mcp: McpConfig | null;
139
128
  instructionsPath: string | null;
140
129
  cursorRulesPath: string | null;
141
- hooksPath: string | null;
142
- hooksFormat: HooksFormat | null;
130
+ /** Search-hook paths are retained for uninstalling hooks created by older releases. */
131
+ legacyHooksPath: string | null;
132
+ legacyHooksFormat: LegacyHooksFormat | null;
143
133
  subagentPath: string | null;
144
134
  subagentId: AgentId | null;
145
135
  /**
@@ -151,6 +141,8 @@ export interface AgentTarget {
151
141
  * On-demand STE skill directory (`…/skills/ste/`), or null if unsupported.
152
142
  */
153
143
  steSkillDir: string | null;
144
+ /** OpenCode's global Miru code-search skill, advertised on each model step. */
145
+ miruSkillPath?: string | null;
154
146
  }
155
147
 
156
148
  export function opencodeMcpPath(): string {
@@ -166,11 +158,11 @@ export function opencodeMcpPath(): string {
166
158
  return jsonc;
167
159
  }
168
160
 
169
- export function vscodeMcpPath(): string {
170
- if (process.platform === "darwin") {
161
+ export function vscodeMcpPath(platform: NodeJS.Platform = process.platform): string {
162
+ if (platform === "darwin") {
171
163
  return join(HOME, "Library", "Application Support", "Code", "User", "mcp.json");
172
164
  }
173
- if (process.platform === "win32") {
165
+ if (platform === "win32") {
174
166
  const appData = process.env.APPDATA ?? HOME;
175
167
  return join(appData, "Code", "User", "mcp.json");
176
168
  }
@@ -184,23 +176,27 @@ export function visualStudioMcpPath(): string {
184
176
  return join(profile, ".mcp.json");
185
177
  }
186
178
 
187
- export function visualStudioInstallDir(): string | null {
188
- if (process.platform !== "win32") {
179
+ export function visualStudioInstallDir(
180
+ platform: NodeJS.Platform = process.platform,
181
+ ): string | null {
182
+ if (platform !== "win32") {
189
183
  return null;
190
184
  }
191
185
  const programFiles = process.env.ProgramFiles ?? join("C:", "Program Files");
192
186
  return join(programFiles, "Microsoft Visual Studio");
193
187
  }
194
188
 
195
- async function detectVisualStudio(): Promise<boolean> {
196
- if (process.platform !== "win32") {
189
+ async function detectVisualStudio(dependencies: AgentDetectionDependencies = {}): Promise<boolean> {
190
+ const platform = dependencies.platform ?? process.platform;
191
+ if (platform !== "win32") {
197
192
  return false;
198
193
  }
199
194
 
200
- const installDir = visualStudioInstallDir();
201
- if (installDir && existsSync(installDir)) {
195
+ const installDir = visualStudioInstallDir(platform);
196
+ const pathExists = dependencies.existsSync ?? existsSync;
197
+ if (installDir && pathExists(installDir)) {
202
198
  try {
203
- const entries = await readdir(installDir);
199
+ const entries = await (dependencies.readdir ?? ((path: string) => readdir(path)))(installDir);
204
200
  if (entries.length > 0) {
205
201
  return true;
206
202
  }
@@ -210,8 +206,9 @@ async function detectVisualStudio(): Promise<boolean> {
210
206
  }
211
207
 
212
208
  try {
213
- const proc = Bun.spawn(["where", "devenv"], { stdout: "pipe", stderr: "ignore" });
214
- return (await proc.exited) === 0;
209
+ return await (dependencies.commandOnPath
210
+ ? dependencies.commandOnPath("devenv")
211
+ : commandOnPath("devenv", platform, dependencies.spawn));
215
212
  } catch {
216
213
  return false;
217
214
  }
@@ -233,8 +230,8 @@ export const AGENT_TARGETS: AgentTarget[] = [
233
230
  mcp: jsonMcp(join(HOME, ".claude.json"), "mcpServers", STDIO_SERVER_CONFIG),
234
231
  instructionsPath: join(HOME, ".claude", "CLAUDE.md"),
235
232
  cursorRulesPath: null,
236
- hooksPath: join(HOME, ".claude", "settings.json"),
237
- hooksFormat: "claude",
233
+ legacyHooksPath: join(HOME, ".claude", "settings.json"),
234
+ legacyHooksFormat: "claude",
238
235
  subagentPath: join(HOME, ".claude", "agents", "miru-code.md"),
239
236
  subagentId: "claude",
240
237
  cavemanSkillPath: nativeCavemanSkillPath(HOME, "claude"),
@@ -248,12 +245,13 @@ export const AGENT_TARGETS: AgentTarget[] = [
248
245
  mcp: jsonMcp(join(HOME, ".cursor", "mcp.json"), "mcpServers", STDIO_SERVER_CONFIG),
249
246
  instructionsPath: null,
250
247
  cursorRulesPath: join(HOME, ".cursor", "rules", "miru-code.mdc"),
251
- hooksPath: join(HOME, ".cursor", "hooks.json"),
252
- hooksFormat: "cursor",
248
+ legacyHooksPath: join(HOME, ".cursor", "hooks.json"),
249
+ legacyHooksFormat: "cursor",
253
250
  subagentPath: join(HOME, ".cursor", "agents", "miru-code.md"),
254
251
  subagentId: "cursor",
255
252
  cavemanSkillPath: SHARED_CAVEMAN_SKILL,
256
253
  steSkillDir: SHARED_STE_SKILL_DIR,
254
+ miruSkillPath: opencodeMiruSkillPath(HOME),
257
255
  },
258
256
  {
259
257
  id: "gemini",
@@ -263,8 +261,8 @@ export const AGENT_TARGETS: AgentTarget[] = [
263
261
  mcp: jsonMcp(join(HOME, ".gemini", "settings.json"), "mcpServers", STDIO_SERVER_CONFIG),
264
262
  instructionsPath: join(HOME, ".gemini", "GEMINI.md"),
265
263
  cursorRulesPath: null,
266
- hooksPath: join(HOME, ".gemini", "settings.json"),
267
- hooksFormat: "gemini",
264
+ legacyHooksPath: join(HOME, ".gemini", "settings.json"),
265
+ legacyHooksFormat: "gemini",
268
266
  subagentPath: join(HOME, ".gemini", "agents", "miru-code.md"),
269
267
  subagentId: "gemini",
270
268
  cavemanSkillPath: SHARED_CAVEMAN_SKILL,
@@ -278,8 +276,8 @@ export const AGENT_TARGETS: AgentTarget[] = [
278
276
  mcp: jsonMcp(join(HOME, ".kiro", "settings", "mcp.json"), "mcpServers", STDIO_SERVER_CONFIG),
279
277
  instructionsPath: join(HOME, ".kiro", "steering", "miru.md"),
280
278
  cursorRulesPath: null,
281
- hooksPath: kiroHooksPath(HOME),
282
- hooksFormat: "kiro",
279
+ legacyHooksPath: join(HOME, ".kiro", "settings", "hooks.json"),
280
+ legacyHooksFormat: "kiro",
283
281
  subagentPath: join(HOME, ".kiro", "agents", "miru-code.md"),
284
282
  subagentId: "kiro",
285
283
  cavemanSkillPath: nativeCavemanSkillPath(HOME, "kiro"),
@@ -290,11 +288,11 @@ export const AGENT_TARGETS: AgentTarget[] = [
290
288
  displayName: "OpenCode",
291
289
  binary: "opencode",
292
290
  configDir: opencodeConfigDir(HOME),
293
- mcp: jsonMcp(opencodeMcpPath(), "mcp", OPENCODE_SERVER_CONFIG),
291
+ mcp: jsonMcp(opencodeMcpPath(), "mcp.servers", OPENCODE_SERVER_CONFIG),
294
292
  instructionsPath: join(opencodeConfigDir(HOME), "AGENTS.md"),
295
293
  cursorRulesPath: null,
296
- hooksPath: opencodePluginPath(HOME),
297
- hooksFormat: "opencode",
294
+ legacyHooksPath: join(opencodeConfigDir(HOME), "plugins", "miru-search-guard.ts"),
295
+ legacyHooksFormat: "opencode",
298
296
  subagentPath: join(opencodeConfigDir(HOME), "agents", "miru-code.md"),
299
297
  subagentId: "opencode",
300
298
  cavemanSkillPath: SHARED_CAVEMAN_SKILL,
@@ -312,8 +310,8 @@ export const AGENT_TARGETS: AgentTarget[] = [
312
310
  ),
313
311
  instructionsPath: null,
314
312
  cursorRulesPath: null,
315
- hooksPath: copilotHooksPath(HOME),
316
- hooksFormat: "vscode",
313
+ legacyHooksPath: join(copilotHomeDir(HOME), "hooks", "miru-search.json"),
314
+ legacyHooksFormat: "vscode",
317
315
  subagentPath: join(copilotHomeDir(HOME), "agents", "miru-code.agent.md"),
318
316
  subagentId: "copilot",
319
317
  cavemanSkillPath: SHARED_CAVEMAN_SKILL,
@@ -333,8 +331,8 @@ export const AGENT_TARGETS: AgentTarget[] = [
333
331
  },
334
332
  instructionsPath: join(HOME, ".codex", "AGENTS.md"),
335
333
  cursorRulesPath: null,
336
- hooksPath: join(HOME, ".codex", "hooks.json"),
337
- hooksFormat: "claude",
334
+ legacyHooksPath: join(HOME, ".codex", "hooks.json"),
335
+ legacyHooksFormat: "claude",
338
336
  subagentPath: null,
339
337
  subagentId: null,
340
338
  cavemanSkillPath: SHARED_CAVEMAN_SKILL,
@@ -348,8 +346,8 @@ export const AGENT_TARGETS: AgentTarget[] = [
348
346
  mcp: jsonMcp(vscodeMcpPath(), "servers", STDIO_SERVER_CONFIG),
349
347
  instructionsPath: null,
350
348
  cursorRulesPath: null,
351
- hooksPath: copilotHooksPath(HOME),
352
- hooksFormat: "vscode",
349
+ legacyHooksPath: join(copilotHomeDir(HOME), "hooks", "miru-search.json"),
350
+ legacyHooksFormat: "vscode",
353
351
  subagentPath: null,
354
352
  subagentId: null,
355
353
  cavemanSkillPath: SHARED_CAVEMAN_SKILL,
@@ -363,8 +361,8 @@ export const AGENT_TARGETS: AgentTarget[] = [
363
361
  mcp: null,
364
362
  instructionsPath: null,
365
363
  cursorRulesPath: null,
366
- hooksPath: windsurfHooksPath(HOME),
367
- hooksFormat: "windsurf",
364
+ legacyHooksPath: join(HOME, ".codeium", "windsurf", "hooks.json"),
365
+ legacyHooksFormat: "windsurf",
368
366
  subagentPath: null,
369
367
  subagentId: null,
370
368
  cavemanSkillPath: SHARED_CAVEMAN_SKILL,
@@ -378,8 +376,8 @@ export const AGENT_TARGETS: AgentTarget[] = [
378
376
  mcp: jsonMcp(visualStudioMcpPath(), "servers", STDIO_SERVER_CONFIG),
379
377
  instructionsPath: null,
380
378
  cursorRulesPath: null,
381
- hooksPath: copilotHooksPath(HOME),
382
- hooksFormat: "vscode",
379
+ legacyHooksPath: join(copilotHomeDir(HOME), "hooks", "miru-search.json"),
380
+ legacyHooksFormat: "vscode",
383
381
  subagentPath: null,
384
382
  subagentId: null,
385
383
  cavemanSkillPath: SHARED_CAVEMAN_SKILL,
@@ -387,38 +385,60 @@ export const AGENT_TARGETS: AgentTarget[] = [
387
385
  },
388
386
  ];
389
387
 
390
- async function commandOnPath(command: string): Promise<boolean> {
391
- const lookup = process.platform === "win32" ? ["where", command] : ["which", command];
388
+ async function commandOnPath(
389
+ command: string,
390
+ platform: NodeJS.Platform = process.platform,
391
+ spawn: typeof Bun.spawn = Bun.spawn,
392
+ ): Promise<boolean> {
393
+ const lookup = platform === "win32" ? ["where", command] : ["which", command];
392
394
  try {
393
- const proc = Bun.spawn(lookup, { stdout: "pipe", stderr: "ignore" });
395
+ const proc = spawn(lookup, { stdout: "pipe", stderr: "ignore" });
394
396
  return (await proc.exited) === 0;
395
397
  } catch {
396
398
  return false;
397
399
  }
398
400
  }
399
401
 
400
- export async function isAgentDetected(agent: AgentTarget): Promise<boolean> {
402
+ export interface AgentDetectionDependencies {
403
+ platform?: NodeJS.Platform;
404
+ existsSync?: typeof existsSync;
405
+ readdir?: (path: string) => Promise<string[]>;
406
+ commandOnPath?: (command: string) => Promise<boolean>;
407
+ spawn?: typeof Bun.spawn;
408
+ home?: string;
409
+ }
410
+
411
+ export async function isAgentDetected(
412
+ agent: AgentTarget,
413
+ dependencies: AgentDetectionDependencies = {},
414
+ ): Promise<boolean> {
401
415
  if (agent.id === "visualstudio") {
402
- return detectVisualStudio();
416
+ return detectVisualStudio(dependencies);
403
417
  }
404
418
  if (agent.id === "windsurf") {
405
- if (agent.configDir && existsSync(agent.configDir)) {
419
+ if (agent.configDir && (dependencies.existsSync ?? existsSync)(agent.configDir)) {
406
420
  return true;
407
421
  }
408
- if (agent.binary && (await commandOnPath(agent.binary))) {
422
+ const commandExists = dependencies.commandOnPath
423
+ ? dependencies.commandOnPath
424
+ : (command: string) => commandOnPath(command, dependencies.platform, dependencies.spawn);
425
+ if (agent.binary && (await commandExists(agent.binary))) {
409
426
  return true;
410
427
  }
411
428
  return false;
412
429
  }
413
430
  // Copilot-specific artifacts only — ~/.copilot alone is shared with VS Code / VS.
414
431
  if (agent.id === "copilot") {
415
- return isCopilotInstalled(HOME);
432
+ return isCopilotInstalled(dependencies.home ?? HOME);
416
433
  }
417
- if (agent.binary && (await commandOnPath(agent.binary))) {
434
+ const commandExists = dependencies.commandOnPath
435
+ ? dependencies.commandOnPath
436
+ : (command: string) => commandOnPath(command, dependencies.platform, dependencies.spawn);
437
+ if (agent.binary && (await commandExists(agent.binary))) {
418
438
  return true;
419
439
  }
420
440
  if (agent.configDir) {
421
- return Bun.file(agent.configDir).exists();
441
+ return (dependencies.existsSync ?? existsSync)(agent.configDir);
422
442
  }
423
443
  return false;
424
444
  }