@takara-ai/miru-code 1.5.3 → 1.7.2

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.
@@ -1,4 +1,5 @@
1
- import { unlink } from "node:fs/promises";
1
+ import { mkdir, rmdir, unlink } from "node:fs/promises";
2
+ import { dirname, join } 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";
@@ -13,6 +14,14 @@ import {
13
14
  } from "./agents.ts";
14
15
  import { withPreservedBenchmarkFlag } from "./benchmark-mode.ts";
15
16
  import {
17
+ ensureSharedCavemanOwnersOnInstall,
18
+ isSharedCavemanPath,
19
+ removeCavemanSkillFiles,
20
+ resolveSharedCavemanUninstall,
21
+ } from "./caveman-shared.ts";
22
+ import {
23
+ codexSkillsFeatureEnabled,
24
+ ensureCodexSkillsFeature,
16
25
  mergeJsonMember,
17
26
  mergeTomlBlock,
18
27
  removeJsonMember,
@@ -24,13 +33,44 @@ import {
24
33
  import { mergeHooks, removeHooks } from "./hooks/install.ts";
25
34
  import { promptConfirm, promptMultiSelect, requireInteractiveTerminal } from "./prompt.ts";
26
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";
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;
27
48
 
28
49
  export interface WriteResult {
29
50
  path: string;
30
51
  action: InstallAction;
52
+ note?: string;
31
53
  }
32
54
 
33
- export type IntegrationId = "mcp" | "instructions" | "subagent" | "hooks" | "rules";
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
+ }
34
74
 
35
75
  interface Integration {
36
76
  id: IntegrationId;
@@ -39,7 +79,7 @@ interface Integration {
39
79
  experimental?: boolean;
40
80
  defaultChecked?: boolean;
41
81
  planPath: (agent: AgentTarget) => string | null;
42
- apply: (agent: AgentTarget, mode: InstallMode) => Promise<WriteResult | null>;
82
+ apply: (agent: AgentTarget, mode: InstallMode, ctx?: ApplyCtx) => Promise<WriteResult | null>;
43
83
  }
44
84
 
45
85
  const ACTION_DETAIL: Partial<Record<InstallAction, string>> = {
@@ -183,6 +223,202 @@ async function applySubagent(agent: AgentTarget, mode: InstallMode): Promise<Wri
183
223
  }
184
224
  }
185
225
 
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> {
262
+ const path = agent.cavemanSkillPath;
263
+ if (!path) {
264
+ return null;
265
+ }
266
+
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
+ };
275
+
276
+ if (mode === "uninstall") {
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
+ }
282
+ }
283
+ return { path, action: await removeCavemanSkillFiles(path, skillDir) };
284
+ }
285
+
286
+ if (shared) {
287
+ await ensureSharedCavemanOwnersOnInstall(skillDir, path, agent, sharedCtx);
288
+ }
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
+
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
+
304
+ await mkdir(skillDir, { recursive: true });
305
+ await Bun.write(path, CAVEMAN_SKILL_MD);
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
+ });
420
+ }
421
+
186
422
  const INTEGRATIONS: Integration[] = [
187
423
  {
188
424
  id: "mcp",
@@ -221,26 +457,94 @@ const INTEGRATIONS: Integration[] = [
221
457
  planPath: (agent) => agent.hooksPath,
222
458
  apply: applyHooks,
223
459
  },
460
+ {
461
+ id: "caveman",
462
+ label: "Caveman",
463
+ description: "on-demand chat compression skill (/caveman)",
464
+ experimental: true,
465
+ defaultChecked: false,
466
+ planPath: (agent) => agent.cavemanSkillPath,
467
+ apply: applyCaveman,
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
+ },
224
478
  ];
225
479
 
480
+ function integrationApplies(integration: Integration, agent: AgentTarget): boolean {
481
+ return integration.planPath(agent) !== null;
482
+ }
483
+
484
+ function integrationsForAgents(agents: AgentTarget[]): Integration[] {
485
+ return INTEGRATIONS.filter((integration) =>
486
+ agents.some((agent) => integrationApplies(integration, agent)),
487
+ );
488
+ }
489
+
226
490
  function formatActionLine(integration: Integration, result: WriteResult): string {
227
491
  const icon = ACTION_ICON[result.action] ?? dim("·");
228
- const detail = ACTION_DETAIL[result.action];
492
+ const detail = result.note ?? ACTION_DETAIL[result.action];
229
493
  const suffix = detail ? dim(` — ${detail}`) : "";
230
494
  return ` ${icon} ${integration.label.padEnd(13)} ${dim(result.action)}${suffix}\n ${dim(result.path)}`;
231
495
  }
232
496
 
233
- function printPlan(agents: AgentTarget[], integrations: Integration[]): void {
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> {
234
519
  writeStdout("");
235
520
  writeStdout(dim("Plan"));
236
521
  divider();
237
522
 
238
523
  for (const agent of agents) {
239
524
  writeStdout(` ${agent.displayName}`);
525
+ let printedCodexSkillsNote = false;
240
526
  for (const integration of integrations) {
241
527
  const path = integration.planPath(agent);
242
- const icon = path ? green("✓") : dim("–");
243
- writeStdout(` ${icon} ${integration.label.padEnd(13)} ${path ?? dim("(not supported)")}`);
528
+ if (!path) {
529
+ continue;
530
+ }
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
+ }
244
548
  }
245
549
  }
246
550
  writeStdout("");
@@ -255,12 +559,20 @@ async function apply(
255
559
  writeStdout(dim(mode === "install" ? "Installing" : "Removing"));
256
560
  divider();
257
561
 
562
+ const ctx: ApplyCtx = {
563
+ selectedAgents: agents,
564
+ cavemanSeenPaths: new Set(),
565
+ steSeenPaths: new Set(),
566
+ };
567
+
258
568
  for (const agent of agents) {
259
569
  writeStdout(` ${agent.displayName}`);
260
570
  for (const integration of integrations) {
261
- const result = await integration.apply(agent, mode);
571
+ if (!integrationApplies(integration, agent)) {
572
+ continue;
573
+ }
574
+ const result = await integration.apply(agent, mode, ctx);
262
575
  if (!result) {
263
- writeStdout(` ${dim("–")} ${integration.id.padEnd(13)} ${dim("not supported")}`);
264
576
  continue;
265
577
  }
266
578
  writeStdout(formatActionLine(integration, result));
@@ -317,7 +629,13 @@ export async function runInstaller(mode: InstallMode): Promise<void> {
317
629
  return;
318
630
  }
319
631
 
320
- const integrationItems = INTEGRATIONS.map((integration) => ({
632
+ const availableIntegrations = integrationsForAgents(chosenAgents);
633
+ if (availableIntegrations.length === 0) {
634
+ hint("No integrations apply to the selected agents. Exiting.");
635
+ return;
636
+ }
637
+
638
+ const integrationItems = availableIntegrations.map((integration) => ({
321
639
  label: `${integration.label}${integration.experimental ? dim(" (experimental)") : ""} — ${integration.description}`,
322
640
  value: integration,
323
641
  checked: install ? (integration.defaultChecked ?? true) : true,
@@ -333,7 +651,7 @@ export async function runInstaller(mode: InstallMode): Promise<void> {
333
651
  return;
334
652
  }
335
653
 
336
- printPlan(chosenAgents, chosenIntegrations);
654
+ await printPlan(mode, chosenAgents, chosenIntegrations);
337
655
 
338
656
  const proceed = await promptConfirm(install ? "Proceed?" : "Remove miru configuration?", install);
339
657
  if (!proceed) {
@@ -355,11 +673,14 @@ export async function runInstaller(mode: InstallMode): Promise<void> {
355
673
  }
356
674
 
357
675
  export {
676
+ applyCaveman,
358
677
  applyHooks,
359
678
  applyInstructions,
360
679
  applyMcp,
680
+ applySte,
361
681
  applySubagent,
362
682
  INTEGRATIONS,
683
+ integrationsForAgents,
363
684
  mergeJsonMember as mergeMcpJson,
364
685
  removeJsonMember as removeMcpJson,
365
686
  };
@@ -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
- if (yesSelected) {
333
- yesSelected = false;
334
- updateConfirmPromptLine(question, yesSelected);
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
  }
@@ -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
 
@@ -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
+ }