@takara-ai/miru-code 1.1.1 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/installer/config.ts +267 -17
- package/src/installer/hooks/install.ts +125 -25
- package/src/installer/installer.ts +4 -2
- package/src/mcp/index-cache.ts +14 -4
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Find code by meaning, not grep. Miru returns the best **chunks** (path, lines, s
|
|
|
10
10
|
|
|
11
11
|
Miru sends **file contents** to the [Takara inference API](https://takara.ai) when building an index and when embedding search queries. Chunks from your repo are transmitted over HTTPS to generate embeddings. API usage may incur cost depending on your Takara plan.
|
|
12
12
|
|
|
13
|
-
If you index proprietary code, make sure that sending snippets to Takara's endpoint fits your security and compliance requirements.
|
|
13
|
+
If you index proprietary code, make sure that sending snippets to Takara's endpoint fits your security and compliance requirements. `MIRU_WORKSPACE_ROOT` is an opt-in boundary for MCP local `repo` paths only, and restricts indexing to a single workspace directory when set.
|
|
14
14
|
|
|
15
15
|
## Install
|
|
16
16
|
|
|
@@ -231,7 +231,7 @@ const results = await index.search({ query: "BM25 tokenize", topK: 10 });
|
|
|
231
231
|
| `TAKARA_API_KEY` | Required |
|
|
232
232
|
| `MIRU_OPENAI_BASE_URL` | Default `https://infer.takara.ai/v1` |
|
|
233
233
|
| `MIRU_OPENAI_EMBEDDING_MODEL` | Default `ds1-miru-int8` |
|
|
234
|
-
| `MIRU_WORKSPACE_ROOT` |
|
|
234
|
+
| `MIRU_WORKSPACE_ROOT` | Optional: restrict MCP local `repo` paths to this directory |
|
|
235
235
|
| `MIRU_MAX_INDEX_FILES` | Cap files indexed per operation |
|
|
236
236
|
| `MIRU_ALLOW_HTTP_GIT` | Set `1` to allow plain `http://` git clones |
|
|
237
237
|
| `MIRU_MCP_WATCH` | Set `0` to disable MCP file watch |
|
package/package.json
CHANGED
package/src/installer/config.ts
CHANGED
|
@@ -107,13 +107,13 @@ export async function mergeJsonMember(
|
|
|
107
107
|
if (JSON.stringify(section[memberKey]) === JSON.stringify(value)) {
|
|
108
108
|
return "unchanged";
|
|
109
109
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
110
|
+
const next = upsertJsonMemberText(text, sectionKey, memberKey, value);
|
|
111
|
+
await Bun.write(
|
|
112
|
+
path,
|
|
113
|
+
ensureTrailingNewline(
|
|
114
|
+
next ?? JSON.stringify(withMergedMember(parsed, sectionKey, memberKey, value), null, 2),
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
117
|
return existed ? "updated" : "created";
|
|
118
118
|
}
|
|
119
119
|
|
|
@@ -136,26 +136,276 @@ export async function removeJsonMember(
|
|
|
136
136
|
if (section === "error" || !(memberKey in section)) {
|
|
137
137
|
return "not-found";
|
|
138
138
|
}
|
|
139
|
+
const nextText = removeJsonMemberText(text, sectionKey, memberKey);
|
|
140
|
+
const nextObject = withRemovedMember(parsed, sectionKey, memberKey);
|
|
141
|
+
const next = nextText ?? JSON.stringify(nextObject, null, 2);
|
|
142
|
+
if (ensureTrailingNewline(next) === ensureTrailingNewline(text)) {
|
|
143
|
+
return "not-found";
|
|
144
|
+
}
|
|
145
|
+
const nextParsed = parseJsonObject(next);
|
|
146
|
+
if (!nextParsed || typeof nextParsed !== "object" || Object.keys(nextParsed).length === 0) {
|
|
147
|
+
await unlink(path);
|
|
148
|
+
return "removed";
|
|
149
|
+
}
|
|
150
|
+
await Bun.write(path, ensureTrailingNewline(next));
|
|
151
|
+
return "removed";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function withMergedMember(
|
|
155
|
+
root: Record<string, unknown>,
|
|
156
|
+
sectionKey: string,
|
|
157
|
+
memberKey: string,
|
|
158
|
+
value: Record<string, unknown>,
|
|
159
|
+
): Record<string, unknown> {
|
|
160
|
+
const next = { ...root };
|
|
161
|
+
const section =
|
|
162
|
+
next[sectionKey] && typeof next[sectionKey] === "object" && !Array.isArray(next[sectionKey])
|
|
163
|
+
? { ...(next[sectionKey] as Record<string, unknown>) }
|
|
164
|
+
: {};
|
|
165
|
+
section[memberKey] = value;
|
|
166
|
+
next[sectionKey] = section;
|
|
167
|
+
return next;
|
|
168
|
+
}
|
|
139
169
|
|
|
170
|
+
function withRemovedMember(
|
|
171
|
+
root: Record<string, unknown>,
|
|
172
|
+
sectionKey: string,
|
|
173
|
+
memberKey: string,
|
|
174
|
+
): Record<string, unknown> {
|
|
175
|
+
const next = { ...root };
|
|
176
|
+
const section =
|
|
177
|
+
next[sectionKey] && typeof next[sectionKey] === "object" && !Array.isArray(next[sectionKey])
|
|
178
|
+
? { ...(next[sectionKey] as Record<string, unknown>) }
|
|
179
|
+
: null;
|
|
180
|
+
if (!section || !(memberKey in section)) {
|
|
181
|
+
return next;
|
|
182
|
+
}
|
|
140
183
|
delete section[memberKey];
|
|
141
184
|
if (Object.keys(section).length === 0) {
|
|
142
|
-
delete
|
|
185
|
+
delete next[sectionKey];
|
|
143
186
|
} else {
|
|
144
|
-
|
|
187
|
+
next[sectionKey] = section;
|
|
145
188
|
}
|
|
189
|
+
return next;
|
|
190
|
+
}
|
|
146
191
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
192
|
+
function upsertJsonMemberText(
|
|
193
|
+
text: string,
|
|
194
|
+
sectionKey: string,
|
|
195
|
+
memberKey: string,
|
|
196
|
+
value: Record<string, unknown>,
|
|
197
|
+
): string | null {
|
|
198
|
+
if (!hasJsoncSyntax(text)) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
const sectionRange = findSectionRange(text, sectionKey);
|
|
202
|
+
if (!sectionRange) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
const { openBrace, closeBrace, indent } = sectionRange;
|
|
206
|
+
const sectionBody = text.slice(openBrace + 1, closeBrace);
|
|
207
|
+
const memberMatch = new RegExp(`"(${escapeRegExp(memberKey)})"\\s*:`).exec(sectionBody);
|
|
208
|
+
const valueText = JSON.stringify(value);
|
|
209
|
+
if (memberMatch) {
|
|
210
|
+
const memberKeyStart = openBrace + 1 + memberMatch.index;
|
|
211
|
+
const colonIndex = text.indexOf(":", memberKeyStart);
|
|
212
|
+
const valueStart = skipWhitespace(text, colonIndex + 1);
|
|
213
|
+
const valueEnd = parseJsonValueEnd(text, valueStart);
|
|
214
|
+
if (valueEnd <= valueStart) {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
return `${text.slice(0, valueStart)} ${valueText}${text.slice(valueEnd)}`;
|
|
150
218
|
}
|
|
151
219
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
220
|
+
const memberIndent = `${indent} `;
|
|
221
|
+
const insertion = `${sectionBody.trim().length === 0 ? "" : ","}\n${memberIndent}"${memberKey}": ${valueText}\n${indent}`;
|
|
222
|
+
return `${text.slice(0, closeBrace)}${insertion}${text.slice(closeBrace)}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function removeJsonMemberText(text: string, sectionKey: string, memberKey: string): string | null {
|
|
226
|
+
if (!hasJsoncSyntax(text)) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
const sectionRange = findSectionRange(text, sectionKey);
|
|
230
|
+
if (!sectionRange) {
|
|
231
|
+
return null;
|
|
155
232
|
}
|
|
233
|
+
const { openBrace, closeBrace } = sectionRange;
|
|
234
|
+
const sectionBody = text.slice(openBrace + 1, closeBrace);
|
|
235
|
+
const memberMatch = new RegExp(`"(${escapeRegExp(memberKey)})"\\s*:`).exec(sectionBody);
|
|
236
|
+
if (!memberMatch) {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
const memberStart = openBrace + 1 + memberMatch.index;
|
|
240
|
+
const colonIndex = text.indexOf(":", memberStart);
|
|
241
|
+
const valueStart = skipWhitespace(text, colonIndex + 1);
|
|
242
|
+
const valueEnd = parseJsonValueEnd(text, valueStart);
|
|
243
|
+
let removeStart = memberStart;
|
|
244
|
+
let removeEnd = valueEnd;
|
|
245
|
+
|
|
246
|
+
const trailing = skipWhitespace(text, valueEnd);
|
|
247
|
+
if (text[trailing] === ",") {
|
|
248
|
+
removeEnd = trailing + 1;
|
|
249
|
+
} else {
|
|
250
|
+
const leadingComma = findLeadingComma(text, memberStart, openBrace + 1);
|
|
251
|
+
if (leadingComma >= 0) {
|
|
252
|
+
removeStart = leadingComma;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return `${text.slice(0, removeStart)}${text.slice(removeEnd)}`;
|
|
256
|
+
}
|
|
156
257
|
|
|
157
|
-
|
|
158
|
-
return "
|
|
258
|
+
function hasJsoncSyntax(text: string): boolean {
|
|
259
|
+
return text.includes("//") || text.includes("/*");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function findSectionRange(
|
|
263
|
+
text: string,
|
|
264
|
+
sectionKey: string,
|
|
265
|
+
): { openBrace: number; closeBrace: number; indent: string } | null {
|
|
266
|
+
const keyMatch = new RegExp(`(^|\\n)([ \\t]*)"${escapeRegExp(sectionKey)}"\\s*:\\s*\\{`).exec(
|
|
267
|
+
text,
|
|
268
|
+
);
|
|
269
|
+
if (!keyMatch) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
const openBrace = text.indexOf("{", keyMatch.index);
|
|
273
|
+
const closeBrace = findMatchingBrace(text, openBrace);
|
|
274
|
+
if (openBrace < 0 || closeBrace < 0) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
return { openBrace, closeBrace, indent: keyMatch[2] ?? "" };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function findMatchingBrace(text: string, openIndex: number): number {
|
|
281
|
+
let depth = 0;
|
|
282
|
+
let inString = false;
|
|
283
|
+
let quote = "";
|
|
284
|
+
let inLineComment = false;
|
|
285
|
+
let inBlockComment = false;
|
|
286
|
+
for (let i = openIndex; i < text.length; i++) {
|
|
287
|
+
const ch = text[i] ?? "";
|
|
288
|
+
const next = text[i + 1] ?? "";
|
|
289
|
+
if (inLineComment) {
|
|
290
|
+
if (ch === "\n") inLineComment = false;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (inBlockComment) {
|
|
294
|
+
if (ch === "*" && next === "/") {
|
|
295
|
+
inBlockComment = false;
|
|
296
|
+
i++;
|
|
297
|
+
}
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (inString) {
|
|
301
|
+
if (ch === "\\") {
|
|
302
|
+
i++;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (ch === quote) {
|
|
306
|
+
inString = false;
|
|
307
|
+
}
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (ch === "/" && next === "/") {
|
|
311
|
+
inLineComment = true;
|
|
312
|
+
i++;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (ch === "/" && next === "*") {
|
|
316
|
+
inBlockComment = true;
|
|
317
|
+
i++;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (ch === '"' || ch === "'") {
|
|
321
|
+
inString = true;
|
|
322
|
+
quote = ch;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (ch === "{") depth++;
|
|
326
|
+
if (ch === "}") {
|
|
327
|
+
depth--;
|
|
328
|
+
if (depth === 0) return i;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return -1;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function parseJsonValueEnd(text: string, start: number): number {
|
|
335
|
+
const first = text[start];
|
|
336
|
+
if (!first) return start;
|
|
337
|
+
if (first === "{") return findMatchingBrace(text, start) + 1;
|
|
338
|
+
if (first === "[") return findMatchingBracket(text, start) + 1;
|
|
339
|
+
if (first === '"' || first === "'") return findStringEnd(text, start) + 1;
|
|
340
|
+
let i = start;
|
|
341
|
+
while (i < text.length && ![",", "}", "]", "\n"].includes(text[i] ?? "")) {
|
|
342
|
+
i++;
|
|
343
|
+
}
|
|
344
|
+
return i;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function findMatchingBracket(text: string, openIndex: number): number {
|
|
348
|
+
let depth = 0;
|
|
349
|
+
let inString = false;
|
|
350
|
+
let quote = "";
|
|
351
|
+
for (let i = openIndex; i < text.length; i++) {
|
|
352
|
+
const ch = text[i] ?? "";
|
|
353
|
+
if (inString) {
|
|
354
|
+
if (ch === "\\") {
|
|
355
|
+
i++;
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (ch === quote) inString = false;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (ch === '"' || ch === "'") {
|
|
362
|
+
inString = true;
|
|
363
|
+
quote = ch;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (ch === "[") depth++;
|
|
367
|
+
if (ch === "]") {
|
|
368
|
+
depth--;
|
|
369
|
+
if (depth === 0) return i;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return -1;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function findStringEnd(text: string, start: number): number {
|
|
376
|
+
const quote = text[start] ?? '"';
|
|
377
|
+
for (let i = start + 1; i < text.length; i++) {
|
|
378
|
+
const ch = text[i] ?? "";
|
|
379
|
+
if (ch === "\\") {
|
|
380
|
+
i++;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (ch === quote) return i;
|
|
384
|
+
}
|
|
385
|
+
return start;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function skipWhitespace(text: string, index: number): number {
|
|
389
|
+
let i = index;
|
|
390
|
+
while (i < text.length && /\s/.test(text[i] ?? "")) i++;
|
|
391
|
+
return i;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function findLeadingComma(text: string, index: number, min: number): number {
|
|
395
|
+
for (let i = index - 1; i >= min; i--) {
|
|
396
|
+
const ch = text[i] ?? "";
|
|
397
|
+
if (ch === ",") return i;
|
|
398
|
+
if (!/\s/.test(ch)) break;
|
|
399
|
+
}
|
|
400
|
+
return -1;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function escapeRegExp(value: string): string {
|
|
404
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function ensureTrailingNewline(text: string): string {
|
|
408
|
+
return text.endsWith("\n") ? text : `${text}\n`;
|
|
159
409
|
}
|
|
160
410
|
|
|
161
411
|
export async function replaceOrAppendMarked(path: string, content: string): Promise<InstallAction> {
|
|
@@ -5,6 +5,8 @@ import { stripJsonComments } from "../config.ts";
|
|
|
5
5
|
|
|
6
6
|
const HOOK_GUARD_MARKER = "hook-guard";
|
|
7
7
|
const MIRU_HOOK_MARKER = "miru-search";
|
|
8
|
+
const VSCODE_OWNERS_KEY = "miruOwners";
|
|
9
|
+
const SHARED_VSCODE_HOOK_OWNERS = ["copilot", "vscode", "visualstudio"] as const;
|
|
8
10
|
|
|
9
11
|
const MATCHERS = {
|
|
10
12
|
cursor: "Grep|Glob|Shell|SemanticSearch",
|
|
@@ -304,37 +306,127 @@ export async function removeGeminiHooks(path: string): Promise<InstallAction> {
|
|
|
304
306
|
return removeHooksSection(path, ["BeforeTool"], (entry) => hookCommandMatches(entry.command));
|
|
305
307
|
}
|
|
306
308
|
|
|
307
|
-
export async function mergeVscodeHooks(path: string): Promise<InstallAction> {
|
|
309
|
+
export async function mergeVscodeHooks(path: string, owner?: string): Promise<InstallAction> {
|
|
308
310
|
const command = resolveHookCommand();
|
|
309
|
-
const content = {
|
|
310
|
-
hooks: {
|
|
311
|
-
PreToolUse: [
|
|
312
|
-
{
|
|
313
|
-
type: "command",
|
|
314
|
-
command,
|
|
315
|
-
matcher: MATCHERS.claude,
|
|
316
|
-
statusMessage: "Miru search policy",
|
|
317
|
-
},
|
|
318
|
-
],
|
|
319
|
-
},
|
|
320
|
-
};
|
|
321
311
|
const existed = await Bun.file(path).exists();
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
return "unchanged";
|
|
327
|
-
}
|
|
312
|
+
const text = existed ? await Bun.file(path).text() : "";
|
|
313
|
+
const parsed = parseJsonObject(text);
|
|
314
|
+
if (parsed === "error") {
|
|
315
|
+
return "error";
|
|
328
316
|
}
|
|
329
|
-
|
|
317
|
+
|
|
318
|
+
const hooksRoot =
|
|
319
|
+
parsed.hooks && typeof parsed.hooks === "object" && !Array.isArray(parsed.hooks)
|
|
320
|
+
? (parsed.hooks as Record<string, unknown>)
|
|
321
|
+
: {};
|
|
322
|
+
|
|
323
|
+
const nextEntry = {
|
|
324
|
+
type: "command",
|
|
325
|
+
command,
|
|
326
|
+
matcher: MATCHERS.claude,
|
|
327
|
+
statusMessage: "Miru search policy",
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const existingPreToolUse = Array.isArray(hooksRoot.PreToolUse)
|
|
331
|
+
? (hooksRoot.PreToolUse as unknown[])
|
|
332
|
+
: [];
|
|
333
|
+
const mergedPreToolUse =
|
|
334
|
+
existingPreToolUse.length > 0 &&
|
|
335
|
+
existingPreToolUse.some((entry) =>
|
|
336
|
+
hookCommandMatches((entry as Record<string, unknown>).command),
|
|
337
|
+
)
|
|
338
|
+
? existingPreToolUse.map((entry) => {
|
|
339
|
+
const hookEntry = entry as Record<string, unknown>;
|
|
340
|
+
if (!hookCommandMatches(hookEntry.command)) {
|
|
341
|
+
return hookEntry;
|
|
342
|
+
}
|
|
343
|
+
return nextEntry;
|
|
344
|
+
})
|
|
345
|
+
: [...existingPreToolUse, nextEntry];
|
|
346
|
+
|
|
347
|
+
const currentOwners = Array.isArray(parsed[VSCODE_OWNERS_KEY])
|
|
348
|
+
? (parsed[VSCODE_OWNERS_KEY] as unknown[]).filter(
|
|
349
|
+
(item): item is string =>
|
|
350
|
+
typeof item === "string" &&
|
|
351
|
+
SHARED_VSCODE_HOOK_OWNERS.includes(item as (typeof SHARED_VSCODE_HOOK_OWNERS)[number]),
|
|
352
|
+
)
|
|
353
|
+
: [];
|
|
354
|
+
const nextOwners = owner
|
|
355
|
+
? Array.from(
|
|
356
|
+
new Set([
|
|
357
|
+
...currentOwners,
|
|
358
|
+
...(SHARED_VSCODE_HOOK_OWNERS.includes(
|
|
359
|
+
owner as (typeof SHARED_VSCODE_HOOK_OWNERS)[number],
|
|
360
|
+
)
|
|
361
|
+
? [owner]
|
|
362
|
+
: []),
|
|
363
|
+
]),
|
|
364
|
+
)
|
|
365
|
+
: currentOwners;
|
|
366
|
+
|
|
367
|
+
const next = {
|
|
368
|
+
...parsed,
|
|
369
|
+
hooks: { ...hooksRoot, PreToolUse: mergedPreToolUse },
|
|
370
|
+
...(nextOwners.length > 0 ? { [VSCODE_OWNERS_KEY]: nextOwners } : {}),
|
|
371
|
+
};
|
|
372
|
+
const nextJson = `${JSON.stringify(next, null, 2)}\n`;
|
|
373
|
+
if (nextJson === `${text.endsWith("\n") ? text : `${text}\n`}`) {
|
|
374
|
+
return "unchanged";
|
|
375
|
+
}
|
|
376
|
+
await Bun.write(path, nextJson);
|
|
330
377
|
return existed ? "updated" : "created";
|
|
331
378
|
}
|
|
332
379
|
|
|
333
|
-
export async function removeVscodeHooks(path: string): Promise<InstallAction> {
|
|
380
|
+
export async function removeVscodeHooks(path: string, owner?: string): Promise<InstallAction> {
|
|
334
381
|
if (!(await Bun.file(path).exists())) {
|
|
335
382
|
return "not-found";
|
|
336
383
|
}
|
|
337
384
|
const text = await Bun.file(path).text();
|
|
385
|
+
const parsed = parseJsonObject(text);
|
|
386
|
+
if (parsed === "error") {
|
|
387
|
+
return "error";
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const owners = Array.isArray(parsed[VSCODE_OWNERS_KEY])
|
|
391
|
+
? (parsed[VSCODE_OWNERS_KEY] as unknown[]).filter(
|
|
392
|
+
(item): item is string => typeof item === "string",
|
|
393
|
+
)
|
|
394
|
+
: null;
|
|
395
|
+
if (owners && owner) {
|
|
396
|
+
const remainingOwners = owners.filter((value) => value !== owner);
|
|
397
|
+
if (remainingOwners.length > 0) {
|
|
398
|
+
parsed[VSCODE_OWNERS_KEY] = remainingOwners;
|
|
399
|
+
await Bun.write(path, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
400
|
+
return "updated";
|
|
401
|
+
}
|
|
402
|
+
delete parsed[VSCODE_OWNERS_KEY];
|
|
403
|
+
const hooksRoot =
|
|
404
|
+
parsed.hooks && typeof parsed.hooks === "object" && !Array.isArray(parsed.hooks)
|
|
405
|
+
? (parsed.hooks as Record<string, unknown>)
|
|
406
|
+
: {};
|
|
407
|
+
const filtered = Array.isArray(hooksRoot.PreToolUse)
|
|
408
|
+
? (hooksRoot.PreToolUse as Record<string, unknown>[]).filter(
|
|
409
|
+
(entry) => !hookCommandMatches(entry.command),
|
|
410
|
+
)
|
|
411
|
+
: [];
|
|
412
|
+
if (filtered.length === 0) {
|
|
413
|
+
delete hooksRoot.PreToolUse;
|
|
414
|
+
} else {
|
|
415
|
+
hooksRoot.PreToolUse = filtered;
|
|
416
|
+
}
|
|
417
|
+
if (Object.keys(hooksRoot).length === 0) {
|
|
418
|
+
delete parsed.hooks;
|
|
419
|
+
} else {
|
|
420
|
+
parsed.hooks = hooksRoot;
|
|
421
|
+
}
|
|
422
|
+
if (isEmptyHookConfig(parsed)) {
|
|
423
|
+
await unlink(path);
|
|
424
|
+
return "removed";
|
|
425
|
+
}
|
|
426
|
+
await Bun.write(path, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
427
|
+
return "removed";
|
|
428
|
+
}
|
|
429
|
+
|
|
338
430
|
if (!text.includes(HOOK_GUARD_MARKER) && !text.includes(MIRU_HOOK_MARKER)) {
|
|
339
431
|
return "not-found";
|
|
340
432
|
}
|
|
@@ -413,7 +505,11 @@ export async function removeOpenCodePlugin(pluginPath: string): Promise<InstallA
|
|
|
413
505
|
return "removed";
|
|
414
506
|
}
|
|
415
507
|
|
|
416
|
-
export async function mergeHooks(
|
|
508
|
+
export async function mergeHooks(
|
|
509
|
+
format: HooksFormat,
|
|
510
|
+
path: string,
|
|
511
|
+
owner?: string,
|
|
512
|
+
): Promise<InstallAction> {
|
|
417
513
|
switch (format) {
|
|
418
514
|
case "cursor":
|
|
419
515
|
return mergeCursorHooks(path);
|
|
@@ -422,7 +518,7 @@ export async function mergeHooks(format: HooksFormat, path: string): Promise<Ins
|
|
|
422
518
|
case "gemini":
|
|
423
519
|
return mergeGeminiHooks(path);
|
|
424
520
|
case "vscode":
|
|
425
|
-
return mergeVscodeHooks(path);
|
|
521
|
+
return mergeVscodeHooks(path, owner);
|
|
426
522
|
case "kiro":
|
|
427
523
|
return mergeKiroHooks(path);
|
|
428
524
|
case "windsurf":
|
|
@@ -436,7 +532,11 @@ export async function mergeHooks(format: HooksFormat, path: string): Promise<Ins
|
|
|
436
532
|
}
|
|
437
533
|
}
|
|
438
534
|
|
|
439
|
-
export async function removeHooks(
|
|
535
|
+
export async function removeHooks(
|
|
536
|
+
format: HooksFormat,
|
|
537
|
+
path: string,
|
|
538
|
+
owner?: string,
|
|
539
|
+
): Promise<InstallAction> {
|
|
440
540
|
switch (format) {
|
|
441
541
|
case "cursor":
|
|
442
542
|
return removeCursorHooks(path);
|
|
@@ -445,7 +545,7 @@ export async function removeHooks(format: HooksFormat, path: string): Promise<In
|
|
|
445
545
|
case "gemini":
|
|
446
546
|
return removeGeminiHooks(path);
|
|
447
547
|
case "vscode":
|
|
448
|
-
return removeVscodeHooks(path);
|
|
548
|
+
return removeVscodeHooks(path, owner);
|
|
449
549
|
case "kiro":
|
|
450
550
|
return removeKiroHooks(path);
|
|
451
551
|
case "windsurf":
|
|
@@ -95,7 +95,9 @@ async function applyHooks(agent: AgentTarget, mode: InstallMode): Promise<WriteR
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
const action =
|
|
98
|
-
mode === "install"
|
|
98
|
+
mode === "install"
|
|
99
|
+
? await mergeHooks(format, path, agent.id)
|
|
100
|
+
: await removeHooks(format, path, agent.id);
|
|
99
101
|
|
|
100
102
|
return { path, action };
|
|
101
103
|
}
|
|
@@ -181,7 +183,7 @@ const INTEGRATIONS: Integration[] = [
|
|
|
181
183
|
label: "Search hooks",
|
|
182
184
|
description: "blocks built-in search; routes to Miru MCP",
|
|
183
185
|
experimental: true,
|
|
184
|
-
defaultChecked:
|
|
186
|
+
defaultChecked: true,
|
|
185
187
|
planPath: (agent) => agent.hooksPath,
|
|
186
188
|
apply: applyHooks,
|
|
187
189
|
},
|
package/src/mcp/index-cache.ts
CHANGED
|
@@ -117,11 +117,15 @@ export class IndexCache {
|
|
|
117
117
|
ref: string | null | undefined,
|
|
118
118
|
cacheKey: string,
|
|
119
119
|
): Promise<MiruIndex> {
|
|
120
|
+
const entry = this.ensureEntry(cacheKey, source);
|
|
120
121
|
const task = (async () => {
|
|
121
122
|
const index = await MiruIndex.fromSource(source, this.content, undefined, ref);
|
|
122
123
|
if (!isGitUrl(source)) {
|
|
123
124
|
await index.saveToCache(resolve(source));
|
|
124
125
|
}
|
|
126
|
+
// Publish before stale reconciliation so flushFileUpdates can use
|
|
127
|
+
// entry.index instead of awaiting this same task (self-deadlock).
|
|
128
|
+
entry.index = index;
|
|
125
129
|
if (index.loadedFromDisk) {
|
|
126
130
|
// Await reconciliation here (not fire-and-forget) so callers of `get()`
|
|
127
131
|
// never observe an index that's stale relative to what's on disk.
|
|
@@ -130,7 +134,6 @@ export class IndexCache {
|
|
|
130
134
|
return index;
|
|
131
135
|
})();
|
|
132
136
|
|
|
133
|
-
const entry = this.ensureEntry(cacheKey, source);
|
|
134
137
|
entry.task = task;
|
|
135
138
|
void task
|
|
136
139
|
.then((index) => {
|
|
@@ -141,6 +144,7 @@ export class IndexCache {
|
|
|
141
144
|
.catch(() => {
|
|
142
145
|
if (entry.task === task) {
|
|
143
146
|
entry.task = null;
|
|
147
|
+
entry.index = null;
|
|
144
148
|
}
|
|
145
149
|
});
|
|
146
150
|
|
|
@@ -225,7 +229,11 @@ export class IndexCache {
|
|
|
225
229
|
this.scheduleFlush(cacheKey, source, entry);
|
|
226
230
|
}
|
|
227
231
|
|
|
228
|
-
private flushFileUpdates(
|
|
232
|
+
private flushFileUpdates(
|
|
233
|
+
cacheKey: string,
|
|
234
|
+
source: string,
|
|
235
|
+
knownIndex?: MiruIndex,
|
|
236
|
+
): Promise<void> {
|
|
229
237
|
const entry = this.entries.get(cacheKey);
|
|
230
238
|
if (!entry) {
|
|
231
239
|
return Promise.resolve();
|
|
@@ -239,7 +247,9 @@ export class IndexCache {
|
|
|
239
247
|
return;
|
|
240
248
|
}
|
|
241
249
|
|
|
242
|
-
|
|
250
|
+
// Prefer an index already in hand (or published on the entry). Never await
|
|
251
|
+
// entry.task while that task is itself waiting on this flush — that deadlocks.
|
|
252
|
+
let index = knownIndex ?? entry.index;
|
|
243
253
|
if (!index && entry.task) {
|
|
244
254
|
try {
|
|
245
255
|
index = await entry.task;
|
|
@@ -318,7 +328,7 @@ export class IndexCache {
|
|
|
318
328
|
}
|
|
319
329
|
|
|
320
330
|
if (entry.pendingPaths.size > 0) {
|
|
321
|
-
await this.flushFileUpdates(cacheKey, source);
|
|
331
|
+
await this.flushFileUpdates(cacheKey, source, index);
|
|
322
332
|
}
|
|
323
333
|
} catch {}
|
|
324
334
|
}
|