@takara-ai/miru-code 1.6.0 → 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,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 = "mcp" | "instructions" | "subagent" | "hooks" | "rules" | "caveman";
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 applyCaveman(agent: AgentTarget, mode: InstallMode): Promise<WriteResult | null> {
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 (!(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.
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: "removed" };
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 { path, action: existed ? "updated" : "created" };
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
- 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> {
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,
@@ -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
  }
@@ -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
+ }