@takara-ai/miru-code 0.3.0 → 0.4.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 +180 -259
- package/package.json +1 -1
- package/src/agents/claude.md +1 -1
- package/src/agents/copilot.md +1 -1
- package/src/agents/cursor.md +1 -1
- package/src/agents/gemini.md +1 -1
- package/src/agents/kiro.md +1 -1
- package/src/agents/opencode.md +1 -1
- package/src/cli-ui.ts +189 -0
- package/src/cli.ts +110 -33
- package/src/help.ts +115 -130
- package/src/installer/agents.ts +246 -0
- package/src/installer/config.ts +273 -0
- package/src/installer/installer.ts +256 -0
- package/src/installer/prompt.ts +77 -0
- package/src/setup.ts +17 -20
- package/src/spinner.ts +10 -4
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { type InstallAction, MIRU_END, MIRU_START } from "./agents.ts";
|
|
4
|
+
|
|
5
|
+
const CODEX_MCP_HEADER = "[mcp_servers.miru]";
|
|
6
|
+
const CODEX_MCP_BLOCK = `[mcp_servers.miru]
|
|
7
|
+
command = "bunx"
|
|
8
|
+
args = ["@takara-ai/miru-code"]
|
|
9
|
+
env = { TAKARA_API_KEY = "\${TAKARA_API_KEY}" }
|
|
10
|
+
`;
|
|
11
|
+
|
|
12
|
+
/** Strip line and block comments so JSONC configs can be parsed. */
|
|
13
|
+
export function stripJsonComments(text: string): string {
|
|
14
|
+
let out = "";
|
|
15
|
+
let i = 0;
|
|
16
|
+
let inString = false;
|
|
17
|
+
let stringQuote = '"';
|
|
18
|
+
while (i < text.length) {
|
|
19
|
+
const ch = text[i];
|
|
20
|
+
const next = text[i + 1];
|
|
21
|
+
if (inString) {
|
|
22
|
+
out += ch;
|
|
23
|
+
if (ch === "\\" && next !== undefined) {
|
|
24
|
+
out += next;
|
|
25
|
+
i += 2;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (ch === stringQuote) {
|
|
29
|
+
inString = false;
|
|
30
|
+
}
|
|
31
|
+
i++;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (ch === '"' || ch === "'") {
|
|
35
|
+
inString = true;
|
|
36
|
+
stringQuote = ch;
|
|
37
|
+
out += ch;
|
|
38
|
+
i++;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (ch === "/" && next === "/") {
|
|
42
|
+
while (i < text.length && text[i] !== "\n") {
|
|
43
|
+
i++;
|
|
44
|
+
}
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (ch === "/" && next === "*") {
|
|
48
|
+
i += 2;
|
|
49
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) {
|
|
50
|
+
i++;
|
|
51
|
+
}
|
|
52
|
+
i += 2;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
out += ch;
|
|
56
|
+
i++;
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseJsonObject(text: string): Record<string, unknown> | "error" {
|
|
62
|
+
const trimmed = text.trim();
|
|
63
|
+
if (!trimmed) {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const parsed: unknown = JSON.parse(stripJsonComments(trimmed));
|
|
68
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
69
|
+
return "error";
|
|
70
|
+
}
|
|
71
|
+
return parsed as Record<string, unknown>;
|
|
72
|
+
} catch {
|
|
73
|
+
return "error";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sectionObject(
|
|
78
|
+
root: Record<string, unknown>,
|
|
79
|
+
sectionKey: string,
|
|
80
|
+
): Record<string, unknown> | "error" {
|
|
81
|
+
const section = root[sectionKey];
|
|
82
|
+
if (section === undefined) {
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
if (!section || typeof section !== "object" || Array.isArray(section)) {
|
|
86
|
+
return "error";
|
|
87
|
+
}
|
|
88
|
+
return section as Record<string, unknown>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function mergeJsonMember(
|
|
92
|
+
path: string,
|
|
93
|
+
sectionKey: string,
|
|
94
|
+
memberKey: string,
|
|
95
|
+
value: Record<string, unknown>,
|
|
96
|
+
): Promise<InstallAction> {
|
|
97
|
+
const existed = await Bun.file(path).exists();
|
|
98
|
+
const text = existed ? await Bun.file(path).text() : "";
|
|
99
|
+
const parsed = parseJsonObject(text);
|
|
100
|
+
if (parsed === "error") {
|
|
101
|
+
return "error";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const section = sectionObject(parsed, sectionKey);
|
|
105
|
+
if (section === "error") {
|
|
106
|
+
return "error";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (JSON.stringify(section[memberKey]) === JSON.stringify(value)) {
|
|
110
|
+
return "unchanged";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
section[memberKey] = value;
|
|
114
|
+
parsed[sectionKey] = section;
|
|
115
|
+
|
|
116
|
+
const next = `${JSON.stringify(parsed, null, 2)}\n`;
|
|
117
|
+
|
|
118
|
+
await mkdir(dirname(path), { recursive: true });
|
|
119
|
+
await writeFile(path, next, "utf-8");
|
|
120
|
+
return existed ? "updated" : "created";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function removeJsonMember(
|
|
124
|
+
path: string,
|
|
125
|
+
sectionKey: string,
|
|
126
|
+
memberKey: string,
|
|
127
|
+
): Promise<InstallAction> {
|
|
128
|
+
if (!(await Bun.file(path).exists())) {
|
|
129
|
+
return "not-found";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const text = await Bun.file(path).text();
|
|
133
|
+
const parsed = parseJsonObject(text);
|
|
134
|
+
if (parsed === "error") {
|
|
135
|
+
return "error";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const section = sectionObject(parsed, sectionKey);
|
|
139
|
+
if (section === "error" || !(memberKey in section)) {
|
|
140
|
+
return "not-found";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
delete section[memberKey];
|
|
144
|
+
if (Object.keys(section).length === 0) {
|
|
145
|
+
delete parsed[sectionKey];
|
|
146
|
+
} else {
|
|
147
|
+
parsed[sectionKey] = section;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const next = `${JSON.stringify(parsed, null, 2)}\n`;
|
|
151
|
+
if (next === `${text.endsWith("\n") ? text : `${text}\n`}`) {
|
|
152
|
+
return "not-found";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (Object.keys(parsed).length === 0) {
|
|
156
|
+
await unlink(path);
|
|
157
|
+
return "removed";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
await writeFile(path, next, "utf-8");
|
|
161
|
+
return "removed";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function replaceOrAppendMarked(path: string, content: string): Promise<InstallAction> {
|
|
165
|
+
await mkdir(dirname(path), { recursive: true });
|
|
166
|
+
const existed = await Bun.file(path).exists();
|
|
167
|
+
const existing = existed ? await Bun.file(path).text() : "";
|
|
168
|
+
|
|
169
|
+
const startIdx = existing.indexOf(MIRU_START);
|
|
170
|
+
const endIdx = existing.indexOf(MIRU_END);
|
|
171
|
+
|
|
172
|
+
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
|
173
|
+
const before = existing.slice(0, startIdx);
|
|
174
|
+
const after = existing.slice(endIdx + MIRU_END.length);
|
|
175
|
+
const updated = `${before}${content.trim()}\n${after.replace(/^\n+/, "")}`;
|
|
176
|
+
if (updated === existing) {
|
|
177
|
+
return "unchanged";
|
|
178
|
+
}
|
|
179
|
+
await writeFile(path, updated, "utf-8");
|
|
180
|
+
return "updated";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const separator =
|
|
184
|
+
existing && !existing.endsWith("\n\n") ? (existing.endsWith("\n") ? "\n" : "\n\n") : "";
|
|
185
|
+
await writeFile(path, `${existing}${separator}${content}`, "utf-8");
|
|
186
|
+
return existed ? "updated" : "created";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function removeMarked(path: string): Promise<InstallAction> {
|
|
190
|
+
if (!(await Bun.file(path).exists())) {
|
|
191
|
+
return "not-found";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const existing = await Bun.file(path).text();
|
|
195
|
+
const startIdx = existing.indexOf(MIRU_START);
|
|
196
|
+
const endIdx = existing.indexOf(MIRU_END);
|
|
197
|
+
|
|
198
|
+
if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
|
|
199
|
+
return "not-found";
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const before = existing.slice(0, startIdx).replace(/\n+$/, "");
|
|
203
|
+
const after = existing.slice(endIdx + MIRU_END.length).replace(/^\n+/, "");
|
|
204
|
+
const updated = [before, after].filter((part) => part.length > 0).join("\n");
|
|
205
|
+
const withNewline = updated.length > 0 ? `${updated}\n` : "";
|
|
206
|
+
|
|
207
|
+
if (withNewline.trim().length === 0) {
|
|
208
|
+
await unlink(path);
|
|
209
|
+
return "removed";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
await writeFile(path, withNewline, "utf-8");
|
|
213
|
+
return "removed";
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function stripTomlSection(text: string, header: string): string {
|
|
217
|
+
const prefix = header.trim().slice(1, -1);
|
|
218
|
+
const lines = text.split("\n");
|
|
219
|
+
const result: string[] = [];
|
|
220
|
+
let skipping = false;
|
|
221
|
+
|
|
222
|
+
for (const line of lines) {
|
|
223
|
+
const tableKey = line.split("#")[0]?.trim() ?? "";
|
|
224
|
+
if (tableKey.startsWith("[") && tableKey.endsWith("]")) {
|
|
225
|
+
const tableName = tableKey.slice(1, -1);
|
|
226
|
+
if (tableName === prefix || tableName.startsWith(`${prefix}.`)) {
|
|
227
|
+
skipping = true;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
skipping = false;
|
|
231
|
+
}
|
|
232
|
+
if (!skipping) {
|
|
233
|
+
result.push(line);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return result.join("\n");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function mergeTomlBlock(path: string): Promise<InstallAction> {
|
|
241
|
+
await mkdir(dirname(path), { recursive: true });
|
|
242
|
+
const existed = await Bun.file(path).exists();
|
|
243
|
+
const existing = existed ? await Bun.file(path).text() : "";
|
|
244
|
+
|
|
245
|
+
if (existing.includes(CODEX_MCP_BLOCK.trim())) {
|
|
246
|
+
return "unchanged";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const base = stripTomlSection(existing, CODEX_MCP_HEADER).replace(/\n+$/, "");
|
|
250
|
+
const next = base.length > 0 ? `${base}\n\n${CODEX_MCP_BLOCK}` : CODEX_MCP_BLOCK;
|
|
251
|
+
await writeFile(path, next.endsWith("\n") ? next : `${next}\n`, "utf-8");
|
|
252
|
+
return existed ? "updated" : "created";
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function removeTomlBlock(path: string): Promise<InstallAction> {
|
|
256
|
+
if (!(await Bun.file(path).exists())) {
|
|
257
|
+
return "not-found";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const existing = await Bun.file(path).text();
|
|
261
|
+
if (!existing.includes(CODEX_MCP_HEADER)) {
|
|
262
|
+
return "not-found";
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const remaining = stripTomlSection(existing, CODEX_MCP_HEADER).trim();
|
|
266
|
+
if (remaining.length === 0) {
|
|
267
|
+
await unlink(path);
|
|
268
|
+
return "removed";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
await writeFile(path, `${remaining}\n`, "utf-8");
|
|
272
|
+
return "removed";
|
|
273
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { loadAgentTemplate } from "../agents.ts";
|
|
4
|
+
import { brandTitle, dim, divider, green, hint, success, writeStdout } from "../cli-ui.ts";
|
|
5
|
+
import {
|
|
6
|
+
AGENT_TARGETS,
|
|
7
|
+
type AgentTarget,
|
|
8
|
+
INSTRUCTIONS,
|
|
9
|
+
type InstallAction,
|
|
10
|
+
type InstallMode,
|
|
11
|
+
isAgentDetected,
|
|
12
|
+
} from "./agents.ts";
|
|
13
|
+
import {
|
|
14
|
+
mergeJsonMember,
|
|
15
|
+
mergeTomlBlock,
|
|
16
|
+
removeJsonMember,
|
|
17
|
+
removeMarked,
|
|
18
|
+
removeTomlBlock,
|
|
19
|
+
replaceOrAppendMarked,
|
|
20
|
+
} from "./config.ts";
|
|
21
|
+
import { promptConfirm, promptMultiSelect, requireInteractiveTerminal } from "./prompt.ts";
|
|
22
|
+
|
|
23
|
+
export interface WriteResult {
|
|
24
|
+
path: string;
|
|
25
|
+
action: InstallAction;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type IntegrationId = "mcp" | "instructions" | "subagent";
|
|
29
|
+
|
|
30
|
+
interface Integration {
|
|
31
|
+
id: IntegrationId;
|
|
32
|
+
label: string;
|
|
33
|
+
description: string;
|
|
34
|
+
planPath: (agent: AgentTarget) => string | null;
|
|
35
|
+
apply: (agent: AgentTarget, mode: InstallMode) => Promise<WriteResult | null>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const ACTION_DETAIL: Partial<Record<InstallAction, string>> = {
|
|
39
|
+
skipped: "config uses comments or invalid JSON — add manually (see README)",
|
|
40
|
+
error: "could not parse or edit config",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const ACTION_ICON: Partial<Record<InstallAction, string>> = {
|
|
44
|
+
created: green("✓"),
|
|
45
|
+
updated: green("✓"),
|
|
46
|
+
removed: green("✓"),
|
|
47
|
+
unchanged: dim("·"),
|
|
48
|
+
"not-found": dim("–"),
|
|
49
|
+
skipped: dim("!"),
|
|
50
|
+
error: dim("✗"),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
async function applyMcp(agent: AgentTarget, mode: InstallMode): Promise<WriteResult | null> {
|
|
54
|
+
const mcp = agent.mcp;
|
|
55
|
+
if (!mcp) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (mcp.format === "toml") {
|
|
60
|
+
const action =
|
|
61
|
+
mode === "install" ? await mergeTomlBlock(mcp.path) : await removeTomlBlock(mcp.path);
|
|
62
|
+
return { path: mcp.path, action };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const action =
|
|
66
|
+
mode === "install"
|
|
67
|
+
? await mergeJsonMember(mcp.path, mcp.key, mcp.memberKey, mcp.entry)
|
|
68
|
+
: await removeJsonMember(mcp.path, mcp.key, mcp.memberKey);
|
|
69
|
+
return { path: mcp.path, action };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function applyInstructions(
|
|
73
|
+
agent: AgentTarget,
|
|
74
|
+
mode: InstallMode,
|
|
75
|
+
): Promise<WriteResult | null> {
|
|
76
|
+
const path = agent.instructionsPath;
|
|
77
|
+
if (!path) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const action =
|
|
82
|
+
mode === "install" ? await replaceOrAppendMarked(path, INSTRUCTIONS) : await removeMarked(path);
|
|
83
|
+
return { path, action };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function applySubagent(agent: AgentTarget, mode: InstallMode): Promise<WriteResult | null> {
|
|
87
|
+
const dest = agent.subagentPath;
|
|
88
|
+
const templateId = agent.subagentId;
|
|
89
|
+
if (!dest || !templateId) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (mode === "uninstall") {
|
|
94
|
+
if (!(await Bun.file(dest).exists())) {
|
|
95
|
+
return { path: dest, action: "not-found" };
|
|
96
|
+
}
|
|
97
|
+
await unlink(dest);
|
|
98
|
+
return { path: dest, action: "removed" };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const content = await loadAgentTemplate(templateId);
|
|
103
|
+
const existed = await Bun.file(dest).exists();
|
|
104
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
105
|
+
await writeFile(dest, content, "utf-8");
|
|
106
|
+
return { path: dest, action: existed ? "updated" : "created" };
|
|
107
|
+
} catch {
|
|
108
|
+
return { path: dest, action: "error" };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const INTEGRATIONS: Integration[] = [
|
|
113
|
+
{
|
|
114
|
+
id: "mcp",
|
|
115
|
+
label: "MCP server",
|
|
116
|
+
description: "lets the agent call miru directly as a tool",
|
|
117
|
+
planPath: (agent) => agent.mcp?.path ?? null,
|
|
118
|
+
apply: applyMcp,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "instructions",
|
|
122
|
+
label: "Instructions",
|
|
123
|
+
description: "adds CLI usage guidance to CLAUDE.md / AGENTS.md",
|
|
124
|
+
planPath: (agent) => agent.instructionsPath,
|
|
125
|
+
apply: applyInstructions,
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
id: "subagent",
|
|
129
|
+
label: "Sub-agent",
|
|
130
|
+
description: "installs a dedicated miru-code sub-agent",
|
|
131
|
+
planPath: (agent) => agent.subagentPath,
|
|
132
|
+
apply: applySubagent,
|
|
133
|
+
},
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
function formatActionLine(integration: Integration, result: WriteResult): string {
|
|
137
|
+
const icon = ACTION_ICON[result.action] ?? dim("·");
|
|
138
|
+
const detail = ACTION_DETAIL[result.action];
|
|
139
|
+
const suffix = detail ? dim(` — ${detail}`) : "";
|
|
140
|
+
return ` ${icon} ${integration.label.padEnd(13)} ${dim(result.action)}${suffix}\n ${dim(result.path)}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function printPlan(agents: AgentTarget[], integrations: Integration[]): void {
|
|
144
|
+
writeStdout("");
|
|
145
|
+
writeStdout(dim("Plan"));
|
|
146
|
+
divider();
|
|
147
|
+
|
|
148
|
+
for (const agent of agents) {
|
|
149
|
+
writeStdout(` ${agent.displayName}`);
|
|
150
|
+
for (const integration of integrations) {
|
|
151
|
+
const path = integration.planPath(agent);
|
|
152
|
+
const icon = path ? green("✓") : dim("–");
|
|
153
|
+
writeStdout(` ${icon} ${integration.label.padEnd(13)} ${path ?? dim("(not supported)")}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
writeStdout("");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function apply(
|
|
160
|
+
mode: InstallMode,
|
|
161
|
+
agents: AgentTarget[],
|
|
162
|
+
integrations: Integration[],
|
|
163
|
+
): Promise<void> {
|
|
164
|
+
writeStdout("");
|
|
165
|
+
writeStdout(dim(mode === "install" ? "Installing" : "Removing"));
|
|
166
|
+
divider();
|
|
167
|
+
|
|
168
|
+
for (const agent of agents) {
|
|
169
|
+
writeStdout(` ${agent.displayName}`);
|
|
170
|
+
for (const integration of integrations) {
|
|
171
|
+
const result = await integration.apply(agent, mode);
|
|
172
|
+
if (!result) {
|
|
173
|
+
writeStdout(` ${dim("–")} ${integration.id.padEnd(13)} ${dim("not supported")}`);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
writeStdout(formatActionLine(integration, result));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
writeStdout("");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function runInstaller(mode: InstallMode): Promise<void> {
|
|
183
|
+
const install = mode === "install";
|
|
184
|
+
requireInteractiveTerminal(`miru ${mode}`);
|
|
185
|
+
|
|
186
|
+
writeStdout("");
|
|
187
|
+
writeStdout(`${brandTitle()}${install ? " installer" : " uninstaller"}`);
|
|
188
|
+
divider();
|
|
189
|
+
hint("Select agents and integrations to configure.");
|
|
190
|
+
|
|
191
|
+
const detected = await Promise.all(
|
|
192
|
+
AGENT_TARGETS.map(async (agent) => ({
|
|
193
|
+
agent,
|
|
194
|
+
detected: await isAgentDetected(agent),
|
|
195
|
+
})),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const agentItems = detected.map(({ agent, detected: isDetected }) => ({
|
|
199
|
+
label: `${agent.displayName}${isDetected ? dim(" (detected)") : ""}`,
|
|
200
|
+
value: agent,
|
|
201
|
+
checked: isDetected && install,
|
|
202
|
+
}));
|
|
203
|
+
|
|
204
|
+
const chosenAgents = await promptMultiSelect(
|
|
205
|
+
`Agents to ${install ? "configure" : "clean up"}`,
|
|
206
|
+
agentItems,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
if (!chosenAgents || chosenAgents.length === 0) {
|
|
210
|
+
hint("Nothing selected. Exiting.");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const integrationItems = INTEGRATIONS.map((integration) => ({
|
|
215
|
+
label: `${integration.label} — ${integration.description}`,
|
|
216
|
+
value: integration,
|
|
217
|
+
checked: true,
|
|
218
|
+
}));
|
|
219
|
+
|
|
220
|
+
const chosenIntegrations = await promptMultiSelect(
|
|
221
|
+
`Integrations to ${install ? "enable" : "remove"}`,
|
|
222
|
+
integrationItems,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
if (!chosenIntegrations || chosenIntegrations.length === 0) {
|
|
226
|
+
hint("Nothing selected. Exiting.");
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
printPlan(chosenAgents, chosenIntegrations);
|
|
231
|
+
|
|
232
|
+
const proceed = await promptConfirm(install ? "Proceed?" : "Remove miru configuration?", install);
|
|
233
|
+
if (!proceed) {
|
|
234
|
+
hint("Cancelled.");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
await apply(mode, chosenAgents, chosenIntegrations);
|
|
239
|
+
|
|
240
|
+
success(
|
|
241
|
+
install ? "Done! Restart your agents to pick up changes." : "Done! Configuration removed.",
|
|
242
|
+
);
|
|
243
|
+
if (install) {
|
|
244
|
+
hint("Run miru setup or export TAKARA_API_KEY for MCP env expansion.");
|
|
245
|
+
}
|
|
246
|
+
writeStdout("");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export {
|
|
250
|
+
applyInstructions,
|
|
251
|
+
applyMcp,
|
|
252
|
+
applySubagent,
|
|
253
|
+
INTEGRATIONS,
|
|
254
|
+
mergeJsonMember as mergeMcpJson,
|
|
255
|
+
removeJsonMember as removeMcpJson,
|
|
256
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
2
|
+
import * as readline from "node:readline/promises";
|
|
3
|
+
import { bold, cyan, dim, divider, hint, warn } from "../cli-ui.ts";
|
|
4
|
+
|
|
5
|
+
export function requireInteractiveTerminal(command: string): void {
|
|
6
|
+
if (!input.isTTY) {
|
|
7
|
+
throw new Error(
|
|
8
|
+
`${command} requires an interactive terminal. Configure agents manually (see README) ` +
|
|
9
|
+
"or run from a TTY.",
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function readLine(prompt: string): Promise<string> {
|
|
15
|
+
const rl = readline.createInterface({ input, output });
|
|
16
|
+
try {
|
|
17
|
+
return (await rl.question(prompt)).trim();
|
|
18
|
+
} finally {
|
|
19
|
+
rl.close();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function promptConfirm(question: string, defaultYes = true): Promise<boolean> {
|
|
24
|
+
const hintText = defaultYes ? dim("[Y/n]") : dim("[y/N]");
|
|
25
|
+
const answer = (await readLine(`${question} ${hintText} `)).toLowerCase();
|
|
26
|
+
if (!answer) {
|
|
27
|
+
return defaultYes;
|
|
28
|
+
}
|
|
29
|
+
return answer === "y" || answer === "yes";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function promptMultiSelect<T>(
|
|
33
|
+
title: string,
|
|
34
|
+
items: Array<{ label: string; value: T; checked: boolean }>,
|
|
35
|
+
): Promise<T[] | null> {
|
|
36
|
+
output.write("\n");
|
|
37
|
+
output.write(`${bold(title)}\n`);
|
|
38
|
+
divider("─", 48, output);
|
|
39
|
+
|
|
40
|
+
for (let i = 0; i < items.length; i++) {
|
|
41
|
+
const item = items[i];
|
|
42
|
+
if (!item) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const mark = item.checked ? cyan("[x]") : dim("[ ]");
|
|
46
|
+
output.write(` ${dim(String(i + 1).padStart(2))}. ${mark} ${item.label}\n`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
output.write("\n");
|
|
50
|
+
hint("Enter numbers (1,3), 'all', or press Enter for defaults", output);
|
|
51
|
+
|
|
52
|
+
const answer = (await readLine(`${dim("> ")}`)).toLowerCase();
|
|
53
|
+
if (!answer) {
|
|
54
|
+
return items.filter((item) => item.checked).map((item) => item.value);
|
|
55
|
+
}
|
|
56
|
+
if (answer === "all" || answer === "a") {
|
|
57
|
+
return items.map((item) => item.value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const indices = new Set<number>();
|
|
61
|
+
for (const part of answer.split(/[,\s]+/)) {
|
|
62
|
+
const n = Number(part);
|
|
63
|
+
if (Number.isInteger(n) && n >= 1 && n <= items.length) {
|
|
64
|
+
indices.add(n - 1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (indices.size === 0) {
|
|
69
|
+
warn("No valid selection.", output);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return [...indices]
|
|
74
|
+
.sort((a, b) => a - b)
|
|
75
|
+
.map((i) => items[i]?.value)
|
|
76
|
+
.filter((value): value is T => value !== undefined);
|
|
77
|
+
}
|
package/src/setup.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { brandTitle, divider, fail, hint, info, success, writeStderr } from "./cli-ui.ts";
|
|
2
2
|
import {
|
|
3
3
|
clearStoredCredentials,
|
|
4
4
|
loadStoredCredentials,
|
|
@@ -16,7 +16,7 @@ async function promptApiKey(): Promise<string> {
|
|
|
16
16
|
while (!key) {
|
|
17
17
|
key = await promptHidden("Takara API key (input hidden): ");
|
|
18
18
|
if (!key) {
|
|
19
|
-
|
|
19
|
+
fail("API key cannot be empty.");
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
return key;
|
|
@@ -33,33 +33,28 @@ export async function runSetup(options: RunSetupOptions = {}): Promise<string> {
|
|
|
33
33
|
const path = resolveCredentialsPath();
|
|
34
34
|
const stored = await readStoredCredentials();
|
|
35
35
|
if (stored) {
|
|
36
|
-
|
|
37
|
-
`API key already configured (env var and ${path}). Use --force to replace the stored key.\n`,
|
|
38
|
-
);
|
|
36
|
+
info(`API key already configured (env + ${path}). Use --force to replace stored key.`);
|
|
39
37
|
return path;
|
|
40
38
|
}
|
|
41
|
-
|
|
42
|
-
"API key already set via environment variable. Stored credentials were not changed.\n",
|
|
43
|
-
);
|
|
39
|
+
info("API key already set via environment variable. Stored credentials unchanged.");
|
|
44
40
|
return resolveCredentialsPath();
|
|
45
41
|
}
|
|
46
42
|
|
|
47
43
|
if (!options.force) {
|
|
48
44
|
const stored = await readStoredCredentials();
|
|
49
45
|
if (stored && !options.apiKey) {
|
|
50
|
-
|
|
51
|
-
`API key already stored at ${resolveCredentialsPath()}. Use --force to replace it.\n`,
|
|
52
|
-
);
|
|
46
|
+
info(`API key already stored at ${resolveCredentialsPath()}. Use --force to replace.`);
|
|
53
47
|
process.env.TAKARA_API_KEY = stored.takara_api_key;
|
|
54
48
|
return resolveCredentialsPath();
|
|
55
49
|
}
|
|
56
50
|
}
|
|
57
51
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
);
|
|
52
|
+
writeStderr("");
|
|
53
|
+
writeStderr(`${brandTitle()} setup`);
|
|
54
|
+
divider("─", 48, process.stderr);
|
|
55
|
+
writeStderr("Miru needs a Takara API key for code embeddings.");
|
|
56
|
+
hint("Get a bearer token from Takara, then enter it below.");
|
|
57
|
+
writeStderr("");
|
|
63
58
|
|
|
64
59
|
const apiKey = options.apiKey ?? (await promptApiKey());
|
|
65
60
|
|
|
@@ -76,18 +71,20 @@ export async function runSetup(options: RunSetupOptions = {}): Promise<string> {
|
|
|
76
71
|
|
|
77
72
|
const path = await saveStoredCredentials(apiKey);
|
|
78
73
|
process.env.TAKARA_API_KEY = apiKey;
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
writeStderr("");
|
|
75
|
+
success(`Saved credentials to ${path}`);
|
|
76
|
+
hint("Set TAKARA_API_KEY in MCP config for IDE integrations.");
|
|
77
|
+
writeStderr("");
|
|
81
78
|
return path;
|
|
82
79
|
}
|
|
83
80
|
|
|
84
81
|
export async function runClearCredentials(): Promise<void> {
|
|
85
82
|
const { cleared, path } = await clearStoredCredentials();
|
|
86
83
|
if (cleared) {
|
|
87
|
-
|
|
84
|
+
success(`Removed stored API key from ${path}`);
|
|
88
85
|
return;
|
|
89
86
|
}
|
|
90
|
-
|
|
87
|
+
info(`No stored API key at ${path}`);
|
|
91
88
|
}
|
|
92
89
|
|
|
93
90
|
export async function ensureCredentials(options?: { interactive?: boolean }): Promise<void> {
|