@takara-ai/miru-code 0.2.0 → 0.4.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.
@@ -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/prompt.ts ADDED
@@ -0,0 +1,111 @@
1
+ import { stdin as input, stdout as output } from "node:process";
2
+
3
+ export interface HiddenPromptState {
4
+ value: string;
5
+ escapeBuffer: string;
6
+ }
7
+
8
+ export function createHiddenPromptState(): HiddenPromptState {
9
+ return { value: "", escapeBuffer: "" };
10
+ }
11
+
12
+ /** Apply one UTF-16 code unit from raw stdin; returns terminal echo bytes (may be empty). */
13
+ export function applyHiddenPromptChar(
14
+ state: HiddenPromptState,
15
+ char: string,
16
+ ): { state: HiddenPromptState; echo: string; submit?: boolean; cancel?: boolean } {
17
+ if (char === "\x1b") {
18
+ return { state: { ...state, escapeBuffer: "\x1b" }, echo: "" };
19
+ }
20
+
21
+ if (state.escapeBuffer.length > 0) {
22
+ const escapeBuffer = state.escapeBuffer + char;
23
+ let done = false;
24
+ if (escapeBuffer.startsWith("\x1b[")) {
25
+ done = escapeBuffer.length > 2 && char >= "@" && char <= "~";
26
+ } else if (escapeBuffer.startsWith("\x1b")) {
27
+ done = escapeBuffer.length >= 2;
28
+ }
29
+ return {
30
+ state: { ...state, escapeBuffer: done ? "" : escapeBuffer },
31
+ echo: "",
32
+ };
33
+ }
34
+
35
+ if (char === "\n" || char === "\r" || char === "\u0004") {
36
+ return { state, echo: "", submit: true };
37
+ }
38
+
39
+ if (char === "\u0003") {
40
+ return { state, echo: "", cancel: true };
41
+ }
42
+
43
+ if (char === "\u007f" || char === "\b") {
44
+ if (state.value.length === 0) {
45
+ return { state, echo: "" };
46
+ }
47
+ return {
48
+ state: { ...state, value: state.value.slice(0, -1) },
49
+ echo: "\b \b",
50
+ };
51
+ }
52
+
53
+ if (char < " " || char === "\x7f") {
54
+ return { state, echo: "" };
55
+ }
56
+
57
+ return {
58
+ state: { ...state, value: state.value + char },
59
+ echo: "*",
60
+ };
61
+ }
62
+
63
+ export async function promptHidden(message: string): Promise<string> {
64
+ if (!input.isTTY) {
65
+ throw new Error(
66
+ "Cannot prompt for API key: stdin is not a TTY. Run `miru setup --key YOUR_KEY` or set TAKARA_API_KEY.",
67
+ );
68
+ }
69
+
70
+ return new Promise((resolve, reject) => {
71
+ output.write(message);
72
+ input.setRawMode?.(true);
73
+ input.resume();
74
+ input.setEncoding("utf8");
75
+
76
+ let state = createHiddenPromptState();
77
+
78
+ const onData = (chunk: string) => {
79
+ for (const char of chunk) {
80
+ const result = applyHiddenPromptChar(state, char);
81
+ state = result.state;
82
+
83
+ if (result.cancel) {
84
+ cleanup();
85
+ output.write("\n");
86
+ reject(new Error("Setup cancelled."));
87
+ process.exit(130);
88
+ }
89
+
90
+ if (result.submit) {
91
+ cleanup();
92
+ output.write("\n");
93
+ resolve(state.value.trim());
94
+ return;
95
+ }
96
+
97
+ if (result.echo) {
98
+ output.write(result.echo);
99
+ }
100
+ }
101
+ };
102
+
103
+ const cleanup = () => {
104
+ input.setRawMode?.(false);
105
+ input.pause();
106
+ input.removeListener("data", onData);
107
+ };
108
+
109
+ input.on("data", onData);
110
+ });
111
+ }
package/src/setup.ts CHANGED
@@ -1,71 +1,22 @@
1
- import { stdin as input, stdout as output } from "node:process";
1
+ import { brandTitle, divider, fail, hint, info, success, writeStderr } from "./cli-ui.ts";
2
2
  import {
3
3
  clearStoredCredentials,
4
- hasEmbeddingApiKeyInEnv,
5
4
  loadStoredCredentials,
6
5
  readStoredCredentials,
7
6
  resolveCredentialsPath,
8
7
  saveStoredCredentials,
9
8
  } from "./credentials.ts";
10
9
  import { validateEmbeddingApiKey } from "./embeddings/validate.ts";
11
- import { resolveEmbeddingApiKey } from "./env.ts";
10
+ import { hasTakaraApiKeyInEnv, resolveEmbeddingApiKey } from "./env.ts";
11
+ import { promptHidden } from "./prompt.ts";
12
12
  import { Spinner } from "./spinner.ts";
13
13
 
