@takara-ai/miru-code 1.9.0 → 1.10.1
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 +43 -48
- 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 +10 -45
- package/src/credentials.ts +8 -13
- 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 +5 -4
- package/src/index/bm25.ts +66 -6
- 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 +8 -8
- package/src/mcp/auth-tool.ts +93 -33
- package/src/mcp/index-cache.ts +16 -3
- package/src/mcp/server.ts +42 -22
- package/src/mcp/stdio.ts +17 -2
- package/src/miru-index.ts +54 -18
- 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 +4 -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 +0 -4
- 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
|
@@ -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
|
+
}
|
package/src/installer/prompt.ts
CHANGED
|
@@ -113,18 +113,9 @@ function parseArrowKey(chunk: Buffer, text: string): string | null {
|
|
|
113
113
|
const introducer = text.charAt(1);
|
|
114
114
|
if (introducer === "[" || introducer === "O") {
|
|
115
115
|
const arrow = text.charAt(text.length - 1);
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (arrow === "B") {
|
|
120
|
-
return "down";
|
|
121
|
-
}
|
|
122
|
-
if (arrow === "C") {
|
|
123
|
-
return "right";
|
|
124
|
-
}
|
|
125
|
-
if (arrow === "D") {
|
|
126
|
-
return "left";
|
|
127
|
-
}
|
|
116
|
+
return (
|
|
117
|
+
({ A: "up", B: "down", C: "right", D: "left" } as Record<string, string>)[arrow] ?? null
|
|
118
|
+
);
|
|
128
119
|
}
|
|
129
120
|
}
|
|
130
121
|
|
|
@@ -308,9 +299,13 @@ function updateConfirmPromptLine(question: string, yesSelected: boolean): void {
|
|
|
308
299
|
);
|
|
309
300
|
}
|
|
310
301
|
|
|
311
|
-
export async function promptConfirm(
|
|
302
|
+
export async function promptConfirm(
|
|
303
|
+
question: string,
|
|
304
|
+
defaultYes = true,
|
|
305
|
+
io: { input?: typeof input; output?: typeof output } = {},
|
|
306
|
+
): Promise<boolean> {
|
|
312
307
|
if (!input.isTTY) {
|
|
313
|
-
const rl = readline.createInterface({ input, output });
|
|
308
|
+
const rl = readline.createInterface({ input: io.input ?? input, output: io.output ?? output });
|
|
314
309
|
try {
|
|
315
310
|
const hintText = defaultYes ? dim("[Y/n]") : dim("[y/N]");
|
|
316
311
|
const answer = (await rl.question(`${question} ${hintText} `)).trim().toLowerCase();
|
|
@@ -354,10 +349,13 @@ export async function promptConfirm(question: string, defaultYes = true): Promis
|
|
|
354
349
|
export async function promptMultiSelectLegacy<T>(
|
|
355
350
|
title: string,
|
|
356
351
|
items: Array<{ label: string; value: T; checked: boolean }>,
|
|
352
|
+
io: { input?: typeof input; output?: typeof output } = {},
|
|
357
353
|
): Promise<T[] | null> {
|
|
358
|
-
|
|
359
|
-
output
|
|
360
|
-
|
|
354
|
+
const promptInput = io.input ?? input;
|
|
355
|
+
const promptOutput = io.output ?? output;
|
|
356
|
+
promptOutput.write("\n");
|
|
357
|
+
promptOutput.write(`${bold(title)}\n`);
|
|
358
|
+
divider("─", 48, promptOutput);
|
|
361
359
|
|
|
362
360
|
for (let i = 0; i < items.length; i++) {
|
|
363
361
|
const item = items[i];
|
|
@@ -365,13 +363,13 @@ export async function promptMultiSelectLegacy<T>(
|
|
|
365
363
|
continue;
|
|
366
364
|
}
|
|
367
365
|
const mark = item.checked ? cyan("[x]") : dim("[ ]");
|
|
368
|
-
|
|
366
|
+
promptOutput.write(` ${dim(String(i + 1).padStart(2))}. ${mark} ${item.label}\n`);
|
|
369
367
|
}
|
|
370
368
|
|
|
371
|
-
|
|
372
|
-
hint("Enter numbers (1,3), 'all', or press Enter for defaults",
|
|
369
|
+
promptOutput.write("\n");
|
|
370
|
+
hint("Enter numbers (1,3), 'all', or press Enter for defaults", promptOutput);
|
|
373
371
|
|
|
374
|
-
const rl = readline.createInterface({ input, output });
|
|
372
|
+
const rl = readline.createInterface({ input: promptInput, output: promptOutput });
|
|
375
373
|
try {
|
|
376
374
|
const answer = (await rl.question(`${dim("> ")}`)).toLowerCase();
|
|
377
375
|
if (!answer) {
|
|
@@ -390,7 +388,7 @@ export async function promptMultiSelectLegacy<T>(
|
|
|
390
388
|
}
|
|
391
389
|
|
|
392
390
|
if (indices.size === 0) {
|
|
393
|
-
warn("No valid selection.",
|
|
391
|
+
warn("No valid selection.", promptOutput);
|
|
394
392
|
return null;
|
|
395
393
|
}
|
|
396
394
|
|
|
@@ -28,15 +28,17 @@ export function buildSearchPolicyBody(native: NativeToolNames): string {
|
|
|
28
28
|
|
|
29
29
|
${SNIPPET_GUIDANCE}
|
|
30
30
|
|
|
31
|
+
\`repo\` is optional, set it for another repo.
|
|
32
|
+
|
|
31
33
|
Use Miru MCP tools:
|
|
32
34
|
- \`locate\` — exact substring (env var, symbol, error code); prefer \`mode=count\` or \`locations\`
|
|
33
|
-
- \`search\` — one call per conceptual question;
|
|
35
|
+
- \`search\` — one call per conceptual question; use \`include\` / \`exclude\` Gitignore-style globs to scope files (same syntax as \`locate\`)
|
|
34
36
|
- \`expand\` — more context in the same file when \`truncated: true\` (\`file_path\` + \`anchor_line\`)
|
|
35
37
|
- \`find_related\` — similar code in other files (hits may also be snippets; use \`expand\` if truncated)
|
|
36
38
|
|
|
37
39
|
Call shapes:
|
|
38
|
-
- \`locate(literal="<exact substring>"
|
|
39
|
-
- \`search(query="<natural language>",
|
|
40
|
+
- \`locate(literal="<exact substring>")\` — prefer \`mode="locations"\`/\`"count"\`
|
|
41
|
+
- \`search(query="<natural language>", include=["src/auth/**"])\` — use the same \`include\` / \`exclude\` globs on \`locate\` when scoping an exact-string search
|
|
40
42
|
|
|
41
43
|
Stop rules:
|
|
42
44
|
- Literal in the request → \`locate\`, not \`search\`
|
|
@@ -54,7 +56,7 @@ ${buildSearchPolicyTable(native)}`;
|
|
|
54
56
|
export function buildMcpWorkflow(native: NativeToolNames): string {
|
|
55
57
|
return `### MCP workflow
|
|
56
58
|
|
|
57
|
-
1. Call \`search
|
|
59
|
+
1. Call \`search\`; add \`repo\` only for another repo.
|
|
58
60
|
2. For exact literals, call \`locate\` (prefer \`mode=count\` or \`locations\`).
|
|
59
61
|
3. On \`truncated: true\`, call \`expand\` — only if the snippet doesn't already answer.
|
|
60
62
|
4. Use \`find_related\` to trace similar code in other files — not for more context in the same file.
|
|
@@ -71,7 +73,7 @@ miru find-related src/auth.ts 42 .
|
|
|
71
73
|
\`\`\`
|
|
72
74
|
|
|
73
75
|
After large refactors or CLI-only use, run \`miru clear <path>\` to rebuild the disk cache.
|
|
74
|
-
If \`miru\` is not on \`$PATH\`, use \`bunx @takara-ai/miru-code\`.`;
|
|
76
|
+
If \`miru\` is not on \`$PATH\`, use \`bunx @takara-ai/miru-code@latest\`.`;
|
|
75
77
|
|
|
76
78
|
export function buildSubagentBody(native: NativeToolNames): string {
|
|
77
79
|
return `When Miru MCP is available, use MCP \`search\`, \`locate\`, \`expand\`, and \`find_related\` — not ${native.explorationDenied} for exploration.
|
|
@@ -118,7 +120,7 @@ export const MCP_SERVER_INSTRUCTIONS =
|
|
|
118
120
|
"Exact token (identifier, quoted string, env var, error code) → `locate`; everything else → `search` once, no paraphrase retries. " +
|
|
119
121
|
`${SNIPPET_GUIDANCE} ` +
|
|
120
122
|
"`find_related` traces similar code elsewhere, not more context in the same file. " +
|
|
121
|
-
"
|
|
123
|
+
"`repo` is optional, good for multi repo workflows. " +
|
|
122
124
|
"Native Grep/Glob only outside the indexed repo or for non-code tasks. " +
|
|
123
125
|
"Default indexed scope is code, config, and docs; an empty result can mean out-of-scope, not nonexistent. " +
|
|
124
126
|
"On credential errors, call this server's `auth` tool — not the host `mcp_auth` tool.";
|
|
@@ -161,5 +163,3 @@ export const MCP_FIND_RELATED_TOOL_DESCRIPTION =
|
|
|
161
163
|
export const MCP_READ_BENCHMARK_TOOL_DESCRIPTION =
|
|
162
164
|
"Cumulative Miru vs Grep token savings from saved `search` and `locate` calls. Returns compact totals {n,saved,save_pct,miru,grep}. " +
|
|
163
165
|
"Do not call unless the user asks about savings.";
|
|
164
|
-
|
|
165
|
-
export const SEARCH_GUARD_EXPAND_HINT = SNIPPET_GUIDANCE;
|
package/src/mcp/auth-tool.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as z from "zod";
|
|
|
2
2
|
import {
|
|
3
3
|
checkDeviceAuthorizationOnce,
|
|
4
4
|
type DeviceAuthConfig,
|
|
5
|
+
type DeviceAuthorizationCheck,
|
|
5
6
|
type DeviceAuthorizationStart,
|
|
6
7
|
openBrowserForDeviceLogin,
|
|
7
8
|
resolveDeviceAuthConfig,
|
|
@@ -15,24 +16,37 @@ import type { MiruMcpServer } from "./runtime.ts";
|
|
|
15
16
|
type ToolResult = ReturnType<typeof toolText>;
|
|
16
17
|
|
|
17
18
|
const CHECK_HINT =
|
|
18
|
-
|
|
19
|
+
"If the user has not approved yet, call `auth` again to keep waiting; it will not open a second tab.";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* How long one `auth` call waits for the user to approve in the browser. Kept well under
|
|
23
|
+
* typical MCP client tool timeouts; a slower login just takes another call.
|
|
24
|
+
*/
|
|
25
|
+
const DEFAULT_WAIT_MS = 45_000;
|
|
26
|
+
const SLOW_DOWN_STEP_MS = 5_000;
|
|
27
|
+
/** The server may advertise `interval: 0`; never poll the token endpoint faster than this. */
|
|
28
|
+
const MIN_POLL_INTERVAL_MS = 1_000;
|
|
19
29
|
|
|
20
30
|
/** Appended to credentials failures so an agent knows Miru's own recovery step. */
|
|
21
31
|
const RECOVERY_HINT =
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
"
|
|
32
|
+
"Miru could not authorize its current credentials. Call the `auth` tool to open the Takara " +
|
|
33
|
+
"device-login page; it waits for the user to approve. If access is still denied after " +
|
|
34
|
+
"signing in, check the account token balance.";
|
|
25
35
|
|
|
26
36
|
const AUTH_TOOL_DESCRIPTION =
|
|
27
37
|
"Sign in with Takara credentials via device-code login — no terminal required. " +
|
|
28
38
|
"Only call this in direct response to a tool error mentioning missing, expired, rejected, or " +
|
|
29
39
|
"invalid credentials — never speculatively, since it starts a real sign-in prompt for the " +
|
|
30
|
-
|
|
31
|
-
|
|
40
|
+
"user. Call with no arguments: it opens the device-login page in the user's browser, then " +
|
|
41
|
+
"waits (up to about 45 seconds) for them to approve and returns once they have. If it " +
|
|
42
|
+
'returns "still waiting", tell the user the URL and code and call `auth` again.';
|
|
32
43
|
|
|
33
44
|
/** Browser opener seam; the real one spawns a detached `open`/`xdg-open`/`start`. */
|
|
34
45
|
export type BrowserOpener = (url: string) => boolean;
|
|
35
46
|
|
|
47
|
+
/** Sleep seam so tests can drive the wait loop without real delays. */
|
|
48
|
+
export type Sleeper = (ms: number) => Promise<void>;
|
|
49
|
+
|
|
36
50
|
/** Opening is on unless MIRU_OPEN_BROWSER is set to something other than "1" (matches `miru setup`). */
|
|
37
51
|
function openIfAllowed(open: BrowserOpener, url: string): boolean {
|
|
38
52
|
const raw = process.env.MIRU_OPEN_BROWSER;
|
|
@@ -42,8 +56,14 @@ function openIfAllowed(open: BrowserOpener, url: string): boolean {
|
|
|
42
56
|
return open(url);
|
|
43
57
|
}
|
|
44
58
|
|
|
45
|
-
|
|
46
|
-
|
|
59
|
+
/** `opened` is null when resuming an existing login (no new tab was opened). */
|
|
60
|
+
function approvalText(link: string, userCode: string, opened: boolean | null): string {
|
|
61
|
+
const lead =
|
|
62
|
+
opened === null
|
|
63
|
+
? `Still waiting for approval at ${link}`
|
|
64
|
+
: opened
|
|
65
|
+
? `A browser tab is open at ${link}`
|
|
66
|
+
: `Ask the user to open ${link}`;
|
|
47
67
|
return `${lead}. Code: ${userCode}. ${CHECK_HINT}`;
|
|
48
68
|
}
|
|
49
69
|
|
|
@@ -71,11 +91,17 @@ function isExpired(entry: PendingDeviceAuth): boolean {
|
|
|
71
91
|
class AuthToolState {
|
|
72
92
|
private pending: PendingDeviceAuth | null = null;
|
|
73
93
|
|
|
74
|
-
constructor(
|
|
94
|
+
constructor(
|
|
95
|
+
private readonly openBrowser: BrowserOpener,
|
|
96
|
+
private readonly waitMs: number,
|
|
97
|
+
private readonly sleep: Sleeper,
|
|
98
|
+
) {}
|
|
75
99
|
|
|
76
100
|
async start(): Promise<ToolResult> {
|
|
77
101
|
let pending = this.pending;
|
|
102
|
+
let fresh = false;
|
|
78
103
|
if (!pending || isExpired(pending)) {
|
|
104
|
+
fresh = true;
|
|
79
105
|
const config = resolveDeviceAuthConfig();
|
|
80
106
|
const start = await startDeviceAuthorization({ config });
|
|
81
107
|
pending = { start, config, startedAtMs: Date.now() };
|
|
@@ -84,21 +110,33 @@ class AuthToolState {
|
|
|
84
110
|
|
|
85
111
|
const { verificationUriComplete, verificationUri, userCode } = pending.start;
|
|
86
112
|
const link = verificationUriComplete ?? verificationUri;
|
|
87
|
-
//
|
|
88
|
-
|
|
113
|
+
// Only a new login opens a tab; a repeat call just resumes the wait.
|
|
114
|
+
const opened = fresh ? openIfAllowed(this.openBrowser, link) : null;
|
|
115
|
+
|
|
116
|
+
const settled = await this.waitForApproval(pending);
|
|
117
|
+
return settled ?? toolText(approvalText(link, userCode, opened));
|
|
89
118
|
}
|
|
90
119
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
120
|
+
/** Poll until approved, denied or expired, or the wait budget runs out (then null). */
|
|
121
|
+
private async waitForApproval(entry: PendingDeviceAuth): Promise<ToolResult | null> {
|
|
122
|
+
const deadline = Date.now() + this.waitMs;
|
|
123
|
+
let intervalMs = Math.max(entry.start.interval * 1000, MIN_POLL_INTERVAL_MS);
|
|
124
|
+
while (Date.now() + intervalMs <= deadline) {
|
|
125
|
+
await this.sleep(intervalMs);
|
|
126
|
+
const result = await checkDeviceAuthorizationOnce(entry.start, { config: entry.config });
|
|
127
|
+
const settled = await this.settle(result);
|
|
128
|
+
if (settled) {
|
|
129
|
+
return settled;
|
|
130
|
+
}
|
|
131
|
+
if (result.status === "slow_down") {
|
|
132
|
+
intervalMs += SLOW_DOWN_STEP_MS;
|
|
133
|
+
}
|
|
94
134
|
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
95
137
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
// `pending` intact — a transient failure shouldn't force the user to restart
|
|
99
|
-
// the whole login, just retry the check.
|
|
100
|
-
const result = await checkDeviceAuthorizationOnce(start, { config });
|
|
101
|
-
|
|
138
|
+
/** Handle a final poll result; null means the login is still pending. */
|
|
139
|
+
private async settle(result: DeviceAuthorizationCheck): Promise<ToolResult | null> {
|
|
102
140
|
switch (result.status) {
|
|
103
141
|
case "success": {
|
|
104
142
|
this.pending = null;
|
|
@@ -114,31 +152,51 @@ class AuthToolState {
|
|
|
114
152
|
setStoredCredentialsEnvToken(tokens.accessToken);
|
|
115
153
|
return toolText("Signed in successfully. Miru tools are now ready to use.");
|
|
116
154
|
}
|
|
117
|
-
case "pending":
|
|
118
|
-
return toolText(
|
|
119
|
-
'Still waiting for approval. Ask the user to confirm they clicked and approved, then call `auth` again with action "check".',
|
|
120
|
-
);
|
|
121
|
-
case "slow_down":
|
|
122
|
-
return toolText(
|
|
123
|
-
'Checking too soon — wait a bit before calling `auth` again with action "check".',
|
|
124
|
-
);
|
|
125
155
|
case "denied":
|
|
126
156
|
this.pending = null;
|
|
127
|
-
return toolText(
|
|
157
|
+
return toolText("Sign-in was denied. Call `auth` again to try again.");
|
|
128
158
|
case "expired":
|
|
129
159
|
this.pending = null;
|
|
130
160
|
return toolText(
|
|
131
|
-
|
|
161
|
+
"The device code expired before it was approved. Call `auth` again to get a new one.",
|
|
132
162
|
);
|
|
163
|
+
default:
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async check(): Promise<ToolResult> {
|
|
169
|
+
if (!this.pending) {
|
|
170
|
+
return toolText('No device login is pending. Call `auth` with action "start" first.');
|
|
133
171
|
}
|
|
172
|
+
|
|
173
|
+
const { start, config } = this.pending;
|
|
174
|
+
// Errors (network failure, unrecognized OAuth error code) intentionally leave
|
|
175
|
+
// `pending` intact — a transient failure shouldn't force the user to restart
|
|
176
|
+
// the whole login, just retry the check.
|
|
177
|
+
const result = await checkDeviceAuthorizationOnce(start, { config });
|
|
178
|
+
|
|
179
|
+
const settled = await this.settle(result);
|
|
180
|
+
if (settled) {
|
|
181
|
+
return settled;
|
|
182
|
+
}
|
|
183
|
+
return toolText(
|
|
184
|
+
result.status === "slow_down"
|
|
185
|
+
? "Checking too soon — wait a bit before calling `auth` again."
|
|
186
|
+
: "Still waiting for approval. Ask the user to confirm they clicked and approved, then call `auth` again.",
|
|
187
|
+
);
|
|
134
188
|
}
|
|
135
189
|
}
|
|
136
190
|
|
|
137
191
|
export function registerAuthTool(
|
|
138
192
|
server: MiruMcpServer,
|
|
139
|
-
options?: { openBrowser?: BrowserOpener },
|
|
193
|
+
options?: { openBrowser?: BrowserOpener; waitMs?: number; sleep?: Sleeper },
|
|
140
194
|
): void {
|
|
141
|
-
const state = new AuthToolState(
|
|
195
|
+
const state = new AuthToolState(
|
|
196
|
+
options?.openBrowser ?? openBrowserForDeviceLogin,
|
|
197
|
+
options?.waitMs ?? DEFAULT_WAIT_MS,
|
|
198
|
+
options?.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
199
|
+
);
|
|
142
200
|
|
|
143
201
|
server.registerTool(
|
|
144
202
|
"auth",
|
|
@@ -148,7 +206,9 @@ export function registerAuthTool(
|
|
|
148
206
|
action: z
|
|
149
207
|
.enum(["start", "check"])
|
|
150
208
|
.optional()
|
|
151
|
-
.describe(
|
|
209
|
+
.describe(
|
|
210
|
+
'"start" begins or resumes a device-code login and waits for approval (default); "check" is a single non-blocking poll.',
|
|
211
|
+
),
|
|
152
212
|
},
|
|
153
213
|
},
|
|
154
214
|
async ({ action }) => {
|