@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.
- package/README.md +65 -53
- package/docs/coverage-badge.json +6 -0
- package/grammars/manifest.json +10 -2
- package/grammars/tree-sitter-astro.wasm +0 -0
- package/grammars/tree-sitter-php.wasm +0 -0
- package/grammars/tree-sitter-php_only.wasm +0 -0
- package/grammars/tree-sitter-sql.wasm +0 -0
- package/grammars/tree-sitter-svelte.wasm +0 -0
- package/grammars/tree-sitter-vue.wasm +0 -0
- package/package.json +10 -2
- package/src/agents.ts +4 -4
- package/src/auth/device.ts +26 -21
- package/src/auth/providers.ts +26 -11
- package/src/benchmark/compare.ts +3 -10
- package/src/benchmark/grep.ts +64 -37
- package/src/benchmark/locate-compare.ts +8 -1
- package/src/benchmark/rg-literal.ts +9 -4
- package/src/cache.ts +3 -3
- package/src/chunking/ast.ts +21 -3
- package/src/chunking/grammars.ts +8 -3
- package/src/chunking/structural.ts +13 -24
- package/src/cli-mode.ts +18 -0
- package/src/cli.ts +54 -98
- package/src/credentials.ts +14 -14
- package/src/embeddings/openai.ts +6 -3
- package/src/embeddings/sagemaker.ts +18 -3
- package/src/embeddings/validate.ts +10 -7
- package/src/git.ts +6 -2
- package/src/help.ts +7 -7
- package/src/index/bm25.ts +66 -6
- package/src/index/chunk-selector.ts +39 -11
- package/src/index/create.ts +5 -9
- package/src/index/files.ts +5 -1
- package/src/index/incremental.ts +48 -8
- package/src/index/sparse.ts +14 -2
- package/src/installer/agents.ts +85 -65
- package/src/installer/benchmark-mode.ts +40 -11
- package/src/installer/config.ts +105 -42
- package/src/installer/installer.ts +59 -34
- package/src/installer/legacy-hooks.ts +131 -0
- package/src/installer/prompt.ts +20 -22
- package/src/installer/search-policy.ts +10 -10
- package/src/mcp/index-cache.ts +16 -3
- package/src/mcp/server.ts +65 -46
- package/src/mcp/stdio.ts +17 -2
- package/src/miru-index.ts +90 -22
- package/src/prompt.ts +51 -14
- package/src/ranking/boosting.ts +5 -6
- package/src/ranking/location.ts +6 -10
- package/src/ranking/penalties.ts +14 -22
- package/src/search.ts +8 -12
- package/src/setup.ts +25 -13
- package/src/snippet.ts +8 -26
- package/src/spinner.ts +26 -16
- package/src/tokenizer/bert-wordpiece.ts +0 -6
- package/src/utils.ts +1 -15
- package/src/version.ts +4 -4
- package/src/installer/hooks/install.ts +0 -549
- package/src/installer/hooks/opencode-plugin.ts +0 -54
- package/src/installer/hooks/search-guard.ts +0 -272
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
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
|
-
? `["
|
|
11
|
-
:
|
|
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
|
-
|
|
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(
|
|
217
|
+
export async function getBenchmarkModeStatus(
|
|
218
|
+
targets: readonly AgentTarget[] = AGENT_TARGETS,
|
|
219
|
+
): Promise<BenchmarkModeTargetResult[]> {
|
|
194
220
|
const results: BenchmarkModeTargetResult[] = [];
|
|
195
|
-
for (const agent of
|
|
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(
|
|
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
|
|
253
|
+
for (const agent of targets) {
|
|
225
254
|
const mcp = agent.mcp;
|
|
226
255
|
if (!mcp) {
|
|
227
256
|
continue;
|
package/src/installer/config.ts
CHANGED
|
@@ -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 = ["
|
|
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 = ["
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
return
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
return section
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
166
|
-
const
|
|
167
|
-
next
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
next[
|
|
193
|
-
|
|
194
|
-
|
|
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 {
|
|
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
|
-
|
|
114
|
-
|
|
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
|
|
165
|
-
const path = agent.
|
|
166
|
-
const format = agent.
|
|
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: "
|
|
454
|
-
description: "
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|