@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.
package/src/cli-ui.ts ADDED
@@ -0,0 +1,189 @@
1
+ import type { SearchResult } from "./types.ts";
2
+
3
+ const ANSI = {
4
+ reset: "\x1b[0m",
5
+ bold: "\x1b[1m",
6
+ dim: "\x1b[2m",
7
+ cyan: "\x1b[36m",
8
+ green: "\x1b[32m",
9
+ red: "\x1b[31m",
10
+ yellow: "\x1b[33m",
11
+ magenta: "\x1b[35m",
12
+ } as const;
13
+
14
+ function colorEnabled(stream: NodeJS.WriteStream = process.stdout): boolean {
15
+ return stream.isTTY && process.env.NO_COLOR === undefined;
16
+ }
17
+
18
+ function paint(text: string, code: string, stream?: NodeJS.WriteStream): string {
19
+ if (!colorEnabled(stream)) {
20
+ return text;
21
+ }
22
+ return `${code}${text}${ANSI.reset}`;
23
+ }
24
+
25
+ export function bold(text: string, stream?: NodeJS.WriteStream): string {
26
+ return paint(text, ANSI.bold, stream);
27
+ }
28
+
29
+ export function dim(text: string, stream?: NodeJS.WriteStream): string {
30
+ return paint(text, ANSI.dim, stream);
31
+ }
32
+
33
+ export function cyan(text: string, stream?: NodeJS.WriteStream): string {
34
+ return paint(text, ANSI.cyan, stream);
35
+ }
36
+
37
+ export function green(text: string, stream?: NodeJS.WriteStream): string {
38
+ return paint(text, ANSI.green, stream);
39
+ }
40
+
41
+ export function red(text: string, stream?: NodeJS.WriteStream): string {
42
+ return paint(text, ANSI.red, stream);
43
+ }
44
+
45
+ export function yellow(text: string, stream?: NodeJS.WriteStream): string {
46
+ return paint(text, ANSI.yellow, stream);
47
+ }
48
+
49
+ export function magenta(text: string, stream?: NodeJS.WriteStream): string {
50
+ return paint(text, ANSI.magenta, stream);
51
+ }
52
+
53
+ export function isInteractiveStdout(): boolean {
54
+ return process.stdout.isTTY && process.env.NO_COLOR === undefined;
55
+ }
56
+
57
+ export function prefersJsonOutput(jsonFlag: boolean): boolean {
58
+ return jsonFlag || !process.stdout.isTTY;
59
+ }
60
+
61
+ export function brandTitle(): string {
62
+ return bold("Miru", process.stderr) + dim(" (見る)", process.stderr);
63
+ }
64
+
65
+ export function writeStdout(line = ""): void {
66
+ process.stdout.write(`${line}\n`);
67
+ }
68
+
69
+ export function writeStderr(line = ""): void {
70
+ process.stderr.write(`${line}\n`);
71
+ }
72
+
73
+ export function divider(char = "─", width = 52, stream: NodeJS.WriteStream = process.stdout): void {
74
+ const line = char.repeat(width);
75
+ stream.write(`${dim(line, stream)}\n`);
76
+ }
77
+
78
+ export function header(title: string, subtitle?: string): void {
79
+ writeStdout("");
80
+ writeStdout(brandTitle() + (title ? dim(` · ${title}`) : ""));
81
+ if (subtitle) {
82
+ writeStdout(dim(subtitle));
83
+ }
84
+ divider();
85
+ }
86
+
87
+ export function section(title: string): void {
88
+ writeStdout("");
89
+ writeStdout(bold(title));
90
+ }
91
+
92
+ export function commandRow(name: string, description: string, indent = 2): void {
93
+ const pad = " ".repeat(indent);
94
+ const nameCol = name.padEnd(16);
95
+ writeStdout(`${pad}${cyan(nameCol)} ${dim(description)}`);
96
+ }
97
+
98
+ function writeLine(message: string, stream: NodeJS.WriteStream = process.stderr): void {
99
+ stream.write(`${message}\n`);
100
+ }
101
+
102
+ export function success(message: string, stream?: NodeJS.WriteStream): void {
103
+ writeLine(green("✓ ") + message, stream);
104
+ }
105
+
106
+ export function info(message: string, stream?: NodeJS.WriteStream): void {
107
+ writeLine(dim("· ") + message, stream);
108
+ }
109
+
110
+ export function warn(message: string, stream?: NodeJS.WriteStream): void {
111
+ writeLine(yellow("! ") + message, stream);
112
+ }
113
+
114
+ export function fail(message: string, stream?: NodeJS.WriteStream): void {
115
+ writeLine(red("✗ ") + message, stream);
116
+ }
117
+
118
+ export function hint(message: string, stream?: NodeJS.WriteStream): void {
119
+ writeLine(dim(` ${message}`), stream);
120
+ }
121
+
122
+ const PREVIEW_LINES = 12;
123
+ const PREVIEW_WIDTH = 72;
124
+
125
+ function truncateLine(line: string, width: number): string {
126
+ if (line.length <= width) {
127
+ return line;
128
+ }
129
+ return `${line.slice(0, width - 1)}…`;
130
+ }
131
+
132
+ function previewContent(content: string): string[] {
133
+ return content
134
+ .split("\n")
135
+ .slice(0, PREVIEW_LINES)
136
+ .map((line) => truncateLine(line.replace(/\t/g, " "), PREVIEW_WIDTH));
137
+ }
138
+
139
+ export function formatSearchResultsPretty(query: string, results: SearchResult[]): string {
140
+ const lines: string[] = [];
141
+ const count = results.length;
142
+ const label = count === 1 ? "1 result" : `${count} results`;
143
+
144
+ lines.push("");
145
+ lines.push(bold(label) + dim(` for `) + cyan(`"${query}"`));
146
+ lines.push("");
147
+
148
+ for (let i = 0; i < results.length; i++) {
149
+ const result = results[i];
150
+ if (!result) {
151
+ continue;
152
+ }
153
+ const { chunk, score } = result;
154
+ const location = `${chunk.file_path}:${chunk.start_line}-${chunk.end_line}`;
155
+ const lang = chunk.language ? dim(` ${chunk.language}`) : "";
156
+ const rank = dim(`[${i + 1}]`);
157
+ const scoreText = magenta(score.toFixed(3));
158
+
159
+ lines.push(`${rank} ${scoreText} ${bold(location)}${lang}`);
160
+ lines.push(dim("─".repeat(Math.min(PREVIEW_WIDTH, 52))));
161
+
162
+ for (const line of previewContent(chunk.content)) {
163
+ lines.push(dim(" ") + line);
164
+ }
165
+
166
+ const totalLines = chunk.content.split("\n").length;
167
+ if (totalLines > PREVIEW_LINES) {
168
+ lines.push(dim(` … ${totalLines - PREVIEW_LINES} more lines`));
169
+ }
170
+
171
+ if (i < results.length - 1) {
172
+ lines.push("");
173
+ }
174
+ }
175
+
176
+ if (isInteractiveStdout()) {
177
+ lines.push("");
178
+ lines.push(dim("Tip: add --json for machine-readable output"));
179
+ }
180
+ return lines.join("\n");
181
+ }
182
+
183
+ export function formatSearchErrorPretty(message: string): string {
184
+ return `\n${yellow("No results")} ${dim("—")} ${message}\n`;
185
+ }
186
+
187
+ export function formatRelatedHeader(filePath: string, line: number): string {
188
+ return `related to ${filePath}:${line}`;
189
+ }
package/src/cli.ts CHANGED
@@ -2,20 +2,62 @@
2
2
  import { resolve } from "node:path";
