@takara-ai/miru-code 1.6.0 → 1.7.3
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 +39 -15
- package/package.json +1 -1
- package/src/auth/device.ts +13 -5
- package/src/auth/errors.ts +24 -0
- package/src/auth/providers.ts +20 -24
- package/src/cli.ts +25 -3
- package/src/env.ts +3 -1
- package/src/help.ts +22 -10
- package/src/installer/agents.ts +116 -21
- package/src/installer/caveman-shared.ts +51 -0
- package/src/installer/config.ts +73 -0
- package/src/installer/copilot-family.ts +8 -0
- package/src/installer/hooks/install.ts +3 -14
- package/src/installer/installer.ts +279 -18
- package/src/installer/prompt.ts +15 -8
- package/src/installer/search-policy.ts +2 -1
- package/src/installer/shared-skill.ts +200 -0
- package/src/installer/style-packs/ste/skill.ts +218 -0
- package/src/mcp/auth-tool.ts +58 -28
- package/src/mcp/index-cache.ts +2 -1
- package/src/mcp/server.ts +5 -5
- package/src/spinner.ts +33 -6
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { mkdir, rmdir, unlink } from "node:fs/promises";
|
|
2
|
-
import { dirname } from "node:path";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
3
|
import { loadAgentTemplate } from "../agents.ts";
|
|
4
4
|
import { clearBenchmarkHistory } from "../benchmark/history.ts";
|
|
5
5
|
import { brandTitle, dim, divider, green, hint, info, success, writeStdout } from "../cli-ui.ts";
|
|
@@ -14,6 +14,14 @@ import {
|
|
|
14
14
|
} from "./agents.ts";
|
|
15
15
|
import { withPreservedBenchmarkFlag } from "./benchmark-mode.ts";
|
|
16
16
|
import {
|
|
17
|
+
ensureSharedCavemanOwnersOnInstall,
|
|
18
|
+
isSharedCavemanPath,
|
|
19
|
+
removeCavemanSkillFiles,
|
|
20
|
+
resolveSharedCavemanUninstall,
|
|
21
|
+
} from "./caveman-shared.ts";
|
|
22
|
+
import {
|
|
23
|
+
codexSkillsFeatureEnabled,
|
|
24
|
+
ensureCodexSkillsFeature,
|
|
17
25
|
mergeJsonMember,
|
|
18
26
|
mergeTomlBlock,
|
|
19
27
|
removeJsonMember,
|
|
@@ -25,14 +33,44 @@ import {
|
|
|
25
33
|
import { mergeHooks, removeHooks } from "./hooks/install.ts";
|
|
26
34
|
import { promptConfirm, promptMultiSelect, requireInteractiveTerminal } from "./prompt.ts";
|
|
27
35
|
import { CURSOR_RULES_MDC } from "./search-policy.ts";
|
|
36
|
+
import {
|
|
37
|
+
ensureSharedSkillOwnersOnInstall,
|
|
38
|
+
isSharedSkillPath,
|
|
39
|
+
removeSkillMdAndOwners,
|
|
40
|
+
resolveSharedSkillUninstall,
|
|
41
|
+
type SkillPathOf,
|
|
42
|
+
} from "./shared-skill.ts";
|
|
28
43
|
import { CAVEMAN_SKILL_MD } from "./style-packs/caveman.ts";
|
|
44
|
+
import { STE_REFERENCE_FILES, STE_SKILL_MD } from "./style-packs/ste/skill.ts";
|
|
45
|
+
|
|
46
|
+
const steSkillPathOf: SkillPathOf = (agent) =>
|
|
47
|
+
agent.steSkillDir ? join(agent.steSkillDir, "SKILL.md") : null;
|
|
29
48
|
|
|
30
49
|
export interface WriteResult {
|
|
31
50
|
path: string;
|
|
32
51
|
action: InstallAction;
|
|
52
|
+
note?: string;
|
|
33
53
|
}
|
|
34
54
|
|
|
35
|
-
export type IntegrationId =
|
|
55
|
+
export type IntegrationId =
|
|
56
|
+
| "mcp"
|
|
57
|
+
| "instructions"
|
|
58
|
+
| "subagent"
|
|
59
|
+
| "hooks"
|
|
60
|
+
| "rules"
|
|
61
|
+
| "caveman"
|
|
62
|
+
| "ste";
|
|
63
|
+
|
|
64
|
+
/** Shared context for one install/uninstall pass (path dedupe + shared-skill keep). */
|
|
65
|
+
export interface ApplyCtx {
|
|
66
|
+
selectedAgents: AgentTarget[];
|
|
67
|
+
cavemanSeenPaths: Set<string>;
|
|
68
|
+
steSeenPaths: Set<string>;
|
|
69
|
+
/** Defaults to AGENT_TARGETS; override in tests. */
|
|
70
|
+
allAgents?: AgentTarget[];
|
|
71
|
+
/** Defaults to isAgentDetected; override in tests. */
|
|
72
|
+
isDetected?: (agent: AgentTarget) => Promise<boolean>;
|
|
73
|
+
}
|
|
36
74
|
|
|
37
75
|
interface Integration {
|
|
38
76
|
id: IntegrationId;
|
|
@@ -41,7 +79,7 @@ interface Integration {
|
|
|
41
79
|
experimental?: boolean;
|
|
42
80
|
defaultChecked?: boolean;
|
|
43
81
|
planPath: (agent: AgentTarget) => string | null;
|
|
44
|
-
apply: (agent: AgentTarget, mode: InstallMode) => Promise<WriteResult | null>;
|
|
82
|
+
apply: (agent: AgentTarget, mode: InstallMode, ctx?: ApplyCtx) => Promise<WriteResult | null>;
|
|
45
83
|
}
|
|
46
84
|
|
|
47
85
|
const ACTION_DETAIL: Partial<Record<InstallAction, string>> = {
|
|
@@ -185,31 +223,200 @@ async function applySubagent(agent: AgentTarget, mode: InstallMode): Promise<Wri
|
|
|
185
223
|
}
|
|
186
224
|
}
|
|
187
225
|
|
|
188
|
-
async function
|
|
226
|
+
async function withCodexSkillsFeature(
|
|
227
|
+
agent: AgentTarget,
|
|
228
|
+
result: WriteResult,
|
|
229
|
+
): Promise<WriteResult> {
|
|
230
|
+
if (agent.id !== "codex" || !agent.mcp?.path) {
|
|
231
|
+
return result;
|
|
232
|
+
}
|
|
233
|
+
const featureAction = await ensureCodexSkillsFeature(agent.mcp.path);
|
|
234
|
+
if (featureAction === "unchanged") {
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
const skillsNote = "enabled [features] skills = true in config.toml";
|
|
238
|
+
if (result.action === "unchanged") {
|
|
239
|
+
return {
|
|
240
|
+
...result,
|
|
241
|
+
action: "updated",
|
|
242
|
+
note: result.note ? `${result.note}; ${skillsNote}` : skillsNote,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
...result,
|
|
247
|
+
note: result.note
|
|
248
|
+
? `${result.note}; also sets [features] skills = true in config.toml`
|
|
249
|
+
: "also sets [features] skills = true in config.toml",
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function applyCaveman(
|
|
254
|
+
agent: AgentTarget,
|
|
255
|
+
mode: InstallMode,
|
|
256
|
+
ctx: ApplyCtx = {
|
|
257
|
+
selectedAgents: [agent],
|
|
258
|
+
cavemanSeenPaths: new Set(),
|
|
259
|
+
steSeenPaths: new Set(),
|
|
260
|
+
},
|
|
261
|
+
): Promise<WriteResult | null> {
|
|
189
262
|
const path = agent.cavemanSkillPath;
|
|
190
263
|
if (!path) {
|
|
191
264
|
return null;
|
|
192
265
|
}
|
|
193
266
|
|
|
194
267
|
const skillDir = dirname(path);
|
|
268
|
+
const allAgents = ctx.allAgents ?? AGENT_TARGETS;
|
|
269
|
+
const shared = isSharedCavemanPath(path, allAgents);
|
|
270
|
+
const sharedCtx = {
|
|
271
|
+
selectedAgents: ctx.selectedAgents,
|
|
272
|
+
allAgents: ctx.allAgents,
|
|
273
|
+
isDetected: ctx.isDetected,
|
|
274
|
+
};
|
|
195
275
|
|
|
196
276
|
if (mode === "uninstall") {
|
|
197
|
-
if (
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
await rmdir(skillDir);
|
|
203
|
-
} catch {
|
|
204
|
-
// Directory not empty or already gone — leave other skills alone.
|
|
277
|
+
if (shared) {
|
|
278
|
+
const decision = await resolveSharedCavemanUninstall(path, skillDir, agent, sharedCtx);
|
|
279
|
+
if (decision.kind === "defer" || decision.kind === "keep") {
|
|
280
|
+
return { path, action: "unchanged", note: decision.note };
|
|
281
|
+
}
|
|
205
282
|
}
|
|
206
|
-
return { path, action:
|
|
283
|
+
return { path, action: await removeCavemanSkillFiles(path, skillDir) };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (shared) {
|
|
287
|
+
await ensureSharedCavemanOwnersOnInstall(skillDir, path, agent, sharedCtx);
|
|
207
288
|
}
|
|
208
289
|
|
|
290
|
+
if (ctx.cavemanSeenPaths.has(path)) {
|
|
291
|
+
return withCodexSkillsFeature(agent, {
|
|
292
|
+
path,
|
|
293
|
+
action: "unchanged",
|
|
294
|
+
note: "shared skill path already written",
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
ctx.cavemanSeenPaths.add(path);
|
|
298
|
+
|
|
209
299
|
const existed = await Bun.file(path).exists();
|
|
300
|
+
if (existed && (await Bun.file(path).text()) === CAVEMAN_SKILL_MD) {
|
|
301
|
+
return withCodexSkillsFeature(agent, { path, action: "unchanged" });
|
|
302
|
+
}
|
|
303
|
+
|
|
210
304
|
await mkdir(skillDir, { recursive: true });
|
|
211
305
|
await Bun.write(path, CAVEMAN_SKILL_MD);
|
|
212
|
-
return
|
|
306
|
+
return withCodexSkillsFeature(agent, {
|
|
307
|
+
path,
|
|
308
|
+
action: existed ? "updated" : "created",
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function stePackMatches(skillDir: string): Promise<boolean> {
|
|
313
|
+
const skillPath = join(skillDir, "SKILL.md");
|
|
314
|
+
if (!(await Bun.file(skillPath).exists())) {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
if ((await Bun.file(skillPath).text()) !== STE_SKILL_MD) {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
for (const file of STE_REFERENCE_FILES) {
|
|
321
|
+
const path = join(skillDir, file.relativePath);
|
|
322
|
+
if (!(await Bun.file(path).exists()) || (await Bun.file(path).text()) !== file.content) {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function removeSteSkillFiles(
|
|
330
|
+
skillDir: string,
|
|
331
|
+
skillPath: string,
|
|
332
|
+
): Promise<Extract<InstallAction, "removed" | "not-found">> {
|
|
333
|
+
let removedReference = false;
|
|
334
|
+
for (const file of STE_REFERENCE_FILES) {
|
|
335
|
+
const referencePath = join(skillDir, file.relativePath);
|
|
336
|
+
if (await Bun.file(referencePath).exists()) {
|
|
337
|
+
await unlink(referencePath);
|
|
338
|
+
removedReference = true;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Match Caveman's ownership model: remove Miru's known files only. Leave
|
|
343
|
+
// any user-created files in this skill directory untouched.
|
|
344
|
+
try {
|
|
345
|
+
await rmdir(join(skillDir, "references"));
|
|
346
|
+
} catch {
|
|
347
|
+
// User files may keep the directory non-empty.
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const skillAction = await removeSkillMdAndOwners(skillPath, skillDir);
|
|
351
|
+
return removedReference && skillAction === "not-found" ? "removed" : skillAction;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function applySte(
|
|
355
|
+
agent: AgentTarget,
|
|
356
|
+
mode: InstallMode,
|
|
357
|
+
ctx: ApplyCtx = {
|
|
358
|
+
selectedAgents: [agent],
|
|
359
|
+
cavemanSeenPaths: new Set(),
|
|
360
|
+
steSeenPaths: new Set(),
|
|
361
|
+
},
|
|
362
|
+
): Promise<WriteResult | null> {
|
|
363
|
+
const skillDir = agent.steSkillDir;
|
|
364
|
+
if (!skillDir) {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const skillPath = join(skillDir, "SKILL.md");
|
|
369
|
+
const allAgents = ctx.allAgents ?? AGENT_TARGETS;
|
|
370
|
+
const shared = isSharedSkillPath(skillPath, allAgents, steSkillPathOf);
|
|
371
|
+
const sharedCtx = {
|
|
372
|
+
selectedAgents: ctx.selectedAgents,
|
|
373
|
+
allAgents: ctx.allAgents,
|
|
374
|
+
isDetected: ctx.isDetected,
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
if (mode === "uninstall") {
|
|
378
|
+
if (shared) {
|
|
379
|
+
const decision = await resolveSharedSkillUninstall(
|
|
380
|
+
skillPath,
|
|
381
|
+
skillDir,
|
|
382
|
+
agent,
|
|
383
|
+
sharedCtx,
|
|
384
|
+
steSkillPathOf,
|
|
385
|
+
);
|
|
386
|
+
if (decision.kind === "defer" || decision.kind === "keep") {
|
|
387
|
+
return { path: skillPath, action: "unchanged", note: decision.note };
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return { path: skillPath, action: await removeSteSkillFiles(skillDir, skillPath) };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (shared) {
|
|
394
|
+
await ensureSharedSkillOwnersOnInstall(skillDir, skillPath, agent, sharedCtx, steSkillPathOf);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (ctx.steSeenPaths.has(skillPath)) {
|
|
398
|
+
return withCodexSkillsFeature(agent, {
|
|
399
|
+
path: skillPath,
|
|
400
|
+
action: "unchanged",
|
|
401
|
+
note: "shared skill path already written",
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
ctx.steSeenPaths.add(skillPath);
|
|
405
|
+
|
|
406
|
+
const existed = await Bun.file(skillPath).exists();
|
|
407
|
+
if (existed && (await stePackMatches(skillDir))) {
|
|
408
|
+
return withCodexSkillsFeature(agent, { path: skillPath, action: "unchanged" });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
await mkdir(join(skillDir, "references"), { recursive: true });
|
|
412
|
+
await Bun.write(skillPath, STE_SKILL_MD);
|
|
413
|
+
for (const file of STE_REFERENCE_FILES) {
|
|
414
|
+
await Bun.write(join(skillDir, file.relativePath), file.content);
|
|
415
|
+
}
|
|
416
|
+
return withCodexSkillsFeature(agent, {
|
|
417
|
+
path: skillPath,
|
|
418
|
+
action: existed ? "updated" : "created",
|
|
419
|
+
});
|
|
213
420
|
}
|
|
214
421
|
|
|
215
422
|
const INTEGRATIONS: Integration[] = [
|
|
@@ -259,6 +466,15 @@ const INTEGRATIONS: Integration[] = [
|
|
|
259
466
|
planPath: (agent) => agent.cavemanSkillPath,
|
|
260
467
|
apply: applyCaveman,
|
|
261
468
|
},
|
|
469
|
+
{
|
|
470
|
+
id: "ste",
|
|
471
|
+
label: "STE writing",
|
|
472
|
+
description: "on-demand clear technical English for docs (/ste)",
|
|
473
|
+
experimental: true,
|
|
474
|
+
defaultChecked: false,
|
|
475
|
+
planPath: (agent) => (agent.steSkillDir ? join(agent.steSkillDir, "SKILL.md") : null),
|
|
476
|
+
apply: applySte,
|
|
477
|
+
},
|
|
262
478
|
];
|
|
263
479
|
|
|
264
480
|
function integrationApplies(integration: Integration, agent: AgentTarget): boolean {
|
|
@@ -273,24 +489,62 @@ function integrationsForAgents(agents: AgentTarget[]): Integration[] {
|
|
|
273
489
|
|
|
274
490
|
function formatActionLine(integration: Integration, result: WriteResult): string {
|
|
275
491
|
const icon = ACTION_ICON[result.action] ?? dim("·");
|
|
276
|
-
const detail = ACTION_DETAIL[result.action];
|
|
492
|
+
const detail = result.note ?? ACTION_DETAIL[result.action];
|
|
277
493
|
const suffix = detail ? dim(` — ${detail}`) : "";
|
|
278
494
|
return ` ${icon} ${integration.label.padEnd(13)} ${dim(result.action)}${suffix}\n ${dim(result.path)}`;
|
|
279
495
|
}
|
|
280
496
|
|
|
281
|
-
|
|
497
|
+
/** Plan footnote for Codex + skills (Caveman / STE); null when nothing should be shown. */
|
|
498
|
+
export function codexSkillsPlanNote(
|
|
499
|
+
mode: InstallMode,
|
|
500
|
+
skillsFeatureEnabled: boolean,
|
|
501
|
+
): string | null {
|
|
502
|
+
if (mode === "install") {
|
|
503
|
+
return "also sets [features] skills = true in ~/.codex/config.toml";
|
|
504
|
+
}
|
|
505
|
+
if (skillsFeatureEnabled) {
|
|
506
|
+
return "leaves [features] skills = true in ~/.codex/config.toml";
|
|
507
|
+
}
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** @deprecated Prefer codexSkillsPlanNote. */
|
|
512
|
+
export const codexCavemanPlanNote = codexSkillsPlanNote;
|
|
513
|
+
|
|
514
|
+
async function printPlan(
|
|
515
|
+
mode: InstallMode,
|
|
516
|
+
agents: AgentTarget[],
|
|
517
|
+
integrations: Integration[],
|
|
518
|
+
): Promise<void> {
|
|
282
519
|
writeStdout("");
|
|
283
520
|
writeStdout(dim("Plan"));
|
|
284
521
|
divider();
|
|
285
522
|
|
|
286
523
|
for (const agent of agents) {
|
|
287
524
|
writeStdout(` ${agent.displayName}`);
|
|
525
|
+
let printedCodexSkillsNote = false;
|
|
288
526
|
for (const integration of integrations) {
|
|
289
527
|
const path = integration.planPath(agent);
|
|
290
528
|
if (!path) {
|
|
291
529
|
continue;
|
|
292
530
|
}
|
|
293
531
|
writeStdout(` ${green("✓")} ${integration.label.padEnd(13)} ${path}`);
|
|
532
|
+
if (
|
|
533
|
+
!printedCodexSkillsNote &&
|
|
534
|
+
(integration.id === "caveman" || integration.id === "ste") &&
|
|
535
|
+
agent.id === "codex"
|
|
536
|
+
) {
|
|
537
|
+
let skillsEnabled = false;
|
|
538
|
+
const configPath = agent.mcp?.path;
|
|
539
|
+
if (mode === "uninstall" && configPath && (await Bun.file(configPath).exists())) {
|
|
540
|
+
skillsEnabled = codexSkillsFeatureEnabled(await Bun.file(configPath).text());
|
|
541
|
+
}
|
|
542
|
+
const note = codexSkillsPlanNote(mode, skillsEnabled);
|
|
543
|
+
if (note) {
|
|
544
|
+
writeStdout(` ${dim(note)}`);
|
|
545
|
+
printedCodexSkillsNote = true;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
294
548
|
}
|
|
295
549
|
}
|
|
296
550
|
writeStdout("");
|
|
@@ -305,13 +559,19 @@ async function apply(
|
|
|
305
559
|
writeStdout(dim(mode === "install" ? "Installing" : "Removing"));
|
|
306
560
|
divider();
|
|
307
561
|
|
|
562
|
+
const ctx: ApplyCtx = {
|
|
563
|
+
selectedAgents: agents,
|
|
564
|
+
cavemanSeenPaths: new Set(),
|
|
565
|
+
steSeenPaths: new Set(),
|
|
566
|
+
};
|
|
567
|
+
|
|
308
568
|
for (const agent of agents) {
|
|
309
569
|
writeStdout(` ${agent.displayName}`);
|
|
310
570
|
for (const integration of integrations) {
|
|
311
571
|
if (!integrationApplies(integration, agent)) {
|
|
312
572
|
continue;
|
|
313
573
|
}
|
|
314
|
-
const result = await integration.apply(agent, mode);
|
|
574
|
+
const result = await integration.apply(agent, mode, ctx);
|
|
315
575
|
if (!result) {
|
|
316
576
|
continue;
|
|
317
577
|
}
|
|
@@ -391,7 +651,7 @@ export async function runInstaller(mode: InstallMode): Promise<void> {
|
|
|
391
651
|
return;
|
|
392
652
|
}
|
|
393
653
|
|
|
394
|
-
printPlan(chosenAgents, chosenIntegrations);
|
|
654
|
+
await printPlan(mode, chosenAgents, chosenIntegrations);
|
|
395
655
|
|
|
396
656
|
const proceed = await promptConfirm(install ? "Proceed?" : "Remove miru configuration?", install);
|
|
397
657
|
if (!proceed) {
|
|
@@ -417,6 +677,7 @@ export {
|
|
|
417
677
|
applyHooks,
|
|
418
678
|
applyInstructions,
|
|
419
679
|
applyMcp,
|
|
680
|
+
applySte,
|
|
420
681
|
applySubagent,
|
|
421
682
|
INTEGRATIONS,
|
|
422
683
|
integrationsForAgents,
|
package/src/installer/prompt.ts
CHANGED
|
@@ -163,6 +163,17 @@ export function parseInstallerKeyForTest(chunk: Buffer): string {
|
|
|
163
163
|
return parseKey(chunk);
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
/** Map confirm keys to the next Yes selection. Left/← and y → Yes; right/→ and n → No. */
|
|
167
|
+
export function nextConfirmYesSelected(yesSelected: boolean, key: string): boolean {
|
|
168
|
+
if (key === "left" || key === "yes") {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
if (key === "right" || key === "no") {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
return yesSelected;
|
|
175
|
+
}
|
|
176
|
+
|
|
166
177
|
async function withRawMode<T>(fn: () => Promise<T>): Promise<T> {
|
|
167
178
|
input.setRawMode(true);
|
|
168
179
|
input.resume();
|
|
@@ -328,14 +339,10 @@ export async function promptConfirm(question: string, defaultYes = true): Promis
|
|
|
328
339
|
writeRaw("\n");
|
|
329
340
|
return yesSelected;
|
|
330
341
|
}
|
|
331
|
-
if (key === "left" || key === "no") {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
}
|
|
336
|
-
} else if (key === "right" || key === "yes") {
|
|
337
|
-
if (!yesSelected) {
|
|
338
|
-
yesSelected = true;
|
|
342
|
+
if (key === "left" || key === "right" || key === "yes" || key === "no") {
|
|
343
|
+
const next = nextConfirmYesSelected(yesSelected, key);
|
|
344
|
+
if (next !== yesSelected) {
|
|
345
|
+
yesSelected = next;
|
|
339
346
|
updateConfirmPromptLine(question, yesSelected);
|
|
340
347
|
}
|
|
341
348
|
}
|
|
@@ -123,7 +123,8 @@ export const MCP_SERVER_INSTRUCTIONS =
|
|
|
123
123
|
"(3) `find_related` traces similar code in other files, not more context in the same file. " +
|
|
124
124
|
"(4) `locate` finds exact substrings (env vars, symbols, error codes) — prefer over Grep; use mode=count or locations when possible. " +
|
|
125
125
|
"Always pass the project root as `repo`. Local repos return `absolute_path` on each hit — use Read only to edit. " +
|
|
126
|
-
"Native Grep/Glob only outside the indexed repo or for non-code tasks."
|
|
126
|
+
"Native Grep/Glob only outside the indexed repo or for non-code tasks. " +
|
|
127
|
+
"On credential errors, call this server's `auth` tool — not the host `mcp_auth` tool.";
|
|
127
128
|
|
|
128
129
|
export const MCP_BENCHMARK_SERVER_INSTRUCTIONS =
|
|
129
130
|
`${MCP_SERVER_INSTRUCTIONS} ` +
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { mkdir, rmdir, unlink } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { AGENT_TARGETS, type AgentTarget, type InstallAction, isAgentDetected } from "./agents.ts";
|
|
4
|
+
|
|
5
|
+
/** Sidecar next to a shared Agent Skill (`SKILL.md`) tracking which IDEs own it. */
|
|
6
|
+
export const SKILL_OWNERS_FILE = "miru-owners.json";
|
|
7
|
+
|
|
8
|
+
/** Context for shared-path skill install/uninstall (Caveman, STE, …). */
|
|
9
|
+
export interface SharedSkillCtx {
|
|
10
|
+
selectedAgents: AgentTarget[];
|
|
11
|
+
allAgents?: AgentTarget[];
|
|
12
|
+
isDetected?: (agent: AgentTarget) => Promise<boolean>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type SkillPathOf = (agent: AgentTarget) => string | null;
|
|
16
|
+
|
|
17
|
+
function uniqueIds(ids: Iterable<string>): string[] {
|
|
18
|
+
return Array.from(new Set(ids));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function resolvedAgents(ctx: SharedSkillCtx): AgentTarget[] {
|
|
22
|
+
return ctx.allAgents ?? AGENT_TARGETS;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function agentsSharingPath(
|
|
26
|
+
path: string,
|
|
27
|
+
allAgents: AgentTarget[],
|
|
28
|
+
pathOf: SkillPathOf,
|
|
29
|
+
): AgentTarget[] {
|
|
30
|
+
return allAgents.filter((agent) => pathOf(agent) === path);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isSharedSkillPath(
|
|
34
|
+
path: string,
|
|
35
|
+
allAgents: AgentTarget[],
|
|
36
|
+
pathOf: SkillPathOf,
|
|
37
|
+
): boolean {
|
|
38
|
+
return agentsSharingPath(path, allAgents, pathOf).length > 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function readSkillOwners(skillDir: string): Promise<string[] | null> {
|
|
42
|
+
const ownersPath = join(skillDir, SKILL_OWNERS_FILE);
|
|
43
|
+
if (!(await Bun.file(ownersPath).exists())) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(await Bun.file(ownersPath).text()) as unknown;
|
|
48
|
+
if (!Array.isArray(parsed)) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return parsed.filter((item): item is string => typeof item === "string");
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function writeSkillOwners(skillDir: string, owners: string[]): Promise<void> {
|
|
58
|
+
await mkdir(skillDir, { recursive: true });
|
|
59
|
+
await Bun.write(
|
|
60
|
+
join(skillDir, SKILL_OWNERS_FILE),
|
|
61
|
+
`${JSON.stringify([...owners].sort(), null, 2)}\n`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Stamp shared-path ownership on install.
|
|
67
|
+
* - Existing owners file: add current agent.
|
|
68
|
+
* - Fresh skill: start with current agent only.
|
|
69
|
+
* - Legacy skill (file exists, no owners): seed from detected siblings + current;
|
|
70
|
+
* skip writing a singleton so uninstall stays on the legacy keep path.
|
|
71
|
+
*/
|
|
72
|
+
export async function ensureSharedSkillOwnersOnInstall(
|
|
73
|
+
skillDir: string,
|
|
74
|
+
skillPath: string,
|
|
75
|
+
agent: AgentTarget,
|
|
76
|
+
ctx: SharedSkillCtx,
|
|
77
|
+
pathOf: SkillPathOf,
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
const existing = await readSkillOwners(skillDir);
|
|
80
|
+
if (existing !== null) {
|
|
81
|
+
await writeSkillOwners(skillDir, uniqueIds([...existing, agent.id]));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!(await Bun.file(skillPath).exists())) {
|
|
86
|
+
await writeSkillOwners(skillDir, [agent.id]);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const detect = ctx.isDetected ?? isAgentDetected;
|
|
91
|
+
const seeded: string[] = [];
|
|
92
|
+
for (const member of agentsSharingPath(skillPath, resolvedAgents(ctx), pathOf)) {
|
|
93
|
+
if (member.id === agent.id || (await detect(member))) {
|
|
94
|
+
seeded.push(member.id);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const unique = uniqueIds(seeded);
|
|
98
|
+
if (unique.length <= 1) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
await writeSkillOwners(skillDir, unique);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function shouldKeepSharedSkill(
|
|
105
|
+
path: string,
|
|
106
|
+
agent: AgentTarget,
|
|
107
|
+
ctx: SharedSkillCtx,
|
|
108
|
+
pathOf: SkillPathOf,
|
|
109
|
+
): Promise<boolean> {
|
|
110
|
+
const detect = ctx.isDetected ?? isAgentDetected;
|
|
111
|
+
const siblings = agentsSharingPath(path, resolvedAgents(ctx), pathOf).filter(
|
|
112
|
+
(other) => other.id !== agent.id,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
for (const sibling of siblings) {
|
|
116
|
+
if (ctx.selectedAgents.some((selected) => selected.id === sibling.id)) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (await detect(sibling)) {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export type SharedSkillUninstallDecision =
|
|
127
|
+
| { kind: "defer"; note: string }
|
|
128
|
+
| { kind: "keep"; note: string }
|
|
129
|
+
| { kind: "remove" };
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Decide whether a shared-path skill uninstall should defer, keep, or remove.
|
|
133
|
+
* Caller removes files only on `remove`.
|
|
134
|
+
*/
|
|
135
|
+
export async function resolveSharedSkillUninstall(
|
|
136
|
+
path: string,
|
|
137
|
+
skillDir: string,
|
|
138
|
+
agent: AgentTarget,
|
|
139
|
+
ctx: SharedSkillCtx,
|
|
140
|
+
pathOf: SkillPathOf,
|
|
141
|
+
): Promise<SharedSkillUninstallDecision> {
|
|
142
|
+
const selectedSharing = agentsSharingPath(path, ctx.selectedAgents, pathOf);
|
|
143
|
+
const isLast =
|
|
144
|
+
selectedSharing.length === 0 || selectedSharing[selectedSharing.length - 1]?.id === agent.id;
|
|
145
|
+
if (!isLast) {
|
|
146
|
+
return {
|
|
147
|
+
kind: "defer",
|
|
148
|
+
note: "shared skill; remove deferred to last selected IDE",
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const owners = await readSkillOwners(skillDir);
|
|
153
|
+
if (owners !== null) {
|
|
154
|
+
const removing = new Set<string>(selectedSharing.map((selected) => selected.id));
|
|
155
|
+
const remaining = owners.filter((ownerId) => !removing.has(ownerId));
|
|
156
|
+
if (remaining.length > 0) {
|
|
157
|
+
await writeSkillOwners(skillDir, remaining);
|
|
158
|
+
return {
|
|
159
|
+
kind: "keep",
|
|
160
|
+
note: "shared skill kept — still owned by another IDE",
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return { kind: "remove" };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (await shouldKeepSharedSkill(path, agent, ctx, pathOf)) {
|
|
167
|
+
return {
|
|
168
|
+
kind: "keep",
|
|
169
|
+
note: "shared skill kept — another IDE still uses this path",
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return { kind: "remove" };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Remove SKILL.md and owners sidecar; leave sibling skill folders untouched. */
|
|
176
|
+
export async function removeSkillMdAndOwners(
|
|
177
|
+
path: string,
|
|
178
|
+
skillDir: string,
|
|
179
|
+
): Promise<Extract<InstallAction, "removed" | "not-found">> {
|
|
180
|
+
let removedSomething = false;
|
|
181
|
+
|
|
182
|
+
if (await Bun.file(path).exists()) {
|
|
183
|
+
await unlink(path);
|
|
184
|
+
removedSomething = true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const ownersPath = join(skillDir, SKILL_OWNERS_FILE);
|
|
188
|
+
if (await Bun.file(ownersPath).exists()) {
|
|
189
|
+
await unlink(ownersPath);
|
|
190
|
+
removedSomething = true;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
await rmdir(skillDir);
|
|
195
|
+
} catch {
|
|
196
|
+
// Directory not empty or already gone — leave other skills alone.
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return removedSomething ? "removed" : "not-found";
|
|
200
|
+
}
|