@takara-ai/miru-code 0.1.2 → 0.1.4

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 CHANGED
@@ -2,9 +2,31 @@
2
2
 
3
3
  Hybrid code search for AI agents — **TypeScript**, **Bun**, and **Takara embeddings**.
4
4
 
5
- *Miru* means “to see” or “to look” in Japanese — semantic search that helps agents find code by meaning.
5
+ ## What is Miru?
6
6
 
7
- Indexes a local directory, combines semantic search with BM25, RRF fusion, and code-tuned reranking.
7
+ *Miru* (見る) means “to see” or “to look” in Japanese. Miru helps coding agents **find code by meaning** instead of guessing file paths or running exhaustive greps.
8
+
9
+ When an agent asks “where is auth middleware configured?” or “how do we batch embeddings?”, Miru returns the most relevant **chunks** of your codebase — file path, line range, and snippet — so the agent can read only what matters.
10
+
11
+ ### Why hybrid search?
12
+
13
+ Pure semantic search misses exact symbol matches. Pure keyword search misses paraphrased intent. Miru combines both:
14
+
15
+ 1. **Dense (semantic)** — Takara code embeddings (`ds1-potion-code-16m`) over structurally chunked source files.
16
+ 2. **Sparse (BM25)** — keyword scoring with an inverted index; parallelized in Bun workers on large repos.
17
+ 3. **RRF fusion** — reciprocal rank fusion merges the two ranked lists.
18
+ 4. **Reranking** — code-tuned penalties and boosts (e.g. multi-chunk files, query-term overlap).
19
+
20
+ Indexes are built on demand, cached on disk, and invalidated when files change. You can search **code**, **docs**, **config**, or **all** content types.
21
+
22
+ ### How agents use Miru
23
+
24
+ | Mode | Best for | What the agent gets |
25
+ |------|----------|---------------------|
26
+ | **MCP server** | Cursor, Claude Code, VS Code Copilot, Gemini CLI, Kiro, OpenCode | Native `search` and `find_related` tools over the workspace |
27
+ | **CLI sub-agent** | Any agent with shell access | `miru init --agent <id>` writes a specialist agent that runs `miru search` / `miru find-related` via Bash |
28
+
29
+ Both modes share the same index and cache. Pick MCP when your IDE supports it; use the sub-agent when you want a dedicated exploration agent without MCP wiring.
8
30
 
9
31
  ## Install
10
32
 