3
3
  import { type AgentId, writeAgentFile } from "./agents.ts";
4
4
  import { clearCache } from "./cache.ts";
5
+ import {
6
+ fail,
7
+ formatRelatedHeader,
8
+ formatSearchErrorPretty,
9
+ formatSearchResultsPretty,
10
+ hint,
11
+ prefersJsonOutput,
12
+ success,
13
+ } from "./cli-ui.ts";
5
14
  import { loadStoredCredentials } from "./credentials.ts";
6
15
  import { loadEnvFiles } from "./env-files.ts";
16
+ import {
17
+ AGENT_IDS,
18
+ formatUnknownAgent,
19
+ printCommandHelp,
20
+ printFullHelp,
21
+ printMainHelp,
22
+ } from "./help.ts";
23
+ import { runInstaller } from "./installer/installer.ts";
7
24
  import { serveMcp } from "./mcp/serve.ts";
8
25
  import { MiruIndex } from "./miru-index.ts";
9
26
  import { ensureCredentials, runClearCredentials, runSetup } from "./setup.ts";
10
- import type { ContentType } from "./types.ts";
27
+ import { withSpinner } from "./spinner.ts";
28
+ import type { ContentType, SearchResult } from "./types.ts";
11
29
  import { formatResults, resolveChunk, resolveContent } from "./utils.ts";
