@takara-ai/miru-code 1.5.3 → 1.7.2
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 +102 -25
- package/docs/miru-workflow-diagram.svg +47 -0
- package/docs/self-hosted-sagemaker.md +1 -1
- package/package.json +3 -1
- package/scripts/install-git-hooks.ts +29 -0
- package/src/auth/device.ts +306 -0
- package/src/auth/errors.ts +24 -0
- package/src/auth/providers.ts +115 -0
- package/src/auth/types.ts +65 -0
- package/src/cli.ts +59 -8
- package/src/credentials.ts +173 -65
- package/src/embeddings/openai.ts +3 -2
- package/src/env.ts +6 -2
- package/src/help.ts +29 -13
- package/src/installer/agents.ts +118 -16
- package/src/installer/caveman-shared.ts +51 -0
- package/src/installer/config.ts +73 -0
- package/src/installer/copilot-family.ts +8 -0
- package/src/installer/hooks/install.ts +3 -14
- package/src/installer/installer.ts +332 -11
- package/src/installer/prompt.ts +15 -8
- package/src/installer/search-policy.ts +3 -2
- package/src/installer/shared-skill.ts +200 -0
- package/src/installer/style-packs/caveman.ts +118 -0
- package/src/installer/style-packs/ste/skill.ts +218 -0
- package/src/mcp/auth-tool.ts +161 -0
- package/src/mcp/index-cache.ts +2 -1
- package/src/mcp/server.ts +7 -4
- package/src/prompt.ts +8 -5
- package/src/setup.ts +100 -69
- package/src/spinner.ts +33 -6
- package/tokenizer/tokenizer.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
# Miru (見る)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/takara-ai/miru-code/actions/workflows/ci.yml) [](./LICENSE) [](https://bun.sh)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
**Hybrid code search for AI coding agents.** Find code by meaning, not grep.
|
|
7
|
+
|
|
8
|
+
Your AI agent finds the code it needs with up to 50% fewer tokens.
|
|
9
|
+
|
|
10
|
+
Miru returns the best **chunks** (path, lines, snippet) for questions like *"where is auth middleware configured?"* — plugged directly into Claude Code, Cursor, Copilot, Codex, and 9+ other agents via MCP.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## How it fits into your agent's workflow
|
|
15
|
+
|
|
16
|
+

