@hegemonart/get-design-done 1.28.6 → 1.28.7

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.
@@ -2,6 +2,28 @@
2
2
 
3
3
  // Per-runtime install/uninstall orchestrator. Returns a structured Result
4
4
  // for every runtime touched so the caller can render a per-runtime summary.
5
+ //
6
+ // Phase 28.7 (Plan 28.7-08) refactor — the previous `agents-md` kind (which
7
+ // dropped a single AGENTS.md/GEMINI.md placeholder per runtime, see Phase
8
+ // 28.7 D-02 "broken placeholder") is replaced with `multi-artifact`. For
9
+ // `multi-artifact` runtimes, we delegate to `runtime-artifact-layout.cjs`
10
+ // for the destination layout, then drive each kind through the matching
11
+ // per-runtime converter at `./converters/<runtime>.cjs`.
12
+ //
13
+ // Special case (Phase 28.7 D-09): cline is rules-based. Its layout returns
14
+ // `kinds: []` and `specialCase: 'clinerules-embed'`. We aggregate all
15
+ // skills through cline.cjs's `buildClinerulesFile` and write a single
16
+ // `.clinerules` file in the runtime's config dir.
17
+ //
18
+ // Carry-forward invariants (do NOT regress):
19
+ // - claude branch (claude-marketplace) is untouched — settings.json
20
+ // merge + flip enabledPlugins.
21
+ // - models.json side-effect emission per runtime (Phase 26 D-06).
22
+ // - Foreign-file protection — never clobber a user-authored file that
23
+ // lacks any plugin fingerprint.
24
+ // - Idempotent re-install (re-run = unchanged outcome).
25
+ // - Atomic write via `${target}.tmp-${pid}` rename.
26
+ // - Default scope = global (Phase 28.7 D-07).
5
27
 
6
28
  const fs = require('node:fs');
7
29
  const path = require('node:path');
@@ -11,9 +33,12 @@ const { resolveConfigDir } = require('./config-dir.cjs');
11
33
  const {
12
34
  mergeClaudeSettings,
13
35
  removeClaudeSettings,
14
- buildAgentsFileContent,
15
36
  isPluginOwned,
16
37
  } = require('./merge.cjs');
38
+ const {
39
+ resolveRuntimeArtifactLayout,
40
+ findInstallSourceRoot,
41
+ } = require('./runtime-artifact-layout.cjs');
17
42
 
18
43
  // Phase 26 D-06 — schema for the per-runtime models.json file emitted into
19
44
  // each runtime's config directory at install time. Forward-compatible: new
@@ -52,16 +77,33 @@ function ensureDir(dir, dryRun) {
52
77
  return true;
53
78
  }
54
79
 