12
30
 
13
31
  loadEnvFiles();
14
32
  await loadStoredCredentials();
15
33
 
16
- const CLI_COMMANDS = new Set(["search", "find-related", "init", "setup", "clear", "-h", "--help"]);
34
+ const CLI_COMMANDS = new Set([
35
+ "search",
36
+ "find-related",
37
+ "init",
38
+ "install",
39
+ "uninstall",
40
+ "setup",
41
+ "clear",
42
+ "help",
43
+ "-h",
44
+ "--help",
45
+ ]);
17
46
 
18
- const AGENTS = new Set<AgentId>(["claude", "copilot", "cursor", "gemini", "kiro", "opencode"]);
47
+ const AGENTS = new Set<AgentId>(AGENT_IDS);
48
+
49
+ function parseFlagArgv(argv: string[], flag: string): { present: boolean; rest: string[] } {
50
+ const rest: string[] = [];
51
+ let present = false;
52
+ for (const arg of argv) {
53
+ if (arg === flag) {
54
+ present = true;
55
+ continue;
56
+ }
57
+ rest.push(arg);
58
+ }
59
+ return { present, rest };
60
+ }
19
61
 
20
62
  function parseContentArgv(argv: string[]): { content: ContentType[]; rest: string[] } {
21
63
  const rest: string[] = [];
@@ -61,19 +103,46 @@ function parseTopK(argv: string[]): { topK: number; rest: string[] } {
61
103
  return { topK: Number.isFinite(topK) && topK >= 1 ? Math.floor(topK) : 5, rest };
62
104
  }
63
105
 
106
+ function emitSearchOutput(
107
+ query: string,
108
+ results: SearchResult[],
109
+ jsonFlag: boolean,
110
+ emptyMessage = "No results found.",
111
+ ): void {
112
+ if (results.length === 0) {
113
+ if (prefersJsonOutput(jsonFlag)) {
114
+ console.log(JSON.stringify({ error: emptyMessage }));
115
+ return;
116
+ }
117
+ process.stdout.write(formatSearchErrorPretty(emptyMessage));
118
+ return;
119
+ }
120
+
121
+ if (prefersJsonOutput(jsonFlag)) {
122
+ console.log(JSON.stringify(formatResults(query, results)));
123
+ return;
124
+ }
125
+
126
+ process.stdout.write(formatSearchResultsPretty(query, results));
127
+ }
128
+
64
129
  async function runSearch(
65
130
  path: string,
66
131
  query: string,
67
132
  topK: number,
68
133
  content: ContentType[],
134
+ jsonFlag: boolean,
69
135
  ): Promise<void> {
70
136
  await ensureCredentials();
71
- const index = await MiruIndex.fromSource(path, content);
72
- await index.saveToDefaultCache(path);
73
- const results = await index.search({ query, topK });
74
- const out =
75
- results.length > 0 ? formatResults(query, results) : { error: "No results found." as const };
76
- console.log(JSON.stringify(out));
137
+
138
+ const index = await withSpinner("Indexing and searching", async () => {
139
+ const built = await MiruIndex.fromSource(path, content);
140
+ await built.saveToDefaultCache(path);
141
+ const results = await built.search({ query, topK });
142
+ return { index: built, results };
143
+ });
144
+
145
+ emitSearchOutput(query, index.results, jsonFlag);
77
146
  }
78
147
 
79
148
  async function runFindRelated(
@@ -82,81 +151,105 @@ async function runFindRelated(
82
151
  line: number,
83
152
  topK: number,
84
153
  content: ContentType[],
154
+ jsonFlag: boolean,
85
155
  ): Promise<void> {
86
156
  await ensureCredentials();
87
- const index = await MiruIndex.fromSource(path, content);
88
- const chunk = resolveChunk(index.chunks, filePath, line);
89
- if (!chunk) {
90
- console.error(`No chunk found at ${filePath}:${line}.`);
91
- process.exit(1);
157
+
158
+ const { results, label } = await withSpinner("Finding related chunks", async () => {
159
+ const built = await MiruIndex.fromSource(path, content);
160
+ const chunk = resolveChunk(built.chunks, filePath, line);
161
+ if (!chunk) {
162
+ throw new RelatedChunkNotFoundError(filePath, line);
163
+ }
164
+ const hits = await built.findRelated(chunk, topK);
165
+ await built.saveToDefaultCache(path);
166
+ return {
167
+ results: hits,
168
+ label: formatRelatedHeader(filePath, line),
169
+ };
170
+ });
171
+
172
+ emitSearchOutput(label, results, jsonFlag, `No related chunks found for ${filePath}:${line}.`);
173
+ }
174
+
175
+ class RelatedChunkNotFoundError extends Error {
176
+ constructor(filePath: string, line: number) {
177
+ super(`No chunk found at ${filePath}:${line}.`);
178
+ this.name = "RelatedChunkNotFoundError";
92
179
  }
93
- const results = await index.findRelated(chunk, topK);
94
- const label = `Chunks related to ${filePath}:${line}`;
95
- const out =
96
- results.length > 0
97
- ? formatResults(label, results)
98
- : { error: `No related chunks found for ${filePath}:${line}.` };
99
- console.log(JSON.stringify(out));
100
- await index.saveToDefaultCache(path);
101
180
  }
102
181
 
103
182
  async function runInit(agent: AgentId, force: boolean): Promise<void> {
104
- const dest = await writeAgentFile(agent, { force });
105
- console.log(`Created ${dest}`);
183
+ try {
184
+ const dest = await writeAgentFile(agent, { force });
185
+ success(`Wrote sub-agent: ${dest}`);
186
+ } catch (err) {
187
+ const message = err instanceof Error ? err.message : String(err);
188
+ fail(message);
189
+ hint("Use --force to overwrite an existing file.");
190
+ process.exit(1);
191
+ }
106
192
  }
107
193
 
108
194
  async function runClear(path: string): Promise<void> {
109
195
  await clearCache(path);
110
- console.log(`Cleared cached index for: ${path}`);
196
+ success(`Cleared cached index for ${path}`);
111
197
  }
112
198
 
113
- function printHelp(): void {
114
- console.log(`Miru (見る) — hybrid code search (Bun + TypeScript + Takara)
115
-
116
- CLI:
117
- miru search <query> [path] [-k N] [--content code|docs|config|all]
118
- miru find-related <file_path> <line> [path] [-k N] [--content ...]
119
- miru setup [--key KEY] [--force] [--clear]
120
- miru init [--agent cursor] [--force]
121
- miru clear [path]
122
-
123
- MCP server (default when no CLI subcommand):
124
- miru [--ref BRANCH] [--content code ...]
125
-
126
- Indexes a repo on the first search/find_related call via the tool repo argument.
199
+ async function runCli(argv: string[]): Promise<void> {
200
+ const [command, ...rest] = argv;
127
201
 
128
- Examples:
129
- miru search "auth middleware" ./src
130
- miru find-related src/auth.ts 42 .
202
+ if (command === undefined) {
203
+ printMainHelp();
204
+ return;
205
+ }
131
206
 
132
- Environment:
133
- TAKARA_API_KEY / OPENAI_API_KEY / MIRU_OPENAI_API_KEY Takara bearer token
134
- MIRU_OPENAI_BASE_URL Default: infer.dev.takara.ai
135
- `);
136
- }
207
+ if (command === "-h" || command === "--help") {
208
+ printFullHelp();
209
+ return;
210
+ }
137
211
 
138
- async function runCli(argv: string[]): Promise<void> {
139
- const [command, ...rest] = argv;
212
+ if (command === "help") {
213
+ const topic = rest[0];
214
+ if (!topic) {
215
+ printMainHelp();
216
+ return;
217
+ }
218
+ printCommandHelp(topic);
219
+ return;
220
+ }
140
221
 
141
- if (command === "-h" || command === "--help" || command === undefined) {
142
- printHelp();
222
+ if (command === "install" || command === "uninstall") {
223
+ await runInstaller(command);
143
224
  return;
144
225
  }
145
226
 
146
227
  if (command === "init") {
147
- let agent: AgentId = "claude";
228
+ let agent: AgentId | undefined;
148
229
  let force = false;
149
230
  for (let i = 0; i < rest.length; i++) {
150
231
  const arg = rest[i];
151
232
  if (arg === "--force") {
152
233
  force = true;
153
- } else if ((arg === "--agent" || arg === "-a") && rest[i + 1]) {
154
- const value = rest[++i] as AgentId;
155
- if (AGENTS.has(value)) {
156
- agent = value;
234
+ } else if (arg === "--agent" || arg === "-a") {
235
+ const value = rest[++i];
236
+ if (!value) {
237
+ fail("Missing value for --agent.");
238
+ printCommandHelp("init");
239
+ process.exit(1);
157
240
  }
241
+ if (!AGENTS.has(value as AgentId)) {
242
+ fail(formatUnknownAgent(value));
243
+ process.exit(1);
244
+ }
245
+ agent = value as AgentId;
158
246
  }
159
247
  }
248
+ if (!agent) {
249
+ fail("miru init requires --agent.");
250
+ printCommandHelp("init");
251
+ process.exit(1);
252
+ }
160
253
  await runInit(agent, force);
161
254
  return;
162
255
  }
@@ -177,7 +270,7 @@ async function runCli(argv: string[]): Promise<void> {
177
270
  }
178
271
  if (clear) {
179
272
  if (apiKey) {
180
- console.error("Usage: miru setup --clear cannot be combined with --key.");
273
+ fail("miru setup --clear cannot be combined with --key.");
181
274
  process.exit(1);
182
275
  }
183
276
  await runClearCredentials();
@@ -193,17 +286,18 @@ async function runCli(argv: string[]): Promise<void> {
193
286
  return;
194
287
  }
195
288
 
196
- const { content, rest: contentRest } = parseContentArgv(rest);
289
+ const { present: jsonFlag, rest: jsonRest } = parseFlagArgv(rest, "--json");
290
+ const { content, rest: contentRest } = parseContentArgv(jsonRest);
197
291
  const { topK, rest: sizedRest } = parseTopK(contentRest);
198
292
 
199
293
  if (command === "search") {
200
294
  const query = sizedRest[0];
201
295
  if (!query) {
202
- console.error("Usage: miru search <query> [path] [-k N] [--content ...]");
296
+ printCommandHelp("search");
203
297
  process.exit(1);
204
298
  }
205
299
  const path = resolve(sizedRest[1] ?? process.cwd());
206
- await runSearch(path, query, topK, content);
300
+ await runSearch(path, query, topK, content, jsonFlag);
207
301
  return;
208
302
  }
209
303
 
@@ -211,16 +305,25 @@ async function runCli(argv: string[]): Promise<void> {
211
305
  const filePath = sizedRest[0];
212
306
  const lineRaw = sizedRest[1];
213
307
  if (!filePath || !lineRaw) {
214
- console.error("Usage: miru find-related <file_path> <line> [path] [-k N] [--content ...]");
308
+ printCommandHelp("find-related");
215
309
  process.exit(1);
216
310
  }
217
311
  const line = Number(lineRaw);
218
312
  const path = resolve(sizedRest[2] ?? process.cwd());
219
- await runFindRelated(path, filePath, line, topK, content);
313
+ try {
314
+ await runFindRelated(path, filePath, line, topK, content, jsonFlag);
315
+ } catch (err) {
316
+ if (err instanceof RelatedChunkNotFoundError) {
317
+ fail(err.message);
318
+ process.exit(1);
319
+ }
320
+ throw err;
321
+ }
220
322
  return;
221
323
  }
222
324
 
223
- printHelp();
325
+ fail(`Unknown command: ${command}`);
326
+ printMainHelp();
224
327
  process.exit(1);
225
328
  }
226
329
 
@@ -270,6 +373,6 @@ async function main(): Promise<void> {
270
373
  }
271
374
 
272
375
  main().catch((err) => {
273
- console.error(err instanceof Error ? err.message : err);
376
+ fail(err instanceof Error ? err.message : String(err));
274
377
  process.exit(1);
275
378
  });
@@ -1,6 +1,6 @@
1
1
  import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { EMBEDDING_API_KEY_NAMES, envFirstString } from "./env.ts";
3
+ import { hasTakaraApiKeyInEnv } from "./env.ts";
4
4
 
5
5
  const CREDENTIALS_FILENAME = "credentials.json";
6
6
  const CREDENTIALS_VERSION = 1;
@@ -31,10 +31,6 @@ export function resolveCredentialsPath(): string {
31
31
  return join(resolveCredentialsDir(), CREDENTIALS_FILENAME);
32
32
  }
33
33
 
34
- export function hasEmbeddingApiKeyInEnv(): boolean {
35
- return Boolean(envFirstString([...EMBEDDING_API_KEY_NAMES], ""));
36
- }
37
-
38
34
  export async function readStoredCredentials(): Promise<StoredCredentials | null> {
39
35
  const path = resolveCredentialsPath();
40
36
  if (!(await Bun.file(path).exists())) {
@@ -53,7 +49,7 @@ export async function readStoredCredentials(): Promise<StoredCredentials | null>
53
49
 
54
50
  /** Set TAKARA_API_KEY from the user credentials file when env is unset. */
55
51
  export async function loadStoredCredentials(): Promise<boolean> {
56
- if (hasEmbeddingApiKeyInEnv()) {
52
+ if (hasTakaraApiKeyInEnv()) {
57
53
  return false;
58
54
  }
59
55
  const stored = await readStoredCredentials();
package/src/env.ts CHANGED
@@ -31,21 +31,17 @@ export function envOptionalInt(names: string[], min = 1): number | undefined {
31
31
  return undefined;
32
32
  }
33
33
 
34
- const EMBEDDING_API_KEY_NAMES = [
35
- "TAKARA_API_KEY",
36
- "OPENAI_API_KEY",
37
- "MIRU_OPENAI_API_KEY",
38
- "SEMBLE_OPENAI_API_KEY",
39
- ] as const;
34
+ export const TAKARA_API_KEY_ENV = "TAKARA_API_KEY";
40
35
 
41
- export { EMBEDDING_API_KEY_NAMES };
36
+ export function hasTakaraApiKeyInEnv(): boolean {
37
+ return Boolean(process.env[TAKARA_API_KEY_ENV]?.trim());
38
+ }
42
39
 
43
40
  export function resolveEmbeddingApiKey(): string {
44
- const key = envFirstString([...EMBEDDING_API_KEY_NAMES], "");
41
+ const key = process.env[TAKARA_API_KEY_ENV]?.trim() ?? "";
45
42
  if (!key) {
46
43
  throw new Error(
47
- "Embedding API key required. Run `miru setup`, set TAKARA_API_KEY, OPENAI_API_KEY, " +
48
- "MIRU_OPENAI_API_KEY, or SEMBLE_OPENAI_API_KEY in your MCP server env or .env.local.",
44
+ "Takara API key required. Run `miru setup`, or set TAKARA_API_KEY in your MCP server env or .env.local.",
49
45
  );
50
46
  }
51
47
  return key;