@@ -12,7 +34,7 @@ Requires [Bun](https://bun.sh) 1.1+ on your PATH (`curl -fsSL https://bun.sh/ins
12
34
 
13
35
  ```bash
14
36
  # Run once without installing
15
- bun x @takara-ai/miru-code search "auth middleware" ./src
37
+ bunx @takara-ai/miru-code search "auth middleware" ./src
16
38
 
17
39
  # Global CLI
18
40
  bun add -g @takara-ai/miru-code
@@ -34,7 +56,7 @@ const index = await MiruIndex.fromPath("./src");
34
56
  bun install
35
57
  ```
36
58
 
37
- Add credentials to `.env.local` (loaded automatically when you run from this repo), or pass them in your MCP config `env` block (recommended for Cursor — MCP does not inherit IDE env vars reliably).
59
+ Add credentials to `.env.local` (loaded automatically when you run from this repo), or pass them in your MCP config `env` block (recommended — MCP does not inherit IDE env vars reliably).
38
60
 
39
61
  | Variable | Default |
40
62
  |----------|---------|
@@ -52,64 +74,153 @@ Parallelism: file reads/chunking, embedding batches (up to concurrency in flight
52
74
 
53
75
  `OPENAI_*` env aliases are also accepted where noted in the CLI help.
54
76
 
55
- ## CLI
77
+ ## IDE integration
78
+
79
+ Miru supports six agent platforms. For each, you can wire the **MCP server** (recommended) and/or install a **CLI sub-agent** with `miru init`.
80
+
81
+ ### Quick reference
82
+
83
+ | IDE | MCP config file | Sub-agent command | Sub-agent output |
84
+ |-----|-----------------|-------------------|------------------|
85
+ | [Cursor](#cursor) | `~/.cursor/mcp.json` or `.cursor/mcp.json` | `miru init --agent cursor` | `.cursor/agents/miru-code.md` |
86
+ | [Claude Code](#claude-code) | `.mcp.json` (project root) or `~/.claude.json` | `miru init --agent claude` | `.claude/agents/miru-code.md` |
87
+ | [VS Code / Copilot](#vs-code--github-copilot) | `.vscode/mcp.json` or user profile `mcp.json` | `miru init --agent copilot` | `.github/agents/miru-code.md` |
88
+ | [Gemini CLI](#gemini-cli) | `~/.gemini/settings.json` or `.gemini/settings.json` | `miru init --agent gemini` | `.gemini/agents/miru-code.md` |
89
+ | [Kiro](#kiro) | `~/.kiro/settings/mcp.json` or `.kiro/settings/mcp.json` | `miru init --agent kiro` | `.kiro/agents/miru-code.md` |
90
+ | [OpenCode](#opencode) | `~/.config/opencode/opencode.json` or `opencode.json` | `miru init --agent opencode` | `.opencode/agents/miru-code.md` |
91
+
92
+ Run `miru init --agent <id> --force` to overwrite an existing sub-agent file.
93
+
94
+ ### MCP server
95
+
96
+ Run with **no arguments** to start the stdio MCP server. It indexes the **current working directory** (most IDEs set this to your workspace). Optional path/ref flags pre-index a different repo.
56
97
 
57
98
  ```bash
58
- # Hybrid search (positional, Python-compatible)
59
- bun run miru search "where are embeddings created" ./src -k 5
99
+ bunx @takara-ai/miru-code
100
+ # or after bun add -g @takara-ai/miru-code: miru
101
+ ```
60
102
 
61
- # Find semantically related chunks
62
- bun run miru find-related src/embeddings/openai.ts 120 ./src
103
+ **Tools:** `search`, `find_related` — pass `repo` only when querying a different path or git URL.
63
104
 
64
- # Write a miru-code sub-agent for Cursor / Claude / etc.
65
- bun run miru init --agent cursor
105
+ For MCP configs, use `bunx` as the command (`bun` with `x` in `args` works too — they're equivalent).
66
106
 
67
- # Clear disk cache
68
- bun run miru clear ./src
107
+ Put the bearer token in `env`; MCP will not read `.env.local` unless those vars are unset.
108
+
109
+ #### Shared env block
110
+
111
+ Use this in every MCP config below (adjust the key name if your IDE uses `environment` instead of `env`):
112
+
113
+ ```json
114
+ {
115
+ "TAKARA_API_KEY": "your-takara-bearer-token",
116
+ "MIRU_OPENAI_BASE_URL": "https://infer.dev.takara.ai/v1",
117
+ "MIRU_OPENAI_EMBEDDING_MODEL": "ds1-potion-code-16m",
118
+ "MIRU_EMBEDDING_DIMENSIONS": "256"
119
+ }
69
120
  ```
70
121
 
71
- Indexes are cached under `~/Library/Caches/miru` (macOS) or `~/.cache/miru` (Linux).
122
+ ### Cursor
72
123
 
73
- ## MCP server
124
+ **MCP** — `~/.cursor/mcp.json` or project `.cursor/mcp.json`:
74
125
 
75
- Run with **no arguments** to start the stdio MCP server. It indexes the **current working directory** (Cursor sets this to your workspace). Optional path/ref flags pre-index a different repo.
126
+ ```json
127
+ {
128
+ "mcpServers": {
129
+ "miru": {
130
+ "command": "bunx",
131
+ "args": ["@takara-ai/miru-code"],
132
+ "env": {
133
+ "TAKARA_API_KEY": "your-takara-bearer-token"
134
+ }
135
+ }
136
+ }
137
+ }
138
+ ```
139
+
140
+ After `bun add -g @takara-ai/miru-code`, you can use `"command": "miru"` instead.
141
+
142
+ **Sub-agent:**
76
143
 
77
144
  ```bash
78
- bun /path/to/miru/src/cli.ts
79
- # or after bun add -g @takara-ai/miru-code: miru
80
- # or without installing: bun x @takara-ai/miru-code
145
+ miru init --agent cursor
81
146
  ```
82
147
 
83
- **Tools:** `search`, `find_related` — pass `repo` only when querying a different path or git URL.
148
+ Commit `.cursor/agents/miru-code.md` to share the exploration agent with your team.
84
149
 
85
- Cursor `~/.cursor/mcp.json` (or project `.cursor/mcp.json`):
150
+ ### Claude Code
86
151
 
87
- Use **`bun x`** (not `bunx` as the command) so Cursor does not pull the `bun` npm package postinstall, which breaks in MCP sandboxes:
152
+ **MCP** — project `.mcp.json` (check into git) or user scope in `~/.claude.json`:
88
153
 
89
154
  ```json
90
155
  {
91
156
  "mcpServers": {
92
157
  "miru": {
93
- "command": "bun",
94
- "args": ["x", "@takara-ai/miru-code"],
158
+ "type": "stdio",
159
+ "command": "bunx",
160
+ "args": ["@takara-ai/miru-code"],
95
161
  "env": {
96
- "TAKARA_API_KEY": "your-takara-bearer-token",
97
- "MIRU_OPENAI_BASE_URL": "https://infer.dev.takara.ai/v1",
98
- "MIRU_OPENAI_EMBEDDING_MODEL": "ds1-potion-code-16m",
99
- "MIRU_EMBEDDING_DIMENSIONS": "256"
162
+ "TAKARA_API_KEY": "${TAKARA_API_KEY}"
163
+ }
164
+ }
165
+ }
166
+ }
167
+ ```
168
+
169
+ Claude Code supports `${VAR}` expansion in config files. Restart the session and run `/mcp` to verify the connection.
170
+
171
+ Or via CLI:
172
+
173
+ ```bash
174
+ claude mcp add miru -s project -- bunx @takara-ai/miru-code
175
+ ```
176
+
177
+ **Sub-agent:**
178
+
179
+ ```bash
180
+ miru init --agent claude
181
+ ```
182
+
183
+ Writes `.claude/agents/miru-code.md` — invoke the `miru-code` agent for semantic exploration tasks.
184
+
185
+ ### VS Code / GitHub Copilot
186
+
187
+ **MCP** — workspace `.vscode/mcp.json` or user profile (Command Palette: **MCP: Open User Configuration**):
188
+
189
+ ```json
190
+ {
191
+ "servers": {
192
+ "miru": {
193
+ "type": "stdio",
194
+ "command": "bunx",
195
+ "args": ["@takara-ai/miru-code"],
196
+ "env": {
197
+ "TAKARA_API_KEY": "your-takara-bearer-token"
100
198
  }
101
199
  }
102
200
  }
103
201
  }
104
202
  ```
105
203
 
106
- Alternatively, after `bun add -g @takara-ai/miru-code`:
204
+ VS Code uses `"servers"` (not `mcpServers`). Use **MCP: List Servers** to start or restart the server after editing the config.
205
+
206
+ **Sub-agent:**
207
+
208
+ ```bash
209
+ miru init --agent copilot
210
+ ```
211
+
212
+ Writes `.github/agents/miru-code.md` for Copilot’s agent system.
213
+
214
+ ### Gemini CLI
215
+
216
+ **MCP** — `~/.gemini/settings.json` or project `.gemini/settings.json`:
107
217
 
108
218
  ```json
109
219
  {
110
220
  "mcpServers": {
111
221
  "miru": {
112
- "command": "miru",
222
+ "command": "bunx",
223
+ "args": ["@takara-ai/miru-code"],
113
224
  "env": {
114
225
  "TAKARA_API_KEY": "your-takara-bearer-token"
115
226
  }
@@ -118,15 +229,58 @@ Alternatively, after `bun add -g @takara-ai/miru-code`:
118
229
  }
119
230
  ```
120
231
 
121
- Local development (path to this repo):
232
+ Or add interactively: `gemini mcp add`. Run `/mcp` inside a session to confirm tools are available.
233
+
234
+ **Sub-agent:**
235
+
236
+ ```bash
237
+ miru init --agent gemini
238
+ ```
239
+
240
+ Writes `.gemini/agents/miru-code.md`.
241
+
242
+ ### Kiro
243
+
244
+ **MCP** — `~/.kiro/settings/mcp.json` or workspace `.kiro/settings/mcp.json`:
122
245
 
123
246
  ```json
124
247
  {
125
248
  "mcpServers": {
126
249
  "miru": {
127
- "command": "bun",
128
- "args": ["/absolute/path/to/miru/src/cli.ts"],
250
+ "command": "bunx",
251
+ "args": ["@takara-ai/miru-code"],
129
252
  "env": {
253
+ "TAKARA_API_KEY": "your-takara-bearer-token",
254
+ "PATH": "/usr/local/bin:/usr/bin:/bin"
255
+ }
256
+ }
257
+ }
258
+ }
259
+ ```
260
+
261
+ Kiro runs with a minimal environment — include `PATH` if Bun is not on the default path. Approve env vars when prompted in the IDE.
262
+
263
+ **Sub-agent:**
264
+
265
+ ```bash
266
+ miru init --agent kiro
267
+ ```
268
+
269
+ Writes `.kiro/agents/miru-code.md`.
270
+
271
+ ### OpenCode
272
+
273
+ **MCP** — `~/.config/opencode/opencode.json` or project `opencode.json`:
274
+
275
+ ```json
276
+ {
277
+ "$schema": "https://opencode.ai/config.json",
278
+ "mcp": {
279
+ "miru": {
280
+ "type": "local",
281
+ "command": ["bunx", "@takara-ai/miru-code"],
282
+ "enabled": true,
283
+ "environment": {
130
284
  "TAKARA_API_KEY": "your-takara-bearer-token"
131
285
  }
132
286
  }
@@ -134,7 +288,49 @@ Local development (path to this repo):
134
288
  }
135
289
  ```
136
290
 
137
- No project path in `args` for `bun x` / global install — Cursor runs the server with `cwd` set to the open workspace, which becomes the default index. Put the bearer token in `env`; MCP will not read `.env.local` unless those vars are unset.
291
+ OpenCode uses the `mcp` key (not `mcpServers`), requires `"type": "local"`, and uses `environment` instead of `env`. Verify with `opencode mcp list`.
292
+
293
+ **Sub-agent:**
294
+
295
+ ```bash
296
+ miru init --agent opencode
297
+ ```
298
+
299
+ Writes `.opencode/agents/miru-code.md` (subagent mode with bash/read permissions).
300
+
301
+ ### Local development (any IDE)
302
+
303
+ Point MCP at this repo instead of the published package:
304
+
305
+ ```json
306
+ {
307
+ "command": "bun",
308
+ "args": ["/absolute/path/to/miru-code/src/cli.ts"],
309
+ "env": {
310
+ "TAKARA_API_KEY": "your-takara-bearer-token"
311
+ }
312
+ }
313
+ ```
314
+
315
+ Merge into the appropriate wrapper key (`mcpServers`, `servers`, or `mcp`) for your IDE.
316
+
317
+ ## CLI
318
+
319
+ ```bash
320
+ # Hybrid search (positional, Python-compatible)
321
+ bun run miru search "where are embeddings created" ./src -k 5
322
+
323
+ # Find semantically related chunks
324
+ bun run miru find-related src/embeddings/openai.ts 120 ./src
325
+
326
+ # Write a miru-code sub-agent for your IDE
327
+ bun run miru init --agent cursor
328
+
329
+ # Clear disk cache
330
+ bun run miru clear ./src
331
+ ```
332
+
333
+ Indexes are cached under `~/Library/Caches/miru` (macOS) or `~/.cache/miru` (Linux).
138
334
 
139
335
  ## Library
140
336
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takara-ai/miru-code",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Miru (見る) — hybrid code search for agents. Bun, TypeScript, Takara embeddings.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.ts CHANGED
@@ -2,15 +2,18 @@
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 { loadStoredCredentials } from "./credentials.ts";
5
6
  import { loadEnvFiles } from "./env-files.ts";
6
7
  import { serveMcp } from "./mcp/serve.ts";
7
8
  import { MiruIndex } from "./miru-index.ts";
9
+ import { ensureCredentials, runClearCredentials, runSetup } from "./setup.ts";
8
10
  import type { ContentType } from "./types.ts";
9
11
  import { formatResults, isGitUrl, resolveChunk, resolveContent } from "./utils.ts";
10
12
 
11
13
  loadEnvFiles();
14
+ await loadStoredCredentials();
12
15
 
13
- const CLI_COMMANDS = new Set(["search", "find-related", "init", "clear", "-h", "--help"]);
16
+ const CLI_COMMANDS = new Set(["search", "find-related", "init", "setup", "clear", "-h", "--help"]);
14
17
 
15
18
  const AGENTS = new Set<AgentId>(["claude", "copilot", "cursor", "gemini", "kiro", "opencode"]);
16
19
 
@@ -64,6 +67,7 @@ async function runSearch(
64
67
  topK: number,
65
68
  content: ContentType[],
66
69
  ): Promise<void> {
70
+ await ensureCredentials();
67
71
  const index = await MiruIndex.fromSource(path, content);
68
72
  await index.saveToDefaultCache(path);
69
73
  const results = await index.search({ query, topK });
@@ -79,6 +83,7 @@ async function runFindRelated(
79
83
  topK: number,
80
84
  content: ContentType[],
81
85
  ): Promise<void> {
86
+ await ensureCredentials();
82
87
  const index = await MiruIndex.fromSource(path, content);
83
88
  const chunk = resolveChunk(index.chunks, filePath, line);
84
89
  if (!chunk) {
@@ -111,6 +116,7 @@ function printHelp(): void {
111
116
  CLI:
112
117
  miru search <query> [path] [-k N] [--content code|docs|config|all]
113
118
  miru find-related <file_path> <line> [path] [-k N] [--content ...]
119
+ miru setup [--key KEY] [--force] [--clear]
114
120
  miru init [--agent cursor] [--force]
115
121
  miru clear [path]
116
122
 
@@ -156,6 +162,32 @@ async function runCli(argv: string[]): Promise<void> {
156
162
  return;
157
163
  }
158
164
 
165
+ if (command === "setup") {
166
+ let apiKey: string | undefined;
167
+ let force = false;
168
+ let clear = false;
169
+ for (let i = 0; i < rest.length; i++) {
170
+ const arg = rest[i];
171
+ if (arg === "--force") {
172
+ force = true;
173
+ } else if (arg === "--clear") {
174
+ clear = true;
175
+ } else if ((arg === "--key" || arg === "-k") && rest[i + 1]) {
176
+ apiKey = rest[++i];
177
+ }
178
+ }
179
+ if (clear) {
180
+ if (apiKey) {
181
+ console.error("Usage: miru setup --clear cannot be combined with --key.");
182
+ process.exit(1);
183
+ }
184
+ await runClearCredentials();
185
+ return;
186
+ }
187
+ await runSetup({ apiKey, force });
188
+ return;
189
+ }
190
+
159
191
  if (command === "clear") {
160
192
  const path = resolve(rest[0] ?? process.cwd());
161
193
  await runClear(path);
@@ -229,6 +261,11 @@ async function runMcp(argv: string[]): Promise<void> {
229
261
  });
230
262
  }
231
263
 
264
+ async function runMcpWithCredentials(argv: string[]): Promise<void> {
265
+ await ensureCredentials({ interactive: false });
266
+ await runMcp(argv);
267
+ }
268
+
232
269
  async function main(): Promise<void> {
233
270
  const argv = process.argv.slice(2);
234
271
  const first = argv[0];
@@ -236,7 +273,7 @@ async function main(): Promise<void> {
236
273
  await runCli(argv);
237
274
  return;
238
275
  }
239
- await runMcp(argv);
276
+ await runMcpWithCredentials(argv);
240
277
  }
241
278
 
242
279
  main().catch((err) => {
@@ -0,0 +1,101 @@
1
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { EMBEDDING_API_KEY_NAMES, envFirstString } from "./env.ts";
4
+
5
+ const CREDENTIALS_FILENAME = "credentials.json";
6
+ const CREDENTIALS_VERSION = 1;
7
+
8
+ interface StoredCredentials {
9
+ version: number;
10
+ takara_api_key: string;
11
+ }
12
+
13
+ export function resolveCredentialsDir(): string {
14
+ const override = process.env.MIRU_CREDENTIALS_DIR;
15
+ if (override) {
16
+ return override;
17
+ }
18
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
19
+ if (process.platform === "win32") {
20
+ const base = process.env.APPDATA ?? join(home, "AppData", "Roaming");
21
+ return join(base, "miru");
22
+ }
23
+ if (process.platform === "darwin") {
24
+ return join(home, "Library", "Application Support", "miru");
25
+ }
26
+ const xdg = process.env.XDG_CONFIG_HOME ?? join(home, ".config");
27
+ return join(xdg, "miru");
28
+ }
29
+
30
+ export function resolveCredentialsPath(): string {
31
+ return join(resolveCredentialsDir(), CREDENTIALS_FILENAME);
32
+ }
33
+
34
+ export function hasEmbeddingApiKeyInEnv(): boolean {
35
+ return Boolean(envFirstString([...EMBEDDING_API_KEY_NAMES], ""));
36
+ }
37
+
38
+ export async function readStoredCredentials(): Promise<StoredCredentials | null> {
39
+ const path = resolveCredentialsPath();
40
+ if (!(await Bun.file(path).exists())) {
41
+ return null;
42
+ }
43
+ try {
44
+ const parsed = JSON.parse(await readFile(path, "utf-8")) as StoredCredentials;
45
+ if (parsed.version !== CREDENTIALS_VERSION || !parsed.takara_api_key) {
46
+ return null;
47
+ }
48
+ return parsed;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /** Set TAKARA_API_KEY from the user credentials file when env is unset. */
55
+ export async function loadStoredCredentials(): Promise<boolean> {
56
+ if (hasEmbeddingApiKeyInEnv()) {
57
+ return false;
58
+ }
59
+ const stored = await readStoredCredentials();
60
+ if (!stored) {
61
+ return false;
62
+ }
63
+ process.env.TAKARA_API_KEY = stored.takara_api_key;
64
+ return true;
65
+ }
66
+
67
+ export async function saveStoredCredentials(apiKey: string): Promise<string> {
68
+ const dir = resolveCredentialsDir();
69
+ const path = resolveCredentialsPath();
70
+ await mkdir(dir, { recursive: true, mode: 0o700 });
71
+ const payload: StoredCredentials = {
72
+ version: CREDENTIALS_VERSION,
73
+ takara_api_key: apiKey,
74
+ };
75
+ await writeFile(path, `${JSON.stringify(payload, null, 2)}\n`, {
76
+ encoding: "utf-8",
77
+ mode: 0o600,
78
+ });
79
+ try {
80
+ await chmod(path, 0o600);
81
+ } catch {
82
+ // Windows may not support Unix mode bits on all filesystems.
83
+ }
84
+ return path;
85
+ }
86
+
87
+ export async function clearStoredCredentials(): Promise<{ cleared: boolean; path: string }> {
88
+ const path = resolveCredentialsPath();
89
+ if (!(await Bun.file(path).exists())) {
90
+ return { cleared: false, path };
91
+ }
92
+
93
+ const stored = await readStoredCredentials();
94
+ await rm(path, { force: true });
95
+
96
+ if (stored && process.env.TAKARA_API_KEY === stored.takara_api_key) {
97
+ delete process.env.TAKARA_API_KEY;
98
+ }
99
+
100
+ return { cleared: true, path };
101
+ }
@@ -63,14 +63,23 @@ function splitIntoWindows(text: string, maxChars: number): string[] {
63
63
  return out;
64
64
  }
65
65
 
66
- function sanitizeEmbeddingInput(text: string): string {
66
+ /** Unpaired UTF-16 surrogates are invalid in JSON and break strict API parsers. */
67
+ function stripLoneSurrogates(text: string): string {
68
+ return text.replace(
69
+ /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g,
70
+ "\uFFFD",
71
+ );
72
+ }
73
+
74
+ export function sanitizeEmbeddingInput(text: string): string {
75
+ const out = stripLoneSurrogates(text);
67
76
  // Some OpenAI-compatible gateways mis-handle backslash escapes in JSON text
68
77
  // payloads. Keep a benchmark fallback mode to forcefully neutralize them.
69
78
  const mode = process.env.MIRU_EMBED_ESCAPE_MODE ?? process.env.SEMBLE_EMBED_ESCAPE_MODE ?? "quad";
70
79
  if (mode === "strip") {
71
- return text.replaceAll("\\", "/");
80
+ return out.replaceAll("\\", "/");
72
81
  }
73
- return text.replaceAll("\\", "\\\\\\\\");
82
+ return out.replaceAll("\\", "\\\\\\\\");
74
83
  }
75
84
 
76
85
  function isPayloadTooLargeError(err: unknown): boolean {
@@ -0,0 +1,94 @@
1
+ import {
2
+ resolveEmbeddingBaseUrl,
3
+ resolveEmbeddingDimensions,
4
+ resolveEmbeddingModel,
5
+ } from "./openai.ts";
6
+
7
+ export interface ValidateApiKeyResult {
8
+ valid: boolean;
9
+ status?: number;
10
+ message: string;
11
+ }
12
+
13
+ export async function validateEmbeddingApiKey(options: {
14
+ apiKey: string;
15
+ baseUrl?: string;
16
+ model?: string;
17
+ dimensions?: number;
18
+ }): Promise<ValidateApiKeyResult> {
19
+ const baseUrl = (options.baseUrl ?? resolveEmbeddingBaseUrl()).replace(/\/$/, "");
20
+ const model = options.model ?? resolveEmbeddingModel();
21
+ const dimensions = options.dimensions ?? resolveEmbeddingDimensions(model);
22
+ const endpoint = `${baseUrl}/embeddings`;
23
+
24
+ const body: Record<string, unknown> = {
25
+ model,
26
+ input: "miru setup validation",
27
+ };
28
+ if (dimensions != null) {
29
+ body.dimensions = dimensions;
30
+ }
31
+
32
+ let response: Response;
33
+ try {
34
+ response = await fetch(endpoint, {
35
+ method: "POST",
36
+ headers: {
37
+ Authorization: `Bearer ${options.apiKey}`,
38
+ "Content-Type": "application/json",
39
+ },
40
+ body: JSON.stringify(body),
41
+ });
42
+ } catch (err) {
43
+ const message = err instanceof Error ? err.message : String(err);
44
+ return {
45
+ valid: false,
46
+ message: `Could not reach embedding API at ${baseUrl}: ${message}`,
47
+ };
48
+ }
49
+
50
+ if (!response.ok) {
51
+ const text = await response.text();
52
+ if (response.status === 401 || response.status === 403) {
53
+ return {
54
+ valid: false,
55
+ status: response.status,
56
+ message: "Invalid API key (authentication failed).",
57
+ };
58
+ }
59
+ return {
60
+ valid: false,
61
+ status: response.status,
62
+ message: `Embedding API returned ${response.status}: ${text.slice(0, 200)}`,
63
+ };
64
+ }
65
+
66
+ try {
67
+ const payload = (await response.json()) as {
68
+ data?: Array<{ embedding?: number[] }>;
69
+ };
70
+ const embedding = payload.data?.[0]?.embedding;
71
+ if (!embedding || embedding.length === 0) {
72
+ return {
73
+ valid: false,
74
+ status: response.status,
75
+ message: "Embedding API returned an empty response.",
76
+ };
77
+ }
78
+ if (dimensions != null && embedding.length !== dimensions) {
79
+ return {
80
+ valid: false,
81
+ status: response.status,
82
+ message: `Expected ${dimensions} embedding dimensions, got ${embedding.length}.`,
83
+ };
84
+ }
85
+ } catch {
86
+ return {
87
+ valid: false,
88
+ status: response.status,
89
+ message: "Embedding API returned invalid JSON.",
90
+ };
91
+ }
92
+
93
+ return { valid: true, status: response.status, message: "API key is valid." };
94
+ }
package/src/env.ts CHANGED
@@ -38,12 +38,14 @@ const EMBEDDING_API_KEY_NAMES = [
38
38
  "SEMBLE_OPENAI_API_KEY",
39
39
  ] as const;
40
40
 
41
+ export { EMBEDDING_API_KEY_NAMES };
42
+
41
43
  export function resolveEmbeddingApiKey(): string {
42
44
  const key = envFirstString([...EMBEDDING_API_KEY_NAMES], "");
43
45
  if (!key) {
44
46
  throw new Error(
45
- "Embedding API key required. Set TAKARA_API_KEY, OPENAI_API_KEY, MIRU_OPENAI_API_KEY, or SEMBLE_OPENAI_API_KEY " +
46
- "in your MCP server env (Cursor mcp.json) or .env.local.",
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.",
47
49
  );
48
50
  }
49
51
  return key;