80
+ // ---------------------------------------------------------------------------
81
+ // Public API — installRuntime / uninstallRuntime / detectInstalled
82
+ // ---------------------------------------------------------------------------
83
+
55
84
  function installRuntime(runtimeId, opts) {
56
85
  const runtime = getRuntime(runtimeId);
57
86
  const dryRun = Boolean(opts && opts.dryRun);
58
87
  const configDir = resolveConfigDir(runtimeId, opts);
88
+ const scope = (opts && opts.scope) || 'global';
59
89
 
60
90
  let result;
61
91
  if (runtime.kind === 'claude-marketplace') {
62
- result = installClaudeMarketplace(runtime, configDir, dryRun);
63
- } else if (runtime.kind === 'agents-md') {
64
- result = installAgentsMd(runtime, configDir, dryRun);
92
+ // Phase 28.7 Plan 28.7-09 (Rule 1 fix) — claude is the only runtime
93
+ // whose `local` scope routes through the multi-artifact dispatcher
94
+ // rather than the marketplace settings.json branch. The
95
+ // `runtime-artifact-layout.cjs#claude` case explicitly handles
96
+ // `scope === 'local'` with `commandsKind('commands/gdd', ...)` +
97
+ // `agentsKind('agents', ...)`, and the installer must honor that
98
+ // routing or `--local` silently writes the wrong file (settings.json
99
+ // instead of commands/gdd/*.md + agents/*.md).
100
+ if (scope === 'local') {
101
+ result = installMultiArtifact(runtime, configDir, dryRun, { scope });
102
+ } else {
103
+ result = installClaudeMarketplace(runtime, configDir, dryRun);
104
+ }
105
+ } else if (runtime.kind === 'multi-artifact') {
106
+ result = installMultiArtifact(runtime, configDir, dryRun, { scope });
65
107
  } else {
66
108
  throw new Error(`Unsupported runtime kind: ${runtime.kind}`);
67
109
  }
@@ -77,23 +119,34 @@ function uninstallRuntime(runtimeId, opts) {
77
119
  const runtime = getRuntime(runtimeId);
78
120
  const dryRun = Boolean(opts && opts.dryRun);
79
121
  const configDir = resolveConfigDir(runtimeId, opts);
122
+ const scope = (opts && opts.scope) || 'global';
80
123
 
81
124
  let result;
82
125
  if (runtime.kind === 'claude-marketplace') {
83
- result = uninstallClaudeMarketplace(runtime, configDir, dryRun);
84
- } else if (runtime.kind === 'agents-md') {
85
- result = uninstallAgentsMd(runtime, configDir, dryRun);
126
+ // Symmetric with installRuntime — claude `local` was installed via
127
+ // multi-artifact, so it must be uninstalled via multi-artifact too.
128
+ if (scope === 'local') {
129
+ result = uninstallMultiArtifact(runtime, configDir, dryRun, { scope });
130
+ } else {
131
+ result = uninstallClaudeMarketplace(runtime, configDir, dryRun);
132
+ }
133
+ } else if (runtime.kind === 'multi-artifact') {
134
+ result = uninstallMultiArtifact(runtime, configDir, dryRun, { scope });
86
135
  } else {
87
136
  throw new Error(`Unsupported runtime kind: ${runtime.kind}`);
88
137
  }
89
138
 
90
139
  // Phase 26 D-06 — clean up the models.json we wrote on install.
91
140
  // Idempotent: missing file → unchanged; foreign file (no fingerprint) is
92
- // left alone, mirroring the AGENTS.md skipped-foreign discipline.
141
+ // left alone, mirroring the foreign-file discipline above.
93
142
  result.modelsJson = uninstallModelsJson(runtime, configDir, dryRun);
94
143
  return result;
95
144
  }
96
145
 
146
+ // ---------------------------------------------------------------------------
147
+ // Claude branch (claude-marketplace) — UNCHANGED from Phase 24 / 26
148
+ // ---------------------------------------------------------------------------
149
+
97
150
  function installClaudeMarketplace(runtime, configDir, dryRun) {
98
151
  const settingsPath = path.join(configDir, 'settings.json');
99
152
  ensureDir(configDir, dryRun);
@@ -153,68 +206,425 @@ function uninstallClaudeMarketplace(runtime, configDir, dryRun) {
153
206
  };
154
207
  }
155
208
 
156
- function installAgentsMd(runtime, configDir, dryRun) {
157
- ensureDir(configDir, dryRun);
158
- const fileName = (runtime.files && runtime.files[0]) || 'AGENTS.md';
159
- const target = path.join(configDir, fileName);
160
- const desired = buildAgentsFileContent(runtime);
209
+ // ---------------------------------------------------------------------------
210
+ // Phase 28.7 (Plan 28.7-08) — Multi-artifact branch
211
+ // ---------------------------------------------------------------------------
212
+
213
+ /**
214
+ * Compute the destination path for a single staged item.
215
+ *
216
+ * For `kind === 'skills'` → <configDir>/<destSubpath>/<itemName>/SKILL.md
217
+ * For `kind === 'commands'` → <configDir>/<destSubpath>/<itemName>.md
218
+ * For `kind === 'agents'` → <configDir>/<destSubpath>/<itemName>.md
219
+ *
220
+ * `itemName` already has the prefix applied (e.g. `gdd-explore`) per the
221
+ * StagedArtifact contract documented in runtime-artifact-layout.cjs.
222
+ *
223
+ * @param {string} configDir
224
+ * @param {{kind: string, destSubpath: string}} kindDescriptor
225
+ * @param {string} itemName
226
+ * @returns {string}
227
+ */
228
+ function computeDestPath(configDir, kindDescriptor, itemName) {
229
+ const baseDir = path.join(configDir, kindDescriptor.destSubpath);
230
+ if (kindDescriptor.kind === 'skills') {
231
+ return path.join(baseDir, itemName, 'SKILL.md');
232
+ }
233
+ // commands + agents are single-file-per-skill
234
+ return path.join(baseDir, `${itemName}.md`);
235
+ }
161
236
 
237
+ /**
238
+ * Atomic, fingerprint-aware write of a single converter output to disk.
239
+ *
240
+ * Behavior matrix:
241
+ * - file does not exist → `created` (write)
242
+ * - exists, plugin-owned, content equal → `unchanged` (skip)
243
+ * - exists, plugin-owned, content differs → `updated` (write)
244
+ * - exists, NOT plugin-owned → `skipped-foreign` (no-op)
245
+ *
246
+ * Recursively ensures the parent directory exists (needed for nested
247
+ * subpaths like `skills/<name>/SKILL.md` and `commands/gdd/<name>.md`).
248
+ *
249
+ * @param {string} target absolute path to write
250
+ * @param {string} desired desired file content
251
+ * @param {boolean} dryRun
252
+ * @returns {{action: 'created'|'updated'|'unchanged'|'skipped-foreign', reason?: string}}
253
+ */
254
+ function writeFingerprinted(target, desired, dryRun) {
162
255
  if (fs.existsSync(target)) {
163
- const current = fs.readFileSync(target, 'utf8');
164
- if (current === desired) {
256
+ let current = '';
257
+ try {
258
+ current = fs.readFileSync(target, 'utf8');
259
+ } catch (err) {
165
260
  return {
166
- runtime: runtime.id,
167
- path: target,
168
- action: 'unchanged',
169
- dryRun,
261
+ action: 'skipped-foreign',
262
+ reason: `Could not read existing ${path.basename(target)}: ${err.message}`,
170
263
  };
171
264
  }
265
+ if (current === desired) {
266
+ return { action: 'unchanged' };
267
+ }
172
268
  if (!isPluginOwned(current)) {
173
- // Don't clobber unrelated user-authored AGENTS.md / GEMINI.md.
174
269
  return {
175
- runtime: runtime.id,
176
- path: target,
177
270
  action: 'skipped-foreign',
178
- dryRun,
179
- reason: `Existing ${fileName} was not authored by this plugin; refusing to overwrite. Move it aside or pass --force (not yet supported) to replace.`,
271
+ reason: `Existing ${path.basename(target)} was not authored by this plugin; refusing to overwrite. Move it aside or pass --force (not yet supported) to replace.`,
180
272
  };
181
273
  }
182
- if (!dryRun) atomicWrite(target, desired);
183
- return {
184
- runtime: runtime.id,
185
- path: target,
186
- action: 'updated',
187
- dryRun,
188
- };
274
+ if (!dryRun) {
275
+ ensureDir(path.dirname(target), dryRun);
276
+ atomicWrite(target, desired);
277
+ }
278
+ return { action: 'updated' };
189
279
  }
190
- if (!dryRun) atomicWrite(target, desired);
280
+ if (!dryRun) {
281
+ ensureDir(path.dirname(target), dryRun);
282
+ atomicWrite(target, desired);
283
+ }
284
+ return { action: 'created' };
285
+ }
286
+
287
+ /**
288
+ * Aggregate per-file actions into a single top-level action for the
289
+ * runtime's install result.
290
+ *
291
+ * Priority order (highest severity wins):
292
+ * - 'skipped-foreign' — surface immediately so the user sees the
293
+ * refusal (a single foreign file ⇒ runtime action = skipped-foreign).
294
+ * - 'created' — at least one file was newly created.
295
+ * - 'updated' — at least one file changed in place.
296
+ * - 'unchanged' — every file already had the desired content.
297
+ *
298
+ * Used by `installMultiArtifact` to summarize a multi-file install.
299
+ *
300
+ * @param {Array<{action: string}>} perFileResults
301
+ * @returns {string}
302
+ */
303
+ function aggregateAction(perFileResults) {
304
+ if (perFileResults.length === 0) return 'unchanged';
305
+ const actions = new Set(perFileResults.map((r) => r.action));
306
+ if (actions.has('skipped-foreign')) return 'skipped-foreign';
307
+ if (actions.has('created')) return 'created';
308
+ if (actions.has('updated')) return 'updated';
309
+ return 'unchanged';
310
+ }
311
+
312
+ /**
313
+ * Enumerate the skill names available in the source repo's skills/ dir.
314
+ *
315
+ * A "skill name" is a directory containing a SKILL.md file. Used by both
316
+ * install and uninstall to figure out which artifacts the multi-artifact
317
+ * installer is responsible for in this repo.
318
+ *
319
+ * @param {string} skillsRoot absolute path to <repo>/skills
320
+ * @returns {string[]}
321
+ */
322
+ function listSourceSkills(skillsRoot) {
323
+ if (!fs.existsSync(skillsRoot)) return [];
324
+ return fs
325
+ .readdirSync(skillsRoot)
326
+ .filter((name) => {
327
+ const dir = path.join(skillsRoot, name);
328
+ try {
329
+ if (!fs.statSync(dir).isDirectory()) return false;
330
+ return fs.existsSync(path.join(dir, 'SKILL.md'));
331
+ } catch {
332
+ return false;
333
+ }
334
+ });
335
+ }
336
+
337
+ /**
338
+ * Install all artifacts for a `multi-artifact` runtime.
339
+ *
340
+ * Resolves the per-runtime layout from `runtime-artifact-layout.cjs`,
341
+ * stages every kind through its converter (or special-cases cline),
342
+ * then writes each staged file via `writeFingerprinted`.
343
+ *
344
+ * @param {object} runtime registry entry from runtimes.cjs
345
+ * @param {string} configDir absolute path to the runtime's config dir
346
+ * @param {boolean} dryRun
347
+ * @param {{scope?: 'local'|'global'}} [opts]
348
+ * @returns {object} result with `runtime`, `path`, `action`, `dryRun`,
349
+ * `results` (per-file detail), and optional `reason`.
350
+ */
351
+ function installMultiArtifact(runtime, configDir, dryRun, opts) {
352
+ const scope = (opts && opts.scope) || 'global';
353
+ const layout = resolveRuntimeArtifactLayout(runtime.id, configDir, scope);
354
+ const sourceRoot = findInstallSourceRoot(configDir);
355
+ const skillsRoot = path.join(sourceRoot, 'skills');
356
+ const skillNames = listSourceSkills(skillsRoot);
357
+
358
+ // Phase 28.7 D-09 special case — cline is rules-based.
359
+ if (layout.specialCase === 'clinerules-embed') {
360
+ return installCline(runtime, configDir, skillsRoot, skillNames, dryRun);
361
+ }
362
+
363
+ // Ensure the runtime's config dir exists before any per-kind writes.
364
+ ensureDir(configDir, dryRun);
365
+
366
+ const perFile = [];
367
+ for (const kind of layout.kinds) {
368
+ let staged;
369
+ try {
370
+ staged = kind.stage({
371
+ skillsRoot,
372
+ skillNames,
373
+ scope,
374
+ runtime: runtime.id,
375
+ configDir,
376
+ });
377
+ } catch (err) {
378
+ // Converter / layout failure for this kind — surface but don't crash
379
+ // the entire multi-runtime install. Other kinds for the same runtime
380
+ // are still attempted.
381
+ perFile.push({
382
+ kind: kind.kind,
383
+ path: path.join(configDir, kind.destSubpath),
384
+ action: 'skipped-foreign',
385
+ reason: `stage() failed: ${err && err.message ? err.message : err}`,
386
+ });
387
+ continue;
388
+ }
389
+ for (const item of staged) {
390
+ const destPath = computeDestPath(configDir, kind, item.name);
391
+ const writeResult = writeFingerprinted(destPath, item.content, dryRun);
392
+ perFile.push({
393
+ kind: kind.kind,
394
+ path: destPath,
395
+ action: writeResult.action,
396
+ ...(writeResult.reason ? { reason: writeResult.reason } : {}),
397
+ });
398
+ }
399
+ }
400
+
401
+ // Top-level path: report the runtime's config dir as the canonical
402
+ // "result path" so the CLI summariser has something useful to print.
403
+ // Per-file detail lives in `results`.
404
+ const action = aggregateAction(perFile);
405
+ const out = {
406
+ runtime: runtime.id,
407
+ path: configDir,
408
+ action,
409
+ dryRun,
410
+ results: perFile,
411
+ };
412
+ // Surface the first skipped-foreign reason at the top level so CLI
413
+ // summariser callers (which only print `r.reason` once) see why we
414
+ // refused.
415
+ if (action === 'skipped-foreign') {
416
+ const firstSkipped = perFile.find((r) => r.action === 'skipped-foreign');
417
+ if (firstSkipped && firstSkipped.reason) out.reason = firstSkipped.reason;
418
+ }
419
+ return out;
420
+ }
421
+
422
+ /**
423
+ * Uninstall all artifacts for a `multi-artifact` runtime.
424
+ *
425
+ * Walks the same layout used at install time, then for each expected
426
+ * destination file:
427
+ * - file missing → 'unchanged'
428
+ * - file plugin-owned → 'removed' (unlink)
429
+ * - file NOT plugin-owned → 'skipped-foreign' (leave alone)
430
+ *
431
+ * Also tidies up now-empty skill subdirectories (`<configDir>/skills/<name>/`)
432
+ * after removing their SKILL.md. Does NOT remove the top-level
433
+ * `<configDir>/skills/` or `<configDir>/commands/` dirs — those may host
434
+ * user-authored skills/commands alongside ours.
435
+ *
436
+ * @param {object} runtime
437
+ * @param {string} configDir
438
+ * @param {boolean} dryRun
439
+ * @param {{scope?: 'local'|'global'}} [opts]
440
+ * @returns {object}
441
+ */
442
+ function uninstallMultiArtifact(runtime, configDir, dryRun, opts) {
443
+ const scope = (opts && opts.scope) || 'global';
444
+ const layout = resolveRuntimeArtifactLayout(runtime.id, configDir, scope);
445
+
446
+ // Phase 28.7 D-09 special case — cline.
447
+ if (layout.specialCase === 'clinerules-embed') {
448
+ return uninstallCline(runtime, configDir, dryRun);
449
+ }
450
+
451
+ const sourceRoot = findInstallSourceRoot(configDir);
452
+ const skillsRoot = path.join(sourceRoot, 'skills');
453
+ const skillNames = listSourceSkills(skillsRoot);
454
+
455
+ const perFile = [];
456
+ const skillDirsToTrim = [];
457
+
458
+ for (const kind of layout.kinds) {
459
+ for (const bareName of skillNames) {
460
+ const itemName = (kind.prefix || '') + bareName;
461
+ const destPath = computeDestPath(configDir, kind, itemName);
462
+ if (!fs.existsSync(destPath)) {
463
+ perFile.push({ kind: kind.kind, path: destPath, action: 'unchanged' });
464
+ continue;
465
+ }
466
+ let current;
467
+ try {
468
+ current = fs.readFileSync(destPath, 'utf8');
469
+ } catch (err) {
470
+ perFile.push({
471
+ kind: kind.kind,
472
+ path: destPath,
473
+ action: 'skipped-foreign',
474
+ reason: `Could not read ${path.basename(destPath)}: ${err.message}`,
475
+ });
476
+ continue;
477
+ }
478
+ if (!isPluginOwned(current)) {
479
+ perFile.push({
480
+ kind: kind.kind,
481
+ path: destPath,
482
+ action: 'skipped-foreign',
483
+ reason: `Existing ${path.basename(destPath)} was not authored by this plugin; not removing.`,
484
+ });
485
+ continue;
486
+ }
487
+ if (!dryRun) fs.unlinkSync(destPath);
488
+ perFile.push({ kind: kind.kind, path: destPath, action: 'removed' });
489
+
490
+ // If we removed a SKILL.md, remember to trim its now-empty parent.
491
+ if (kind.kind === 'skills') {
492
+ skillDirsToTrim.push(path.dirname(destPath));
493
+ }
494
+ }
495
+ }
496
+
497
+ // Trim empty per-skill subdirectories. Don't touch <configDir>/skills/
498
+ // itself — it may host user skills.
499
+ if (!dryRun) {
500
+ for (const dir of skillDirsToTrim) {
501
+ try {
502
+ const remaining = fs.readdirSync(dir);
503
+ if (remaining.length === 0) fs.rmdirSync(dir);
504
+ } catch {
505
+ // Best effort — never throw from cleanup.
506
+ }
507
+ }
508
+ }
509
+
510
+ const action = aggregateUninstallAction(perFile);
511
+ const out = {
512
+ runtime: runtime.id,
513
+ path: configDir,
514
+ action,
515
+ dryRun,
516
+ results: perFile,
517
+ };
518
+ if (action === 'skipped-foreign') {
519
+ const firstSkipped = perFile.find((r) => r.action === 'skipped-foreign');
520
+ if (firstSkipped && firstSkipped.reason) out.reason = firstSkipped.reason;
521
+ }
522
+ return out;
523
+ }
524
+
525
+ function aggregateUninstallAction(perFileResults) {
526
+ if (perFileResults.length === 0) return 'unchanged';
527
+ const actions = new Set(perFileResults.map((r) => r.action));
528
+ if (actions.has('skipped-foreign')) return 'skipped-foreign';
529
+ if (actions.has('removed')) return 'removed';
530
+ return 'unchanged';
531
+ }
532
+
533
+ // ---------------------------------------------------------------------------
534
+ // Cline special case (Phase 28.7 D-09) — .clinerules file
535
+ // ---------------------------------------------------------------------------
536
+
537
+ /**
538
+ * Install for cline — aggregate all source skills through cline.cjs's
539
+ * convert() helper, then assemble the final `.clinerules` file via
540
+ * `buildClinerulesFile`. Writes one file: `<configDir>/.clinerules`.
541
+ *
542
+ * @param {object} runtime
543
+ * @param {string} configDir
544
+ * @param {string} skillsRoot
545
+ * @param {string[]} skillNames
546
+ * @param {boolean} dryRun
547
+ * @returns {object}
548
+ */
549
+ function installCline(runtime, configDir, skillsRoot, skillNames, dryRun) {
550
+ const cline = require('./converters/cline.cjs');
551
+ ensureDir(configDir, dryRun);
552
+
553
+ const blocks = skillNames.map((name) => {
554
+ const srcPath = path.join(skillsRoot, name, 'SKILL.md');
555
+ const raw = fs.readFileSync(srcPath, 'utf8');
556
+ return { name, block: cline.convert(raw, name, { runtime: 'cline' }) };
557
+ });
558
+
559
+ const desired = cline.buildClinerulesFile(blocks);
560
+ const target = path.join(configDir, '.clinerules');
561
+ const writeResult = writeFingerprinted(target, desired, dryRun);
562
+
191
563
  return {
192
564
  runtime: runtime.id,
193
565
  path: target,
194
- action: 'created',
566
+ action: writeResult.action,
195
567
  dryRun,
568
+ ...(writeResult.reason ? { reason: writeResult.reason } : {}),
569
+ results: [{ kind: 'clinerules', path: target, action: writeResult.action }],
196
570
  };
197
571
  }
198
572
 
199
- function uninstallAgentsMd(runtime, configDir, dryRun) {
200
- const fileName = (runtime.files && runtime.files[0]) || 'AGENTS.md';
201
- const target = path.join(configDir, fileName);
573
+ /**
574
+ * Uninstall for cline remove `<configDir>/.clinerules` if it carries a
575
+ * plugin fingerprint (cline-rules header). Foreign files are left alone.
576
+ *
577
+ * @param {object} runtime
578
+ * @param {string} configDir
579
+ * @param {boolean} dryRun
580
+ * @returns {object}
581
+ */
582
+ function uninstallCline(runtime, configDir, dryRun) {
583
+ const target = path.join(configDir, '.clinerules');
202
584
  if (!fs.existsSync(target)) {
203
585
  return {
204
586
  runtime: runtime.id,
205
587
  path: target,
206
588
  action: 'unchanged',
207
589
  dryRun,
590
+ results: [{ kind: 'clinerules', path: target, action: 'unchanged' }],
591
+ };
592
+ }
593
+ let current;
594
+ try {
595
+ current = fs.readFileSync(target, 'utf8');
596
+ } catch (err) {
597
+ return {
598
+ runtime: runtime.id,
599
+ path: target,
600
+ action: 'skipped-foreign',
601
+ dryRun,
602
+ reason: `Could not read .clinerules: ${err.message}`,
603
+ results: [
604
+ {
605
+ kind: 'clinerules',
606
+ path: target,
607
+ action: 'skipped-foreign',
608
+ reason: `Could not read .clinerules: ${err.message}`,
609
+ },
610
+ ],
208
611
  };
209
612
  }
210
- const current = fs.readFileSync(target, 'utf8');
211
613
  if (!isPluginOwned(current)) {
212
614
  return {
213
615
  runtime: runtime.id,
214
616
  path: target,
215
617
  action: 'skipped-foreign',
216
618
  dryRun,
217
- reason: `Existing ${fileName} was not authored by this plugin; not removing.`,
619
+ reason: `Existing .clinerules was not authored by this plugin; not removing.`,
620
+ results: [
621
+ {
622
+ kind: 'clinerules',
623
+ path: target,
624
+ action: 'skipped-foreign',
625
+ reason: `Existing .clinerules was not authored by this plugin; not removing.`,
626
+ },
627
+ ],
218
628
  };
219
629
  }
220
630
  if (!dryRun) fs.unlinkSync(target);
@@ -223,10 +633,13 @@ function uninstallAgentsMd(runtime, configDir, dryRun) {
223
633
  path: target,
224
634
  action: 'removed',
225
635
  dryRun,
636
+ results: [{ kind: 'clinerules', path: target, action: 'removed' }],
226
637
  };
227
638
  }
228
639
 
229
- // Phase 26 D-06 — `models.json` emission per runtime config-dir.
640
+ // ---------------------------------------------------------------------------
641
+ // Phase 26 D-06 — models.json emission per runtime config-dir.
642
+ // ---------------------------------------------------------------------------
230
643
  //
231
644
  // Format (locked by CONTEXT D-06):
232
645
  // {
@@ -374,6 +787,16 @@ function uninstallModelsJson(runtime, configDir, dryRun) {
374
787
  return { path: target, action: 'removed', dryRun };
375
788
  }
376
789
 
790
+ // ---------------------------------------------------------------------------
791
+ // detectInstalled — figure out which runtimes are currently provisioned
792
+ // ---------------------------------------------------------------------------
793
+ //
794
+ // A runtime is "installed" if at least one of its expected destination
795
+ // files exists AND carries a plugin fingerprint. For claude this is
796
+ // settings.json#enabledPlugins. For multi-artifact runtimes it's any
797
+ // plugin-owned SKILL.md / command file / .clinerules file at the
798
+ // runtime-layout-resolved location.
799
+
377
800
  function detectInstalled(opts) {
378
801
  const installed = [];
379
802
  const { listRuntimes } = require('./runtimes.cjs');
@@ -393,19 +816,73 @@ function detectInstalled(opts) {
393
816
  }
394
817
  continue;
395
818
  }
396
- if (runtime.kind === 'agents-md') {
397
- const fileName = (runtime.files && runtime.files[0]) || 'AGENTS.md';
398
- const target = path.join(configDir, fileName);
399
- if (!fs.existsSync(target)) continue;
819
+ if (runtime.kind === 'multi-artifact') {
820
+ if (detectMultiArtifactInstalled(runtime, configDir, opts)) {
821
+ installed.push(runtime.id);
822
+ }
823
+ }
824
+ }
825
+ return installed;
826
+ }
827
+
828
+ /**
829
+ * Return true iff at least one expected artifact path for this runtime
830
+ * exists on disk AND is plugin-owned. Best-effort: any fs/layout error
831
+ * is treated as "not installed" (we never throw from detection).
832
+ *
833
+ * @param {object} runtime
834
+ * @param {string} configDir
835
+ * @param {object} [opts]
836
+ * @returns {boolean}
837
+ */
838
+ function detectMultiArtifactInstalled(runtime, configDir, opts) {
839
+ try {
840
+ const scope = (opts && opts.scope) || 'global';
841
+ const layout = resolveRuntimeArtifactLayout(runtime.id, configDir, scope);
842
+
843
+ // Cline special case — single .clinerules file.
844
+ if (layout.specialCase === 'clinerules-embed') {
845
+ const target = path.join(configDir, '.clinerules');
846
+ if (!fs.existsSync(target)) return false;
847
+ const content = fs.readFileSync(target, 'utf8');
848
+ return isPluginOwned(content);
849
+ }
850
+
851
+ // Multi-kind: any plugin-owned SKILL.md / command file counts. We
852
+ // discover candidate names by scanning the destination subpath
853
+ // rather than re-walking the source skills/ tree (detectInstalled
854
+ // is called during peer-detection on user machines where the source
855
+ // dir may not be present).
856
+ for (const kind of layout.kinds) {
857
+ const baseDir = path.join(configDir, kind.destSubpath);
858
+ if (!fs.existsSync(baseDir)) continue;
859
+ let entries;
400
860
  try {
401
- const content = fs.readFileSync(target, 'utf8');
402
- if (isPluginOwned(content)) installed.push(runtime.id);
861
+ entries = fs.readdirSync(baseDir);
403
862
  } catch {
404
- // ignore
863
+ continue;
864
+ }
865
+ for (const entry of entries) {
866
+ let candidate;
867
+ if (kind.kind === 'skills') {
868
+ candidate = path.join(baseDir, entry, 'SKILL.md');
869
+ } else {
870
+ // commands + agents: <entry>.md (we already see the .md in entry)
871
+ candidate = path.join(baseDir, entry);
872
+ }
873
+ if (!fs.existsSync(candidate)) continue;
874
+ try {
875
+ const content = fs.readFileSync(candidate, 'utf8');
876
+ if (isPluginOwned(content)) return true;
877
+ } catch {
878
+ // unreadable — skip
879
+ }
405
880
  }
406
881
  }
882
+ return false;
883
+ } catch {
884
+ return false;
407
885
  }
408
- return installed;
409
886
  }
410
887
 
411
888
  module.exports = {
@@ -418,4 +895,9 @@ module.exports = {
418
895
  MODELS_JSON_FILE,
419
896
  MODELS_JSON_SCHEMA_VERSION,
420
897
  MODELS_JSON_SOURCE,
898
+ // Phase 28.7 (Plan 28.7-08) — direct entry points for tests / external
899
+ // tooling that wants to drive the multi-artifact pipeline without going
900
+ // through `installRuntime` (which adds the models.json side-effect).
901
+ installMultiArtifact,
902
+ uninstallMultiArtifact,
421
903
  };