skillspub 0.1.0

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.
@@ -0,0 +1,1014 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { hashDirectory, normalizeSlotName, readStateFile, targetSlotId, scanGlobalInventory, scanProjectInventory, writeStateFile, } from "./inventory.js";
4
+ import { assertPresetName, expandSelector, readBundles, readPresets, readState, readTags, statePath, writeState, } from "./catalog.js";
5
+ function policyFingerprint(file, targets) {
6
+ const { targetInventory: _targetInventory, runtimeInventory: _runtimeInventory, updateAvailability: _updateAvailability, ...state } = readStateFile(file);
7
+ if (!targets)
8
+ return JSON.stringify(state);
9
+ const slots = new Set(targets.map(({ slotId }) => slotId));
10
+ const selected = (value) => value && typeof value === 'object' && !Array.isArray(value)
11
+ ? Object.fromEntries(Object.entries(value).filter(([slotId]) => slots.has(slotId)))
12
+ : {};
13
+ const lastClaims = state.lastClaims && typeof state.lastClaims === 'object' &&
14
+ !Array.isArray(state.lastClaims)
15
+ ? Object.fromEntries(Object.entries(state.lastClaims).filter(([, slotIds]) => Array.isArray(slotIds) && slotIds.some((slotId) => slots.has(String(slotId)))))
16
+ : {};
17
+ return JSON.stringify({
18
+ baseIntent: selected(state.baseIntent),
19
+ claims: selected(state.claims),
20
+ lastClaims,
21
+ mirrors: selected(state.mirrors),
22
+ });
23
+ }
24
+ function planPreconditionFingerprint(report, targets) {
25
+ const resourceIds = new Set(targets.map(({ resourceId }) => resourceId).filter(Boolean));
26
+ const slotIds = new Set(targets.map(({ slotId }) => slotId));
27
+ return JSON.stringify({
28
+ resources: report.resources
29
+ .filter(({ id }) => resourceIds.has(id))
30
+ .map(({ id, hash }) => ({ id, hash }))
31
+ .sort((a, b) => a.id.localeCompare(b.id)),
32
+ relationships: report.relationships
33
+ .filter((relationship) => slotIds.has(targetSlotId(relationship.targetId, relationship.slot)) ||
34
+ Boolean(relationship.resourceId && resourceIds.has(relationship.resourceId)))
35
+ .map(({ targetId, slot, activation, form, path: entryPath, resourceId, target }) => ({
36
+ targetId, slot, activation, form, path: entryPath, resourceId, target,
37
+ }))
38
+ .sort((a, b) => `${a.targetId}\0${a.slot}\0${a.path}`.localeCompare(`${b.targetId}\0${b.slot}\0${b.path}`)),
39
+ });
40
+ }
41
+ function concurrentModification(message) {
42
+ return Object.assign(new Error(message), { code: 'concurrent_modification' });
43
+ }
44
+ function assertPlanPreconditions(home, plan) {
45
+ const expectedPolicy = 'expectedCatalogState' in plan
46
+ ? policyFingerprint(plan.stateFile)
47
+ : policyFingerprint(plan.stateFile, plan.targets);
48
+ if (expectedPolicy !== plan.expectedState)
49
+ throw concurrentModification('policy state changed after preview; preview again');
50
+ const targets = [...new Map(plan.report.targets.map((target) => [target.key, target])).values()];
51
+ const actual = plan.report.scope === 'project'
52
+ ? scanProjectInventory(home, plan.report.projectPath, targets, { persist: false })
53
+ : scanGlobalInventory(home, targets, { persist: false });
54
+ if (planPreconditionFingerprint(actual, plan.targets) !==
55
+ planPreconditionFingerprint(plan.report, plan.targets))
56
+ throw concurrentModification('Relationships or resources changed after preview; preview again');
57
+ }
58
+ function lexists(file) {
59
+ try {
60
+ fs.lstatSync(file);
61
+ return true;
62
+ }
63
+ catch (error) {
64
+ if (error.code === 'ENOENT')
65
+ return false;
66
+ throw error;
67
+ }
68
+ }
69
+ function assertWritableParent(file) {
70
+ let directory = path.dirname(file);
71
+ while (!fs.existsSync(directory)) {
72
+ const parent = path.dirname(directory);
73
+ if (parent === directory)
74
+ throw new Error(`no writable parent for: ${file}`);
75
+ directory = parent;
76
+ }
77
+ try {
78
+ fs.accessSync(directory, fs.constants.W_OK);
79
+ }
80
+ catch {
81
+ throw new Error(`Target Slot parent is not writable: ${directory}`);
82
+ }
83
+ }
84
+ function slotConflict(slotId, candidates) {
85
+ return new Error(`Target Slot ${slotId.replace('\0', '/')} is ambiguous or occupied:\n${candidates
86
+ .map((candidate) => ` - ${candidate.resourceId ? `skill:${candidate.resourceId}` : 'broken link'}${candidate.path ? `: ${candidate.path}` : ''}`)
87
+ .join('\n')}`);
88
+ }
89
+ function creationForm(target) {
90
+ return target.relationship?.support === 'managed' && target.relationship.link === 'unsupported'
91
+ ? 'mirror'
92
+ : 'link';
93
+ }
94
+ function mirrorSource(report, sourceId) {
95
+ const source = report.resources.find((resource) => resource.id === sourceId);
96
+ if (!source || source.realPath !== sourceId)
97
+ throw new Error(`Mirror source is unavailable: ${sourceId}`);
98
+ return source;
99
+ }
100
+ function assertMirrorMaySync(report, relationship, overwrite = false) {
101
+ if (relationship.form !== 'mirror' || !relationship.mirror || !relationship.realPath)
102
+ throw new Error(`not a managed Mirror: ${relationship.path}`);
103
+ mirrorSource(report, relationship.mirror.sourceId);
104
+ if (!overwrite && hashDirectory(relationship.realPath) !== relationship.mirror.hash)
105
+ throw new Error(`Mirror diverged and requires overwrite or conversion: ${relationship.path}`);
106
+ }
107
+ function selectedRelationship(context, resource, target, slotName) {
108
+ const slotId = `${target.id}\0${slotName}`;
109
+ const relationships = context.report.slots.find((candidate) => candidate.targetId === target.id && candidate.name === slotName)
110
+ ?.relationships ?? [];
111
+ const selected = relationships.find((candidate) => candidate.resourceId === resource.id);
112
+ if (relationships.length === 0 || (relationships.length === 1 && selected))
113
+ return selected;
114
+ const candidates = relationships.some((candidate) => candidate.resourceId === resource.id)
115
+ ? relationships
116
+ : [...relationships, { resourceId: resource.id }];
117
+ throw slotConflict(slotId, candidates);
118
+ }
119
+ function activationDestination({ relationship, from, to, target, slotName, }) {
120
+ if (relationship && from !== to) {
121
+ const root = to === 'on' ? target.discoveryRoot : target.parkingRoot;
122
+ return path.join(root, path.basename(relationship.path));
123
+ }
124
+ return !relationship && to === 'on'
125
+ ? path.join(target.discoveryRoot, slotName)
126
+ : undefined;
127
+ }
128
+ function preflightTarget(relationship, from, to, destination) {
129
+ if (destination && lexists(destination))
130
+ throw new Error(`Target Slot path already exists: ${destination}`);
131
+ if (relationship && from !== to) {
132
+ if (!lexists(relationship.path))
133
+ throw new Error(`relationship disappeared during preview: ${relationship.path}`);
134
+ assertWritableParent(relationship.path);
135
+ }
136
+ if (destination)
137
+ assertWritableParent(destination);
138
+ }
139
+ function activationTarget(context, resource, target, slotName) {
140
+ const slotId = `${target.id}\0${slotName}`;
141
+ const to = context.intent === 'off' && (context.claims[slotId]?.length ?? 0) > 0
142
+ ? 'on'
143
+ : context.intent;
144
+ const relationship = selectedRelationship(context, resource, target, slotName);
145
+ const from = relationship?.activation ?? 'missing';
146
+ const destination = activationDestination({
147
+ relationship,
148
+ from,
149
+ to,
150
+ target,
151
+ slotName,
152
+ });
153
+ preflightTarget(relationship, from, to, destination);
154
+ const syncMirror = relationship?.form === 'mirror' && from === 'off' && to === 'on';
155
+ if (syncMirror)
156
+ assertMirrorMaySync(context.report, relationship);
157
+ return {
158
+ slotId,
159
+ targetId: target.id,
160
+ targetKey: target.key,
161
+ slot: slotName,
162
+ resourceId: resource.id,
163
+ from,
164
+ intent: context.intent,
165
+ to,
166
+ relationship,
167
+ destination,
168
+ createForm: !relationship && to === 'on' ? creationForm(target) : undefined,
169
+ syncMirror,
170
+ };
171
+ }
172
+ function activationTargets(context, resource, target) {
173
+ return resourceSlots(resource, target)
174
+ .map((slot) => activationTarget(context, resource, target, slot));
175
+ }
176
+ function uniqueTargets(targets) {
177
+ const unique = new Map();
178
+ for (const target of targets) {
179
+ const previous = unique.get(target.slotId);
180
+ if (previous && previous.resourceId !== target.resourceId) {
181
+ throw slotConflict(target.slotId, [previous, target]);
182
+ }
183
+ unique.set(target.slotId, target);
184
+ }
185
+ return [...unique.values()];
186
+ }
187
+ function preflightDependentLinks(report, targets) {
188
+ const movingLocalResources = new Set(targets.flatMap((target) => target.relationship?.form === 'local' && target.destination
189
+ ? [target.resourceId]
190
+ : []));
191
+ for (const relationship of report.relationships) {
192
+ if (relationship.form !== 'link' || !relationship.resourceId ||
193
+ !movingLocalResources.has(relationship.resourceId))
194
+ continue;
195
+ if (!lexists(relationship.path))
196
+ throw new Error(`dependent Link disappeared during preview: ${relationship.path}`);
197
+ assertWritableParent(relationship.path);
198
+ }
199
+ }
200
+ function readClaims(state) {
201
+ return readStringListRecord(state.claims, 'claims');
202
+ }
203
+ /** Resolve a skill selector against the freshly scanned report: disk is the truth (ADR-0001).
204
+ * Bundle/Tag selectors stay state-based so stale members are reported, not silently dropped. */
205
+ function resolvePlanSelector(home, selector, report) {
206
+ if (selector.startsWith('bundle:') || selector.startsWith('tag:'))
207
+ return expandSelector(home, selector, report);
208
+ const value = selector.startsWith('skill:') ? selector.slice('skill:'.length) : selector;
209
+ if (!value)
210
+ throw new Error(`invalid skill selector: ${selector}`);
211
+ const byId = report.resources.filter((resource) => resource.id === value);
212
+ const matches = byId.length > 0
213
+ ? byId
214
+ : report.resources.filter((resource) => resource.name === value);
215
+ if (matches.length === 0)
216
+ throw new Error(`skill not found: ${selector}`);
217
+ if (matches.length > 1) {
218
+ throw new Error(`skill name "${value}" is ambiguous:\n${matches
219
+ .map((resource) => ` - ${resource.name}: skill:${resource.id}`)
220
+ .join('\n')}\nUse one of the explicit selectors above.`);
221
+ }
222
+ return { resourceIds: [matches[0].id], staleResourceIds: [] };
223
+ }
224
+ /** Scan for a mutation scope: project scopes see project/parent/global targets. */
225
+ function mutationReport(home, scope) {
226
+ return scope.projectPath
227
+ ? scanProjectInventory(home, scope.projectPath, scope.targets, { persist: false })
228
+ : scanGlobalInventory(home, scope.targets, { persist: false });
229
+ }
230
+ /** Resolve one writable Target by key or id; project scans prefer the project-scope match. */
231
+ function resolveWritableTarget(report, name) {
232
+ let matches = report.targets.filter((target) => target.key === name || target.id === name);
233
+ if (matches.length > 1)
234
+ matches = matches.filter((target) => target.scope === 'project');
235
+ if (matches.length !== 1)
236
+ throw new Error(`unknown Target: ${name}`);
237
+ const target = matches[0];
238
+ if (!target.writable)
239
+ throw new Error(`Target is read-only here: ${target.key}`);
240
+ return target;
241
+ }
242
+ export function planActivation(home, selector, targetNames, intent, scope = {}) {
243
+ if (targetNames.length === 0)
244
+ throw new Error(`usage: skillspub ${intent === 'on' ? 'on' : 'off'} <selector> <target...>`);
245
+ const report = mutationReport(home, scope);
246
+ const selected = targetNames.map((name) => resolveWritableTarget(report, name));
247
+ const claims = readClaims(readStateFile(report.stateFile));
248
+ const { resourceIds, staleResourceIds } = resolvePlanSelector(home, selector, report);
249
+ if (staleResourceIds.length > 0) {
250
+ const kind = selector.startsWith('tag:') ? 'Tag' : 'Bundle';
251
+ throw new Error(`stale ${kind} member${staleResourceIds.length === 1 ? '' : 's'}:\n${staleResourceIds
252
+ .map((id) => ` - skill:${id}`)
253
+ .join('\n')}`);
254
+ }
255
+ const context = { report, intent, claims };
256
+ const resourceById = new Map(report.resources.map((resource) => [resource.id, resource]));
257
+ const targets = resourceIds.flatMap((id) => {
258
+ const resource = resourceById.get(id);
259
+ return resource
260
+ ? selected.flatMap((target) => activationTargets(context, resource, target))
261
+ : [];
262
+ });
263
+ const unique = uniqueTargets(targets);
264
+ preflightDependentLinks(report, unique);
265
+ return {
266
+ report,
267
+ expectedState: policyFingerprint(report.stateFile, unique),
268
+ targets: unique,
269
+ staleResourceIds: [],
270
+ stateFile: report.stateFile,
271
+ };
272
+ }
273
+ function slotRelationship(report, targetId, slot) {
274
+ const relationships = report.slots.find((candidate) => candidate.targetId === targetId && candidate.name === slot)
275
+ ?.relationships ?? [];
276
+ if (relationships.length === 0)
277
+ throw new Error(`Target Slot not found: ${targetId.replace('global:', '')}/${slot}`);
278
+ if (relationships.length > 1)
279
+ throw slotConflict(`${targetId}\0${slot}`, relationships);
280
+ return relationships[0];
281
+ }
282
+ function selectedTarget(report, targetId) {
283
+ const target = report.targets.find((candidate) => candidate.id === targetId);
284
+ if (!target)
285
+ throw new Error(`unknown Target: ${targetId}`);
286
+ if (!target.writable)
287
+ throw new Error(`Target is read-only here: ${target.key}`);
288
+ return target;
289
+ }
290
+ function selectSlotMutation(home, targetId, slot, scope = {}) {
291
+ const report = mutationReport(home, scope);
292
+ const relationship = slotRelationship(report, targetId, slot);
293
+ if (relationship.readOnly)
294
+ throw new Error(`Target Slot is read-only here: ${targetId}/${slot}`);
295
+ const target = selectedTarget(report, targetId);
296
+ return { report, target, relationship, slotId: targetSlotId(targetId, slot) };
297
+ }
298
+ /** Flip one existing Relationship, selected by exact Target Slot. Works for broken links too. */
299
+ export function planToggle(home, targetId, slot, scope = {}) {
300
+ const { report, target, relationship, slotId } = selectSlotMutation(home, targetId, slot, scope);
301
+ const from = relationship.activation;
302
+ const intent = from === 'off' ? 'on' : 'off';
303
+ const claims = readClaims(readStateFile(report.stateFile));
304
+ const to = intent === 'off' && (claims[slotId]?.length ?? 0) > 0 ? 'on' : intent;
305
+ // Claimed Slots stay ON: nothing moves, only Base intent is recorded.
306
+ const destination = from === to
307
+ ? undefined
308
+ : path.join(to === 'on' ? target.discoveryRoot : target.parkingRoot, path.basename(relationship.path));
309
+ preflightTarget(relationship, from, to, destination);
310
+ const syncMirror = relationship.form === 'mirror' && from === 'off' && to === 'on';
311
+ if (syncMirror)
312
+ assertMirrorMaySync(report, relationship);
313
+ const targets = [{
314
+ slotId,
315
+ targetId,
316
+ targetKey: target.key,
317
+ slot,
318
+ resourceId: relationship.resourceId ?? '',
319
+ from,
320
+ intent,
321
+ to,
322
+ relationship,
323
+ destination,
324
+ syncMirror,
325
+ }];
326
+ preflightDependentLinks(report, targets);
327
+ return {
328
+ report,
329
+ expectedState: policyFingerprint(report.stateFile, targets),
330
+ targets,
331
+ staleResourceIds: [],
332
+ stateFile: report.stateFile,
333
+ };
334
+ }
335
+ /** Create the missing Relationship from one existing resource; managed copy-only Targets use Mirror. */
336
+ export function planLink(home, resourceId, targetName, scope = {}) {
337
+ const report = mutationReport(home, scope);
338
+ const target = resolveWritableTarget(report, targetName);
339
+ const resource = report.resources.find((candidate) => candidate.id === resourceId);
340
+ if (!resource)
341
+ throw new Error(`skill not found on disk: ${resourceId}`);
342
+ const context = {
343
+ report,
344
+ intent: 'on',
345
+ claims: readClaims(readStateFile(report.stateFile)),
346
+ };
347
+ const targets = uniqueTargets(activationTargets(context, resource, target));
348
+ preflightDependentLinks(report, targets);
349
+ return {
350
+ report,
351
+ expectedState: policyFingerprint(report.stateFile, targets),
352
+ targets,
353
+ staleResourceIds: [],
354
+ stateFile: report.stateFile,
355
+ };
356
+ }
357
+ /** Remove exactly one symlink Relationship. Local skill directories are never deleted. */
358
+ export function planUnlink(home, targetId, slot, scope = {}) {
359
+ const { report, target, relationship, slotId } = selectSlotMutation(home, targetId, slot, scope);
360
+ if (relationship.form !== 'link')
361
+ throw new Error(`cannot unlink ${relationship.path}: local skill directories are never deleted`);
362
+ assertUnlinkAllowed(report.stateFile, slotId);
363
+ if (!lexists(relationship.path))
364
+ throw new Error(`relationship disappeared during preview: ${relationship.path}`);
365
+ assertWritableParent(relationship.path);
366
+ return {
367
+ report,
368
+ expectedState: policyFingerprint(report.stateFile, [{ slotId }]),
369
+ targets: [{
370
+ slotId,
371
+ targetId,
372
+ targetKey: target.key,
373
+ slot,
374
+ resourceId: relationship.resourceId ?? '',
375
+ from: relationship.activation,
376
+ intent: relationship.activation,
377
+ to: relationship.activation,
378
+ relationship,
379
+ remove: true,
380
+ }],
381
+ staleResourceIds: [],
382
+ stateFile: report.stateFile,
383
+ };
384
+ }
385
+ /** Preview an explicit managed-Mirror synchronization, overwrite, removal, or conversion. */
386
+ export function planMirrorAction(home, targetId, slot, action, scope = {}) {
387
+ const { report, relationship, slotId } = selectSlotMutation(home, targetId, slot, scope);
388
+ if (relationship.form !== 'mirror')
389
+ throw new Error(`not a managed Mirror: ${relationship.path}`);
390
+ if (action === 'sync')
391
+ assertMirrorMaySync(report, relationship);
392
+ if (action === 'overwrite')
393
+ assertMirrorMaySync(report, relationship, true);
394
+ if (action === 'remove')
395
+ assertUnlinkAllowed(report.stateFile, slotId);
396
+ return {
397
+ report,
398
+ expectedState: policyFingerprint(report.stateFile, [{ slotId }]),
399
+ targets: [{
400
+ slotId,
401
+ targetId,
402
+ targetKey: relationship.targetKey,
403
+ slot,
404
+ resourceId: relationship.resourceId ?? '',
405
+ from: relationship.activation,
406
+ intent: relationship.activation,
407
+ to: relationship.activation,
408
+ relationship,
409
+ mirrorAction: action,
410
+ }],
411
+ staleResourceIds: [],
412
+ stateFile: report.stateFile,
413
+ };
414
+ }
415
+ function readBaseIntent(state) {
416
+ const value = state.baseIntent ?? {};
417
+ if (!value || typeof value !== 'object' || Array.isArray(value) ||
418
+ Object.values(value).some((activation) => activation !== 'on' && activation !== 'off'))
419
+ throw new Error('invalid state baseIntent');
420
+ return value;
421
+ }
422
+ function replaceSymlink(link, target) {
423
+ const temporary = `${link}.skillspub-${process.pid}.tmp`;
424
+ if (lexists(temporary))
425
+ throw new Error(`temporary Link path already exists: ${temporary}`);
426
+ fs.symlinkSync(target, temporary, 'dir');
427
+ try {
428
+ fs.renameSync(temporary, link);
429
+ }
430
+ catch (error) {
431
+ fs.unlinkSync(temporary);
432
+ throw error;
433
+ }
434
+ }
435
+ function moveRelationships(home, plan) {
436
+ const movedLinks = new Map();
437
+ const movedLocals = new Map();
438
+ const moves = plan.targets
439
+ .filter((target) => target.relationship && target.destination)
440
+ .sort((a, b) => Number(b.relationship?.form === 'local') - Number(a.relationship?.form === 'local'));
441
+ for (const target of moves) {
442
+ const { relationship, destination } = target;
443
+ if (!relationship || !destination)
444
+ continue;
445
+ const originalTarget = relationship.target;
446
+ const absoluteTarget = originalTarget && !path.isAbsolute(originalTarget)
447
+ ? path.resolve(path.dirname(relationship.path), originalTarget)
448
+ : originalTarget;
449
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
450
+ fs.renameSync(relationship.path, destination);
451
+ if (relationship.form === 'local') {
452
+ const moved = fs.realpathSync(destination);
453
+ movedLocals.set(target.resourceId, moved);
454
+ preserveMovedResourceReferences(home, target.resourceId, moved, plan.stateFile);
455
+ }
456
+ else {
457
+ movedLinks.set(relationship.path, destination);
458
+ const movedTarget = movedLocals.get(target.resourceId);
459
+ if (originalTarget && absoluteTarget && (!path.isAbsolute(originalTarget) || movedTarget)) {
460
+ const nextTarget = movedTarget ?? absoluteTarget;
461
+ replaceSymlink(destination, path.isAbsolute(originalTarget)
462
+ ? nextTarget
463
+ : path.relative(path.dirname(destination), nextTarget));
464
+ }
465
+ }
466
+ }
467
+ return { movedLinks, movedLocals };
468
+ }
469
+ function createMissingRelationships(plan, movedLocals) {
470
+ const mirrors = new Map();
471
+ for (const target of plan.targets) {
472
+ if (target.from !== 'missing' || target.to !== 'on' || !target.destination)
473
+ continue;
474
+ const source = movedLocals.get(target.resourceId) ?? target.resourceId;
475
+ fs.mkdirSync(path.dirname(target.destination), { recursive: true });
476
+ if (target.createForm === 'mirror') {
477
+ fs.cpSync(source, target.destination, { recursive: true, errorOnExist: true });
478
+ const hash = hashDirectory(source);
479
+ if (hashDirectory(target.destination) !== hash)
480
+ throw new Error(`Mirror verification failed: ${target.destination}`);
481
+ mirrors.set(target.slotId, { sourceId: source, hash });
482
+ }
483
+ else {
484
+ fs.symlinkSync(source, target.destination, 'dir');
485
+ }
486
+ }
487
+ return mirrors;
488
+ }
489
+ function retargetMovedLinks(plan, movedLinks, movedLocals) {
490
+ for (const relationship of plan.report.relationships) {
491
+ if (relationship.form !== 'link' || !relationship.resourceId)
492
+ continue;
493
+ const moved = movedLocals.get(relationship.resourceId);
494
+ if (!moved || !relationship.target)
495
+ continue;
496
+ const link = movedLinks.get(relationship.path) ?? relationship.path;
497
+ if (!lexists(link))
498
+ continue;
499
+ replaceSymlink(link, path.isAbsolute(relationship.target)
500
+ ? moved
501
+ : path.relative(path.dirname(link), moved));
502
+ }
503
+ }
504
+ function preserveMovedResourceReferences(home, previous, moved, policyStateFile = statePath(home)) {
505
+ const state = readState(home);
506
+ state.bundles = Object.fromEntries(Object.entries(readBundles(state)).map(([name, members]) => [
507
+ name,
508
+ [...new Set(members.map((member) => member === previous ? moved : member))]
509
+ .sort((a, b) => a.localeCompare(b)),
510
+ ]));
511
+ const tags = readTags(state);
512
+ if (tags[previous]) {
513
+ const { [previous]: previousTags, ...remaining } = tags;
514
+ state.tags = {
515
+ ...remaining,
516
+ [moved]: [...new Set([...(remaining[moved] ?? []), ...previousTags])]
517
+ .sort((a, b) => a.localeCompare(b)),
518
+ };
519
+ }
520
+ const presets = readPresets(state);
521
+ const previousSelector = `skill:${previous}`;
522
+ const movedSelector = `skill:${moved}`;
523
+ state.presets = Object.fromEntries(Object.entries(presets).map(([name, preset]) => [
524
+ name,
525
+ {
526
+ selectors: [...new Set(preset.selectors.map((selector) => selector === previousSelector ? movedSelector : selector))]
527
+ .sort((a, b) => a.localeCompare(b)),
528
+ },
529
+ ]));
530
+ const mirrors = state.mirrors;
531
+ if (mirrors) {
532
+ state.mirrors = Object.fromEntries(Object.entries(mirrors).map(([slotId, mirror]) => [
533
+ slotId,
534
+ mirror.sourceId === previous ? { ...mirror, sourceId: moved } : mirror,
535
+ ]));
536
+ }
537
+ writeState(home, state);
538
+ if (policyStateFile !== statePath(home)) {
539
+ const policy = readStateFile(policyStateFile);
540
+ const policyMirrors = policy.mirrors;
541
+ if (policyMirrors) {
542
+ policy.mirrors = Object.fromEntries(Object.entries(policyMirrors).map(([slotId, mirror]) => [
543
+ slotId,
544
+ mirror.sourceId === previous ? { ...mirror, sourceId: moved } : mirror,
545
+ ]));
546
+ writeStateFile(policyStateFile, policy);
547
+ }
548
+ }
549
+ }
550
+ function movedResourceIds(plan) {
551
+ const moved = new Map();
552
+ for (const target of plan.targets) {
553
+ if (target.relationship?.form !== 'local' || !target.destination ||
554
+ lexists(target.relationship.path) || !lexists(target.destination))
555
+ continue;
556
+ moved.set(target.resourceId, fs.realpathSync(target.destination));
557
+ }
558
+ return moved;
559
+ }
560
+ function targetSatisfied(target, actual, moved) {
561
+ const relationships = actual.slots.find((candidate) => candidate.targetId === target.targetId && candidate.name === target.slot)
562
+ ?.relationships ?? [];
563
+ if (target.remove)
564
+ return !relationships.some((relationship) => relationship.path === target.relationship?.path);
565
+ if (relationships.length === 0)
566
+ return target.to === 'off' && target.from === 'missing';
567
+ const relationship = relationships[0];
568
+ return relationships.length === 1 &&
569
+ relationship.activation === target.to &&
570
+ (!target.resourceId ||
571
+ relationship.resourceId === (moved.get(target.resourceId) ?? target.resourceId));
572
+ }
573
+ export function remainingDrift(plan, actual) {
574
+ const moved = movedResourceIds(plan);
575
+ return plan.targets.flatMap((target) => targetSatisfied(target, actual, moved)
576
+ ? []
577
+ : [`${target.targetId}/${target.slot}`]);
578
+ }
579
+ function removePlanRelationships(plan) {
580
+ for (const target of plan.targets) {
581
+ const relationship = target.relationship;
582
+ if (!relationship)
583
+ continue;
584
+ if (target.remove) {
585
+ if (!fs.lstatSync(relationship.path).isSymbolicLink())
586
+ throw new Error(`cannot unlink ${relationship.path}: not a symlink`);
587
+ fs.unlinkSync(relationship.path);
588
+ }
589
+ if (target.mirrorAction === 'remove')
590
+ fs.rmSync(relationship.path, { recursive: true });
591
+ }
592
+ }
593
+ function applyMirrorActions(plan, movedLocals) {
594
+ const mirrors = new Map();
595
+ for (const target of plan.targets) {
596
+ const relationship = target.relationship;
597
+ if (!relationship || (target.mirrorAction !== 'sync' &&
598
+ target.mirrorAction !== 'overwrite' && !target.syncMirror))
599
+ continue;
600
+ const sourceId = movedLocals.get(target.resourceId) ?? target.resourceId;
601
+ const source = mirrorSource(plan.report, target.resourceId);
602
+ const destination = target.destination ?? relationship.path;
603
+ fs.rmSync(destination, { recursive: true, force: true });
604
+ fs.cpSync(source.realPath, destination, { recursive: true });
605
+ const hash = hashDirectory(source.realPath);
606
+ if (hashDirectory(destination) !== hash)
607
+ throw new Error(`Mirror verification failed: ${destination}`);
608
+ mirrors.set(target.slotId, { sourceId, hash });
609
+ }
610
+ return mirrors;
611
+ }
612
+ function updateMirrorMetadata(stateFile, updates, plan) {
613
+ if (updates.size === 0 && !plan.targets.some((target) => target.mirrorAction === 'remove' || target.mirrorAction === 'convert'))
614
+ return;
615
+ const state = readStateFile(stateFile);
616
+ const mirrors = { ...state.mirrors };
617
+ for (const [slotId, mirror] of updates)
618
+ mirrors[slotId] = mirror;
619
+ for (const target of plan.targets)
620
+ if (target.mirrorAction === 'remove' || target.mirrorAction === 'convert')
621
+ delete mirrors[target.slotId];
622
+ if (Object.keys(mirrors).length === 0)
623
+ delete state.mirrors;
624
+ else
625
+ state.mirrors = mirrors;
626
+ writeStateFile(stateFile, state);
627
+ }
628
+ export function applyActivationPlan(home, plan) {
629
+ assertPlanPreconditions(home, plan);
630
+ const state = readStateFile(plan.stateFile);
631
+ const baseIntent = { ...readBaseIntent(state) };
632
+ for (const target of plan.targets) {
633
+ if (target.remove)
634
+ delete baseIntent[target.slotId];
635
+ else
636
+ baseIntent[target.slotId] = target.intent;
637
+ }
638
+ state.baseIntent = baseIntent;
639
+ writeStateFile(plan.stateFile, state);
640
+ removePlanRelationships(plan);
641
+ const { movedLinks, movedLocals } = moveRelationships(home, plan);
642
+ const createdMirrors = createMissingRelationships(plan, movedLocals);
643
+ const synchronizedMirrors = applyMirrorActions(plan, movedLocals);
644
+ updateMirrorMetadata(plan.stateFile, new Map([...createdMirrors, ...synchronizedMirrors]), plan);
645
+ retargetMovedLinks(plan, movedLinks, movedLocals);
646
+ }
647
+ function claimId(preset) {
648
+ return `preset:${preset}`;
649
+ }
650
+ function readStringListRecord(value, field) {
651
+ const record = value ?? {};
652
+ if (!record || typeof record !== 'object' || Array.isArray(record) ||
653
+ Object.values(record).some((items) => !Array.isArray(items) || items.some((item) => typeof item !== 'string')))
654
+ throw new Error(`invalid state ${field}`);
655
+ return record;
656
+ }
657
+ function readPresetActivations(state) {
658
+ const value = state.presetActivations;
659
+ if (value === undefined)
660
+ return {};
661
+ if (Array.isArray(value)) {
662
+ if (value.some((item) => typeof item !== 'string'))
663
+ throw new Error('invalid state presetActivations');
664
+ return Object.fromEntries(value.map((name) => [name, []]));
665
+ }
666
+ return readStringListRecord(value, 'presetActivations');
667
+ }
668
+ function scopeScan(home, scope = {}) {
669
+ if (scope.projectPath) {
670
+ const report = scanProjectInventory(home, scope.projectPath, scope.targets, { persist: false });
671
+ return {
672
+ report,
673
+ stateFile: report.stateFile,
674
+ catalogState: readState(home),
675
+ policyState: readStateFile(report.stateFile),
676
+ };
677
+ }
678
+ const report = scanGlobalInventory(home, scope.targets, { persist: false });
679
+ const stateFile = statePath(home);
680
+ const catalogState = readStateFile(stateFile);
681
+ return { report, stateFile, catalogState, policyState: catalogState };
682
+ }
683
+ function resolveTargetKeys(report, targetNames) {
684
+ if (targetNames.length === 0)
685
+ throw new Error('at least one Target is required');
686
+ return targetNames.map((name) => {
687
+ const matches = report.targets.filter((target) => {
688
+ if (target.key !== name && target.id !== name)
689
+ return false;
690
+ if (report.scope === 'project')
691
+ return target.scope === 'project';
692
+ return target.scope === 'global';
693
+ });
694
+ if (matches.length !== 1)
695
+ throw new Error(`unknown Target: ${name}`);
696
+ return matches[0];
697
+ });
698
+ }
699
+ function resourceSlots(resource, target) {
700
+ const existing = [...new Set(resource.relationships.flatMap((relationship) => relationship.targetId === target.id ? [relationship.slot] : []))];
701
+ return existing.length > 0 ? existing : [normalizeSlotName(resource.name)];
702
+ }
703
+ function expandPresetClaims(home, report, catalogState, activations, previousLastClaims) {
704
+ const presets = readPresets(catalogState);
705
+ const claims = new Map();
706
+ const lastClaims = {};
707
+ const resourcesBySlot = new Map();
708
+ const staleResourceIds = new Set();
709
+ const resourceById = new Map(report.resources.map((resource) => [resource.id, resource]));
710
+ for (const [preset, targetKeys] of Object.entries(activations)) {
711
+ const definition = presets[preset];
712
+ if (!definition) {
713
+ if (previousLastClaims[preset])
714
+ lastClaims[preset] = [...previousLastClaims[preset]];
715
+ continue;
716
+ }
717
+ const targets = targetKeys.length > 0
718
+ ? resolveTargetKeys(report, targetKeys)
719
+ : report.targets.filter((target) => report.scope === 'project' ? target.scope === 'project' : target.scope === 'global');
720
+ const slots = new Set();
721
+ for (const selector of definition.selectors) {
722
+ const expanded = expandSelector(home, selector, report);
723
+ for (const id of expanded.staleResourceIds)
724
+ staleResourceIds.add(id);
725
+ for (const resourceId of expanded.resourceIds) {
726
+ const resource = resourceById.get(resourceId);
727
+ if (!resource) {
728
+ staleResourceIds.add(resourceId);
729
+ continue;
730
+ }
731
+ for (const target of targets) {
732
+ for (const slot of resourceSlots(resource, target)) {
733
+ const slotId = `${target.id}\0${slot}`;
734
+ const set = claims.get(slotId) ?? new Set();
735
+ set.add(claimId(preset));
736
+ claims.set(slotId, set);
737
+ resourcesBySlot.set(slotId, resourceId);
738
+ slots.add(slotId);
739
+ }
740
+ }
741
+ }
742
+ }
743
+ lastClaims[preset] = [...slots].sort((a, b) => a.localeCompare(b));
744
+ }
745
+ return {
746
+ claims: Object.fromEntries([...claims].map(([slotId, ids]) => [
747
+ slotId,
748
+ [...ids].sort((a, b) => a.localeCompare(b)),
749
+ ])),
750
+ lastClaims,
751
+ resourcesBySlot,
752
+ staleResourceIds: [...staleResourceIds],
753
+ };
754
+ }
755
+ function frozenClaimSlots(lastClaims) {
756
+ return new Set(Object.values(lastClaims).flat());
757
+ }
758
+ function buildReconcileTargets(report, claims, lastClaims, baseIntent, resourcesBySlot, previousClaims) {
759
+ const frozen = frozenClaimSlots(lastClaims);
760
+ const slotIds = new Set([
761
+ ...Object.keys(claims),
762
+ ...Object.keys(previousClaims),
763
+ ...frozen,
764
+ ]);
765
+ const targets = [];
766
+ const baseIntentDefaults = {};
767
+ const resourceById = new Map(report.resources.map((resource) => [resource.id, resource]));
768
+ for (const slotId of slotIds) {
769
+ const separator = slotId.indexOf('\0');
770
+ if (separator < 0)
771
+ continue;
772
+ const targetId = slotId.slice(0, separator);
773
+ const slot = slotId.slice(separator + 1);
774
+ const target = report.targets.find((candidate) => candidate.id === targetId);
775
+ if (!target)
776
+ continue;
777
+ const claimed = (claims[slotId]?.length ?? 0) > 0 || frozen.has(slotId);
778
+ const intent = baseIntent[slotId];
779
+ // no claim and no base intent: leave Actual alone unless we previously claimed it
780
+ if (!claimed && intent === undefined && !previousClaims[slotId]?.length)
781
+ continue;
782
+ const relationships = report.slots.find((candidate) => candidate.targetId === targetId && candidate.name === slot)?.relationships ?? [];
783
+ if (relationships.length > 1)
784
+ throw slotConflict(slotId, relationships);
785
+ const desired = claimed
786
+ ? 'on'
787
+ : (intent ?? relationships[0]?.activation ?? 'off');
788
+ const resourceId = resourcesBySlot.get(slotId) ?? relationships[0]?.resourceId;
789
+ if (!resourceId)
790
+ continue;
791
+ const resource = resourceById.get(resourceId);
792
+ const relationship = relationships.find((candidate) => candidate.resourceId === resourceId)
793
+ ?? relationships[0];
794
+ if (relationship && relationships.length === 1 && relationship.resourceId &&
795
+ relationship.resourceId !== resourceId && desired === 'on')
796
+ throw slotConflict(slotId, relationships);
797
+ if (!relationship) {
798
+ if (desired === 'off')
799
+ continue;
800
+ baseIntentDefaults[slotId] = 'off';
801
+ const destination = path.join(target.discoveryRoot, slot);
802
+ preflightTarget(undefined, 'missing', desired, destination);
803
+ targets.push({
804
+ slotId,
805
+ targetId,
806
+ targetKey: target.key,
807
+ slot,
808
+ resourceId: resource?.id ?? resourceId,
809
+ from: 'missing',
810
+ intent: intent ?? 'off',
811
+ to: desired,
812
+ destination,
813
+ createForm: creationForm(target),
814
+ });
815
+ continue;
816
+ }
817
+ const from = relationship.activation;
818
+ if (claimed && intent === undefined)
819
+ baseIntentDefaults[slotId] = from;
820
+ if (from === desired) {
821
+ if (desired === 'on' && relationship.form === 'mirror' && relationship.mirror &&
822
+ !relationship.diverged && mirrorSource(report, relationship.mirror.sourceId).hash !== relationship.mirror.hash) {
823
+ targets.push({
824
+ slotId,
825
+ targetId,
826
+ targetKey: target.key,
827
+ slot,
828
+ resourceId: resource?.id ?? resourceId,
829
+ from,
830
+ intent: intent ?? desired,
831
+ to: desired,
832
+ relationship,
833
+ mirrorAction: 'sync',
834
+ });
835
+ }
836
+ continue;
837
+ }
838
+ const destination = activationDestination({
839
+ relationship,
840
+ from,
841
+ to: desired,
842
+ target,
843
+ slotName: slot,
844
+ });
845
+ preflightTarget(relationship, from, desired, destination);
846
+ const syncMirror = relationship.form === 'mirror' && from === 'off' && desired === 'on';
847
+ if (syncMirror)
848
+ assertMirrorMaySync(report, relationship);
849
+ targets.push({
850
+ slotId,
851
+ targetId,
852
+ targetKey: target.key,
853
+ slot,
854
+ resourceId: resource?.id ?? resourceId,
855
+ from,
856
+ intent: intent ?? desired,
857
+ to: desired,
858
+ relationship,
859
+ destination,
860
+ syncMirror,
861
+ });
862
+ }
863
+ preflightDependentLinks(report, targets);
864
+ return { targets, baseIntentDefaults };
865
+ }
866
+ function planFromActivations(home, activations, scope = {}) {
867
+ const { report, stateFile, catalogState, policyState } = scopeScan(home, scope);
868
+ const previousClaims = readStringListRecord(policyState.claims, 'claims');
869
+ const previousLastClaims = readStringListRecord(policyState.lastClaims, 'lastClaims');
870
+ const baseIntent = readBaseIntent(policyState);
871
+ const expanded = expandPresetClaims(home, report, catalogState, activations, previousLastClaims);
872
+ const { targets, baseIntentDefaults } = buildReconcileTargets(report, expanded.claims, expanded.lastClaims, baseIntent, expanded.resourcesBySlot, previousClaims);
873
+ return {
874
+ report,
875
+ expectedState: policyFingerprint(stateFile),
876
+ expectedCatalogState: policyFingerprint(statePath(home)),
877
+ targets,
878
+ staleResourceIds: expanded.staleResourceIds,
879
+ stateFile,
880
+ claims: expanded.claims,
881
+ lastClaims: expanded.lastClaims,
882
+ presetActivations: Object.fromEntries(Object.entries(activations).map(([name, targets]) => [
883
+ name,
884
+ [...targets].sort((a, b) => a.localeCompare(b)),
885
+ ])),
886
+ baseIntentDefaults,
887
+ };
888
+ }
889
+ export function planPresetReconcile(home, name, targetNames, scope = {}) {
890
+ const { policyState, catalogState } = scopeScan(home, scope);
891
+ const activations = readPresetActivations(policyState);
892
+ if (name) {
893
+ if (!readPresets(catalogState)[name] && !activations[name] &&
894
+ !readStringListRecord(policyState.lastClaims, 'lastClaims')[name])
895
+ throw new Error(`unknown preset: ${name}`);
896
+ if (!activations[name])
897
+ throw new Error(`preset is not active: ${name}`);
898
+ if (targetNames && targetNames.length > 0) {
899
+ const active = new Set(activations[name]);
900
+ for (const target of targetNames)
901
+ if (!active.has(target))
902
+ throw new Error(`preset is not active on Target: ${target}`);
903
+ }
904
+ }
905
+ // Always recompute claims from every active Preset so multi-preset Slots stay correct.
906
+ return planFromActivations(home, activations, scope);
907
+ }
908
+ export function activatePreset(home, name, targetNames, scope = {}) {
909
+ assertPresetName(name);
910
+ const { report, catalogState, policyState } = scopeScan(home, scope);
911
+ if (!readPresets(catalogState)[name])
912
+ throw new Error(`unknown preset: ${name}`);
913
+ resolveTargetKeys(report, targetNames);
914
+ const activations = readPresetActivations(policyState);
915
+ const current = new Set(activations[name] ?? []);
916
+ for (const target of targetNames)
917
+ current.add(target);
918
+ return planFromActivations(home, {
919
+ ...activations,
920
+ [name]: [...current],
921
+ }, scope);
922
+ }
923
+ export function deactivatePreset(home, name, targetNames, scope = {}) {
924
+ assertPresetName(name);
925
+ const { report, policyState } = scopeScan(home, scope);
926
+ resolveTargetKeys(report, targetNames);
927
+ const activations = readPresetActivations(policyState);
928
+ if (!(name in activations) &&
929
+ !readStringListRecord(policyState.lastClaims, 'lastClaims')[name])
930
+ throw new Error(`preset is not active: ${name}`);
931
+ const remaining = new Set(activations[name] ?? []);
932
+ for (const target of targetNames)
933
+ remaining.delete(target);
934
+ const next = { ...activations };
935
+ if (remaining.size === 0)
936
+ delete next[name];
937
+ else
938
+ next[name] = [...remaining];
939
+ return planFromActivations(home, next, scope);
940
+ }
941
+ export function applyPresetReconcile(home, plan, _scope = {}) {
942
+ assertPlanPreconditions(home, plan);
943
+ if (policyFingerprint(statePath(home)) !== plan.expectedCatalogState)
944
+ throw concurrentModification('Preset catalog changed after preview; preview again');
945
+ const state = readStateFile(plan.stateFile);
946
+ const baseIntent = { ...readBaseIntent(state) };
947
+ for (const [slotId, activation] of Object.entries(plan.baseIntentDefaults))
948
+ if (baseIntent[slotId] === undefined)
949
+ baseIntent[slotId] = activation;
950
+ state.baseIntent = baseIntent;
951
+ state.claims = plan.claims;
952
+ state.lastClaims = plan.lastClaims;
953
+ state.presetActivations = plan.presetActivations;
954
+ writeStateFile(plan.stateFile, state);
955
+ const { movedLinks, movedLocals } = moveRelationships(home, plan);
956
+ const createdMirrors = createMissingRelationships(plan, movedLocals);
957
+ const synchronizedMirrors = applyMirrorActions(plan, movedLocals);
958
+ updateMirrorMetadata(plan.stateFile, new Map([...createdMirrors, ...synchronizedMirrors]), plan);
959
+ retargetMovedLinks(plan, movedLinks, movedLocals);
960
+ }
961
+ export function deletePreset(home, name, options = {}) {
962
+ assertPresetName(name);
963
+ if (!options.yes)
964
+ throw new Error('deleting a Preset requires --yes');
965
+ const state = readState(home);
966
+ const presets = readPresets(state);
967
+ if (!presets[name])
968
+ throw new Error(`unknown preset: ${name}`);
969
+ const globalPolicy = readStateFile(statePath(home));
970
+ const activations = readPresetActivations(globalPolicy);
971
+ if (activations[name]?.length) {
972
+ const plan = deactivatePreset(home, name, activations[name]);
973
+ applyPresetReconcile(home, plan);
974
+ }
975
+ if (options.projectPath) {
976
+ const projectState = readStateFile(path.join(path.resolve(options.projectPath), '.skillspub', 'state.json'));
977
+ const projectActivations = readPresetActivations(projectState);
978
+ if (projectActivations[name]?.length) {
979
+ const plan = deactivatePreset(home, name, projectActivations[name], {
980
+ projectPath: options.projectPath,
981
+ });
982
+ applyPresetReconcile(home, plan, { projectPath: options.projectPath });
983
+ }
984
+ }
985
+ const latest = readState(home);
986
+ const { [name]: _, ...remaining } = readPresets(latest);
987
+ latest.presets = remaining;
988
+ writeState(home, latest);
989
+ }
990
+ function assertUnlinkAllowed(stateFile, slotIdOrName) {
991
+ const state = readStateFile(stateFile);
992
+ const claims = readStringListRecord(state.claims, 'claims');
993
+ const lastClaims = readStringListRecord(state.lastClaims, 'lastClaims');
994
+ const slotIds = slotIdOrName.includes('\0')
995
+ ? [slotIdOrName]
996
+ : Object.keys(claims).filter((slotId) => slotId.endsWith(`\0${slotIdOrName}`));
997
+ const check = (slotId) => {
998
+ if ((claims[slotId]?.length ?? 0) > 0)
999
+ throw new Error(`cannot unlink claimed Target Slot ${slotId.replace('\0', '/')}`);
1000
+ for (const [preset, slots] of Object.entries(lastClaims))
1001
+ if (slots.includes(slotId))
1002
+ throw new Error(`cannot unlink claimed Target Slot ${slotId.replace('\0', '/')} (${preset})`);
1003
+ };
1004
+ if (slotIdOrName.includes('\0'))
1005
+ check(slotIdOrName);
1006
+ else {
1007
+ for (const slotId of slotIds)
1008
+ check(slotId);
1009
+ for (const [preset, slots] of Object.entries(lastClaims))
1010
+ for (const slotId of slots)
1011
+ if (slotId.endsWith(`\0${slotIdOrName}`))
1012
+ throw new Error(`cannot unlink claimed Target Slot ${slotId.replace('\0', '/')} (${preset})`);
1013
+ }
1014
+ }