|
|
17
|
+
|
|
18
|
+
Miru replaces the grep/glob-style search agents fall back on today. Install once, and every connected agent gets `search` and `find_related` MCP tools automatically.
|
|
4
19
|
|
|
5
|
-
**Requires:** [Bun](https://bun.sh) 1.1+ · [Takara API key](https://takara.ai)
|
|
6
20
|
|
|
7
21
|
## Install
|
|
8
22
|
|
|
@@ -10,20 +24,21 @@ Your AI agent finds the code it needs in 60% fewer tokens.
|
|
|
10
24
|
bun add -g @takara-ai/miru-code
|
|
11
25
|
```
|
|
12
26
|
|
|
13
|
-
## Set up
|
|
27
|
+
## Set up credentials
|
|
14
28
|
|
|
15
29
|
```bash
|
|
16
30
|
miru setup
|
|
17
31
|
```
|
|
18
32
|
|
|
19
|
-
|
|
33
|
+
Interactive `miru setup` defaults to device-code login and saves the resulting credentials locally. Manual bearer-token entry is still available with `--key`. If credentials are missing, the interactive MCP/plugin path can bootstrap the same device flow automatically on first use.
|
|
20
34
|
|
|
21
35
|
```bash
|
|
22
|
-
miru setup --
|
|
23
|
-
miru setup --
|
|
36
|
+
miru setup --device # explicit device-code login
|
|
37
|
+
miru setup --key YOUR_TOKEN # store a bearer token directly
|
|
38
|
+
miru setup --clear # remove stored credentials
|
|
24
39
|
```
|
|
25
40
|
|
|
26
|
-
|
|
41
|
+
Miru stores versioned credentials in `credentials.json` and automatically loads or refreshes them for MCP and CLI use. `TAKARA_API_KEY` still overrides stored credentials when set explicitly.
|
|
27
42
|
|
|
28
43
|
## Add to your IDE
|
|
29
44
|
|
|
@@ -41,6 +56,8 @@ Interactive TUI — **↑↓** move, **space** toggle, **a** all, **enter** conf
|
|
|
41
56
|
| Sub-agent | Dedicated `miru-code` agent file |
|
|
42
57
|
| Cursor rules | Always-on `.cursor/rules/miru-code.mdc` (Cursor only) |
|
|
43
58
|
| Search hooks *(experimental)* | Block built-in Grep/Glob and redirect to Miru MCP |
|
|
59
|
+
| Caveman *(experimental)* | On-demand chat compression skill (`/caveman`) |
|
|
60
|
+
| STE writing *(experimental)* | On-demand clear technical English for docs (`/ste`) |
|
|
44
61
|
|
|
45
62
|
|
|
46
63
|
Restart the IDE when done.
|
|
@@ -52,21 +69,59 @@ miru uninstall # remove miru config
|
|
|
52
69
|
**Supported:** Cursor · Claude Code · Gemini CLI · Kiro · OpenCode · GitHub Copilot · Codex · VS Code · Visual Studio (Windows) · Windsurf / Devin Desktop
|
|
53
70
|
|
|
54
71
|
|
|
55
|
-
| IDE | MCP | Instructions / rules | Hooks *(experimental)* |
|
|
56
|
-
| -------------- | ------------------------------------- | ------------------------------- | ------------------------------------------------- |
|
|
57
|
-
| Cursor | `~/.cursor/mcp.json` | `~/.cursor/rules/miru-code.mdc` | `~/.cursor/hooks.json` |
|
|
58
|
-
| Claude Code | `~/.claude.json` | `~/.claude/CLAUDE.md` | `~/.claude/settings.json` |
|
|
59
|
-
| Gemini CLI | `~/.gemini/settings.json` | `~/.gemini/GEMINI.md` | `~/.gemini/settings.json` (`BeforeTool`) |
|
|
60
|
-
| Kiro | `~/.kiro/settings/mcp.json` | `~/.kiro/steering/miru.md` | `~/.kiro/settings/hooks.json` |
|
|
61
|
-
| OpenCode |
|
|
62
|
-
| GitHub Copilot | `~/.copilot/mcp-config.json` | — | `~/.copilot/hooks/miru-search.json` |
|
|
63
|
-
| Codex | `~/.codex/config.toml` | `~/.codex/AGENTS.md` | `~/.codex/hooks.json` |
|
|
64
|
-
| VS Code | `…/Code/User/mcp.json` | — | `~/.copilot/hooks/miru-search.json` |
|
|
65
|
-
| Visual Studio | `%USERPROFILE%\.mcp.json` | — | `~/.copilot/hooks/miru-search.json` |
|
|
66
|
-
| Windsurf | — | — | `~/.codeium/windsurf/hooks.json` |
|
|
72
|
+
| IDE | MCP | Instructions / rules | Hooks *(experimental)* | Caveman *(experimental)* | STE *(experimental)* |
|
|
73
|
+
| -------------- | ------------------------------------- | ------------------------------- | ------------------------------------------------- | --------------------------------------------------- | -------------------------------------------- |
|
|
74
|
+
| Cursor | `~/.cursor/mcp.json` | `~/.cursor/rules/miru-code.mdc` | `~/.cursor/hooks.json` | `~/.agents/skills/caveman/SKILL.md` | `~/.agents/skills/ste/SKILL.md` |
|
|
75
|
+
| Claude Code | `~/.claude.json` | `~/.claude/CLAUDE.md` | `~/.claude/settings.json` | `~/.claude/skills/caveman/SKILL.md` | `~/.claude/skills/ste/SKILL.md` |
|
|
76
|
+
| Gemini CLI | `~/.gemini/settings.json` | `~/.gemini/GEMINI.md` | `~/.gemini/settings.json` (`BeforeTool`) | `~/.agents/skills/caveman/SKILL.md` | `~/.agents/skills/ste/SKILL.md` |
|
|
77
|
+
| Kiro | `~/.kiro/settings/mcp.json` | `~/.kiro/steering/miru.md` | `~/.kiro/settings/hooks.json` | `~/.kiro/skills/caveman/SKILL.md` | `~/.kiro/skills/ste/SKILL.md` |
|
|
78
|
+
| OpenCode | `$XDG_CONFIG_HOME/opencode/opencode.json(c)` *(else `~/.config/opencode/…`)* | `…/AGENTS.md` | `…/plugins/miru-search-guard.ts` | `~/.agents/skills/caveman/SKILL.md` | `~/.agents/skills/ste/SKILL.md` |
|
|
79
|
+
| GitHub Copilot | `~/.copilot/mcp-config.json` | — | `~/.copilot/hooks/miru-search.json` | `~/.agents/skills/caveman/SKILL.md` | `~/.agents/skills/ste/SKILL.md` |
|
|
80
|
+
| Codex | `~/.codex/config.toml` | `~/.codex/AGENTS.md` | `~/.codex/hooks.json` | `~/.agents/skills/caveman/SKILL.md` | `~/.agents/skills/ste/SKILL.md` |
|
|
81
|
+
| VS Code | `…/Code/User/mcp.json` | — | `~/.copilot/hooks/miru-search.json` | `~/.agents/skills/caveman/SKILL.md` | `~/.agents/skills/ste/SKILL.md` |
|
|
82
|
+
| Visual Studio | `%USERPROFILE%\.mcp.json` | — | `~/.copilot/hooks/miru-search.json` | `~/.agents/skills/caveman/SKILL.md` | `~/.agents/skills/ste/SKILL.md` |
|
|
83
|
+
| Windsurf | — | — | `~/.codeium/windsurf/hooks.json` | `~/.agents/skills/caveman/SKILL.md` | `~/.agents/skills/ste/SKILL.md` |
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
### Plugin packaging
|
|
87
|
+
|
|
88
|
+
This repo now includes plugin packaging for:
|
|
89
|
+
|
|
90
|
+
- Codex: `.codex-plugin/plugin.json` and `.agents/plugins/marketplace.json`
|
|
91
|
+
- Claude Code: `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json`
|
|
92
|
+
- Cursor: `plugin.json` and `.cursor/rules/miru-code-search.mdc`
|
|
93
|
+
|
|
94
|
+
Current limitation:
|
|
95
|
+
|
|
96
|
+
- these plugin manifests still launch the published Miru runtime through `bunx @takara-ai/miru-code`
|
|
97
|
+
- that means local source edits do not affect plugin behavior until a package version is published
|
|
98
|
+
- and a fully self-contained “no Bun required” plugin install is still future work
|
|
99
|
+
|
|
100
|
+
### Search hooks
|
|
101
|
+
|
|
102
|
+
Sub-agent files are also written where supported (see `miru install` plan). Windsurf hooks only *(experimental)* — no MCP entry yet. Caveman is an on-demand Agent Skill (default off): invoke with `/caveman` or “talk like caveman”; stop with “normal mode”. Invocation UI varies by IDE (`/caveman`, `$caveman`, `@caveman`, etc.). Most IDEs share `~/.agents/skills/caveman/SKILL.md` (including Copilot / VS Code / Visual Studio); Claude Code and Kiro keep vendor-native skill dirs. Ownership is tracked on the shared path so uninstalling one IDE keeps the skill while another still owns it; selecting all owners removes it once. STE is an on-demand Agent Skill (default off): invoke with `/ste` or “de-slop this”; keep articles and complete sentences. Most IDEs share `~/.agents/skills/ste/` the same way (ownership via `miru-owners.json`); Claude Code and Kiro keep vendor-native STE dirs.
|
|
103
|
+
|
|
104
|
+
<details>
|
|
105
|
+
<summary>STE writing <em>(experimental)</em></summary>
|
|
106
|
+
|
|
107
|
+
STE helps write **clear technical English** for docs, runbooks, errors, and release notes (pragmatic ASD-STE100-inspired rules). Invoke with `/ste` or “de-slop this”. Keep articles and complete sentences — not telegraph-style omission.
|
|
108
|
+
|
|
109
|
+
**Not ASD-certified.** Full dictionary compliance needs the official standard at [asd-ste100.org](https://www.asd-ste100.org). Miru does not ship the copyrighted ASD dictionary.
|
|
110
|
+
|
|
111
|
+
Not for marketing or brand copy. Off by default; enable STE in the installer for any supported IDE. Restart the IDE (or reload skills) after install. For Codex, install also sets `[features] skills = true` in `~/.codex/config.toml` (same as Caveman).
|
|
112
|
+
|
|
113
|
+
</details>
|
|
114
|
+
|
|
115
|
+
<details>
|
|
116
|
+
<summary>Caveman mode <em>(experimental)</em></summary>
|
|
117
|
+
|
|
118
|
+
Caveman compresses **live chat replies** (less filler, max meaning). Intensities: `/caveman lite|full|ultra` (default full). Persisted artifacts (commits, PRs, customer docs) stay normal prose unless you ask otherwise.
|
|
67
119
|
|
|
120
|
+
Security / destructive warnings use clear normal prose (auto-clarity) — brevity never hides risk. Session token savings vary; the skill itself costs input tokens. No guaranteed %.
|
|
68
121
|
|
|
69
|
-
|
|
122
|
+
Off by default at install time. Enable Caveman in the installer for any supported IDE. Restart the IDE (or reload skills) after install. For Codex, the installer also sets `[features] skills = true` in `~/.codex/config.toml` (required for Codex to load skills).
|
|
123
|
+
|
|
124
|
+
</details>
|
|
70
125
|
|
|
71
126
|
<details>
|
|
72
127
|
<summary>Search hooks <em>(experimental)</em></summary>
|
|
@@ -180,10 +235,11 @@ Set `MIRU_AST_CHUNKING=0` to disable AST and use structural → lines only.
|
|
|
180
235
|
|
|
181
236
|
## CLI reference
|
|
182
237
|
|
|
238
|
+
Run `miru -h` for the command list, or `miru <command> -h` for details.
|
|
183
239
|
|
|
184
240
|
| Command | Purpose |
|
|
185
|
-
|
|
|
186
|
-
| `miru setup` |
|
|
241
|
+
| ----------------------------------------- | --------------------------------------------------------------------------------- |
|
|
242
|
+
| `miru setup` | Authenticate and store credentials |
|
|
187
243
|
| `miru install` | Configure IDE (global) |
|
|
188
244
|
| `miru uninstall` | Remove IDE config |
|
|
189
245
|
| `miru search <query> [path]` | Search (`-k N`, `--content`, `--json`) |
|
|
@@ -335,7 +391,7 @@ Override with `MIRU_BENCHMARK_HISTORY_PATH`. Append-only JSONL of compact token
|
|
|
335
391
|
|
|
336
392
|
For benchmark mode, set `"args": ["--benchmark"]` (or append that flag). Prefer `miru benchmark on` after a normal install — it updates every agent config.
|
|
337
393
|
|
|
338
|
-
Run `miru setup` once so the server can load
|
|
394
|
+
Run `miru setup` once so the server can load credentials from `credentials.json`. If the MCP server starts in an interactive terminal without stored credentials, it will start device login automatically.
|
|
339
395
|
|
|
340
396
|
Use `bunx` + `@takara-ai/miru-code` if `miru` is not global. Wrapper key varies by IDE (`mcpServers`, `servers`, or `mcp`).
|
|
341
397
|
|
|
@@ -347,10 +403,31 @@ bun install && cp .env.example .env.local
|
|
|
347
403
|
bun test && bun run typecheck
|
|
348
404
|
```
|
|
349
405
|
|
|
406
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for pre-commit hooks, commit message conventions, and the PR process.
|
|
407
|
+
|
|
350
408
|
Local MCP: `"command": "bun", "args": ["/path/to/miru-code/src/cli.ts"]`
|
|
351
409
|
|
|
352
|
-
`miru
|
|
410
|
+
`miru -h` / setup print a framed wordmark on color terminals (`MIRU_QUIET=1` for subtitle only). Crane art lives in `src/brand-banner.ts`; regenerate with `bun run scripts/render-crane-art.ts` (ImageMagick). The crane is a registered mark of Takara.ai Ltd.
|
|
411
|
+
|
|
412
|
+
## Codex plugin in this repo
|
|
413
|
+
|
|
414
|
+
This repo includes a repo-local Codex plugin:
|
|
415
|
+
|
|
416
|
+
- `.codex-plugin/plugin.json`
|
|
417
|
+
- `.mcp.json`
|
|
418
|
+
- `.agents/plugins/marketplace.json`
|
|
419
|
+
|
|
420
|
+
The plugin intentionally launches the published package with `bunx @takara-ai/miru-code` instead of the checked-out source tree, so local source edits here do not affect the Codex plugin until a new package version is published.
|
|
421
|
+
|
|
422
|
+
## Credits
|
|
423
|
+
|
|
424
|
+
Miru uses work by [MinishLab](https://github.com/MinishLab). Thank you to its authors.
|
|
425
|
+
|
|
426
|
+
- **[semble](https://github.com/MinishLab/semble)** (MIT). Miru ports parts of semble. These parts include the file walker, the index layout, the BM25 tokenizer, the hybrid search pipeline, and the ranking signals.
|
|
427
|
+
- **[potion-code](https://huggingface.co/minishlab/potion-code-16M)** and **[Model2Vec](https://github.com/MinishLab/model2vec)** (MIT). The WordPiece tokenizer in `tokenizer/tokenizer.json` comes from potion-code.
|
|
428
|
+
|
|
429
|
+
See [NOTICE](./NOTICE) for the licence text and the Model2Vec citation.
|
|
353
430
|
|
|
354
431
|
## License
|
|
355
432
|
|
|
356
|
-
MIT
|
|
433
|
+
MIT
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<svg width="800" height="280" viewBox="0 0 800 280" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="miru-diagram-title miru-diagram-desc">
|
|
2
|
+
<title id="miru-diagram-title">How Miru fits into an agent's code search workflow</title>
|
|
3
|
+
<desc id="miru-diagram-desc">A coding agent sends a natural-language query to the Miru MCP server, which runs hybrid search (AST-aware embeddings plus BM25 and reranking) over the indexed repository, then returns ranked code chunks back to the agent.</desc>
|
|
4
|
+
|
|
5
|
+
<defs>
|
|
6
|
+
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
7
|
+
<path d="M1 1L8 5L1 9" fill="none" stroke="#4A4D4E" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
|
8
|
+
</marker>
|
|
9
|
+
</defs>
|
|
10
|
+
|
|
11
|
+
<rect x="0" y="0" width="800" height="280" fill="#FFFFFF"/>
|
|
12
|
+
|
|
13
|
+
<text x="400" y="34" text-anchor="middle" font-family="Lato, 'Gill Sans', sans-serif" font-size="13" fill="#4A4D4E">
|
|
14
|
+
Replaces the grep/glob-style search agents fall back on today
|
|
15
|
+
</text>
|
|
16
|
+
|
|
17
|
+
<!-- Coding agent -->
|
|
18
|
+
<g>
|
|
19
|
+
<rect x="40" y="100" width="180" height="80" rx="10" fill="#FFFFFF" stroke="#4A4D4E" stroke-width="1.5"/>
|
|
20
|
+
<text x="130" y="134" text-anchor="middle" font-family="Lato, 'Gill Sans', sans-serif" font-size="16" font-weight="600" fill="#000000">Coding agent</text>
|
|
21
|
+
<text x="130" y="156" text-anchor="middle" font-family="Lato, 'Gill Sans', sans-serif" font-size="12" fill="#4A4D4E">"where's auth set up?"</text>
|
|
22
|
+
</g>
|
|
23
|
+
|
|
24
|
+
<line x1="220" y1="140" x2="288" y2="140" stroke="#4A4D4E" stroke-width="1.5" marker-end="url(#arrow)"/>
|
|
25
|
+
|
|
26
|
+
<!-- Miru MCP server (hero box, Takara Red) -->
|
|
27
|
+
<g>
|
|
28
|
+
<rect x="290" y="70" width="220" height="140" rx="10" fill="#D91009"/>
|
|
29
|
+
<text x="400" y="106" text-anchor="middle" font-family="Lato, 'Gill Sans', sans-serif" font-size="17" font-weight="700" fill="#FFFFFF">Miru MCP server</text>
|
|
30
|
+
<text x="400" y="132" text-anchor="middle" font-family="Lato, 'Gill Sans', sans-serif" font-size="12.5" fill="#FFFFFF">AST-aware embeddings</text>
|
|
31
|
+
<text x="400" y="150" text-anchor="middle" font-family="Lato, 'Gill Sans', sans-serif" font-size="12.5" fill="#FFFFFF">+ BM25 + reranking</text>
|
|
32
|
+
<text x="400" y="168" text-anchor="middle" font-family="Lato, 'Gill Sans', sans-serif" font-size="12.5" fill="#FFFFFF">over indexed repo</text>
|
|
33
|
+
</g>
|
|
34
|
+
|
|
35
|
+
<line x1="510" y1="140" x2="578" y2="140" stroke="#4A4D4E" stroke-width="1.5" marker-end="url(#arrow)"/>
|
|
36
|
+
|
|
37
|
+
<!-- Ranked chunks (accent, Azure Blue) -->
|
|
38
|
+
<g>
|
|
39
|
+
<rect x="580" y="100" width="180" height="80" rx="10" fill="#FFFFFF" stroke="#00A3EE" stroke-width="1.75"/>
|
|
40
|
+
<text x="670" y="134" text-anchor="middle" font-family="Lato, 'Gill Sans', sans-serif" font-size="16" font-weight="600" fill="#00668F">Ranked chunks</text>
|
|
41
|
+
<text x="670" y="156" text-anchor="middle" font-family="Lato, 'Gill Sans', sans-serif" font-size="12" fill="#4A4D4E">path, lines, snippet</text>
|
|
42
|
+
</g>
|
|
43
|
+
|
|
44
|
+
<text x="400" y="248" text-anchor="middle" font-family="Lato, 'Gill Sans', sans-serif" font-size="12" fill="#4A4D4E">
|
|
45
|
+
Every connected agent (Cursor, Claude Code, Copilot, Codex, and more) gets this via MCP
|
|
46
|
+
</text>
|
|
47
|
+
</svg>
|
|
@@ -113,4 +113,4 @@ Prefer `miru setup --sagemaker` so auth is checked automatically. You can also s
|
|
|
113
113
|
|
|
114
114
|
Optional TEI-style knobs: `MIRU_SAGEMAKER_NORMALIZE`, `MIRU_SAGEMAKER_TRUNCATE`, `MIRU_SAGEMAKER_TRUNCATION_DIRECTION`, `MIRU_SAGEMAKER_PROMPT_NAME`.
|
|
115
115
|
|
|
116
|
-
See `miru help setup` and `miru -h` for the same flags in the CLI.
|
|
116
|
+
See `miru help setup` and `miru -h` for the same flags in the CLI.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@takara-ai/miru-code",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.2",
|
|
4
4
|
"description": "Miru (見る) — hybrid code search for agents. Bun, TypeScript, Takara embeddings.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"grammars",
|
|
40
40
|
"tokenizer",
|
|
41
41
|
"docs",
|
|
42
|
+
"scripts/install-git-hooks.ts",
|
|
42
43
|
"README.md",
|
|
43
44
|
"LICENSE"
|
|
44
45
|
],
|
|
@@ -52,6 +53,7 @@
|
|
|
52
53
|
"typecheck": "tsc --noEmit",
|
|
53
54
|
"lint": "biome check .",
|
|
54
55
|
"lint:fix": "biome check --write .",
|
|
56
|
+
"prepare": "bun run scripts/install-git-hooks.ts",
|
|
55
57
|
"vendor-grammars": "bun run scripts/vendor-grammars.ts",
|
|
56
58
|
"prepublishOnly": "bun run vendor-grammars && bun run typecheck && bun test",
|
|
57
59
|
"benchmark:ab": "bun run scripts/search-ab-local.ts",
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package.json `prepare` — wire prek git hooks when developing from a clone.
|
|
3
|
+
* Bun-native (no shell redirects) so `bun install` works on Windows CI.
|
|
4
|
+
*
|
|
5
|
+
* Skips when not a git checkout or `prek` is missing from PATH.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
|
+
|
|
13
|
+
if (!existsSync(join(root, ".git"))) {
|
|
14
|
+
process.exit(0);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const prek = Bun.which("prek");
|
|
18
|
+
if (!prek) {
|
|
19
|
+
console.log("prek not found; skipping git hook install (see CONTRIBUTING.md)");
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const result = Bun.spawnSync([prek, "install", "--install-hooks"], {
|
|
24
|
+
cwd: root,
|
|
25
|
+
stdout: "inherit",
|
|
26
|
+
stderr: "inherit",
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
process.exit(result.exitCode === 0 ? 0 : (result.exitCode ?? 1));
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { envFirstString } from "../env.ts";
|
|
2
|
+
import { CredentialsError } from "./errors.ts";
|
|
3
|
+
import type { StoredDeviceCodeCredentials } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_AUTH_BASE_URL = "https://auth.takara.ai";
|
|
6
|
+
const DEFAULT_DEVICE_CODE_PATH = "/oauth/device/code";
|
|
7
|
+
const DEFAULT_TOKEN_PATH = "/oauth/token";
|
|
8
|
+
const DEFAULT_CLIENT_ID = "miru-code";
|
|
9
|
+
const EXPIRY_SKEW_MS = 60_000;
|
|
10
|
+
|
|
11
|
+
export interface DeviceAuthConfig {
|
|
12
|
+
baseUrl: string;
|
|
13
|
+
clientId: string;
|
|
14
|
+
scope?: string;
|
|
15
|
+
audience?: string;
|
|
16
|
+
deviceCodePath: string;
|
|
17
|
+
tokenPath: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DeviceAuthorizationStart {
|
|
21
|
+
deviceCode: string;
|
|
22
|
+
userCode: string;
|
|
23
|
+
verificationUri: string;
|
|
24
|
+
verificationUriComplete?: string;
|
|
25
|
+
expiresIn: number;
|
|
26
|
+
interval: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DeviceAuthorizationTokens {
|
|
30
|
+
accessToken: string;
|
|
31
|
+
refreshToken?: string;
|
|
32
|
+
expiresAt?: string;
|
|
33
|
+
tokenType?: string;
|
|
34
|
+
scope?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface OAuthTokenSuccess {
|
|
38
|
+
access_token: string;
|
|
39
|
+
refresh_token?: string;
|
|
40
|
+
expires_in?: number;
|
|
41
|
+
token_type?: string;
|
|
42
|
+
scope?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface OAuthTokenError {
|
|
46
|
+
error?: string;
|
|
47
|
+
error_description?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function resolveDeviceAuthConfig(): DeviceAuthConfig {
|
|
51
|
+
return {
|
|
52
|
+
baseUrl: envFirstString(["MIRU_AUTH_BASE_URL"], DEFAULT_AUTH_BASE_URL).replace(/\/$/, ""),
|
|
53
|
+
clientId: envFirstString(["MIRU_AUTH_CLIENT_ID"], DEFAULT_CLIENT_ID),
|
|
54
|
+
scope: process.env.MIRU_AUTH_SCOPE?.trim() || undefined,
|
|
55
|
+
audience: process.env.MIRU_AUTH_AUDIENCE?.trim() || undefined,
|
|
56
|
+
deviceCodePath: process.env.MIRU_DEVICE_CODE_PATH?.trim() || DEFAULT_DEVICE_CODE_PATH,
|
|
57
|
+
tokenPath: process.env.MIRU_TOKEN_PATH?.trim() || DEFAULT_TOKEN_PATH,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resolveUrl(baseUrl: string, path: string): string {
|
|
62
|
+
return new URL(path, `${baseUrl}/`).toString();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function tokenExpiryToIso(expiresIn?: number): string | undefined {
|
|
66
|
+
if (expiresIn == null || !Number.isFinite(expiresIn) || expiresIn <= 0) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
return new Date(Date.now() + expiresIn * 1000).toISOString();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeTokenSuccess(payload: OAuthTokenSuccess): DeviceAuthorizationTokens {
|
|
73
|
+
return {
|
|
74
|
+
accessToken: payload.access_token,
|
|
75
|
+
refreshToken: payload.refresh_token,
|
|
76
|
+
expiresAt: tokenExpiryToIso(payload.expires_in),
|
|
77
|
+
tokenType: payload.token_type,
|
|
78
|
+
scope: payload.scope,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseTokenError(payload: OAuthTokenError): string {
|
|
83
|
+
const code = payload.error?.trim();
|
|
84
|
+
const description = payload.error_description?.trim();
|
|
85
|
+
if (code && description) {
|
|
86
|
+
return `${code}: ${description}`;
|
|
87
|
+
}
|
|
88
|
+
return code || description || "unknown_error";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function postForm(
|
|
92
|
+
url: string,
|
|
93
|
+
body: URLSearchParams,
|
|
94
|
+
fetchImpl: typeof fetch,
|
|
95
|
+
): Promise<Response> {
|
|
96
|
+
return fetchImpl(url, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
99
|
+
body: body.toString(),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function startDeviceAuthorization(options?: {
|
|
104
|
+
config?: DeviceAuthConfig;
|
|
105
|
+
fetchImpl?: typeof fetch;
|
|
106
|
+
}): Promise<DeviceAuthorizationStart> {
|
|
107
|
+
const config = options?.config ?? resolveDeviceAuthConfig();
|
|
108
|
+
const fetchImpl = options?.fetchImpl ?? fetch;
|
|
109
|
+
const body = new URLSearchParams({ client_id: config.clientId });
|
|
110
|
+
if (config.scope) {
|
|
111
|
+
body.set("scope", config.scope);
|
|
112
|
+
}
|
|
113
|
+
if (config.audience) {
|
|
114
|
+
body.set("audience", config.audience);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const response = await postForm(
|
|
118
|
+
resolveUrl(config.baseUrl, config.deviceCodePath),
|
|
119
|
+
body,
|
|
120
|
+
fetchImpl,
|
|
121
|
+
);
|
|
122
|
+
const text = await response.text();
|
|
123
|
+
const payload = text ? (JSON.parse(text) as Record<string, unknown>) : {};
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
throw new Error(`Device authorization failed: ${text || response.statusText}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const deviceCode = String(payload.device_code ?? "").trim();
|
|
129
|
+
const userCode = String(payload.user_code ?? "").trim();
|
|
130
|
+
const verificationUri = String(payload.verification_uri ?? payload.verification_url ?? "").trim();
|
|
131
|
+
if (!deviceCode || !userCode || !verificationUri) {
|
|
132
|
+
throw new Error("Device authorization response was missing required fields.");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
deviceCode,
|
|
137
|
+
userCode,
|
|
138
|
+
verificationUri,
|
|
139
|
+
verificationUriComplete:
|
|
140
|
+
typeof payload.verification_uri_complete === "string"
|
|
141
|
+
? payload.verification_uri_complete
|
|
142
|
+
: undefined,
|
|
143
|
+
expiresIn:
|
|
144
|
+
typeof payload.expires_in === "number" && payload.expires_in > 0 ? payload.expires_in : 600,
|
|
145
|
+
interval: typeof payload.interval === "number" && payload.interval >= 0 ? payload.interval : 5,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function sleep(ms: number): Promise<void> {
|
|
150
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export type DeviceAuthorizationCheck =
|
|
154
|
+
| { status: "success"; tokens: DeviceAuthorizationTokens }
|
|
155
|
+
| { status: "pending" }
|
|
156
|
+
| { status: "slow_down" }
|
|
157
|
+
| { status: "denied" }
|
|
158
|
+
| { status: "expired" };
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Single non-blocking poll attempt against the token endpoint — no internal
|
|
162
|
+
* sleep. Used directly by the `auth` MCP tool (one check per tool call, so a
|
|
163
|
+
* call never blocks for the device code's full expiry window), and by
|
|
164
|
+
* pollDeviceAuthorization's blocking loop below for the CLI path.
|
|
165
|
+
*/
|
|
166
|
+
export async function checkDeviceAuthorizationOnce(
|
|
167
|
+
start: DeviceAuthorizationStart,
|
|
168
|
+
options?: { config?: DeviceAuthConfig; fetchImpl?: typeof fetch },
|
|
169
|
+
): Promise<DeviceAuthorizationCheck> {
|
|
170
|
+
const config = options?.config ?? resolveDeviceAuthConfig();
|
|
171
|
+
const fetchImpl = options?.fetchImpl ?? fetch;
|
|
172
|
+
|
|
173
|
+
const body = new URLSearchParams({
|
|
174
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
175
|
+
device_code: start.deviceCode,
|
|
176
|
+
client_id: config.clientId,
|
|
177
|
+
});
|
|
178
|
+
const response = await postForm(resolveUrl(config.baseUrl, config.tokenPath), body, fetchImpl);
|
|
179
|
+
const text = await response.text();
|
|
180
|
+
const payload = text ? (JSON.parse(text) as OAuthTokenSuccess | OAuthTokenError) : {};
|
|
181
|
+
|
|
182
|
+
if (response.ok) {
|
|
183
|
+
const success = payload as OAuthTokenSuccess;
|
|
184
|
+
if (!success.access_token?.trim()) {
|
|
185
|
+
throw new Error("Device login succeeded but did not return an access token.");
|
|
186
|
+
}
|
|
187
|
+
return { status: "success", tokens: normalizeTokenSuccess(success) };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const error = (payload as OAuthTokenError).error;
|
|
191
|
+
if (error === "authorization_pending") {
|
|
192
|
+
return { status: "pending" };
|
|
193
|
+
}
|
|
194
|
+
if (error === "slow_down") {
|
|
195
|
+
return { status: "slow_down" };
|
|
196
|
+
}
|
|
197
|
+
if (error === "access_denied") {
|
|
198
|
+
return { status: "denied" };
|
|
199
|
+
}
|
|
200
|
+
if (error === "expired_token") {
|
|
201
|
+
return { status: "expired" };
|
|
202
|
+
}
|
|
203
|
+
throw new Error(`Device login failed: ${parseTokenError(payload as OAuthTokenError)}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function pollDeviceAuthorization(
|
|
207
|
+
start: DeviceAuthorizationStart,
|
|
208
|
+
options?: { config?: DeviceAuthConfig; fetchImpl?: typeof fetch },
|
|
209
|
+
): Promise<DeviceAuthorizationTokens> {
|
|
210
|
+
const deadline = Date.now() + start.expiresIn * 1000;
|
|
211
|
+
let intervalMs = start.interval * 1000;
|
|
212
|
+
|
|
213
|
+
while (Date.now() < deadline) {
|
|
214
|
+
await sleep(intervalMs);
|
|
215
|
+
|
|
216
|
+
const check = await checkDeviceAuthorizationOnce(start, options);
|
|
217
|
+
if (check.status === "success") {
|
|
218
|
+
return check.tokens;
|
|
219
|
+
}
|
|
220
|
+
if (check.status === "pending") {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (check.status === "slow_down") {
|
|
224
|
+
intervalMs += 5_000;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (check.status === "denied") {
|
|
228
|
+
throw new Error("Device login was denied.");
|
|
229
|
+
}
|
|
230
|
+
if (check.status === "expired") {
|
|
231
|
+
throw new Error("Device login expired before it was completed.");
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
throw new Error("Device login timed out before it was completed.");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function refreshDeviceAuthorization(
|
|
239
|
+
credentials: StoredDeviceCodeCredentials,
|
|
240
|
+
options?: { config?: DeviceAuthConfig; fetchImpl?: typeof fetch },
|
|
241
|
+
): Promise<DeviceAuthorizationTokens> {
|
|
242
|
+
if (!credentials.refresh_token?.trim()) {
|
|
243
|
+
throw new CredentialsError(
|
|
244
|
+
"Stored device credentials cannot be refreshed because no refresh token exists.",
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const config = options?.config ?? resolveDeviceAuthConfig();
|
|
249
|
+
const fetchImpl = options?.fetchImpl ?? fetch;
|
|
250
|
+
const body = new URLSearchParams({
|
|
251
|
+
grant_type: "refresh_token",
|
|
252
|
+
refresh_token: credentials.refresh_token,
|
|
253
|
+
client_id: config.clientId,
|
|
254
|
+
});
|
|
255
|
+
const response = await postForm(resolveUrl(config.baseUrl, config.tokenPath), body, fetchImpl);
|
|
256
|
+
const text = await response.text();
|
|
257
|
+
const payload = text ? (JSON.parse(text) as OAuthTokenSuccess | OAuthTokenError) : {};
|
|
258
|
+
|
|
259
|
+
if (!response.ok) {
|
|
260
|
+
throw new CredentialsError(
|
|
261
|
+
`Device token refresh failed: ${parseTokenError(payload as OAuthTokenError)}`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const success = payload as OAuthTokenSuccess;
|
|
266
|
+
if (!success.access_token?.trim()) {
|
|
267
|
+
throw new CredentialsError("Token refresh succeeded but did not return an access token.");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return normalizeTokenSuccess({
|
|
271
|
+
...success,
|
|
272
|
+
refresh_token: success.refresh_token ?? credentials.refresh_token,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function deviceCredentialsNeedRefresh(credentials: StoredDeviceCodeCredentials): boolean {
|
|
277
|
+
if (!credentials.expires_at) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
const expiresAt = Date.parse(credentials.expires_at);
|
|
281
|
+
if (!Number.isFinite(expiresAt)) {
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
return expiresAt <= Date.now() + EXPIRY_SKEW_MS;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function openBrowserForDeviceLogin(url: string): boolean {
|
|
288
|
+
const command =
|
|
289
|
+
process.platform === "darwin"
|
|
290
|
+
? ["open", url]
|
|
291
|
+
: process.platform === "win32"
|
|
292
|
+
? ["cmd", "/c", "start", "", url]
|
|
293
|
+
: ["xdg-open", url];
|
|
294
|
+
try {
|
|
295
|
+
const proc = Bun.spawn(command, {
|
|
296
|
+
stdin: "ignore",
|
|
297
|
+
stdout: "ignore",
|
|
298
|
+
stderr: "ignore",
|
|
299
|
+
});
|
|
300
|
+
// Detach so the spinner's event loop / process lifecycle can't kill `open`.
|
|
301
|
+
proc.unref();
|
|
302
|
+
return true;
|
|
303
|
+
} catch {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signals that Miru has no usable credentials — missing, expired, or revoked.
|
|
3
|
+
* Callers that can recover (the MCP `auth` tool, `miru setup`) branch on this
|
|
4
|
+
* instead of matching error text.
|
|
5
|
+
*/
|
|
6
|
+
export class CredentialsError extends Error {
|
|
7
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
8
|
+
super(message, options);
|
|
9
|
+
this.name = "CredentialsError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** True when `error`, or anything in its `cause` chain, is a CredentialsError. */
|
|
14
|
+
export function isCredentialsError(error: unknown): boolean {
|
|
15
|
+
let current: unknown = error;
|
|
16
|
+
// Bounded so a self-referencing cause chain cannot spin forever.
|
|
17
|
+
for (let depth = 0; depth < 10 && current instanceof Error; depth++) {
|
|
18
|
+
if (current instanceof CredentialsError) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
current = current.cause;
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|