@takara-ai/miru-code 1.5.3 → 1.6.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/README.md +78 -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 +298 -0
- package/src/auth/providers.ts +119 -0
- package/src/auth/types.ts +65 -0
- package/src/cli.ts +34 -5
- package/src/credentials.ts +173 -65
- package/src/embeddings/openai.ts +3 -2
- package/src/env.ts +3 -1
- package/src/help.ts +11 -7
- package/src/installer/agents.ts +13 -6
- package/src/installer/installer.ts +66 -6
- package/src/installer/search-policy.ts +1 -1
- package/src/installer/style-packs/caveman.ts +118 -0
- package/src/mcp/auth-tool.ts +131 -0
- package/src/mcp/server.ts +3 -0
- package/src/prompt.ts +8 -5
- package/src/setup.ts +100 -69
- 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,7 @@ 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`) |
|
|
44
60
|
|
|
45
61
|
|
|
46
62
|
Restart the IDE when done.
|
|
@@ -52,21 +68,47 @@ miru uninstall # remove miru config
|
|
|
52
68
|
**Supported:** Cursor · Claude Code · Gemini CLI · Kiro · OpenCode · GitHub Copilot · Codex · VS Code · Visual Studio (Windows) · Windsurf / Devin Desktop
|
|
53
69
|
|
|
54
70
|
|
|
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 | `~/.config/opencode/opencode.json(c)` | `~/.config/opencode/AGENTS.md` | `~/.config/opencode/plugins/miru-search-guard.ts` |
|
|
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` |
|
|
71
|
+
| IDE | MCP | Instructions / rules | Hooks *(experimental)* | Caveman *(experimental)* |
|
|
72
|
+
| -------------- | ------------------------------------- | ------------------------------- | ------------------------------------------------- | ------------------------------------- |
|
|
73
|
+
| Cursor | `~/.cursor/mcp.json` | `~/.cursor/rules/miru-code.mdc` | `~/.cursor/hooks.json` | `~/.cursor/skills/caveman/SKILL.md` |
|
|
74
|
+
| Claude Code | `~/.claude.json` | `~/.claude/CLAUDE.md` | `~/.claude/settings.json` | `~/.claude/skills/caveman/SKILL.md` |
|
|
75
|
+
| Gemini CLI | `~/.gemini/settings.json` | `~/.gemini/GEMINI.md` | `~/.gemini/settings.json` (`BeforeTool`) | — |
|
|
76
|
+
| Kiro | `~/.kiro/settings/mcp.json` | `~/.kiro/steering/miru.md` | `~/.kiro/settings/hooks.json` | — |
|
|
77
|
+
| OpenCode | `~/.config/opencode/opencode.json(c)` | `~/.config/opencode/AGENTS.md` | `~/.config/opencode/plugins/miru-search-guard.ts` | — |
|
|
78
|
+
| GitHub Copilot | `~/.copilot/mcp-config.json` | — | `~/.copilot/hooks/miru-search.json` | — |
|
|
79
|
+
| Codex | `~/.codex/config.toml` | `~/.codex/AGENTS.md` | `~/.codex/hooks.json` | — |
|
|
80
|
+
| VS Code | `…/Code/User/mcp.json` | — | `~/.copilot/hooks/miru-search.json` | — |
|
|
81
|
+
| Visual Studio | `%USERPROFILE%\.mcp.json` | — | `~/.copilot/hooks/miru-search.json` | — |
|
|
82
|
+
| Windsurf | — | — | `~/.codeium/windsurf/hooks.json` | — |
|
|
83
|
+
|
|
84
|
+
### Plugin packaging
|
|
85
|
+
|
|
86
|
+
This repo now includes plugin packaging for:
|
|
87
|
+
|
|
88
|
+
- Codex: `.codex-plugin/plugin.json` and `.agents/plugins/marketplace.json`
|
|
89
|
+
- Claude Code: `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json`
|
|
90
|
+
- Cursor: `plugin.json` and `.cursor/rules/miru-code-search.mdc`
|
|
91
|
+
|
|
92
|
+
Current limitation:
|
|
93
|
+
|
|
94
|
+
- these plugin manifests still launch the published Miru runtime through `bunx @takara-ai/miru-code`
|
|
95
|
+
- that means local source edits do not affect plugin behavior until a package version is published
|
|
96
|
+
- and a fully self-contained “no Bun required” plugin install is still future work
|
|
67
97
|
|
|
98
|
+
### Search hooks
|
|
68
99
|
|
|
69
|
-
Sub-agent files are also written where supported (see `miru install` plan). Windsurf hooks only *(experimental)* — no MCP entry yet.
|
|
100
|
+
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”.
|
|
101
|
+
|
|
102
|
+
<details>
|
|
103
|
+
<summary>Caveman mode <em>(experimental)</em></summary>
|
|
104
|
+
|
|
105
|
+
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.
|
|
106
|
+
|
|
107
|
+
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 %.
|
|
108
|
+
|
|
109
|
+
Off by default at install time. Enable Caveman in the installer for Cursor and/or Claude Code.
|
|
110
|
+
|
|
111
|
+
</details>
|
|
70
112
|
|
|
71
113
|
<details>
|
|
72
114
|
<summary>Search hooks <em>(experimental)</em></summary>
|
|
@@ -180,10 +222,9 @@ Set `MIRU_AST_CHUNKING=0` to disable AST and use structural → lines only.
|
|
|
180
222
|
|
|
181
223
|
## CLI reference
|
|
182
224
|
|
|
183
|
-
|
|
184
225
|
| Command | Purpose |
|
|
185
|
-
|
|
|
186
|
-
| `miru setup` |
|
|
226
|
+
| ----------------------------------------- | --------------------------------------------------------------------------------- |
|
|
227
|
+
| `miru setup` | Authenticate and store credentials |
|
|
187
228
|
| `miru install` | Configure IDE (global) |
|
|
188
229
|
| `miru uninstall` | Remove IDE config |
|
|
189
230
|
| `miru search <query> [path]` | Search (`-k N`, `--content`, `--json`) |
|
|
@@ -335,7 +376,7 @@ Override with `MIRU_BENCHMARK_HISTORY_PATH`. Append-only JSONL of compact token
|
|
|
335
376
|
|
|
336
377
|
For benchmark mode, set `"args": ["--benchmark"]` (or append that flag). Prefer `miru benchmark on` after a normal install — it updates every agent config.
|
|
337
378
|
|
|
338
|
-
Run `miru setup` once so the server can load
|
|
379
|
+
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
380
|
|
|
340
381
|
Use `bunx` + `@takara-ai/miru-code` if `miru` is not global. Wrapper key varies by IDE (`mcpServers`, `servers`, or `mcp`).
|
|
341
382
|
|
|
@@ -347,10 +388,22 @@ bun install && cp .env.example .env.local
|
|
|
347
388
|
bun test && bun run typecheck
|
|
348
389
|
```
|
|
349
390
|
|
|
391
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for pre-commit hooks, commit message conventions, and the PR process.
|
|
392
|
+
|
|
350
393
|
Local MCP: `"command": "bun", "args": ["/path/to/miru-code/src/cli.ts"]`
|
|
351
394
|
|
|
352
395
|
`miru help` / 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.
|
|
353
396
|
|
|
397
|
+
## Codex plugin in this repo
|
|
398
|
+
|
|
399
|
+
This repo includes a repo-local Codex plugin:
|
|
400
|
+
|
|
401
|
+
- `.codex-plugin/plugin.json`
|
|
402
|
+
- `.mcp.json`
|
|
403
|
+
- `.agents/plugins/marketplace.json`
|
|
404
|
+
|
|
405
|
+
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.
|
|
406
|
+
|
|
354
407
|
## License
|
|
355
408
|
|
|
356
|
-
MIT
|
|
409
|
+
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.6.0",
|
|
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,298 @@
|
|
|
1
|
+
import { envFirstString } from "../env.ts";
|
|
2
|
+
import type { StoredDeviceCodeCredentials } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_AUTH_BASE_URL = "https://auth.takara.ai";
|
|
5
|
+
const DEFAULT_DEVICE_CODE_PATH = "/oauth/device/code";
|
|
6
|
+
const DEFAULT_TOKEN_PATH = "/oauth/token";
|
|
7
|
+
const DEFAULT_CLIENT_ID = "miru-code";
|
|
8
|
+
const EXPIRY_SKEW_MS = 60_000;
|
|
9
|
+
|
|
10
|
+
export interface DeviceAuthConfig {
|
|
11
|
+
baseUrl: string;
|
|
12
|
+
clientId: string;
|
|
13
|
+
scope?: string;
|
|
14
|
+
audience?: string;
|
|
15
|
+
deviceCodePath: string;
|
|
16
|
+
tokenPath: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DeviceAuthorizationStart {
|
|
20
|
+
deviceCode: string;
|
|
21
|
+
userCode: string;
|
|
22
|
+
verificationUri: string;
|
|
23
|
+
verificationUriComplete?: string;
|
|
24
|
+
expiresIn: number;
|
|
25
|
+
interval: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DeviceAuthorizationTokens {
|
|
29
|
+
accessToken: string;
|
|
30
|
+
refreshToken?: string;
|
|
31
|
+
expiresAt?: string;
|
|
32
|
+
tokenType?: string;
|
|
33
|
+
scope?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface OAuthTokenSuccess {
|
|
37
|
+
access_token: string;
|
|
38
|
+
refresh_token?: string;
|
|
39
|
+
expires_in?: number;
|
|
40
|
+
token_type?: string;
|
|
41
|
+
scope?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface OAuthTokenError {
|
|
45
|
+
error?: string;
|
|
46
|
+
error_description?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveDeviceAuthConfig(): DeviceAuthConfig {
|
|
50
|
+
return {
|
|
51
|
+
baseUrl: envFirstString(["MIRU_AUTH_BASE_URL"], DEFAULT_AUTH_BASE_URL).replace(/\/$/, ""),
|
|
52
|
+
clientId: envFirstString(["MIRU_AUTH_CLIENT_ID"], DEFAULT_CLIENT_ID),
|
|
53
|
+
scope: process.env.MIRU_AUTH_SCOPE?.trim() || undefined,
|
|
54
|
+
audience: process.env.MIRU_AUTH_AUDIENCE?.trim() || undefined,
|
|
55
|
+
deviceCodePath: process.env.MIRU_DEVICE_CODE_PATH?.trim() || DEFAULT_DEVICE_CODE_PATH,
|
|
56
|
+
tokenPath: process.env.MIRU_TOKEN_PATH?.trim() || DEFAULT_TOKEN_PATH,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resolveUrl(baseUrl: string, path: string): string {
|
|
61
|
+
return new URL(path, `${baseUrl}/`).toString();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function tokenExpiryToIso(expiresIn?: number): string | undefined {
|
|
65
|
+
if (expiresIn == null || !Number.isFinite(expiresIn) || expiresIn <= 0) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
return new Date(Date.now() + expiresIn * 1000).toISOString();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeTokenSuccess(payload: OAuthTokenSuccess): DeviceAuthorizationTokens {
|
|
72
|
+
return {
|
|
73
|
+
accessToken: payload.access_token,
|
|
74
|
+
refreshToken: payload.refresh_token,
|
|
75
|
+
expiresAt: tokenExpiryToIso(payload.expires_in),
|
|
76
|
+
tokenType: payload.token_type,
|
|
77
|
+
scope: payload.scope,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseTokenError(payload: OAuthTokenError): string {
|
|
82
|
+
const code = payload.error?.trim();
|
|
83
|
+
const description = payload.error_description?.trim();
|
|
84
|
+
if (code && description) {
|
|
85
|
+
return `${code}: ${description}`;
|
|
86
|
+
}
|
|
87
|
+
return code || description || "unknown_error";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function postForm(
|
|
91
|
+
url: string,
|
|
92
|
+
body: URLSearchParams,
|
|
93
|
+
fetchImpl: typeof fetch,
|
|
94
|
+
): Promise<Response> {
|
|
95
|
+
return fetchImpl(url, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
98
|
+
body: body.toString(),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function startDeviceAuthorization(options?: {
|
|
103
|
+
config?: DeviceAuthConfig;
|
|
104
|
+
fetchImpl?: typeof fetch;
|
|
105
|
+
}): Promise<DeviceAuthorizationStart> {
|
|
106
|
+
const config = options?.config ?? resolveDeviceAuthConfig();
|
|
107
|
+
const fetchImpl = options?.fetchImpl ?? fetch;
|
|
108
|
+
const body = new URLSearchParams({ client_id: config.clientId });
|
|
109
|
+
if (config.scope) {
|
|
110
|
+
body.set("scope", config.scope);
|
|
111
|
+
}
|
|
112
|
+
if (config.audience) {
|
|
113
|
+
body.set("audience", config.audience);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const response = await postForm(
|
|
117
|
+
resolveUrl(config.baseUrl, config.deviceCodePath),
|
|
118
|
+
body,
|
|
119
|
+
fetchImpl,
|
|
120
|
+
);
|
|
121
|
+
const text = await response.text();
|
|
122
|
+
const payload = text ? (JSON.parse(text) as Record<string, unknown>) : {};
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
throw new Error(`Device authorization failed: ${text || response.statusText}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const deviceCode = String(payload.device_code ?? "").trim();
|
|
128
|
+
const userCode = String(payload.user_code ?? "").trim();
|
|
129
|
+
const verificationUri = String(payload.verification_uri ?? payload.verification_url ?? "").trim();
|
|
130
|
+
if (!deviceCode || !userCode || !verificationUri) {
|
|
131
|
+
throw new Error("Device authorization response was missing required fields.");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
deviceCode,
|
|
136
|
+
userCode,
|
|
137
|
+
verificationUri,
|
|
138
|
+
verificationUriComplete:
|
|
139
|
+
typeof payload.verification_uri_complete === "string"
|
|
140
|
+
? payload.verification_uri_complete
|
|
141
|
+
: undefined,
|
|
142
|
+
expiresIn:
|
|
143
|
+
typeof payload.expires_in === "number" && payload.expires_in > 0 ? payload.expires_in : 600,
|
|
144
|
+
interval: typeof payload.interval === "number" && payload.interval >= 0 ? payload.interval : 5,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function sleep(ms: number): Promise<void> {
|
|
149
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export type DeviceAuthorizationCheck =
|
|
153
|
+
| { status: "success"; tokens: DeviceAuthorizationTokens }
|
|
154
|
+
| { status: "pending" }
|
|
155
|
+
| { status: "slow_down" }
|
|
156
|
+
| { status: "denied" }
|
|
157
|
+
| { status: "expired" };
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Single non-blocking poll attempt against the token endpoint — no internal
|
|
161
|
+
* sleep. Used directly by the `auth` MCP tool (one check per tool call, so a
|
|
162
|
+
* call never blocks for the device code's full expiry window), and by
|
|
163
|
+
* pollDeviceAuthorization's blocking loop below for the CLI path.
|
|
164
|
+
*/
|
|
165
|
+
export async function checkDeviceAuthorizationOnce(
|
|
166
|
+
start: DeviceAuthorizationStart,
|
|
167
|
+
options?: { config?: DeviceAuthConfig; fetchImpl?: typeof fetch },
|
|
168
|
+
): Promise<DeviceAuthorizationCheck> {
|
|
169
|
+
const config = options?.config ?? resolveDeviceAuthConfig();
|
|
170
|
+
const fetchImpl = options?.fetchImpl ?? fetch;
|
|
171
|
+
|
|
172
|
+
const body = new URLSearchParams({
|
|
173
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
174
|
+
device_code: start.deviceCode,
|
|
175
|
+
client_id: config.clientId,
|
|
176
|
+
});
|
|
177
|
+
const response = await postForm(resolveUrl(config.baseUrl, config.tokenPath), body, fetchImpl);
|
|
178
|
+
const text = await response.text();
|
|
179
|
+
const payload = text ? (JSON.parse(text) as OAuthTokenSuccess | OAuthTokenError) : {};
|
|
180
|
+
|
|
181
|
+
if (response.ok) {
|
|
182
|
+
const success = payload as OAuthTokenSuccess;
|
|
183
|
+
if (!success.access_token?.trim()) {
|
|
184
|
+
throw new Error("Device login succeeded but did not return an access token.");
|
|
185
|
+
}
|
|
186
|
+
return { status: "success", tokens: normalizeTokenSuccess(success) };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const error = (payload as OAuthTokenError).error;
|
|
190
|
+
if (error === "authorization_pending") {
|
|
191
|
+
return { status: "pending" };
|
|
192
|
+
}
|
|
193
|
+
if (error === "slow_down") {
|
|
194
|
+
return { status: "slow_down" };
|
|
195
|
+
}
|
|
196
|
+
if (error === "access_denied") {
|
|
197
|
+
return { status: "denied" };
|
|
198
|
+
}
|
|
199
|
+
if (error === "expired_token") {
|
|
200
|
+
return { status: "expired" };
|
|
201
|
+
}
|
|
202
|
+
throw new Error(`Device login failed: ${parseTokenError(payload as OAuthTokenError)}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function pollDeviceAuthorization(
|
|
206
|
+
start: DeviceAuthorizationStart,
|
|
207
|
+
options?: { config?: DeviceAuthConfig; fetchImpl?: typeof fetch },
|
|
208
|
+
): Promise<DeviceAuthorizationTokens> {
|
|
209
|
+
const deadline = Date.now() + start.expiresIn * 1000;
|
|
210
|
+
let intervalMs = start.interval * 1000;
|
|
211
|
+
|
|
212
|
+
while (Date.now() < deadline) {
|
|
213
|
+
await sleep(intervalMs);
|
|
214
|
+
|
|
215
|
+
const check = await checkDeviceAuthorizationOnce(start, options);
|
|
216
|
+
if (check.status === "success") {
|
|
217
|
+
return check.tokens;
|
|
218
|
+
}
|
|
219
|
+
if (check.status === "pending") {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (check.status === "slow_down") {
|
|
223
|
+
intervalMs += 5_000;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (check.status === "denied") {
|
|
227
|
+
throw new Error("Device login was denied.");
|
|
228
|
+
}
|
|
229
|
+
if (check.status === "expired") {
|
|
230
|
+
throw new Error("Device login expired before it was completed.");
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
throw new Error("Device login timed out before it was completed.");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function refreshDeviceAuthorization(
|
|
238
|
+
credentials: StoredDeviceCodeCredentials,
|
|
239
|
+
options?: { config?: DeviceAuthConfig; fetchImpl?: typeof fetch },
|
|
240
|
+
): Promise<DeviceAuthorizationTokens> {
|
|
241
|
+
if (!credentials.refresh_token?.trim()) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
"Stored device credentials cannot be refreshed because no refresh token exists.",
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const config = options?.config ?? resolveDeviceAuthConfig();
|
|
248
|
+
const fetchImpl = options?.fetchImpl ?? fetch;
|
|
249
|
+
const body = new URLSearchParams({
|
|
250
|
+
grant_type: "refresh_token",
|
|
251
|
+
refresh_token: credentials.refresh_token,
|
|
252
|
+
client_id: config.clientId,
|
|
253
|
+
});
|
|
254
|
+
const response = await postForm(resolveUrl(config.baseUrl, config.tokenPath), body, fetchImpl);
|
|
255
|
+
const text = await response.text();
|
|
256
|
+
const payload = text ? (JSON.parse(text) as OAuthTokenSuccess | OAuthTokenError) : {};
|
|
257
|
+
|
|
258
|
+
if (!response.ok) {
|
|
259
|
+
throw new Error(`Device token refresh failed: ${parseTokenError(payload as OAuthTokenError)}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const success = payload as OAuthTokenSuccess;
|
|
263
|
+
if (!success.access_token?.trim()) {
|
|
264
|
+
throw new Error("Token refresh succeeded but did not return an access token.");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return normalizeTokenSuccess({
|
|
268
|
+
...success,
|
|
269
|
+
refresh_token: success.refresh_token ?? credentials.refresh_token,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function deviceCredentialsNeedRefresh(credentials: StoredDeviceCodeCredentials): boolean {
|
|
274
|
+
if (!credentials.expires_at) {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
const expiresAt = Date.parse(credentials.expires_at);
|
|
278
|
+
if (!Number.isFinite(expiresAt)) {
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
return expiresAt <= Date.now() + EXPIRY_SKEW_MS;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function openBrowserForDeviceLogin(url: string): boolean {
|
|
285
|
+
const command =
|
|
286
|
+
process.platform === "darwin"
|
|
287
|
+
? ["open", url]
|
|
288
|
+
: process.platform === "win32"
|
|
289
|
+
? ["cmd", "/c", "start", "", url]
|
|
290
|
+
: ["xdg-open", url];
|
|
291
|
+
try {
|
|
292
|
+
const proc = Bun.spawn(command, { stdout: "ignore", stderr: "ignore" });
|
|
293
|
+
void proc.exited;
|
|
294
|
+
return true;
|
|
295
|
+
} catch {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
}
|