@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 CHANGED
@@ -1,8 +1,22 @@
1
1
  # Miru (見る)
2
2
 
3
- Your AI agent finds the code it needs in 60% fewer tokens.
3
+ [![CI](https://github.com/takara-ai/miru-code/actions/workflows/ci.yml/badge.svg)](https://github.com/takara-ai/miru-code/actions/workflows/ci.yml) [![license](https://img.shields.io/badge/license-MIT-blue)](./LICENSE) [![bun](https://img.shields.io/badge/runtime-bun%201.1%2B-black)](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
+ ![How Miru fits into an agent's workflow](docs/miru-workflow-diagram.svg)
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 API key
27
+ ## Set up credentials
14
28
 
15
29
  ```bash
16
30
  miru setup
17
31
  ```
18
32
 
19
- Validates your key and saves it locally. Skip this if you prefer — `miru search` or `miru install` will prompt on first run.
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 --key YOUR_TOKEN # non-interactive
23
- miru setup --clear # remove stored key
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
- MCP loads your key from `credentials.json` automatically — no env block needed in MCP config.
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` | Store API key |
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 your key from `credentials.json`.
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.5.3",
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
+ }