@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +46 -0
- package/README.md +2 -0
- package/package.json +1 -1
- package/scripts/install.cjs +7 -0
- package/scripts/lib/install/converters/antigravity.cjs +48 -0
- package/scripts/lib/install/converters/augment.cjs +68 -0
- package/scripts/lib/install/converters/cline.cjs +206 -0
- package/scripts/lib/install/converters/codebuddy.cjs +55 -0
- package/scripts/lib/install/converters/codex.cjs +61 -0
- package/scripts/lib/install/converters/copilot.cjs +47 -0
- package/scripts/lib/install/converters/cursor.cjs +49 -0
- package/scripts/lib/install/converters/gemini.cjs +116 -0
- package/scripts/lib/install/converters/kilo.cjs +62 -0
- package/scripts/lib/install/converters/opencode.cjs +64 -0
- package/scripts/lib/install/converters/qwen.cjs +51 -0
- package/scripts/lib/install/converters/shared.cjs +377 -0
- package/scripts/lib/install/converters/trae.cjs +47 -0
- package/scripts/lib/install/converters/windsurf.cjs +47 -0
- package/scripts/lib/install/installer.cjs +529 -47
- package/scripts/lib/install/merge.cjs +31 -1
- package/scripts/lib/install/runtime-artifact-layout.cjs +431 -0
- package/scripts/lib/install/runtime-homes.cjs +225 -0
- package/scripts/lib/install/runtime-slash.cjs +172 -0
- package/scripts/lib/install/runtimes.cjs +25 -32
|
@@ -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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
164
|
-
|
|
256
|
+
let current = '';
|
|
257
|
+
try {
|
|
258
|
+
current = fs.readFileSync(target, 'utf8');
|
|
259
|
+
} catch (err) {
|
|
165
260
|
return {
|
|
166
|
-
|
|
167
|
-
path:
|
|
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
|
-
|
|
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)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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)
|
|
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:
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
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
|
-
//
|
|
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 === '
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
402
|
-
if (isPluginOwned(content)) installed.push(runtime.id);
|
|
861
|
+
entries = fs.readdirSync(baseDir);
|
|
403
862
|
} catch {
|
|
404
|
-
|
|
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
|
};
|