@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.
@@ -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
- const icon = path ? green("✓") : dim("–");
243
- writeStdout(` ${icon} ${integration.label.padEnd(13)} ${path ?? dim("(not supported)")}`);
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 integrationItems = INTEGRATIONS.map((integration) => ({
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 your API key from \`credentials.json\`.
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(message: string): Promise<string> {
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
- output.write(message);
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
- output.write("\n");
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
- output.write("\n");
108
+ stream.write("\n");
106
109
  resolve(state.value.trim());
107
110
  return;
108
111
  }
109
112
 
110
113
  if (result.echo) {
111
- output.write(result.echo);
114
+ stream.write(result.echo);
112
115
  }
113
116
  }
114
117
  };