@takara-ai/miru-code 0.4.5 → 0.4.9
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 +38 -15
- package/package.json +1 -1
- package/src/agents/claude.md +16 -28
- package/src/agents/copilot.md +9 -35
- package/src/agents/cursor.md +15 -28
- package/src/agents/gemini.md +10 -36
- package/src/agents/kiro.md +4 -37
- package/src/agents/opencode.md +4 -37
- package/src/cache.ts +1 -1
- package/src/cli.ts +10 -1
- package/src/env.ts +2 -1
- package/src/installer/agents.ts +146 -52
- package/src/installer/config.ts +0 -1
- package/src/installer/hooks/install.ts +463 -0
- package/src/installer/hooks/opencode-plugin.ts +68 -0
- package/src/installer/hooks/search-guard.ts +215 -0
- package/src/installer/installer.ts +56 -3
- package/src/installer/prompt.ts +218 -32
- package/src/installer/search-policy.ts +68 -0
- package/src/mcp/server.ts +8 -9
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
const REDIRECT_MESSAGE =
|
|
2
|
+
"Use Miru MCP `search` (pass project root as `repo`) instead of built-in search tools. " +
|
|
3
|
+
"Grep/Glob/Shell are only for exact literal confirmation or non-code tasks.";
|
|
4
|
+
|
|
5
|
+
const MIRU_TOOL_RE = /miru/i;
|
|
6
|
+
|
|
7
|
+
const GREP_TOOL_RE = /^(grep|grep_search|Grep)$/i;
|
|
8
|
+
const GLOB_TOOL_RE = /^(glob|glob_file_search|Glob)$/i;
|
|
9
|
+
const SEMANTIC_TOOL_RE = /^(SemanticSearch|codebase_search)$/i;
|
|
10
|
+
const SHELL_TOOL_RE = /^(Shell|Bash|execute_bash|shell)$/i;
|
|
11
|
+
|
|
12
|
+
/** Returns true when a grep pattern looks like an exact literal lookup. */
|
|
13
|
+
export function isLiteralGrepPattern(pattern: string): boolean {
|
|
14
|
+
const trimmed = pattern.trim();
|
|
15
|
+
if (!trimmed) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
if (/^[`'"][^`'"]+[`'"]$/.test(trimmed)) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
if (/^[A-Z][A-Z0-9_]{2,}$/.test(trimmed)) {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
if (/^[a-f0-9-]{36}$/i.test(trimmed)) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
if (/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(trimmed) && trimmed.length <= 48) {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Shell commands that look like codebase exploration rather than builds/tests. */
|
|
34
|
+
export function isExplorationShell(command: string): boolean {
|
|
35
|
+
const cmd = command.trim();
|
|
36
|
+
if (!cmd) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
if (
|
|
40
|
+
/\b(git|npm|bun|pnpm|yarn|cargo|go|make|cmake|docker|kubectl|pytest|jest|vitest)\b/.test(cmd)
|
|
41
|
+
) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
return /\b(rg|ripgrep|grep|find|ag|ack|fd|locate)\b/.test(cmd);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type HookPayload = {
|
|
48
|
+
tool_name?: string;
|
|
49
|
+
tool_input?: Record<string, unknown>;
|
|
50
|
+
hook_event_name?: string;
|
|
51
|
+
agent_action_name?: string;
|
|
52
|
+
tool_info?: Record<string, unknown>;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type GuardDecision = {
|
|
56
|
+
block: boolean;
|
|
57
|
+
reason: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type HookResponseFormat = "claude" | "gemini" | "cursor" | "stderr";
|
|
61
|
+
|
|
62
|
+
function grepPatternFromInput(toolInput: Record<string, unknown>): string {
|
|
63
|
+
return String(
|
|
64
|
+
toolInput.pattern ??
|
|
65
|
+
toolInput.query ??
|
|
66
|
+
toolInput.regex ??
|
|
67
|
+
toolInput.needle ??
|
|
68
|
+
toolInput.search_term ??
|
|
69
|
+
"",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Map host-specific stdin JSON into a common evaluation shape. */
|
|
74
|
+
export function normalizeHookPayload(raw: Record<string, unknown>): HookPayload {
|
|
75
|
+
const agentAction = raw.agent_action_name;
|
|
76
|
+
if (typeof agentAction === "string") {
|
|
77
|
+
const toolInfo =
|
|
78
|
+
raw.tool_info && typeof raw.tool_info === "object" && !Array.isArray(raw.tool_info)
|
|
79
|
+
? (raw.tool_info as Record<string, unknown>)
|
|
80
|
+
: {};
|
|
81
|
+
if (agentAction === "pre_run_command") {
|
|
82
|
+
return {
|
|
83
|
+
hook_event_name: "windsurf_pre_run_command",
|
|
84
|
+
tool_name: "Shell",
|
|
85
|
+
tool_input: { command: toolInfo.command_line ?? "" },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
tool_name: typeof raw.tool_name === "string" ? raw.tool_name : undefined,
|
|
92
|
+
tool_input:
|
|
93
|
+
raw.tool_input && typeof raw.tool_input === "object" && !Array.isArray(raw.tool_input)
|
|
94
|
+
? (raw.tool_input as Record<string, unknown>)
|
|
95
|
+
: {},
|
|
96
|
+
hook_event_name: typeof raw.hook_event_name === "string" ? raw.hook_event_name : undefined,
|
|
97
|
+
agent_action_name:
|
|
98
|
+
typeof raw.agent_action_name === "string" ? raw.agent_action_name : undefined,
|
|
99
|
+
tool_info:
|
|
100
|
+
raw.tool_info && typeof raw.tool_info === "object" && !Array.isArray(raw.tool_info)
|
|
101
|
+
? (raw.tool_info as Record<string, unknown>)
|
|
102
|
+
: undefined,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function evaluateSearchGuard(payload: HookPayload): GuardDecision {
|
|
107
|
+
const toolName = String(payload.tool_name ?? "");
|
|
108
|
+
const toolInput = payload.tool_input ?? {};
|
|
109
|
+
|
|
110
|
+
if (!toolName || MIRU_TOOL_RE.test(toolName)) {
|
|
111
|
+
return { block: false, reason: "" };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (GREP_TOOL_RE.test(toolName)) {
|
|
115
|
+
const pattern = grepPatternFromInput(toolInput);
|
|
116
|
+
if (isLiteralGrepPattern(pattern)) {
|
|
117
|
+
return { block: false, reason: "" };
|
|
118
|
+
}
|
|
119
|
+
return { block: true, reason: REDIRECT_MESSAGE };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (GLOB_TOOL_RE.test(toolName)) {
|
|
123
|
+
return { block: true, reason: REDIRECT_MESSAGE };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (SEMANTIC_TOOL_RE.test(toolName)) {
|
|
127
|
+
return { block: true, reason: REDIRECT_MESSAGE };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (SHELL_TOOL_RE.test(toolName)) {
|
|
131
|
+
const command = String(toolInput.command ?? toolInput.cmd ?? "");
|
|
132
|
+
if (isExplorationShell(command)) {
|
|
133
|
+
return { block: true, reason: REDIRECT_MESSAGE };
|
|
134
|
+
}
|
|
135
|
+
return { block: false, reason: "" };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { block: false, reason: "" };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function hookResponseFormat(payload: HookPayload): HookResponseFormat {
|
|
142
|
+
const event = payload.hook_event_name ?? "";
|
|
143
|
+
if (event === "PreToolUse") {
|
|
144
|
+
return "claude";
|
|
145
|
+
}
|
|
146
|
+
if (event === "BeforeTool") {
|
|
147
|
+
return "gemini";
|
|
148
|
+
}
|
|
149
|
+
if (event === "preToolUse" || event === "windsurf_pre_run_command") {
|
|
150
|
+
return "stderr";
|
|
151
|
+
}
|
|
152
|
+
return "cursor";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function claudeHookResponse(reason: string): string {
|
|
156
|
+
return JSON.stringify({
|
|
157
|
+
hookSpecificOutput: {
|
|
158
|
+
hookEventName: "PreToolUse",
|
|
159
|
+
permissionDecision: "deny",
|
|
160
|
+
permissionDecisionReason: reason,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function geminiHookResponse(reason: string): string {
|
|
166
|
+
return JSON.stringify({
|
|
167
|
+
decision: "deny",
|
|
168
|
+
reason,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function cursorHookResponse(reason: string): string {
|
|
173
|
+
return JSON.stringify({
|
|
174
|
+
permission: "deny",
|
|
175
|
+
agent_message: reason,
|
|
176
|
+
user_message: "Miru: use MCP search instead of built-in grep/glob.",
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function runSearchGuardFromStdin(): Promise<number> {
|
|
181
|
+
const text = await Bun.stdin.text();
|
|
182
|
+
if (!text.trim()) {
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let raw: Record<string, unknown>;
|
|
187
|
+
try {
|
|
188
|
+
raw = JSON.parse(text) as Record<string, unknown>;
|
|
189
|
+
} catch {
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const payload = normalizeHookPayload(raw);
|
|
194
|
+
const decision = evaluateSearchGuard(payload);
|
|
195
|
+
if (!decision.block) {
|
|
196
|
+
return 0;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const format = hookResponseFormat(payload);
|
|
200
|
+
if (format === "claude") {
|
|
201
|
+
process.stdout.write(claudeHookResponse(decision.reason));
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
if (format === "gemini") {
|
|
205
|
+
process.stdout.write(geminiHookResponse(decision.reason));
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
|
208
|
+
if (format === "stderr") {
|
|
209
|
+
process.stderr.write(decision.reason);
|
|
210
|
+
return 2;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
process.stdout.write(cursorHookResponse(decision.reason));
|
|
214
|
+
return 2;
|
|
215
|
+
}
|
|
@@ -19,14 +19,16 @@ import {
|
|
|
19
19
|
removeTomlBlock,
|
|
20
20
|
replaceOrAppendMarked,
|
|
21
21
|
} from "./config.ts";
|
|
22
|
+
import { mergeHooks, removeHooks } from "./hooks/install.ts";
|
|
22
23
|
import { promptConfirm, promptMultiSelect, requireInteractiveTerminal } from "./prompt.ts";
|
|
24
|
+
import { CURSOR_RULES_MDC } from "./search-policy.ts";
|
|
23
25
|
|
|
24
26
|
export interface WriteResult {
|
|
25
27
|
path: string;
|
|
26
28
|
action: InstallAction;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
export type IntegrationId = "mcp" | "instructions" | "subagent";
|
|
31
|
+
export type IntegrationId = "mcp" | "instructions" | "subagent" | "hooks" | "rules";
|
|
30
32
|
|
|
31
33
|
interface Integration {
|
|
32
34
|
id: IntegrationId;
|
|
@@ -84,6 +86,42 @@ async function applyInstructions(
|
|
|
84
86
|
return { path, action };
|
|
85
87
|
}
|
|
86
88
|
|
|
89
|
+
async function applyHooks(agent: AgentTarget, mode: InstallMode): Promise<WriteResult | null> {
|
|
90
|
+
const path = agent.hooksPath;
|
|
91
|
+
const format = agent.hooksFormat;
|
|
92
|
+
if (!path || !format) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const action =
|
|
97
|
+
mode === "install" ? await mergeHooks(format, path) : await removeHooks(format, path);
|
|
98
|
+
|
|
99
|
+
return { path, action };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function applyCursorRules(
|
|
103
|
+
agent: AgentTarget,
|
|
104
|
+
mode: InstallMode,
|
|
105
|
+
): Promise<WriteResult | null> {
|
|
106
|
+
const path = agent.cursorRulesPath;
|
|
107
|
+
if (!path) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (mode === "uninstall") {
|
|
112
|
+
if (!(await Bun.file(path).exists())) {
|
|
113
|
+
return { path, action: "not-found" };
|
|
114
|
+
}
|
|
115
|
+
await unlink(path);
|
|
116
|
+
return { path, action: "removed" };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const existed = await Bun.file(path).exists();
|
|
120
|
+
await mkdir(dirname(path), { recursive: true });
|
|
121
|
+
await writeFile(path, `${CURSOR_RULES_MDC.trim()}\n`, "utf-8");
|
|
122
|
+
return { path, action: existed ? "updated" : "created" };
|
|
123
|
+
}
|
|
124
|
+
|
|
87
125
|
async function applySubagent(agent: AgentTarget, mode: InstallMode): Promise<WriteResult | null> {
|
|
88
126
|
const dest = agent.subagentPath;
|
|
89
127
|
const templateId = agent.subagentId;
|
|
@@ -132,6 +170,20 @@ const INTEGRATIONS: Integration[] = [
|
|
|
132
170
|
planPath: (agent) => agent.subagentPath,
|
|
133
171
|
apply: applySubagent,
|
|
134
172
|
},
|
|
173
|
+
{
|
|
174
|
+
id: "rules",
|
|
175
|
+
label: "Cursor rules",
|
|
176
|
+
description: "always-on .cursor/rules policy for code search",
|
|
177
|
+
planPath: (agent) => agent.cursorRulesPath,
|
|
178
|
+
apply: applyCursorRules,
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: "hooks",
|
|
182
|
+
label: "Search hooks",
|
|
183
|
+
description: "blocks Grep/Glob and redirects agents to Miru MCP",
|
|
184
|
+
planPath: (agent) => agent.hooksPath,
|
|
185
|
+
apply: applyHooks,
|
|
186
|
+
},
|
|
135
187
|
];
|
|
136
188
|
|
|
137
189
|
function formatActionLine(integration: Integration, result: WriteResult): string {
|
|
@@ -191,7 +243,7 @@ export async function runInstaller(mode: InstallMode): Promise<void> {
|
|
|
191
243
|
writeStdout("");
|
|
192
244
|
writeStdout(`${brandTitle()}${install ? " installer" : " uninstaller"}`);
|
|
193
245
|
divider();
|
|
194
|
-
hint("
|
|
246
|
+
hint("↑↓ move space select enter confirm");
|
|
195
247
|
|
|
196
248
|
const detected = await Promise.all(
|
|
197
249
|
AGENT_TARGETS.map(async (agent) => ({
|
|
@@ -246,12 +298,13 @@ export async function runInstaller(mode: InstallMode): Promise<void> {
|
|
|
246
298
|
install ? "Done! Restart your agents to pick up changes." : "Done! Configuration removed.",
|
|
247
299
|
);
|
|
248
300
|
if (install) {
|
|
249
|
-
hint("
|
|
301
|
+
hint("Restart agents after install. Hooks block built-in search in favor of Miru MCP.");
|
|
250
302
|
}
|
|
251
303
|
writeStdout("");
|
|
252
304
|
}
|
|
253
305
|
|
|
254
306
|
export {
|
|
307
|
+
applyHooks,
|
|
255
308
|
applyInstructions,
|
|
256
309
|
applyMcp,
|
|
257
310
|
applySubagent,
|
package/src/installer/prompt.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { stdin as input, stdout as output } from "node:process";
|
|
2
2
|
import * as readline from "node:readline/promises";
|
|
3
|
-
import { bold, cyan, dim, divider, hint, warn } from "../cli-ui.ts";
|
|
3
|
+
import { bold, cyan, dim, divider, green, hint, warn } from "../cli-ui.ts";
|
|
4
4
|
|
|
5
5
|
export function requireInteractiveTerminal(command: string): void {
|
|
6
6
|
if (!input.isTTY) {
|
|
@@ -11,27 +11,208 @@ export function requireInteractiveTerminal(command: string): void {
|
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
const ESC = "\x1b";
|
|
15
|
+
const HIDE_CURSOR = `${ESC}[?25l`;
|
|
16
|
+
const SHOW_CURSOR = `${ESC}[?25h`;
|
|
17
|
+
const CLEAR_LINE = `${ESC}[2K`;
|
|
18
|
+
|
|
19
|
+
function writeRaw(text: string): void {
|
|
20
|
+
output.write(text);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readKey(): Promise<Buffer> {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const onData = (chunk: Buffer) => {
|
|
26
|
+
cleanup();
|
|
27
|
+
resolve(chunk);
|
|
28
|
+
};
|
|
29
|
+
const onError = (err: Error) => {
|
|
30
|
+
cleanup();
|
|
31
|
+
reject(err);
|
|
32
|
+
};
|
|
33
|
+
const cleanup = () => {
|
|
34
|
+
input.off("data", onData);
|
|
35
|
+
input.off("error", onError);
|
|
36
|
+
};
|
|
37
|
+
input.once("data", onData);
|
|
38
|
+
input.once("error", onError);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseKey(chunk: Buffer): string {
|
|
43
|
+
const text = chunk.toString("utf8");
|
|
44
|
+
if (chunk.length >= 3 && chunk[0] === 0x1b && chunk[1] === 0x5b) {
|
|
45
|
+
const code = chunk[2];
|
|
46
|
+
if (code === 0x41) {
|
|
47
|
+
return "up";
|
|
48
|
+
}
|
|
49
|
+
if (code === 0x42) {
|
|
50
|
+
return "down";
|
|
51
|
+
}
|
|
52
|
+
if (code === 0x43) {
|
|
53
|
+
return "right";
|
|
54
|
+
}
|
|
55
|
+
if (code === 0x44) {
|
|
56
|
+
return "left";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (text === "\r" || text === "\n") {
|
|
60
|
+
return "enter";
|
|
61
|
+
}
|
|
62
|
+
if (text === " ") {
|
|
63
|
+
return "space";
|
|
64
|
+
}
|
|
65
|
+
if (text === "\x03") {
|
|
66
|
+
return "ctrl-c";
|
|
67
|
+
}
|
|
68
|
+
if (text === "a" || text === "A") {
|
|
69
|
+
return "all";
|
|
70
|
+
}
|
|
71
|
+
if (text === "y" || text === "Y") {
|
|
72
|
+
return "yes";
|
|
73
|
+
}
|
|
74
|
+
if (text === "n" || text === "N") {
|
|
75
|
+
return "no";
|
|
76
|
+
}
|
|
77
|
+
return text;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function withRawMode<T>(fn: () => Promise<T>): Promise<T> {
|
|
81
|
+
input.setRawMode(true);
|
|
82
|
+
input.resume();
|
|
83
|
+
writeRaw(HIDE_CURSOR);
|
|
16
84
|
try {
|
|
17
|
-
return
|
|
85
|
+
return await fn();
|
|
18
86
|
} finally {
|
|
19
|
-
|
|
87
|
+
writeRaw(SHOW_CURSOR);
|
|
88
|
+
input.setRawMode(false);
|
|
89
|
+
input.pause();
|
|
20
90
|
}
|
|
21
91
|
}
|
|
22
92
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
93
|
+
function renderMultiSelect<T>(
|
|
94
|
+
title: string,
|
|
95
|
+
items: Array<{ label: string; value: T; checked: boolean }>,
|
|
96
|
+
cursor: number,
|
|
97
|
+
footer: string,
|
|
98
|
+
): void {
|
|
99
|
+
const lines: string[] = [];
|
|
100
|
+
lines.push("");
|
|
101
|
+
lines.push(bold(title));
|
|
102
|
+
lines.push(dim("─".repeat(48)));
|
|
103
|
+
for (let i = 0; i < items.length; i++) {
|
|
104
|
+
const item = items[i];
|
|
105
|
+
if (!item) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const selected = i === cursor;
|
|
109
|
+
const pointer = selected ? cyan("›") : " ";
|
|
110
|
+
const mark = item.checked ? cyan("[x]") : dim("[ ]");
|
|
111
|
+
const label = selected ? bold(item.label) : item.label;
|
|
112
|
+
lines.push(` ${pointer} ${mark} ${label}`);
|
|
28
113
|
}
|
|
29
|
-
|
|
114
|
+
lines.push("");
|
|
115
|
+
lines.push(dim(footer));
|
|
116
|
+
writeRaw(`${CLEAR_LINE}${lines.join(`\n${CLEAR_LINE}`)}\n`);
|
|
30
117
|
}
|
|
31
118
|
|
|
32
119
|
export async function promptMultiSelect<T>(
|
|
33
120
|
title: string,
|
|
34
121
|
items: Array<{ label: string; value: T; checked: boolean }>,
|
|
122
|
+
): Promise<T[] | null> {
|
|
123
|
+
if (items.length === 0) {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!input.isTTY) {
|
|
128
|
+
return items.filter((item) => item.checked).map((item) => item.value);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const footer = "↑↓ move space toggle a all enter confirm ctrl-c cancel";
|
|
132
|
+
let cursor = 0;
|
|
133
|
+
|
|
134
|
+
return withRawMode(async () => {
|
|
135
|
+
while (true) {
|
|
136
|
+
renderMultiSelect(title, items, cursor, footer);
|
|
137
|
+
const key = parseKey(await readKey());
|
|
138
|
+
|
|
139
|
+
if (key === "ctrl-c") {
|
|
140
|
+
writeRaw("\n");
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
if (key === "enter") {
|
|
144
|
+
writeRaw("\n");
|
|
145
|
+
return items.filter((item) => item.checked).map((item) => item.value);
|
|
146
|
+
}
|
|
147
|
+
if (key === "up") {
|
|
148
|
+
cursor = Math.max(0, cursor - 1);
|
|
149
|
+
} else if (key === "down") {
|
|
150
|
+
cursor = Math.min(items.length - 1, cursor + 1);
|
|
151
|
+
} else if (key === "space") {
|
|
152
|
+
const item = items[cursor];
|
|
153
|
+
if (item) {
|
|
154
|
+
item.checked = !item.checked;
|
|
155
|
+
}
|
|
156
|
+
} else if (key === "all") {
|
|
157
|
+
const allChecked = items.every((item) => item.checked);
|
|
158
|
+
for (const item of items) {
|
|
159
|
+
item.checked = !allChecked;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function renderConfirm(question: string, yesSelected: boolean): void {
|
|
167
|
+
const yes = yesSelected ? green(bold(" Yes ")) : dim(" Yes ");
|
|
168
|
+
const no = yesSelected ? dim(" No ") : green(bold(" No "));
|
|
169
|
+
writeRaw(`${CLEAR_LINE}${question} ${yes} / ${no}\n`);
|
|
170
|
+
writeRaw(`${CLEAR_LINE}${dim("←→ or y/n to choose enter to confirm ctrl-c to cancel")}\n`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function promptConfirm(question: string, defaultYes = true): Promise<boolean> {
|
|
174
|
+
if (!input.isTTY) {
|
|
175
|
+
const rl = readline.createInterface({ input, output });
|
|
176
|
+
try {
|
|
177
|
+
const hintText = defaultYes ? dim("[Y/n]") : dim("[y/N]");
|
|
178
|
+
const answer = (await rl.question(`${question} ${hintText} `)).trim().toLowerCase();
|
|
179
|
+
if (!answer) {
|
|
180
|
+
return defaultYes;
|
|
181
|
+
}
|
|
182
|
+
return answer === "y" || answer === "yes";
|
|
183
|
+
} finally {
|
|
184
|
+
rl.close();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let yesSelected = defaultYes;
|
|
189
|
+
|
|
190
|
+
return withRawMode(async () => {
|
|
191
|
+
while (true) {
|
|
192
|
+
renderConfirm(question, yesSelected);
|
|
193
|
+
const key = parseKey(await readKey());
|
|
194
|
+
|
|
195
|
+
if (key === "ctrl-c") {
|
|
196
|
+
writeRaw("\n");
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
if (key === "enter") {
|
|
200
|
+
writeRaw("\n");
|
|
201
|
+
return yesSelected;
|
|
202
|
+
}
|
|
203
|
+
if (key === "left" || key === "no") {
|
|
204
|
+
yesSelected = false;
|
|
205
|
+
} else if (key === "right" || key === "yes") {
|
|
206
|
+
yesSelected = true;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Legacy numbered prompt kept for non-TTY fallback in tests. */
|
|
213
|
+
export async function promptMultiSelectLegacy<T>(
|
|
214
|
+
title: string,
|
|
215
|
+
items: Array<{ label: string; value: T; checked: boolean }>,
|
|
35
216
|
): Promise<T[] | null> {
|
|
36
217
|
output.write("\n");
|
|
37
218
|
output.write(`${bold(title)}\n`);
|
|
@@ -49,29 +230,34 @@ export async function promptMultiSelect<T>(
|
|
|
49
230
|
output.write("\n");
|
|
50
231
|
hint("Enter numbers (1,3), 'all', or press Enter for defaults", output);
|
|
51
232
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
233
|
+
const rl = readline.createInterface({ input, output });
|
|
234
|
+
try {
|
|
235
|
+
const answer = (await rl.question(`${dim("> ")}`)).toLowerCase();
|
|
236
|
+
if (!answer) {
|
|
237
|
+
return items.filter((item) => item.checked).map((item) => item.value);
|
|
238
|
+
}
|
|
239
|
+
if (answer === "all" || answer === "a") {
|
|
240
|
+
return items.map((item) => item.value);
|
|
241
|
+
}
|
|
59
242
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
243
|
+
const indices = new Set<number>();
|
|
244
|
+
for (const part of answer.split(/[,\s]+/)) {
|
|
245
|
+
const n = Number(part);
|
|
246
|
+
if (Number.isInteger(n) && n >= 1 && n <= items.length) {
|
|
247
|
+
indices.add(n - 1);
|
|
248
|
+
}
|
|
65
249
|
}
|
|
66
|
-
}
|
|
67
250
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
251
|
+
if (indices.size === 0) {
|
|
252
|
+
warn("No valid selection.", output);
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
72
255
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
256
|
+
return [...indices]
|
|
257
|
+
.sort((a, b) => a - b)
|
|
258
|
+
.map((i) => items[i]?.value)
|
|
259
|
+
.filter((value): value is T => value !== undefined);
|
|
260
|
+
} finally {
|
|
261
|
+
rl.close();
|
|
262
|
+
}
|
|
77
263
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Shared code-search policy text for instructions, Cursor rules, and sub-agents. */
|
|
2
|
+
|
|
3
|
+
export const SEARCH_POLICY_TABLE = `| Task | Use | Not |
|
|
4
|
+
|------|-----|-----|
|
|
5
|
+
| How/where/what handles X? | Miru MCP \`search\` | Grep, Glob, SemanticSearch, Read |
|
|
6
|
+
| Similar code elsewhere | Miru MCP \`find_related\` | Read whole files |
|
|
7
|
+
| Search docs or config | Miru \`search\` with \`content\` | Grep README paths |
|
|
8
|
+
| Exact literal exists? | Grep | Miru |
|
|
9
|
+
| Edit a known file:line | Read (after Miru found it) | Read-before-search |`;
|
|
10
|
+
|
|
11
|
+
export const SEARCH_POLICY_BODY = `DO NOT use Grep, Glob, SemanticSearch, or Read to explore how code works when Miru MCP is available.
|
|
12
|
+
|
|
13
|
+
Use Miru MCP tools first:
|
|
14
|
+
- \`search\` — natural-language or code query; pass project root as \`repo\`
|
|
15
|
+
- \`find_related\` — similar chunks after a \`search\` hit (\`file_path\` + \`line\`)
|
|
16
|
+
|
|
17
|
+
Native tools are allowed ONLY when:
|
|
18
|
+
- confirming an exact literal string (env var name, error code, quoted text)
|
|
19
|
+
- reading a file you already located via Miru, to edit it
|
|
20
|
+
- searching outside the indexed repo
|
|
21
|
+
|
|
22
|
+
${SEARCH_POLICY_TABLE}`;
|
|
23
|
+
|
|
24
|
+
export const INSTRUCTIONS_MARKDOWN = `## Miru Code Search
|
|
25
|
+
|
|
26
|
+
${SEARCH_POLICY_BODY}
|
|
27
|
+
|
|
28
|
+
Run \`miru setup\` once — the MCP server loads your API key from \`credentials.json\`.
|
|
29
|
+
|
|
30
|
+
CLI fallback when MCP is unavailable:
|
|
31
|
+
|
|
32
|
+
\`\`\`bash
|
|
33
|
+
miru search "authentication flow" .
|
|
34
|
+
miru find-related src/auth.ts 42 .
|
|
35
|
+
\`\`\`
|
|
36
|
+
|
|
37
|
+
After large refactors or CLI-only use, run \`miru clear <path>\` to rebuild the disk cache.
|
|
38
|
+
If \`miru\` is not on \`$PATH\`, use \`bunx @takara-ai/miru-code\`.`;
|
|
39
|
+
|
|
40
|
+
export const CURSOR_RULES_MDC = `---
|
|
41
|
+
description: Miru MCP is the default for all code exploration
|
|
42
|
+
alwaysApply: true
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
# Code search policy (Miru)
|
|
46
|
+
|
|
47
|
+
${SEARCH_POLICY_BODY}
|
|
48
|
+
|
|
49
|
+
When Miru MCP is connected, call \`search\` before any built-in search tool. Never use Cursor SemanticSearch or Grep for codebase questions unless confirming an exact literal.`;
|
|
50
|
+
|
|
51
|
+
export const SUBAGENT_BODY = `When Miru MCP is available, use MCP \`search\` and \`find_related\` — not Grep, Glob, SemanticSearch, or Read-for-exploration.
|
|
52
|
+
|
|
53
|
+
${SEARCH_POLICY_BODY}
|
|
54
|
+
|
|
55
|
+
### MCP workflow
|
|
56
|
+
|
|
57
|
+
1. Call \`search\` with \`repo\` set to the project root (local path or https:// git URL).
|
|
58
|
+
2. Use returned \`file_path\` and \`line\` from hits; call \`find_related\` to explore neighbors.
|
|
59
|
+
3. Read files only when a chunk lacks context or you are about to edit.
|
|
60
|
+
|
|
61
|
+
### CLI fallback (no MCP in this session)
|
|
62
|
+
|
|
63
|
+
\`\`\`bash
|
|
64
|
+
miru search "authentication flow" .
|
|
65
|
+
miru find-related src/auth.ts 42 .
|
|
66
|
+
\`\`\`
|
|
67
|
+
|
|
68
|
+
If \`miru\` is not on \`$PATH\`, use \`bunx @takara-ai/miru-code\`.`;
|