14
- async function promptHidden(message: string): Promise<string> {
15
- if (!input.isTTY) {
16
- throw new Error(
17
- "Cannot prompt for API key: stdin is not a TTY. Run `miru setup --key YOUR_KEY` or set TAKARA_API_KEY.",
18
- );
19
- }
20
-
21
- return new Promise((resolve, reject) => {
22
- output.write(message);
23
- input.setRawMode?.(true);
24
- input.resume();
25
- input.setEncoding("utf8");
26
-
27
- let value = "";
28
- const onData = (chunk: string) => {
29
- const char = chunk[0];
30
- if (char === "\n" || char === "\r" || char === "\u0004") {
31
- cleanup();
32
- output.write("\n");
33
- resolve(value.trim());
34
- return;
35
- }
36
- if (char === "\u0003") {
37
- cleanup();
38
- output.write("\n");
39
- reject(new Error("Setup cancelled."));
40
- process.exit(130);
41
- }
42
- if (char === "\u007f" || char === "\b") {
43
- if (value.length > 0) {
44
- value = value.slice(0, -1);
45
- output.write("\b \b");
46
- }
47
- return;
48
- }
49
- value += chunk;
50
- output.write("*");
51
- };
52
-
53
- const cleanup = () => {
54
- input.setRawMode?.(false);
55
- input.pause();
56
- input.removeListener("data", onData);
57
- };
58
-
59
- input.on("data", onData);
60
- });
61
- }
62
-
63
14
  async function promptApiKey(): Promise<string> {
64
15
  let key = "";
65
16
  while (!key) {
66
17
  key = await promptHidden("Takara API key (input hidden): ");
67
18
  if (!key) {
68
- output.write("API key cannot be empty.\n");
19
+ fail("API key cannot be empty.");
69
20
  }
70
21
  }
71
22
  return key;
@@ -78,37 +29,32 @@ export interface RunSetupOptions {
78
29
  }
79
30
 
80
31
  export async function runSetup(options: RunSetupOptions = {}): Promise<string> {
81
- if (!options.force && hasEmbeddingApiKeyInEnv()) {
32
+ if (!options.force && hasTakaraApiKeyInEnv()) {
82
33
  const path = resolveCredentialsPath();
83
34
  const stored = await readStoredCredentials();
84
35
  if (stored) {
85
- process.stderr.write(
86
- `API key already configured (env var and ${path}). Use --force to replace the stored key.\n`,
87
- );
36
+ info(`API key already configured (env + ${path}). Use --force to replace stored key.`);
88
37
  return path;
89
38
  }
90
- process.stderr.write(
91
- "API key already set via environment variable. Stored credentials were not changed.\n",
92
- );
39
+ info("API key already set via environment variable. Stored credentials unchanged.");
93
40
  return resolveCredentialsPath();
94
41
  }
95
42
 
96
43
  if (!options.force) {
97
44
  const stored = await readStoredCredentials();
98
45
  if (stored && !options.apiKey) {
99
- process.stderr.write(
100
- `API key already stored at ${resolveCredentialsPath()}. Use --force to replace it.\n`,
101
- );
46
+ info(`API key already stored at ${resolveCredentialsPath()}. Use --force to replace.`);
102
47
  process.env.TAKARA_API_KEY = stored.takara_api_key;
103
48
  return resolveCredentialsPath();
104
49
  }
105
50
  }
106
51
 
107
- process.stderr.write("\nMiru (見る) — first-time setup\n\n");
108
- process.stderr.write(
109
- "Miru needs a Takara API key for code embeddings.\n" +
110
- "Get a bearer token from Takara, then enter it below.\n\n",
111
- );
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("");
112
58
 
113
59
  const apiKey = options.apiKey ?? (await promptApiKey());
114
60
 
@@ -125,18 +71,20 @@ export async function runSetup(options: RunSetupOptions = {}): Promise<string> {
125
71
 
126
72
  const path = await saveStoredCredentials(apiKey);
127
73
  process.env.TAKARA_API_KEY = apiKey;
128
- process.stderr.write(`\nSaved credentials to ${path} (readable only by your user account).\n`);
129
- process.stderr.write("You can also set TAKARA_API_KEY in MCP config for IDE integrations.\n\n");
74
+ writeStderr("");
75
+ success(`Saved credentials to ${path}`);
76
+ hint("Set TAKARA_API_KEY in MCP config for IDE integrations.");
77
+ writeStderr("");
130
78
  return path;
131
79
  }
132
80
 
133
81
  export async function runClearCredentials(): Promise<void> {
134
82
  const { cleared, path } = await clearStoredCredentials();
135
83
  if (cleared) {
136
- process.stderr.write(`Removed stored API key from ${path}.\n`);
84
+ success(`Removed stored API key from ${path}`);
137
85
  return;
138
86
  }
139
- process.stderr.write(`No stored API key at ${path}.\n`);
87
+ info(`No stored API key at ${path}`);
140
88
  }
141
89
 
142
90
  export async function ensureCredentials(options?: { interactive?: boolean }): Promise<void> {
package/src/spinner.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { dim, green, red } from "./cli-ui.ts";
2
+
1
3
  const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
2
4
 
3
5
  export class Spinner {
@@ -13,7 +15,7 @@ export class Spinner {
13
15
  }
14
16
  this.timer = setInterval(() => {
15
17
  const glyph = FRAMES[this.frame++ % FRAMES.length];
16
- process.stderr.write(`\r${glyph} ${this.message}`);
18
+ process.stderr.write(`\r${dim(glyph)} ${this.message}`);
17
19
  }, 80);
18
20
  }
19
21
 
@@ -31,11 +33,15 @@ export class Spinner {
31
33
  }
32
34
 
33
35
  succeed(message?: string): void {
34
- this.stop(message ?? `${this.message} — ok`);
36
+ if (message === "") {
37
+ this.stop();
38
+ return;
39
+ }
40
+ this.stop(green("✓ ") + (message ?? this.message));
35
41
  }
36
42
 
37
43
  fail(message?: string): void {
38
- this.stop(message ?? `${this.message} — failed`);
44
+ this.stop(red("✗ ") + (message ?? this.message));
39
45
  }
40
46
  }
41
47
 
@@ -48,7 +54,7 @@ export async function withSpinner<T>(
48
54
  spinner.start();
49
55
  try {
50
56
  const result = await fn();
51
- spinner.succeed(options?.successMessage);
57
+ spinner.succeed(options?.successMessage ?? "");
52
58
  return result;
53
59
  } catch (err) {
54
60
  spinner.fail(options?.failMessage);