@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/src/installer/agents.ts
CHANGED
|
@@ -92,6 +92,8 @@ export interface AgentTarget {
|
|
|
92
92
|
hooksFormat: HooksFormat | null;
|
|
93
93
|
subagentPath: string | null;
|
|
94
94
|
subagentId: AgentId | null;
|
|
95
|
+
/** On-demand Caveman Agent Skill (`…/skills/caveman/SKILL.md`), or null if unsupported. */
|
|
96
|
+
cavemanSkillPath: string | null;
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
export function opencodeMcpPath(): string {
|
|
@@ -176,6 +178,7 @@ export const AGENT_TARGETS: AgentTarget[] = [
|
|
|
176
178
|
hooksFormat: "claude",
|
|
177
179
|
subagentPath: join(HOME, ".claude", "agents", "miru-code.md"),
|
|
178
180
|
subagentId: "claude",
|
|
181
|
+
cavemanSkillPath: join(HOME, ".claude", "skills", "caveman", "SKILL.md"),
|
|
179
182
|
},
|
|
180
183
|
{
|
|
181
184
|
id: "cursor",
|
|
@@ -189,6 +192,7 @@ export const AGENT_TARGETS: AgentTarget[] = [
|
|
|
189
192
|
hooksFormat: "cursor",
|
|
190
193
|
subagentPath: join(HOME, ".cursor", "agents", "miru-code.md"),
|
|
191
194
|
subagentId: "cursor",
|
|
195
|
+
cavemanSkillPath: join(HOME, ".cursor", "skills", "caveman", "SKILL.md"),
|
|
192
196
|
},
|
|
193
197
|
{
|
|
194
198
|
id: "gemini",
|
|
@@ -202,24 +206,21 @@ export const AGENT_TARGETS: AgentTarget[] = [
|
|
|
202
206
|
hooksFormat: "gemini",
|
|
203
207
|
subagentPath: join(HOME, ".gemini", "agents", "miru-code.md"),
|
|
204
208
|
subagentId: "gemini",
|
|
209
|
+
cavemanSkillPath: null,
|
|
205
210
|
},
|
|
206
211
|
{
|
|
207
212
|
id: "kiro",
|
|
208
213
|
displayName: "Kiro",
|
|
209
214
|
binary: "kiro",
|
|
210
215
|
configDir: join(HOME, ".kiro"),
|
|
211
|
-
mcp: jsonMcp(join(HOME, ".kiro", "settings", "mcp.json"), "mcpServers",
|
|
212
|
-
...STDIO_SERVER_CONFIG,
|
|
213
|
-
env: {
|
|
214
|
-
PATH: "/usr/local/bin:/usr/bin:/bin",
|
|
215
|
-
},
|
|
216
|
-
}),
|
|
216
|
+
mcp: jsonMcp(join(HOME, ".kiro", "settings", "mcp.json"), "mcpServers", STDIO_SERVER_CONFIG),
|
|
217
217
|
instructionsPath: join(HOME, ".kiro", "steering", "miru.md"),
|
|
218
218
|
cursorRulesPath: null,
|
|
219
219
|
hooksPath: kiroHooksPath(HOME),
|
|
220
220
|
hooksFormat: "kiro",
|
|
221
221
|
subagentPath: join(HOME, ".kiro", "agents", "miru-code.md"),
|
|
222
222
|
subagentId: "kiro",
|
|
223
|
+
cavemanSkillPath: null,
|
|
223
224
|
},
|
|
224
225
|
{
|
|
225
226
|
id: "opencode",
|
|
@@ -233,6 +234,7 @@ export const AGENT_TARGETS: AgentTarget[] = [
|
|
|
233
234
|
hooksFormat: "opencode",
|
|
234
235
|
subagentPath: join(HOME, ".config", "opencode", "agents", "miru-code.md"),
|
|
235
236
|
subagentId: "opencode",
|
|
237
|
+
cavemanSkillPath: null,
|
|
236
238
|
},
|
|
237
239
|
{
|
|
238
240
|
id: "copilot",
|
|
@@ -246,6 +248,7 @@ export const AGENT_TARGETS: AgentTarget[] = [
|
|
|
246
248
|
hooksFormat: "vscode",
|
|
247
249
|
subagentPath: join(HOME, ".copilot", "agents", "miru-code.agent.md"),
|
|
248
250
|
subagentId: "copilot",
|
|
251
|
+
cavemanSkillPath: null,
|
|
249
252
|
},
|
|
250
253
|
{
|
|
251
254
|
id: "codex",
|
|
@@ -265,6 +268,7 @@ export const AGENT_TARGETS: AgentTarget[] = [
|
|
|
265
268
|
hooksFormat: "claude",
|
|
266
269
|
subagentPath: null,
|
|
267
270
|
subagentId: null,
|
|
271
|
+
cavemanSkillPath: null,
|
|
268
272
|
},
|
|
269
273
|
{
|
|
270
274
|
id: "vscode",
|
|
@@ -278,6 +282,7 @@ export const AGENT_TARGETS: AgentTarget[] = [
|
|
|
278
282
|
hooksFormat: "vscode",
|
|
279
283
|
subagentPath: null,
|
|
280
284
|
subagentId: null,
|
|
285
|
+
cavemanSkillPath: null,
|
|
281
286
|
},
|
|
282
287
|
{
|
|
283
288
|
id: "windsurf",
|
|
@@ -291,6 +296,7 @@ export const AGENT_TARGETS: AgentTarget[] = [
|
|
|
291
296
|
hooksFormat: "windsurf",
|
|
292
297
|
subagentPath: null,
|
|
293
298
|
subagentId: null,
|
|
299
|
+
cavemanSkillPath: null,
|
|
294
300
|
},
|
|
295
301
|
{
|
|
296
302
|
id: "visualstudio",
|
|
@@ -304,6 +310,7 @@ export const AGENT_TARGETS: AgentTarget[] = [
|
|
|
304
310
|
hooksFormat: "vscode",
|
|
305
311
|
subagentPath: null,
|
|
306
312
|
subagentId: null,
|
|
313
|
+
cavemanSkillPath: null,
|
|
307
314
|
},
|
|
308
315
|
];
|
|
309
316
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { unlink } from "node:fs/promises";
|
|
1
|
+
import { mkdir, rmdir, unlink } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
2
3
|
import { loadAgentTemplate } from "../agents.ts";
|
|
3
4
|
import { clearBenchmarkHistory } from "../benchmark/history.ts";
|
|
4
5
|
import { brandTitle, dim, divider, green, hint, info, success, writeStdout } from "../cli-ui.ts";
|
|
@@ -24,13 +25,14 @@ import {
|
|
|
24
25
|
import { mergeHooks, removeHooks } from "./hooks/install.ts";
|
|
25
26
|
import { promptConfirm, promptMultiSelect, requireInteractiveTerminal } from "./prompt.ts";
|
|
26
27
|
import { CURSOR_RULES_MDC } from "./search-policy.ts";
|
|
28
|
+
import { CAVEMAN_SKILL_MD } from "./style-packs/caveman.ts";
|
|
27
29
|
|
|
28
30
|
export interface WriteResult {
|
|
29
31
|
path: string;
|
|
30
32
|
action: InstallAction;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
export type IntegrationId = "mcp" | "instructions" | "subagent" | "hooks" | "rules";
|
|
35
|
+
export type IntegrationId = "mcp" | "instructions" | "subagent" | "hooks" | "rules" | "caveman";
|
|
34
36
|
|
|
35
37
|
interface Integration {
|
|
36
38
|
id: IntegrationId;
|
|
@@ -183,6 +185,33 @@ async function applySubagent(agent: AgentTarget, mode: InstallMode): Promise<Wri
|
|
|
183
185
|
}
|
|
184
186
|
}
|
|
185
187
|
|
|
188
|
+
async function applyCaveman(agent: AgentTarget, mode: InstallMode): Promise<WriteResult | null> {
|
|
189
|
+
const path = agent.cavemanSkillPath;
|
|
190
|
+
if (!path) {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const skillDir = dirname(path);
|
|
195
|
+
|
|
196
|
+
if (mode === "uninstall") {
|
|
197
|
+
if (!(await Bun.file(path).exists())) {
|
|
198
|
+
return { path, action: "not-found" };
|
|
199
|
+
}
|
|
200
|
+
await unlink(path);
|
|
201
|
+
try {
|
|
202
|
+
await rmdir(skillDir);
|
|
203
|
+
} catch {
|
|
204
|
+
// Directory not empty or already gone — leave other skills alone.
|
|
205
|
+
}
|
|
206
|
+
return { path, action: "removed" };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const existed = await Bun.file(path).exists();
|
|
210
|
+
await mkdir(skillDir, { recursive: true });
|
|
211
|
+
await Bun.write(path, CAVEMAN_SKILL_MD);
|
|
212
|
+
return { path, action: existed ? "updated" : "created" };
|
|
213
|
+
}
|
|
214
|
+
|
|
186
215
|
const INTEGRATIONS: Integration[] = [
|
|
187
216
|
{
|
|
188
217
|
id: "mcp",
|
|
@@ -221,8 +250,27 @@ const INTEGRATIONS: Integration[] = [
|
|
|
221
250
|
planPath: (agent) => agent.hooksPath,
|
|
222
251
|
apply: applyHooks,
|
|
223
252
|
},
|
|
253
|
+
{
|
|
254
|
+
id: "caveman",
|
|
255
|
+
label: "Caveman",
|
|
256
|
+
description: "on-demand chat compression skill (/caveman)",
|
|
257
|
+
experimental: true,
|
|
258
|
+
defaultChecked: false,
|
|
259
|
+
planPath: (agent) => agent.cavemanSkillPath,
|
|
260
|
+
apply: applyCaveman,
|
|
261
|
+
},
|
|
224
262
|
];
|
|
225
263
|
|
|
264
|
+
function integrationApplies(integration: Integration, agent: AgentTarget): boolean {
|
|
265
|
+
return integration.planPath(agent) !== null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function integrationsForAgents(agents: AgentTarget[]): Integration[] {
|
|
269
|
+
return INTEGRATIONS.filter((integration) =>
|
|
270
|
+
agents.some((agent) => integrationApplies(integration, agent)),
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
226
274
|
function formatActionLine(integration: Integration, result: WriteResult): string {
|
|
227
275
|
const icon = ACTION_ICON[result.action] ?? dim("·");
|
|
228
276
|
const detail = ACTION_DETAIL[result.action];
|
|
@@ -239,8 +287,10 @@ function printPlan(agents: AgentTarget[], integrations: Integration[]): void {
|
|
|
239
287
|
writeStdout(` ${agent.displayName}`);
|
|
240
288
|
for (const integration of integrations) {
|
|
241
289
|
const path = integration.planPath(agent);
|
|
242
|
-
|
|
243
|
-
|
|
290
|
+
if (!path) {
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
writeStdout(` ${green("✓")} ${integration.label.padEnd(13)} ${path}`);
|
|
244
294
|
}
|
|
245
295
|
}
|
|
246
296
|
writeStdout("");
|
|
@@ -258,9 +308,11 @@ async function apply(
|
|
|
258
308
|
for (const agent of agents) {
|
|
259
309
|
writeStdout(` ${agent.displayName}`);
|
|
260
310
|
for (const integration of integrations) {
|
|
311
|
+
if (!integrationApplies(integration, agent)) {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
261
314
|
const result = await integration.apply(agent, mode);
|
|
262
315
|
if (!result) {
|
|
263
|
-
writeStdout(` ${dim("–")} ${integration.id.padEnd(13)} ${dim("not supported")}`);
|
|
264
316
|
continue;
|
|
265
317
|
}
|
|
266
318
|
writeStdout(formatActionLine(integration, result));
|
|
@@ -317,7 +369,13 @@ export async function runInstaller(mode: InstallMode): Promise<void> {
|
|
|
317
369
|
return;
|
|
318
370
|
}
|
|
319
371
|
|
|
320
|
-
const
|
|
372
|
+
const availableIntegrations = integrationsForAgents(chosenAgents);
|
|
373
|
+
if (availableIntegrations.length === 0) {
|
|
374
|
+
hint("No integrations apply to the selected agents. Exiting.");
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const integrationItems = availableIntegrations.map((integration) => ({
|
|
321
379
|
label: `${integration.label}${integration.experimental ? dim(" (experimental)") : ""} — ${integration.description}`,
|
|
322
380
|
value: integration,
|
|
323
381
|
checked: install ? (integration.defaultChecked ?? true) : true,
|
|
@@ -355,11 +413,13 @@ export async function runInstaller(mode: InstallMode): Promise<void> {
|
|
|
355
413
|
}
|
|
356
414
|
|
|
357
415
|
export {
|
|
416
|
+
applyCaveman,
|
|
358
417
|
applyHooks,
|
|
359
418
|
applyInstructions,
|
|
360
419
|
applyMcp,
|
|
361
420
|
applySubagent,
|
|
362
421
|
INTEGRATIONS,
|
|
422
|
+
integrationsForAgents,
|
|
363
423
|
mergeJsonMember as mergeMcpJson,
|
|
364
424
|
removeJsonMember as removeMcpJson,
|
|
365
425
|
};
|
|
@@ -88,7 +88,7 @@ export const INSTRUCTIONS_MARKDOWN = `## Miru Code Search
|
|
|
88
88
|
|
|
89
89
|
${SEARCH_POLICY_BODY}
|
|
90
90
|
|
|
91
|
-
Run \`miru setup\` once — the MCP server loads
|
|
91
|
+
Run \`miru setup\` once — the MCP server loads credentials from \`credentials.json\`, and interactive first use can auto-start device login when nothing is stored.
|
|
92
92
|
|
|
93
93
|
CLI fallback when MCP is unavailable:
|
|
94
94
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caveman mode — on-demand Agent Skill body (chat compression).
|
|
3
|
+
* Installed by `miru install` (Phase B); content-only module for Phase A.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const CAVEMAN_SKILL_NAME = "caveman";
|
|
7
|
+
|
|
8
|
+
/** Full `SKILL.md` (YAML frontmatter + body) written to the agent skill path. */
|
|
9
|
+
export const CAVEMAN_SKILL_MD = `---
|
|
10
|
+
name: caveman
|
|
11
|
+
description: >-
|
|
12
|
+
Compress agent chat replies: less filler, max meaning. Use when the user
|
|
13
|
+
says caveman mode, /caveman, talk like caveman, be brief, or asks for fewer
|
|
14
|
+
tokens in chat. Intensities: /caveman lite|full|ultra. Stop with stop caveman
|
|
15
|
+
or normal mode. Chat narration only — not commits, PRs, or customer docs
|
|
16
|
+
unless the user asks.
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
# Caveman mode
|
|
20
|
+
|
|
21
|
+
On-demand chat compression for Miru. **Brain big, mouth small.**
|
|
22
|
+
|
|
23
|
+
Inspired by [JuliusBrussee/caveman](https://github.com/JuliusBrussee/caveman) — original Miru skill text.
|
|
24
|
+
|
|
25
|
+
## Activate / stop
|
|
26
|
+
|
|
27
|
+
| User says | Effect |
|
|
28
|
+
|-----------|--------|
|
|
29
|
+
| \`/caveman\`, \`caveman mode\`, \`talk like caveman\` | ON at **full** (default) |
|
|
30
|
+
| \`/caveman lite\` | ON at lite |
|
|
31
|
+
| \`/caveman full\` | ON at full |
|
|
32
|
+
| \`/caveman ultra\` | ON at ultra |
|
|
33
|
+
| \`stop caveman\`, \`normal mode\` | OFF — resume normal prose |
|
|
34
|
+
|
|
35
|
+
Level sticks until the user changes it or the session ends. Do not stay in caveman after stop.
|
|
36
|
+
|
|
37
|
+
## Intensity
|
|
38
|
+
|
|
39
|
+
| Level | Behaviour |
|
|
40
|
+
|-------|-----------|
|
|
41
|
+
| **lite** | No polite filler, no task-restatement preamble. Keep articles and full sentences. Lead with the answer. |
|
|
42
|
+
| **full** (default) | Classic caveman: drop glue words when meaning stays clear; short sentences / fragments OK; dense keywords. |
|
|
43
|
+
| **ultra** | Maximum fragment compression. Still never sacrifice untouchables or safety (below). |
|
|
44
|
+
|
|
45
|
+
## Style rules (when ON)
|
|
46
|
+
|
|
47
|
+
- No polite talk ("sure", "happy to help", "great question")
|
|
48
|
+
- No restating the user's task as a preamble
|
|
49
|
+
- Lead with the answer
|
|
50
|
+
- Prefer keywords / symbols (\`→\`, \`=\`, \`vs\`) over glue-heavy prose
|
|
51
|
+
- Drop articles/copulas only when meaning stays clear
|
|
52
|
+
- Short sentence or fragment OK
|
|
53
|
+
|
|
54
|
+
## Auto-clarity (drop caveman for that segment)
|
|
55
|
+
|
|
56
|
+
Use **normal clear prose** for:
|
|
57
|
+
|
|
58
|
+
- Security warnings and security-relevant detail
|
|
59
|
+
- Irreversible / destructive action confirmations (force-push, drop table, delete prod, etc.)
|
|
60
|
+
- Multi-step sequences where fragment order could be misread
|
|
61
|
+
- When the user asks to clarify or repeats a question
|
|
62
|
+
|
|
63
|
+
Resume caveman after that segment if still ON.
|
|
64
|
+
|
|
65
|
+
**CAV-13 / safety:** Never omit security-relevant detail or destructive-action warnings for brevity. Brevity never hides risk. If unsure, use normal prose for the warning.
|
|
66
|
+
|
|
67
|
+
## Never sacrifice (untouchables)
|
|
68
|
+
|
|
69
|
+
Byte-exact — do not compress or paraphrase:
|
|
70
|
+
|
|
71
|
+
- Paths, file:line citations
|
|
72
|
+
- Code fences and identifiers
|
|
73
|
+
- CLI commands
|
|
74
|
+
- Exact error strings / quoted text
|
|
75
|
+
- Miru MCP tool use (\`search\` / \`locate\` / \`expand\` / \`find_related\`) when search policy is installed
|
|
76
|
+
|
|
77
|
+
Caveman applies to surrounding prose only.
|
|
78
|
+
|
|
79
|
+
## Chat vs persisted docs
|
|
80
|
+
|
|
81
|
+
Default: caveman = **live chat replies only**.
|
|
82
|
+
|
|
83
|
+
Commits, PR bodies, customer docs, READMEs, Linear text, memory files → **normal usable English** unless the user explicitly asks for caveman tone on that artifact.
|
|
84
|
+
|
|
85
|
+
## Token honesty
|
|
86
|
+
|
|
87
|
+
Output can shrink a lot on chatty replies. Session-level savings are often smaller; the skill itself costs input tokens each turn. No guaranteed % savings.
|
|
88
|
+
|
|
89
|
+
## Clarity over parody
|
|
90
|
+
|
|
91
|
+
If a reply is harder to use than normal concise prose, loosen grammar for that turn.
|
|
92
|
+
|
|
93
|
+
## Examples
|
|
94
|
+
|
|
95
|
+
### 1. Factual lookup
|
|
96
|
+
|
|
97
|
+
**Before:**
|
|
98
|
+
> Sure! I'd be happy to help. Looking at your question about where auth middleware is configured — I searched the codebase and it appears the authentication middleware is set up in \`src/auth/middleware.ts\` around line 42.
|
|
99
|
+
|
|
100
|
+
**After (full):**
|
|
101
|
+
> Auth middleware → \`src/auth/middleware.ts:42\`
|
|
102
|
+
|
|
103
|
+
### 2. Recommendation
|
|
104
|
+
|
|
105
|
+
**Before:**
|
|
106
|
+
> Great question! There are a few approaches you could take here. I would generally recommend using the existing cache helper rather than rolling your own, because it already handles invalidation.
|
|
107
|
+
|
|
108
|
+
**After (full):**
|
|
109
|
+
> Use existing cache helper → handles invalidation. Don't roll own.
|
|
110
|
+
|
|
111
|
+
### 3. Debug / failure finding
|
|
112
|
+
|
|
113
|
+
**Before:**
|
|
114
|
+
> I looked into the failure you described. It seems like the issue is that the API key is missing from the environment, which causes the client to throw a 401 Unauthorized when calling embeddings.
|
|
115
|
+
|
|
116
|
+
**After (full):**
|
|
117
|
+
> Fail = missing API key → embeddings 401 Unauthorized.
|
|
118
|
+
`;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import {
|
|
3
|
+
checkDeviceAuthorizationOnce,
|
|
4
|
+
type DeviceAuthConfig,
|
|
5
|
+
type DeviceAuthorizationStart,
|
|
6
|
+
resolveDeviceAuthConfig,
|
|
7
|
+
startDeviceAuthorization,
|
|
8
|
+
} from "../auth/device.ts";
|
|
9
|
+
import { saveStoredCredentials, setStoredCredentialsEnvToken } from "../credentials.ts";
|
|
10
|
+
import { toolText } from "./index-cache.ts";
|
|
11
|
+
import type { MiruMcpServer } from "./runtime.ts";
|
|
12
|
+
|
|
13
|
+
const AUTH_TOOL_DESCRIPTION =
|
|
14
|
+
"Sign in with Takara credentials via device-code login — no terminal required. " +
|
|
15
|
+
"Only call this in direct response to a tool error mentioning missing/expired " +
|
|
16
|
+
"credentials — never speculatively, since it starts a real sign-in prompt for the " +
|
|
17
|
+
'user. Call with no arguments (or action "start") to begin: it returns a URL and a ' +
|
|
18
|
+
"short code. Show both to the user and ask them to open the link and approve. Once " +
|
|
19
|
+
'they confirm, call again with action "check" to complete sign-in.';
|
|
20
|
+
|
|
21
|
+
type PendingDeviceAuth = {
|
|
22
|
+
start: DeviceAuthorizationStart;
|
|
23
|
+
config: DeviceAuthConfig;
|
|
24
|
+
startedAtMs: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function isExpired(entry: Pick<PendingDeviceAuth, "start" | "startedAtMs">): boolean {
|
|
28
|
+
return Date.now() >= entry.startedAtMs + entry.start.expiresIn * 1000;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Per-registration auth-tool state, scoped to one `registerAuthTool` call rather than
|
|
33
|
+
* the module — a process could in principle host more than one MiruMcpServer (tests
|
|
34
|
+
* already do), and a module-level singleton would let their device logins collide.
|
|
35
|
+
*/
|
|
36
|
+
class AuthToolState {
|
|
37
|
+
private pending: PendingDeviceAuth | null = null;
|
|
38
|
+
|
|
39
|
+
async start(): Promise<{ content: Array<{ type: "text"; text: string }> }> {
|
|
40
|
+
if (this.pending && !isExpired(this.pending)) {
|
|
41
|
+
const { userCode, verificationUriComplete, verificationUri } = this.pending.start;
|
|
42
|
+
return toolText(
|
|
43
|
+
`A device login is already pending. Open ${verificationUriComplete ?? verificationUri} ` +
|
|
44
|
+
`and enter code ${userCode} if not already filled in, then call \`auth\` again with ` +
|
|
45
|
+
'action "check" once approved.',
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
this.pending = null;
|
|
49
|
+
|
|
50
|
+
const config = resolveDeviceAuthConfig();
|
|
51
|
+
const start = await startDeviceAuthorization({ config });
|
|
52
|
+
this.pending = { start, config, startedAtMs: Date.now() };
|
|
53
|
+
|
|
54
|
+
const link = start.verificationUriComplete ?? start.verificationUri;
|
|
55
|
+
return toolText(
|
|
56
|
+
`Open ${link} and approve the request` +
|
|
57
|
+
(start.verificationUriComplete ? "." : ` (enter code ${start.userCode} if prompted).`) +
|
|
58
|
+
` Code: ${start.userCode}. Once the user confirms they've approved it, call \`auth\` again ` +
|
|
59
|
+
'with action "check" to finish signing in.',
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async check(): Promise<{ content: Array<{ type: "text"; text: string }> }> {
|
|
64
|
+
if (!this.pending) {
|
|
65
|
+
return toolText('No device login is pending. Call `auth` with action "start" first.');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const { start, config } = this.pending;
|
|
69
|
+
// Errors (network failure, unrecognized OAuth error code) intentionally leave
|
|
70
|
+
// `pending` intact — a transient failure shouldn't force the user to restart
|
|
71
|
+
// the whole login, just retry the check.
|
|
72
|
+
const result = await checkDeviceAuthorizationOnce(start, { config });
|
|
73
|
+
|
|
74
|
+
switch (result.status) {
|
|
75
|
+
case "success": {
|
|
76
|
+
this.pending = null;
|
|
77
|
+
const { tokens } = result;
|
|
78
|
+
await saveStoredCredentials({
|
|
79
|
+
kind: "device_code",
|
|
80
|
+
accessToken: tokens.accessToken,
|
|
81
|
+
refreshToken: tokens.refreshToken,
|
|
82
|
+
expiresAt: tokens.expiresAt,
|
|
83
|
+
tokenType: tokens.tokenType,
|
|
84
|
+
scope: tokens.scope,
|
|
85
|
+
});
|
|
86
|
+
setStoredCredentialsEnvToken(tokens.accessToken);
|
|
87
|
+
return toolText("Signed in successfully. Miru tools are now ready to use.");
|
|
88
|
+
}
|
|
89
|
+
case "pending":
|
|
90
|
+
return toolText(
|
|
91
|
+
'Still waiting for approval. Ask the user to confirm they clicked and approved, then call `auth` again with action "check".',
|
|
92
|
+
);
|
|
93
|
+
case "slow_down":
|
|
94
|
+
return toolText(
|
|
95
|
+
'Checking too soon — wait a bit before calling `auth` again with action "check".',
|
|
96
|
+
);
|
|
97
|
+
case "denied":
|
|
98
|
+
this.pending = null;
|
|
99
|
+
return toolText('Sign-in was denied. Call `auth` with action "start" to try again.');
|
|
100
|
+
case "expired":
|
|
101
|
+
this.pending = null;
|
|
102
|
+
return toolText(
|
|
103
|
+
'The device code expired before it was approved. Call `auth` with action "start" to try again.',
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function registerAuthTool(server: MiruMcpServer): void {
|
|
110
|
+
const state = new AuthToolState();
|
|
111
|
+
|
|
112
|
+
server.registerTool(
|
|
113
|
+
"auth",
|
|
114
|
+
{
|
|
115
|
+
description: AUTH_TOOL_DESCRIPTION,
|
|
116
|
+
inputSchema: {
|
|
117
|
+
action: z
|
|
118
|
+
.enum(["start", "check"])
|
|
119
|
+
.optional()
|
|
120
|
+
.describe('"start" begins a device-code login (default); "check" completes it.'),
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
async ({ action }) => {
|
|
124
|
+
try {
|
|
125
|
+
return action === "check" ? await state.check() : await state.start();
|
|
126
|
+
} catch (err) {
|
|
127
|
+
return toolText(err instanceof Error ? err.message : String(err));
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
}
|
package/src/mcp/server.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
MAX_MCP_TOP_K,
|
|
35
35
|
resolveChunk,
|
|
36
36
|
} from "../utils.ts";
|
|
37
|
+
import { registerAuthTool } from "./auth-tool.ts";
|
|
37
38
|
import {
|
|
38
39
|
formatExpandResultsText,
|
|
39
40
|
formatLiteralLocateText,
|
|
@@ -88,6 +89,8 @@ export function createMcpServer(
|
|
|
88
89
|
},
|
|
89
90
|
);
|
|
90
91
|
|
|
92
|
+
registerAuthTool(server);
|
|
93
|
+
|
|
91
94
|
server.registerTool(
|
|
92
95
|
"search",
|
|
93
96
|
{
|
package/src/prompt.ts
CHANGED
|
@@ -73,7 +73,10 @@ export async function promptText(message: string, defaultValue = ""): Promise<st
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
export async function promptHidden(
|
|
76
|
+
export async function promptHidden(
|
|
77
|
+
message: string,
|
|
78
|
+
stream: NodeJS.WriteStream = output,
|
|
79
|
+
): Promise<string> {
|
|
77
80
|
if (!input.isTTY) {
|
|
78
81
|
throw new Error(
|
|
79
82
|
"Cannot prompt for API key: stdin is not a TTY. Run `miru setup --key YOUR_KEY` or set TAKARA_API_KEY.",
|
|
@@ -81,7 +84,7 @@ export async function promptHidden(message: string): Promise<string> {
|
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
return new Promise((resolve, reject) => {
|
|
84
|
-
|
|
87
|
+
stream.write(message);
|
|
85
88
|
input.setRawMode?.(true);
|
|
86
89
|
input.resume();
|
|
87
90
|
input.setEncoding("utf8");
|
|
@@ -95,20 +98,20 @@ export async function promptHidden(message: string): Promise<string> {
|
|
|
95
98
|
|
|
96
99
|
if (result.cancel) {
|
|
97
100
|
cleanup();
|
|
98
|
-
|
|
101
|
+
stream.write("\n");
|
|
99
102
|
reject(new Error("Setup cancelled."));
|
|
100
103
|
process.exit(130);
|
|
101
104
|
}
|
|
102
105
|
|
|
103
106
|
if (result.submit) {
|
|
104
107
|
cleanup();
|
|
105
|
-
|
|
108
|
+
stream.write("\n");
|
|
106
109
|
resolve(state.value.trim());
|
|
107
110
|
return;
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
if (result.echo) {
|
|
111
|
-
|
|
114
|
+
stream.write(result.echo);
|
|
112
115
|
}
|
|
113
116
|
}
|
|
114
117
|
};
|