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.
package/dist/shared.js ADDED
@@ -0,0 +1,1411 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { stripVTControlCharacters } from 'node:util';
6
+ import { hashDirectory, loadTargets, readStateFile, scanGlobalInventory, scanProjectInventory, writeStateFile, } from "./inventory.js";
7
+ import { NPX_SKILLS_PACKAGE, checkNpxSkillsSource, npxSkillsAddArgs, npxSkillsDescribeArgs, npxSkillsFindArgs, npxSkillsProvenanceLabel, npxSkillsRemoveArgs, npxSkillsUpdateArgs, npxSkillsSourceKey, normalizeNpxSkillsName, parseNpxSkillsFindOutput, readNpxSkillsLock, runNpxSkills, sameNpxSkillsSource, } from "./npx-skills.js";
8
+ function scan(home, projectPath, persist = false) {
9
+ const targets = loadTargets(home);
10
+ return projectPath
11
+ ? scanProjectInventory(home, projectPath, targets, { persist })
12
+ : scanGlobalInventory(home, targets, { persist });
13
+ }
14
+ function targetFromInventory(home, report) {
15
+ const exactProject = report.scope === 'project' ? report.projectPath : undefined;
16
+ const matches = report.targets.filter((target) => target.kind === 'shared' && target.key === 'shared' && target.writable &&
17
+ (exactProject ? target.scope === 'project' : target.scope === 'global'));
18
+ if (matches.length !== 1)
19
+ throw new Error(`expected exactly one writable Shared Target named "shared"; found ${matches.length}`);
20
+ const target = matches[0];
21
+ const canonicalRoot = exactProject
22
+ ? path.join(exactProject, '.agents', 'skills')
23
+ : path.join(os.homedir(), '.agents', 'skills');
24
+ if (path.resolve(target.discoveryRoot) !== canonicalRoot)
25
+ throw new Error(`Shared Target must use canonical root ${canonicalRoot}`);
26
+ if (!target.lockFile)
27
+ throw new Error('Shared Target has no installer lock');
28
+ return {
29
+ home,
30
+ projectPath: exactProject,
31
+ target,
32
+ report,
33
+ cwd: exactProject ?? process.cwd(),
34
+ lockFile: target.lockFile,
35
+ };
36
+ }
37
+ function resolveTarget(home, projectPath) {
38
+ const exactProject = projectPath ? fs.realpathSync(projectPath) : undefined;
39
+ return targetFromInventory(home, scan(home, exactProject));
40
+ }
41
+ function validateName(name) {
42
+ if (!name || name === '.' || name === '..' || /[\\/\0]/.test(name))
43
+ throw new Error(`invalid skill name: ${name || '(empty)'}`);
44
+ return normalizeNpxSkillsName(name);
45
+ }
46
+ function validateSource(source) {
47
+ if (!source || source.startsWith('-'))
48
+ throw new Error('source is required');
49
+ }
50
+ function relationship(target, slot) {
51
+ const relationships = target.report.relationships.filter((item) => item.targetId === target.target.id && item.slot === slot);
52
+ if (relationships.length > 1)
53
+ throw new Error(`Target Slot ${target.target.id}/${slot} has ON/OFF or normalized-name conflicts`);
54
+ const found = relationships[0];
55
+ if (found?.form === 'link' && !found.realPath)
56
+ throw new Error(`Target Slot ${target.target.id}/${slot} is a broken link`);
57
+ return found;
58
+ }
59
+ function concurrentModification(message) {
60
+ return Object.assign(new Error(message), { code: 'concurrent_modification' });
61
+ }
62
+ function operationLock(target) {
63
+ return `${target.lockFile}.skillspub-operation-lock`;
64
+ }
65
+ export function sharedOperationLockPath(home, projectPath) {
66
+ return operationLock(resolveTarget(home, projectPath));
67
+ }
68
+ function assertNoOperationLock(target) {
69
+ const lock = operationLock(target);
70
+ if (fs.existsSync(lock))
71
+ throw concurrentModification(`Shared Target operation already in progress: ${lock}`);
72
+ }
73
+ function withOperationLock(target, operation) {
74
+ const lock = operationLock(target);
75
+ fs.mkdirSync(path.dirname(lock), { recursive: true });
76
+ let descriptor;
77
+ try {
78
+ descriptor = fs.openSync(lock, 'wx');
79
+ }
80
+ catch (error) {
81
+ if (error.code === 'EEXIST')
82
+ throw concurrentModification(`Shared Target operation already in progress: ${lock}`);
83
+ throw error;
84
+ }
85
+ try {
86
+ fs.writeFileSync(descriptor, `${process.pid}\n`);
87
+ return operation();
88
+ }
89
+ finally {
90
+ fs.closeSync(descriptor);
91
+ fs.rmSync(lock, { force: true });
92
+ }
93
+ }
94
+ function managedIdentity(target, skill) {
95
+ const current = relationship(target, skill.slot);
96
+ let installedHash = 'missing';
97
+ if (current) {
98
+ try {
99
+ installedHash = hashDirectory(current.path);
100
+ }
101
+ catch {
102
+ installedHash = 'unreadable';
103
+ }
104
+ }
105
+ return {
106
+ identity: JSON.stringify({
107
+ name: skill.name,
108
+ source: skill.provenance.source,
109
+ sourceUrl: skill.provenance.sourceUrl,
110
+ sourceType: skill.sourceType,
111
+ ref: skill.ref,
112
+ skillPath: skill.provenance.skillPath,
113
+ skillFolderHash: skill.skillFolderHash,
114
+ computedHash: skill.computedHash,
115
+ installedHash,
116
+ }),
117
+ installed: installedHash !== 'missing' && installedHash !== 'unreadable',
118
+ };
119
+ }
120
+ function isCachedUpdateStatus(value) {
121
+ return typeof value === 'string' &&
122
+ ['current', 'available', 'upstream-missing', 'check-failed'].includes(value);
123
+ }
124
+ function updateAvailabilityCache(target) {
125
+ const raw = readStateFile(target.report.stateFile).updateAvailability;
126
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw) ||
127
+ raw.version !== 1)
128
+ return new Map();
129
+ const entries = raw.entries;
130
+ if (!entries || typeof entries !== 'object' || Array.isArray(entries))
131
+ return new Map();
132
+ const result = new Map();
133
+ for (const [slot, value] of Object.entries(entries)) {
134
+ if (!value || typeof value !== 'object' || Array.isArray(value))
135
+ continue;
136
+ const entry = value;
137
+ if (typeof entry.identity !== 'string' || !isCachedUpdateStatus(entry.status) ||
138
+ typeof entry.checkedAt !== 'string' ||
139
+ (entry.error !== undefined && typeof entry.error !== 'string'))
140
+ continue;
141
+ result.set(slot, {
142
+ identity: entry.identity,
143
+ status: entry.status,
144
+ checkedAt: entry.checkedAt,
145
+ ...(entry.error ? { error: entry.error } : {}),
146
+ });
147
+ }
148
+ return result;
149
+ }
150
+ function availabilityResult(target, skills, entries) {
151
+ return {
152
+ scope: target.projectPath ? 'project' : 'global',
153
+ ...(target.projectPath ? { projectPath: target.projectPath } : {}),
154
+ entries: skills.map((skill) => {
155
+ const cached = entries.get(skill.slot);
156
+ const currentIdentity = managedIdentity(target, skill).identity;
157
+ const current = cached?.identity === currentIdentity ? cached : undefined;
158
+ return {
159
+ name: skill.name,
160
+ slot: skill.slot,
161
+ source: npxSkillsProvenanceLabel(skill.provenance),
162
+ ...(skill.provenance.skillPath ? { skillPath: skill.provenance.skillPath } : {}),
163
+ status: current?.status ?? 'unknown',
164
+ ...(current ? { checkedAt: current.checkedAt } : {}),
165
+ ...(current?.error ? { error: current.error } : {}),
166
+ };
167
+ }),
168
+ };
169
+ }
170
+ function managedSelection(target, requested) {
171
+ const managed = readNpxSkillsLock(target.lockFile);
172
+ const bySlot = new Map(managed.map((skill) => [skill.slot, skill]));
173
+ const selected = requested.length > 0
174
+ ? requested.map((name) => {
175
+ const found = bySlot.get(validateName(name));
176
+ if (!found)
177
+ throw new Error(`${name} is not managed by ${NPX_SKILLS_PACKAGE}`);
178
+ return found;
179
+ })
180
+ : managed;
181
+ if (selected.length === 0)
182
+ throw new Error(`no skills managed by ${NPX_SKILLS_PACKAGE}`);
183
+ return [...new Map(selected.map((skill) => [skill.slot, skill])).values()];
184
+ }
185
+ function stringLists(value, field) {
186
+ const lists = value ?? {};
187
+ if (!lists || typeof lists !== 'object' || Array.isArray(lists) ||
188
+ Object.values(lists).some((items) => !Array.isArray(items) || items.some((item) => typeof item !== 'string')))
189
+ throw new Error(`invalid state ${field}`);
190
+ return lists;
191
+ }
192
+ function claimedSlots(state) {
193
+ const claims = stringLists(state.claims, 'claims');
194
+ const lastClaims = stringLists(state.lastClaims, 'lastClaims');
195
+ return new Set([
196
+ ...Object.entries(claims)
197
+ .filter(([, claimIds]) => claimIds.length > 0)
198
+ .map(([slotId]) => slotId),
199
+ ...Object.values(lastClaims).flat(),
200
+ ]);
201
+ }
202
+ function baseIntents(state) {
203
+ const intents = state.baseIntent ?? {};
204
+ if (!intents || typeof intents !== 'object' || Array.isArray(intents) ||
205
+ Object.values(intents).some((intent) => intent !== 'on' && intent !== 'off'))
206
+ throw new Error('invalid state baseIntent');
207
+ return intents;
208
+ }
209
+ function desiredActivation(target, skill, current) {
210
+ const state = readStateFile(target.report.stateFile);
211
+ const id = `${target.target.id}\0${skill.slot}`;
212
+ const claims = claimedSlots(state);
213
+ const intents = baseIntents(state);
214
+ if (claims.has(id))
215
+ return 'on';
216
+ return intents[id] ?? current.activation;
217
+ }
218
+ function validatePolicyState(target) {
219
+ const state = readStateFile(target.report.stateFile);
220
+ claimedSlots(state);
221
+ baseIntents(state);
222
+ }
223
+ function move(relationship, destinationRoot) {
224
+ const destination = path.join(destinationRoot, relationship.name);
225
+ if (fs.existsSync(destination) || fs.lstatSync(destination, { throwIfNoEntry: false }))
226
+ throw new Error(`path conflict: ${destination}`);
227
+ fs.mkdirSync(destinationRoot, { recursive: true });
228
+ fs.renameSync(relationship.path, destination);
229
+ }
230
+ function desiredFor(target, skills) {
231
+ const desired = new Map();
232
+ for (const skill of skills) {
233
+ const current = relationship(target, skill.slot);
234
+ if (!current)
235
+ throw new Error(`installer lock/file mismatch: ${skill.name}`);
236
+ desired.set(skill.slot, desiredActivation(target, skill, current));
237
+ }
238
+ return desired;
239
+ }
240
+ function dependencyFingerprint(form, entryPath) {
241
+ const stat = fs.lstatSync(entryPath, { throwIfNoEntry: false });
242
+ if (!stat || (form === 'link' && !stat.isSymbolicLink()))
243
+ throw new Error(`relationship disappeared during preview: ${entryPath}`);
244
+ return form === 'link' ? fs.readlinkSync(entryPath) : hashDirectory(entryPath);
245
+ }
246
+ function pathIsWithin(root, entryPath) {
247
+ const relative = path.relative(root, entryPath);
248
+ return relative !== '' && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative);
249
+ }
250
+ function removalManifestPath(target, slot) {
251
+ const id = crypto.createHash('sha256').update(`${target.target.id}\0${slot}`).digest('hex').slice(0, 12);
252
+ return `${target.lockFile}.skillspub-remove-${id}.json`;
253
+ }
254
+ function removalDependencies(target, source) {
255
+ if (!source.resourceId)
256
+ return [];
257
+ return target.report.relationships
258
+ .filter((item) => item.path !== source.path &&
259
+ (item.form === 'link' || item.form === 'mirror') && item.resourceId === source.resourceId)
260
+ .map((item) => ({
261
+ scope: target.report.targets.find(({ id }) => id === item.targetId)?.scope ?? 'global',
262
+ targetId: item.targetId,
263
+ targetKey: item.targetKey,
264
+ name: item.name,
265
+ slot: item.slot,
266
+ form: item.form,
267
+ activation: item.activation,
268
+ path: item.path,
269
+ source: source.path,
270
+ resourceId: item.resourceId,
271
+ fingerprint: dependencyFingerprint(item.form, item.path),
272
+ plannedAction: 'delete',
273
+ }))
274
+ .sort((left, right) => left.scope.localeCompare(right.scope) ||
275
+ left.targetId.localeCompare(right.targetId) ||
276
+ left.path.localeCompare(right.path));
277
+ }
278
+ function sameDependencies(expected, actual) {
279
+ return expected.length === actual.length && expected.every((dependency, index) => JSON.stringify(dependency) === JSON.stringify(actual[index]));
280
+ }
281
+ function removalBlockers(target, skill, source, sourceRelationships, dependencies) {
282
+ const claims = claimedSlots(readStateFile(target.report.stateFile));
283
+ const blockers = [];
284
+ if (sourceRelationships.length !== 1)
285
+ blockers.push(`Target Slot ${target.target.id}/${skill.slot} has unresolved same-name or ON/OFF conflicts.`);
286
+ if (source) {
287
+ if (source.form !== 'local' || !source.resourceId)
288
+ blockers.push('Shared source ownership is unknown; expected one local Vercel-managed resource.');
289
+ if (!pathIsWithin(target.target.discoveryRoot, source.path) &&
290
+ !pathIsWithin(target.target.parkingRoot, source.path))
291
+ blockers.push(`unsafe Shared source path: ${source.path}`);
292
+ }
293
+ else {
294
+ blockers.push(`installer lock/file mismatch: ${skill.name}`);
295
+ }
296
+ if (!skill.provenance.source && !skill.provenance.sourceUrl)
297
+ blockers.push('Shared source ownership is not proven by the Vercel skills lock.');
298
+ if (source && writableAt(path.dirname(source.path)) === 'blocked')
299
+ blockers.push(`Shared source parent is not writable: ${path.dirname(source.path)}`);
300
+ if (writableAt(path.dirname(target.lockFile)) === 'blocked')
301
+ blockers.push(`Source lock directory is not writable: ${path.dirname(target.lockFile)}`);
302
+ if (writableAt(path.dirname(target.report.stateFile)) === 'blocked')
303
+ blockers.push(`policy state directory is not writable: ${path.dirname(target.report.stateFile)}`);
304
+ const sourceSlotId = `${target.target.id}\0${skill.slot}`;
305
+ if (claims.has(sourceSlotId))
306
+ blockers.push(`cannot remove claimed Target Slot ${sourceSlotId.replace('\0', '/')}`);
307
+ for (const dependency of dependencies) {
308
+ const slotId = `${dependency.targetId}\0${dependency.slot}`;
309
+ if (claims.has(slotId))
310
+ blockers.push(`cannot remove claimed Target Slot ${slotId.replace('\0', '/')}`);
311
+ const dependencyTarget = target.report.targets.find(({ id }) => id === dependency.targetId);
312
+ if (dependencyTarget && !pathIsWithin(dependencyTarget.discoveryRoot, dependency.path) &&
313
+ !pathIsWithin(dependencyTarget.parkingRoot, dependency.path))
314
+ blockers.push(`unsafe dependent Relationship path: ${dependency.path}`);
315
+ if (target.report.relationships.find((item) => item.path === dependency.path)?.readOnly)
316
+ blockers.push(`cannot remove Shared source with read-only dependent Relationship: ${dependency.path}`);
317
+ if (writableAt(path.dirname(dependency.path)) === 'blocked')
318
+ blockers.push(`dependent Relationship parent is not writable: ${path.dirname(dependency.path)}`);
319
+ }
320
+ return [...new Set(blockers)];
321
+ }
322
+ function assertRemovalDependenciesAllowed(target, dependencies) {
323
+ const claims = claimedSlots(readStateFile(target.report.stateFile));
324
+ for (const dependency of dependencies) {
325
+ const slotId = `${dependency.targetId}\0${dependency.slot}`;
326
+ if (claims.has(slotId))
327
+ throw new Error(`cannot remove claimed Target Slot ${slotId.replace('\0', '/')}`);
328
+ if (dependencyFingerprint(dependency.form, dependency.path) !== dependency.fingerprint)
329
+ throw concurrentModification(`dependent Relationship changed after preview: ${dependency.path}`);
330
+ }
331
+ }
332
+ function stageDependencies(target, dependencies) {
333
+ if (dependencies.length === 0)
334
+ return undefined;
335
+ assertRemovalDependenciesAllowed(target, dependencies);
336
+ const root = fs.mkdtempSync(path.join(path.dirname(target.lockFile), '.skillspub-remove-'));
337
+ const staged = [];
338
+ const rollback = () => {
339
+ for (const item of staged.toReversed())
340
+ fs.renameSync(item.to, item.from);
341
+ fs.rmSync(root, { recursive: true, force: true });
342
+ };
343
+ try {
344
+ for (const [index, dependency] of dependencies.entries()) {
345
+ assertRemovalDependenciesAllowed(target, [dependency]);
346
+ const destination = path.join(root, String(index));
347
+ fs.renameSync(dependency.path, destination);
348
+ staged.push({ from: dependency.path, to: destination });
349
+ }
350
+ }
351
+ catch (error) {
352
+ rollback();
353
+ throw error;
354
+ }
355
+ return {
356
+ stagingRoot: root,
357
+ rollback,
358
+ commit: () => fs.rmSync(root, { recursive: true, force: true }),
359
+ };
360
+ }
361
+ function removeDependencyState(target, dependencies) {
362
+ if (dependencies.length === 0)
363
+ return;
364
+ const state = readStateFile(target.report.stateFile);
365
+ const baseIntent = { ...baseIntents(state) };
366
+ const mirrors = { ...state.mirrors };
367
+ for (const { targetId, slot } of dependencies) {
368
+ const slotId = `${targetId}\0${slot}`;
369
+ delete baseIntent[slotId];
370
+ delete mirrors[slotId];
371
+ }
372
+ const { baseIntent: _baseIntent, mirrors: _mirrors, ...remaining } = state;
373
+ writeStateFile(target.report.stateFile, {
374
+ ...remaining,
375
+ ...(Object.keys(baseIntent).length > 0 ? { baseIntent } : {}),
376
+ ...(Object.keys(mirrors).length > 0 ? { mirrors } : {}),
377
+ });
378
+ }
379
+ function manifestMatches(manifestPath, targetId, slot, sourceFingerprint, lockFingerprint) {
380
+ try {
381
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
382
+ return manifest.targetId === targetId && manifest.slot === slot &&
383
+ manifest.sourceFingerprint === sourceFingerprint &&
384
+ manifest.lockFingerprint === lockFingerprint;
385
+ }
386
+ catch {
387
+ return false;
388
+ }
389
+ }
390
+ function assertRemovalPlan(expected, fresh) {
391
+ if (expected.targetId !== fresh.targetId || expected.source.slot !== fresh.source.slot ||
392
+ expected.source.path !== fresh.source.path || expected.source.fingerprint !== fresh.source.fingerprint ||
393
+ expected.preconditions.lock.hash !== fresh.preconditions.lock.hash ||
394
+ expected.preconditions.policy.hash !== fresh.preconditions.policy.hash ||
395
+ !sameDependencies(expected.dependencies, fresh.dependencies))
396
+ throw concurrentModification('Shared source or dependent Relationships changed after preview');
397
+ }
398
+ function assertRemovalUnblocked(plan) {
399
+ if (plan.blockers.length > 0)
400
+ throw new Error(plan.blockers.join('; '));
401
+ }
402
+ export function planSharedRemove(home, names, projectPath) {
403
+ if (names.length !== 1)
404
+ throw new Error('usage: skillspub shared remove <managed-name>');
405
+ const target = resolveTarget(home, projectPath);
406
+ validatePolicyState(target);
407
+ assertNoOperationLock(target);
408
+ const skill = managedSelection(target, names)[0];
409
+ const sourceRelationships = target.report.relationships.filter((item) => item.targetId === target.target.id && item.slot === skill.slot);
410
+ const source = sourceRelationships.length === 1 ? sourceRelationships[0] : undefined;
411
+ const dependencies = source ? removalDependencies(target, source) : [];
412
+ const blockers = removalBlockers(target, skill, source, sourceRelationships, dependencies);
413
+ const sourcePath = source?.path ?? path.join(target.target.discoveryRoot, skill.slot);
414
+ const sourceEntry = contentFingerprint(sourcePath);
415
+ const desired = source ? desiredActivation(target, skill, source) : 'unknown';
416
+ const manifest = removalManifestPath(target, skill.slot);
417
+ const provenance = npxSkillsProvenanceLabel(skill.provenance);
418
+ return {
419
+ operation: 'shared.remove',
420
+ targetId: target.target.id,
421
+ slots: [skill.slot],
422
+ source: {
423
+ name: skill.name,
424
+ slot: skill.slot,
425
+ path: sourcePath,
426
+ provenance,
427
+ fingerprint: sourceEntry.hash,
428
+ },
429
+ scope: {
430
+ kind: target.projectPath ? 'project' : 'global',
431
+ path: target.projectPath ?? path.dirname(path.dirname(target.target.discoveryRoot)),
432
+ },
433
+ target: {
434
+ discoveryRoot: target.target.discoveryRoot,
435
+ parkingRoot: target.target.parkingRoot,
436
+ stateFile: target.report.stateFile,
437
+ lockFile: target.lockFile,
438
+ },
439
+ sourceAdapter: {
440
+ package: NPX_SKILLS_PACKAGE,
441
+ removeOwner: 'vercel-skills',
442
+ proceedOwner: 'vercel-skills',
443
+ },
444
+ preconditions: {
445
+ sourceEntry,
446
+ lock: {
447
+ path: target.lockFile,
448
+ hash: contentFingerprint(target.lockFile).hash,
449
+ owner: skill.provenance.source || skill.provenance.sourceUrl ? 'vercel-skills' : 'unknown',
450
+ },
451
+ policy: {
452
+ path: target.report.stateFile,
453
+ hash: contentFingerprint(target.report.stateFile).hash,
454
+ },
455
+ permissions: {
456
+ source: writableAt(path.dirname(sourcePath)),
457
+ state: writableAt(path.dirname(target.report.stateFile)),
458
+ lock: writableAt(path.dirname(target.lockFile)),
459
+ dependencies: dependencies.map(({ path: dependencyPath }) => ({
460
+ path: dependencyPath,
461
+ status: writableAt(path.dirname(dependencyPath)),
462
+ })),
463
+ },
464
+ },
465
+ selection: {
466
+ included: [
467
+ { identity: `${provenance}\0${skill.name}`, reason: 'proven Vercel-managed local Shared source' },
468
+ ...dependencies.map(({ targetId, slot }) => ({
469
+ identity: `${targetId}\0${slot}`,
470
+ reason: 'known dependent Link or Mirror',
471
+ })),
472
+ ],
473
+ excluded: [],
474
+ },
475
+ dependencies,
476
+ blockers,
477
+ warnings: target.projectPath ? [] : [
478
+ 'SkillsPub has no central project index; projects outside this scan may retain broken Links when unopened.',
479
+ ],
480
+ cascadeConfirmed: manifestMatches(manifest, target.target.id, skill.slot, sourceEntry.hash, contentFingerprint(target.lockFile).hash),
481
+ recovery: {
482
+ operationLock: `${target.lockFile}.skillspub-operation-lock`,
483
+ manifest,
484
+ evidence: [target.lockFile, target.report.stateFile, manifest, 'final filesystem rescan'],
485
+ completedWork: 'preserved',
486
+ },
487
+ currentTruth: {
488
+ actual: actualSummary(target.report, target.target.id, [skill.slot]),
489
+ desired,
490
+ drift: source && desired !== 'unknown' && source.activation === desired ? 'none' : 'observed',
491
+ source: provenance,
492
+ relationships: dependencies.length,
493
+ },
494
+ expectedFinalTruth: {
495
+ actual: `${skill.slot}=missing`,
496
+ desired: 'removed',
497
+ drift: 'none',
498
+ source: 'removed',
499
+ relationships: 0,
500
+ effectiveVisibility: 'recompute-after-rescan',
501
+ },
502
+ };
503
+ }
504
+ function contentFingerprint(entryPath) {
505
+ const stat = fs.lstatSync(entryPath, { throwIfNoEntry: false });
506
+ if (!stat)
507
+ return { path: entryPath, state: 'missing', hash: 'missing' };
508
+ const hash = stat.isDirectory() || stat.isSymbolicLink()
509
+ ? hashDirectory(entryPath)
510
+ : crypto.createHash('sha256').update(fs.readFileSync(entryPath)).digest('hex');
511
+ return { path: entryPath, state: 'present', hash };
512
+ }
513
+ function writableAt(entryPath) {
514
+ let current = entryPath;
515
+ while (!fs.existsSync(current)) {
516
+ const parent = path.dirname(current);
517
+ if (parent === current)
518
+ return 'blocked';
519
+ current = parent;
520
+ }
521
+ try {
522
+ fs.accessSync(current, fs.constants.W_OK);
523
+ return 'writable';
524
+ }
525
+ catch {
526
+ return 'blocked';
527
+ }
528
+ }
529
+ function policyIntent(target, slot, resourceId, fallback) {
530
+ const policy = readStateFile(target.report.stateFile);
531
+ const catalog = readStateFile(path.join(target.home.configDir, 'state.json'));
532
+ const slotId = `${target.target.id}\0${slot}`;
533
+ const claims = stringLists(policy.claims, 'claims')[slotId] ?? [];
534
+ const lastClaims = Object.entries(stringLists(policy.lastClaims, 'lastClaims'))
535
+ .filter(([, slots]) => slots.includes(slotId))
536
+ .map(([preset]) => `preset:${preset}`);
537
+ const baseIntent = baseIntents(policy)[slotId] ?? fallback;
538
+ const tags = resourceId
539
+ ? stringLists(catalog.tags, 'tags')[resourceId] ?? []
540
+ : [];
541
+ const bundles = resourceId
542
+ ? Object.entries(stringLists(catalog.bundles, 'bundles'))
543
+ .filter(([, members]) => members.includes(resourceId))
544
+ .map(([bundle]) => bundle)
545
+ .sort()
546
+ : [];
547
+ const presets = catalog.presets && typeof catalog.presets === 'object' && !Array.isArray(catalog.presets)
548
+ ? catalog.presets
549
+ : {};
550
+ const presetSelectors = resourceId
551
+ ? Object.entries(presets)
552
+ .filter(([, preset]) => Array.isArray(preset.selectors) && preset.selectors.includes(`skill:${resourceId}`))
553
+ .map(([preset]) => preset)
554
+ .sort()
555
+ : [];
556
+ return {
557
+ baseIntent,
558
+ tags: [...tags],
559
+ bundles,
560
+ presetClaims: [...new Set([...claims, ...lastClaims])].sort(),
561
+ presetSelectors,
562
+ };
563
+ }
564
+ function addRelationshipEffects(target, slot, existing, replacement) {
565
+ if (!existing)
566
+ return [{
567
+ scope: target.target.scope,
568
+ targetId: target.target.id,
569
+ targetKey: target.target.key,
570
+ resourceId: path.join(target.target.discoveryRoot, slot),
571
+ name: slot,
572
+ slot,
573
+ form: 'local',
574
+ activation: 'on',
575
+ sourcePath: path.join(target.target.discoveryRoot, slot),
576
+ targetPath: path.join(target.target.discoveryRoot, slot),
577
+ plannedAction: 'create',
578
+ sourcePreserved: true,
579
+ }];
580
+ const resourceId = existing.resourceId ?? existing.realPath ?? existing.path;
581
+ return target.report.relationships
582
+ .filter((item) => item.resourceId === resourceId)
583
+ .map((item) => ({
584
+ scope: item.targetId === target.target.id ? target.target.scope :
585
+ target.report.targets.find(({ id }) => id === item.targetId)?.scope ?? 'global',
586
+ targetId: item.targetId,
587
+ targetKey: item.targetKey,
588
+ resourceId,
589
+ name: item.name,
590
+ slot: item.slot,
591
+ form: item.form,
592
+ activation: item.activation,
593
+ sourcePath: existing.path,
594
+ targetPath: item.path,
595
+ plannedAction: item.targetId === target.target.id
596
+ ? replacement ? 'replace-content' : 'refresh-content'
597
+ : item.form === 'mirror'
598
+ ? 'mirror-sync'
599
+ : replacement ? 'consume-replacement' : 'consume-refresh',
600
+ sourcePreserved: true,
601
+ }))
602
+ .sort((left, right) => left.scope.localeCompare(right.scope) ||
603
+ left.targetId.localeCompare(right.targetId) ||
604
+ left.targetPath.localeCompare(right.targetPath));
605
+ }
606
+ function buildSharedAddPlan(target, source, name, replace) {
607
+ const slot = validateName(name);
608
+ const existing = relationship(target, slot);
609
+ const slotInfo = target.report.slots.find((item) => item.targetId === target.target.id && item.name === slot);
610
+ const managed = readNpxSkillsLock(target.lockFile).find((skill) => skill.slot === slot);
611
+ const currentSource = existing ? npxSkillsProvenanceLabel(slotInfo?.provenance) : undefined;
612
+ const replacement = existing && !sameNpxSkillsSource(source, name, slotInfo?.provenance)
613
+ ? { from: currentSource, to: source }
614
+ : undefined;
615
+ if (existing)
616
+ desiredFor(target, [{ name: existing.name, slot, provenance: slotInfo?.provenance ?? {} }]);
617
+ const discoveryEntry = path.join(target.target.discoveryRoot, slot);
618
+ const parkingEntry = path.join(target.target.parkingRoot, slot);
619
+ const sourceEntry = existing?.path ?? discoveryEntry;
620
+ const sourceFingerprint = contentFingerprint(sourceEntry);
621
+ const discoveryFingerprint = contentFingerprint(discoveryEntry);
622
+ const parkingFingerprint = contentFingerprint(parkingEntry);
623
+ const lockOwner = managed
624
+ ? 'vercel-skills'
625
+ : existing ? 'unknown' : 'unclaimed';
626
+ const intentPreservation = policyIntent(target, slot, existing?.resourceId ?? existing?.realPath ?? existing?.path, existing?.activation ?? 'on');
627
+ const desired = intentPreservation.presetClaims.length > 0 ? 'on' : intentPreservation.baseIntent;
628
+ const relationshipEffects = addRelationshipEffects(target, slot, existing, Boolean(replacement));
629
+ const pathConflicts = [discoveryFingerprint, parkingFingerprint].flatMap((entry) => {
630
+ const isExpectedExisting = existing && entry.path === existing.path;
631
+ return entry.state === 'present' && !isExpectedExisting
632
+ ? [`Shared Slot path conflict: ${entry.path}`]
633
+ : [];
634
+ });
635
+ const blockers = [
636
+ ...pathConflicts,
637
+ ...(replacement && !replace ? ['source replacement requires --replace'] : []),
638
+ ...(existing && lockOwner === 'unknown'
639
+ ? ['Shared Slot ownership is not proven by the Vercel skills lock.']
640
+ : []),
641
+ ...(writableAt(target.target.discoveryRoot) === 'blocked' ? ['Shared Target is not writable.'] : []),
642
+ ...(writableAt(path.dirname(target.lockFile)) === 'blocked' ? ['Source lock directory is not writable.'] : []),
643
+ ];
644
+ return {
645
+ operation: 'shared.add',
646
+ targetId: target.target.id,
647
+ slots: [slot],
648
+ source,
649
+ replace,
650
+ ...(currentSource ? { currentSource } : {}),
651
+ ...(replacement ? { replacement } : {}),
652
+ scope: {
653
+ kind: target.projectPath ? 'project' : 'global',
654
+ path: target.projectPath ?? path.dirname(path.dirname(target.target.discoveryRoot)),
655
+ },
656
+ target: {
657
+ discoveryRoot: target.target.discoveryRoot,
658
+ parkingRoot: target.target.parkingRoot,
659
+ stateFile: target.report.stateFile,
660
+ lockFile: target.lockFile,
661
+ },
662
+ candidate: {
663
+ identity: `${source}\0${name}`,
664
+ source,
665
+ name,
666
+ normalizedSlot: slot,
667
+ provenance: { source },
668
+ },
669
+ sourceAdapter: {
670
+ package: NPX_SKILLS_PACKAGE,
671
+ securityAuditOwner: 'vercel-skills',
672
+ proceedOwner: 'vercel-skills',
673
+ },
674
+ preconditions: {
675
+ sourceEntry: sourceFingerprint,
676
+ discoveryEntry: discoveryFingerprint,
677
+ parkingEntry: parkingFingerprint,
678
+ lock: {
679
+ path: target.lockFile,
680
+ hash: contentFingerprint(target.lockFile).hash,
681
+ owner: lockOwner,
682
+ },
683
+ policy: {
684
+ path: target.report.stateFile,
685
+ hash: contentFingerprint(target.report.stateFile).hash,
686
+ },
687
+ permissions: {
688
+ target: writableAt(target.target.discoveryRoot),
689
+ lock: writableAt(path.dirname(target.lockFile)),
690
+ },
691
+ },
692
+ blockers,
693
+ intentPreservation,
694
+ relationshipEffects,
695
+ recovery: {
696
+ operationLock: `${target.lockFile}.skillspub-operation-lock`,
697
+ evidence: [target.lockFile, target.report.stateFile, 'final filesystem rescan'],
698
+ completedWork: 'preserved',
699
+ },
700
+ currentTruth: {
701
+ actual: actualSummary(target.report, target.target.id, [slot]),
702
+ desired,
703
+ drift: existing && existing.activation === desired ? 'none' : existing ? 'activation' : 'missing',
704
+ source: currentSource ?? 'Source unknown',
705
+ relationships: relationshipEffects.length,
706
+ },
707
+ expectedFinalTruth: {
708
+ actual: `${slot}=${desired}/local`,
709
+ desired,
710
+ drift: relationshipEffects.some(({ plannedAction }) => plannedAction === 'mirror-sync')
711
+ ? 'mirror-sync'
712
+ : 'none',
713
+ source,
714
+ relationships: relationshipEffects.length,
715
+ effectiveVisibility: 'recompute-after-rescan',
716
+ },
717
+ };
718
+ }
719
+ export function planSharedAdd(home, source, name, replace, projectPath) {
720
+ validateSource(source);
721
+ const target = resolveTarget(home, projectPath);
722
+ validatePolicyState(target);
723
+ assertNoOperationLock(target);
724
+ return buildSharedAddPlan(target, source, name, replace);
725
+ }
726
+ function buildSharedUpdatePlan(target, names, consideredNames = names) {
727
+ const managed = readNpxSkillsLock(target.lockFile);
728
+ const bySlot = new Map(managed.map((skill) => [skill.slot, skill]));
729
+ const requested = new Set((names.length > 0 ? names : managed.map(({ slot }) => slot)).map(validateName));
730
+ const considered = [...new Set((consideredNames.length > 0
731
+ ? consideredNames
732
+ : [...requested]).map(validateName))];
733
+ if (considered.length === 0)
734
+ throw new Error(`no skills managed by ${NPX_SKILLS_PACKAGE}`);
735
+ const cache = updateAvailabilityCache(target);
736
+ const items = considered.map((slot) => {
737
+ const skill = bySlot.get(slot);
738
+ if (!skill)
739
+ return {
740
+ name: slot,
741
+ slot,
742
+ source: 'Source unknown',
743
+ status: 'unknown',
744
+ identity: `unmanaged:${slot}`,
745
+ included: false,
746
+ reason: `not managed by ${NPX_SKILLS_PACKAGE}`,
747
+ desired: 'off',
748
+ temporaryVisibility: false,
749
+ currentTruth: { actual: 'unmanaged', hash: 'unknown', source: 'Source unknown', relationships: 0 },
750
+ intentPreservation: {
751
+ baseIntent: 'off', tags: [], bundles: [], presetClaims: [], presetSelectors: [],
752
+ },
753
+ relationshipEffects: [],
754
+ expectedFinalTruth: {
755
+ actual: 'unmanaged', desired: 'off', drift: 'none', source: 'Source unknown',
756
+ relationships: 0, effectiveVisibility: 'recompute-after-rescan',
757
+ },
758
+ };
759
+ const current = relationship(target, slot);
760
+ if (!current)
761
+ throw new Error(`installer lock/file mismatch: ${skill.name}`);
762
+ const identity = managedIdentity(target, skill).identity;
763
+ const cached = cache.get(slot);
764
+ const observation = cached?.identity === identity ? cached : undefined;
765
+ const status = observation?.status ?? 'unknown';
766
+ const intentPreservation = policyIntent(target, slot, current.resourceId ?? current.realPath ?? current.path, current.activation);
767
+ const desired = intentPreservation.presetClaims.length > 0
768
+ ? 'on'
769
+ : intentPreservation.baseIntent;
770
+ const marked = requested.has(slot);
771
+ const relationshipEffects = addRelationshipEffects(target, slot, current, false);
772
+ const expectedDrift = relationshipEffects.some(({ plannedAction }) => plannedAction === 'mirror-sync')
773
+ ? 'mirror-sync'
774
+ : 'none';
775
+ return {
776
+ name: skill.name,
777
+ slot,
778
+ source: npxSkillsProvenanceLabel(skill.provenance),
779
+ ...(skill.provenance.skillPath ? { skillPath: skill.provenance.skillPath } : {}),
780
+ status,
781
+ ...(observation ? { checkedAt: observation.checkedAt } : {}),
782
+ ...(observation?.error ? { error: observation.error } : {}),
783
+ identity,
784
+ included: marked && status === 'available',
785
+ ...(marked ? status === 'available' ? {} : { reason: status } : { reason: 'unmarked' }),
786
+ desired,
787
+ temporaryVisibility: desired === 'off',
788
+ currentTruth: {
789
+ actual: `${current.activation}/${current.form}`,
790
+ hash: hashDirectory(current.path),
791
+ source: npxSkillsProvenanceLabel(skill.provenance),
792
+ relationships: relationshipEffects.length,
793
+ },
794
+ intentPreservation,
795
+ relationshipEffects,
796
+ expectedFinalTruth: {
797
+ actual: `${slot}=${desired}/local`,
798
+ desired,
799
+ drift: expectedDrift,
800
+ source: npxSkillsProvenanceLabel(skill.provenance),
801
+ relationships: relationshipEffects.length,
802
+ effectiveVisibility: 'recompute-after-rescan',
803
+ },
804
+ };
805
+ });
806
+ return {
807
+ operation: 'shared.update',
808
+ targetId: target.target.id,
809
+ scope: {
810
+ kind: target.projectPath ? 'project' : 'global',
811
+ path: target.projectPath ?? path.dirname(path.dirname(target.target.discoveryRoot)),
812
+ },
813
+ target: {
814
+ discoveryRoot: target.target.discoveryRoot,
815
+ parkingRoot: target.target.parkingRoot,
816
+ stateFile: target.report.stateFile,
817
+ lockFile: target.lockFile,
818
+ },
819
+ sourceAdapter: { package: NPX_SKILLS_PACKAGE, updateOwner: 'vercel-skills' },
820
+ preconditions: {
821
+ lock: { ...contentFingerprint(target.lockFile), owner: 'vercel-skills' },
822
+ policy: contentFingerprint(target.report.stateFile),
823
+ permissions: {
824
+ target: writableAt(target.target.discoveryRoot),
825
+ lock: writableAt(path.dirname(target.lockFile)),
826
+ },
827
+ },
828
+ blockers: [
829
+ ...(writableAt(target.target.discoveryRoot) === 'blocked' ? ['Shared Target is not writable.'] : []),
830
+ ...(writableAt(path.dirname(target.lockFile)) === 'blocked' ? ['Source lock directory is not writable.'] : []),
831
+ ],
832
+ items,
833
+ recovery: {
834
+ operationLock: `${target.lockFile}.skillspub-operation-lock`,
835
+ evidence: [target.lockFile, target.report.stateFile, 'final filesystem rescan'],
836
+ completedWork: 'preserved',
837
+ },
838
+ };
839
+ }
840
+ export function planSharedUpdate(home, names, projectPath, consideredNames = names) {
841
+ const target = resolveTarget(home, projectPath);
842
+ validatePolicyState(target);
843
+ assertNoOperationLock(target);
844
+ return buildSharedUpdatePlan(target, names, consideredNames);
845
+ }
846
+ function ensureVisible(target, skills) {
847
+ for (const skill of skills) {
848
+ const current = relationship(target, skill.slot);
849
+ if (!current)
850
+ throw new Error(`installer lock/file mismatch: ${skill.name}`);
851
+ if (current.activation === 'off') {
852
+ move(current, target.target.discoveryRoot);
853
+ target.report = scan(target.home, target.projectPath);
854
+ }
855
+ }
856
+ }
857
+ function restoreDesired(target, desired) {
858
+ const drift = [];
859
+ for (const [slot, activation] of desired) {
860
+ let current;
861
+ try {
862
+ target.report = scan(target.home, target.projectPath);
863
+ current = relationship(target, slot);
864
+ if (!current) {
865
+ drift.push(`${target.target.id}/${slot}: missing`);
866
+ continue;
867
+ }
868
+ if (current.activation !== activation)
869
+ move(current, activation === 'on' ? target.target.discoveryRoot : target.target.parkingRoot);
870
+ }
871
+ catch (error) {
872
+ drift.push(`${target.target.id}/${slot}: ${error.message}`);
873
+ }
874
+ }
875
+ return drift;
876
+ }
877
+ function finalActual(target, slots, drift) {
878
+ try {
879
+ target.report = scan(target.home, target.projectPath, true);
880
+ return actualSummary(target.report, target.target.id, slots);
881
+ }
882
+ catch (error) {
883
+ drift.push(`final rescan: ${error.message}`);
884
+ return 'unavailable';
885
+ }
886
+ }
887
+ function actualSummary(report, targetId, slots) {
888
+ return slots.map((slot) => {
889
+ const states = report.relationships
890
+ .filter((item) => item.targetId === targetId && item.slot === slot)
891
+ .map((item) => `${item.activation}/${item.form}`);
892
+ return `${slot}=${states.join('+') || 'missing'}`;
893
+ }).join(', ');
894
+ }
895
+ function updateBaseIntent(target, slots, value) {
896
+ const state = readStateFile(target.report.stateFile);
897
+ const baseIntent = { ...baseIntents(state) };
898
+ for (const slot of slots) {
899
+ const id = `${target.target.id}\0${slot}`;
900
+ if (value)
901
+ baseIntent[id] = value;
902
+ else
903
+ delete baseIntent[id];
904
+ }
905
+ writeStateFile(target.report.stateFile, { ...state, baseIntent });
906
+ }
907
+ function cleanOutput(output) {
908
+ return stripVTControlCharacters(output);
909
+ }
910
+ function outputWarnings(stderr) {
911
+ return cleanOutput(stderr).split(/\r?\n/).filter(Boolean);
912
+ }
913
+ export function sharedFind(home, query, projectPath, write = true) {
914
+ if (query.length === 0)
915
+ throw new Error('usage: skillspub shared find <query>');
916
+ const target = resolveTarget(home, projectPath);
917
+ const result = runNpxSkills(npxSkillsFindArgs(query), target.cwd, true);
918
+ if (write && result.stderr)
919
+ process.stderr.write(result.stderr);
920
+ const parsed = parseNpxSkillsFindOutput(result.stdout);
921
+ if (write) {
922
+ if (!parsed.complete || parsed.candidates.length === 0)
923
+ process.stdout.write(result.stdout);
924
+ else
925
+ for (const candidate of parsed.candidates)
926
+ console.log(`${candidate.source}@${candidate.name}\t${candidate.installs ?? ''}\t${candidate.detailUrl}`);
927
+ }
928
+ if (result.status !== 0)
929
+ throw new Error(`skills find failed (exit ${result.status})`);
930
+ return {
931
+ candidates: parsed.candidates,
932
+ complete: parsed.complete,
933
+ ...(!parsed.complete || parsed.candidates.length === 0
934
+ ? { raw: cleanOutput(parsed.raw) }
935
+ : {}),
936
+ warnings: outputWarnings(result.stderr),
937
+ };
938
+ }
939
+ export function sharedDescribe(home, source, projectPath, write = true) {
940
+ validateSource(source);
941
+ const target = resolveTarget(home, projectPath);
942
+ const result = runNpxSkills(npxSkillsDescribeArgs(source), target.cwd, true);
943
+ if (write && result.stdout)
944
+ process.stdout.write(result.stdout);
945
+ if (write && result.stderr)
946
+ process.stderr.write(result.stderr);
947
+ if (result.status !== 0)
948
+ throw new Error(`skills description lookup failed (exit ${result.status})`);
949
+ return {
950
+ source,
951
+ output: cleanOutput(result.stdout),
952
+ warnings: outputWarnings(result.stderr),
953
+ };
954
+ }
955
+ function refreshTarget(target) {
956
+ const skills = readNpxSkillsLock(target.lockFile);
957
+ const checkedAt = new Date().toISOString();
958
+ const cached = new Map();
959
+ const groups = new Map();
960
+ const failed = (skill, error) => {
961
+ cached.set(skill.slot, {
962
+ identity: managedIdentity(target, skill).identity,
963
+ status: 'check-failed',
964
+ checkedAt,
965
+ error,
966
+ });
967
+ };
968
+ for (const skill of skills) {
969
+ const installed = managedIdentity(target, skill);
970
+ if (!installed.installed) {
971
+ failed(skill, 'installed Skill is missing or unreadable');
972
+ continue;
973
+ }
974
+ if (!skill.provenance.skillPath || (!skill.skillFolderHash && !skill.computedHash)) {
975
+ failed(skill, 'installer lock lacks skillPath or content hash');
976
+ continue;
977
+ }
978
+ const key = npxSkillsSourceKey(skill);
979
+ if (!key) {
980
+ failed(skill, 'installer lock has no supported remote source');
981
+ continue;
982
+ }
983
+ groups.set(key, [...(groups.get(key) ?? []), skill]);
984
+ }
985
+ for (const group of groups.values()) {
986
+ try {
987
+ const results = new Map(checkNpxSkillsSource(group).map((entry) => [entry.slot, entry]));
988
+ for (const skill of group) {
989
+ const result = results.get(skill.slot);
990
+ cached.set(skill.slot, {
991
+ identity: managedIdentity(target, skill).identity,
992
+ status: result?.status ?? 'check-failed',
993
+ checkedAt,
994
+ ...(result ? {} : { error: 'source check returned no result' }),
995
+ ...(result?.error ? { error: result.error } : {}),
996
+ });
997
+ }
998
+ }
999
+ catch (error) {
1000
+ for (const skill of group)
1001
+ failed(skill, error.message);
1002
+ }
1003
+ }
1004
+ const state = readStateFile(target.report.stateFile);
1005
+ state.updateAvailability = {
1006
+ version: 1,
1007
+ entries: Object.fromEntries(cached),
1008
+ };
1009
+ writeStateFile(target.report.stateFile, state);
1010
+ return availabilityResult(target, skills, cached);
1011
+ }
1012
+ export function sharedRefresh(home, projectPath) {
1013
+ const initial = resolveTarget(home, projectPath);
1014
+ return withOperationLock(initial, () => refreshTarget(resolveTarget(home, projectPath)));
1015
+ }
1016
+ export function sharedOutdatedFromInventory(home, report) {
1017
+ const target = targetFromInventory(home, report);
1018
+ const skills = readNpxSkillsLock(target.lockFile);
1019
+ return availabilityResult(target, skills, updateAvailabilityCache(target));
1020
+ }
1021
+ export function sharedOutdated(home, projectPath) {
1022
+ const target = resolveTarget(home, projectPath);
1023
+ return sharedOutdatedFromInventory(home, target.report);
1024
+ }
1025
+ function sharedApplyFailure(operation, reason, actual, drift, partialEffects, stage) {
1026
+ return Object.assign(new Error(`skills ${operation} failed (${reason})\nActual: ${actual}\nRemaining drift: ${drift.join(', ') || 'none'}`), { code: 'apply_failed', details: { actual, remainingDrift: drift, partialEffects, stage } });
1027
+ }
1028
+ function guardedSkillsOp(home, projectPath, op) {
1029
+ const initial = resolveTarget(home, projectPath);
1030
+ return withOperationLock(initial, () => {
1031
+ const target = resolveTarget(home, projectPath);
1032
+ validatePolicyState(target);
1033
+ const selected = op.select(target);
1034
+ const desired = desiredFor(target, selected);
1035
+ const args = op.args(selected, !projectPath);
1036
+ let result;
1037
+ let failure;
1038
+ let staged;
1039
+ let drift = [];
1040
+ const restore = () => {
1041
+ if (desired.size > 0)
1042
+ drift = restoreDesired(target, desired);
1043
+ };
1044
+ try {
1045
+ staged = op.before?.(target, selected);
1046
+ ensureVisible(target, selected);
1047
+ result = runNpxSkills(args, target.cwd, op.capture);
1048
+ }
1049
+ catch (error) {
1050
+ failure = error;
1051
+ }
1052
+ const runFailed = Boolean(failure || !result || result.status !== 0);
1053
+ const slots = op.slots?.(selected) ?? selected.map(({ slot }) => slot);
1054
+ let checkError;
1055
+ let actual;
1056
+ if (op.restoreOnFailureOnly) {
1057
+ try {
1058
+ op.check?.(target, selected, drift, { result, failure });
1059
+ }
1060
+ catch (error) {
1061
+ checkError = error;
1062
+ }
1063
+ if (runFailed || checkError) {
1064
+ restore();
1065
+ staged?.rollback();
1066
+ }
1067
+ actual = finalActual(target, slots, drift);
1068
+ }
1069
+ else {
1070
+ restore();
1071
+ actual = finalActual(target, slots, drift);
1072
+ try {
1073
+ op.check?.(target, selected, drift, { result, failure, actual });
1074
+ }
1075
+ catch (error) {
1076
+ checkError = error;
1077
+ }
1078
+ }
1079
+ if (runFailed || checkError) {
1080
+ const reason = failure?.message ?? checkError?.message ?? `exit ${result?.status ?? 1}`;
1081
+ throw sharedApplyFailure(op.name, reason, actual, drift, drift.length > 0 ? 'present' : 'none-detected', checkError ? 'verify' : 'upstream');
1082
+ }
1083
+ try {
1084
+ op.after?.(target, selected, actual);
1085
+ staged?.commit();
1086
+ }
1087
+ catch (error) {
1088
+ actual = finalActual(target, slots, drift);
1089
+ throw sharedApplyFailure(op.name, error.message, actual, drift, 'unknown', 'verify');
1090
+ }
1091
+ return { actual, drift };
1092
+ });
1093
+ }
1094
+ export function sharedAdd(home, source, name, replace, projectPath, expectedPlan, nonInteractive = false) {
1095
+ validateSource(source);
1096
+ const slot = validateName(name);
1097
+ const preview = expectedPlan ?? planSharedAdd(home, source, name, replace, projectPath);
1098
+ const preflight = resolveTarget(home, projectPath);
1099
+ validatePolicyState(preflight);
1100
+ const currentPreview = buildSharedAddPlan(preflight, source, name, replace);
1101
+ if (JSON.stringify(currentPreview) !== JSON.stringify(preview))
1102
+ throw concurrentModification('Source add plan changed after preview; create a new preview.');
1103
+ const blockers = currentPreview.blockers ?? [];
1104
+ if (blockers.length > 0)
1105
+ throw new Error(blockers.join('\n'));
1106
+ return guardedSkillsOp(home, projectPath, {
1107
+ name: 'add',
1108
+ capture: nonInteractive,
1109
+ select(target) {
1110
+ const currentPlan = buildSharedAddPlan(target, source, name, replace);
1111
+ if (JSON.stringify(currentPlan) !== JSON.stringify(preview))
1112
+ throw concurrentModification('Source add plan changed after preview; create a new preview.');
1113
+ const currentBlockers = currentPlan.blockers ?? [];
1114
+ if (currentBlockers.length > 0)
1115
+ throw new Error(currentBlockers.join('\n'));
1116
+ const existing = relationship(target, slot);
1117
+ const slotInfo = target.report.slots.find((item) => item.targetId === target.target.id && item.name === slot);
1118
+ return existing
1119
+ ? [{ name: existing.name, slot, provenance: slotInfo?.provenance ?? {} }]
1120
+ : [];
1121
+ },
1122
+ args: (_selected, global) => npxSkillsAddArgs(source, name, global),
1123
+ slots: () => [slot],
1124
+ check(target, selected, drift, { result, failure, actual }) {
1125
+ const installed = actual !== 'unavailable' && target.report.relationships.some((item) => item.targetId === target.target.id && item.slot === slot);
1126
+ const runFailed = failure || !result || result.status !== 0;
1127
+ if (runFailed && selected.length === 0 && installed)
1128
+ drift.push(`${target.target.id}/${slot}: expected missing`);
1129
+ if (!runFailed && !installed)
1130
+ throw new Error(`exit ${result?.status ?? 1}`);
1131
+ },
1132
+ after(target, selected, actual) {
1133
+ const managed = readNpxSkillsLock(target.lockFile)
1134
+ .find((skill) => skill.slot === slot);
1135
+ const expectsManagedProvenance = /^[^/@\s]+\/[^/@\s]+(?:@[^/\s]+)?$/.test(source);
1136
+ if ((expectsManagedProvenance && !managed) ||
1137
+ (managed && !sameNpxSkillsSource(source, name, managed.provenance)))
1138
+ throw new Error(`skills add failed (installer lock source changed or missing)\nActual: ${actual}\nRemaining drift: ${target.target.id}/${slot}: unverified provenance`);
1139
+ if (selected.length === 0)
1140
+ updateBaseIntent(target, [slot], 'on');
1141
+ },
1142
+ });
1143
+ }
1144
+ export function sharedUpdate(home, names, projectPath, expectedPlan, nonInteractive = false) {
1145
+ const preview = expectedPlan ?? planSharedUpdate(home, names, projectPath);
1146
+ const initial = resolveTarget(home, projectPath);
1147
+ return withOperationLock(initial, () => {
1148
+ const target = resolveTarget(home, projectPath);
1149
+ validatePolicyState(target);
1150
+ const currentPlan = buildSharedUpdatePlan(target, names, expectedPlan?.items.map(({ name }) => name) ?? names);
1151
+ if (JSON.stringify(currentPlan) !== JSON.stringify(preview))
1152
+ throw concurrentModification('Source update plan changed after preview; create a new preview.');
1153
+ if (currentPlan.blockers.length > 0)
1154
+ throw new Error(currentPlan.blockers.join('\n'));
1155
+ const includedNames = currentPlan.items.filter(({ included }) => included).map(({ name }) => name);
1156
+ if (includedNames.length === 0) {
1157
+ const excluded = currentPlan.items.map(({ name, reason }) => `${reason ?? 'ineligible'} Skill: ${name}`);
1158
+ throw new Error(`cannot update ${excluded.join(', ')}`);
1159
+ }
1160
+ const selected = managedSelection(target, includedNames);
1161
+ const selectedBySlot = new Map(selected.map((skill) => [skill.slot, skill]));
1162
+ const desired = desiredFor(target, selected);
1163
+ const allDrift = [];
1164
+ const results = [];
1165
+ for (const item of currentPlan.items) {
1166
+ if (!item.included) {
1167
+ results.push({ ...item, outcome: 'skipped' });
1168
+ continue;
1169
+ }
1170
+ const skill = selectedBySlot.get(item.slot);
1171
+ if (!skill) {
1172
+ results.push({ ...item, outcome: 'skipped', reason: 'selection changed' });
1173
+ continue;
1174
+ }
1175
+ let result;
1176
+ let failure;
1177
+ try {
1178
+ ensureVisible(target, [skill]);
1179
+ result = runNpxSkills(npxSkillsUpdateArgs([skill.name], !projectPath), target.cwd, nonInteractive ? true : 'output');
1180
+ }
1181
+ catch (error) {
1182
+ failure = error;
1183
+ }
1184
+ const itemDrift = restoreDesired(target, new Map([[skill.slot, desired.get(skill.slot)]]));
1185
+ const actual = finalActual(target, [skill.slot], itemDrift);
1186
+ let verificationFailure;
1187
+ if (!failure && result?.status === 0) {
1188
+ const updated = readNpxSkillsLock(target.lockFile).find(({ slot }) => slot === skill.slot);
1189
+ if (!updated || npxSkillsSourceKey(updated) !== npxSkillsSourceKey(skill))
1190
+ verificationFailure = 'installer lock provenance changed or disappeared';
1191
+ else if (managedIdentity(target, updated).identity === item.identity)
1192
+ verificationFailure = 'updater reported success without a verified local change';
1193
+ }
1194
+ const failed = failure || !result || result.status !== 0 || verificationFailure;
1195
+ for (const effect of item.relationshipEffects) {
1196
+ if (effect.plannedAction !== 'mirror-sync')
1197
+ continue;
1198
+ const mirrorFindings = target.report.findings.filter((finding) => finding.targetId === effect.targetId && finding.slot === effect.slot &&
1199
+ (finding.code === 'mirror-drift' || finding.code === 'mirror-diverged'));
1200
+ if (mirrorFindings.some(({ code }) => code === 'mirror-drift'))
1201
+ itemDrift.push(`${effect.targetId}/${effect.slot}: mirror-sync`);
1202
+ if (mirrorFindings.some(({ code }) => code === 'mirror-diverged'))
1203
+ itemDrift.push(`${effect.targetId}/${effect.slot}: mirror-diverged (explicit overwrite or convert required)`);
1204
+ }
1205
+ allDrift.push(...itemDrift);
1206
+ const reason = failure?.message ?? verificationFailure ??
1207
+ (result?.stderr.trim() || `exit ${result?.status ?? 1}`);
1208
+ const log = [
1209
+ `$ npx --yes ${NPX_SKILLS_PACKAGE} ${npxSkillsUpdateArgs([skill.name], !projectPath).join(' ')}`,
1210
+ result?.stdout.trim(),
1211
+ result?.stderr.trim(),
1212
+ failure?.message,
1213
+ `exit ${result?.status ?? 1}`,
1214
+ ].filter(Boolean).join('\n');
1215
+ results.push({
1216
+ ...item,
1217
+ outcome: failed ? 'failed' : 'updated',
1218
+ ...(failed ? { reason } : {}),
1219
+ actual,
1220
+ drift: itemDrift,
1221
+ log,
1222
+ });
1223
+ }
1224
+ return {
1225
+ actual: finalActual(target, currentPlan.items.map(({ slot }) => slot), allDrift),
1226
+ drift: [...new Set(allDrift)],
1227
+ items: results,
1228
+ };
1229
+ });
1230
+ }
1231
+ export function sharedRemoveCascade(home, names, expected, projectPath) {
1232
+ const preview = planSharedRemove(home, names, projectPath);
1233
+ assertRemovalPlan(expected, preview);
1234
+ assertRemovalUnblocked(preview);
1235
+ const initial = resolveTarget(home, projectPath);
1236
+ try {
1237
+ return withOperationLock(initial, () => {
1238
+ const target = resolveTarget(home, projectPath);
1239
+ const skill = managedSelection(target, names)[0];
1240
+ if (!skill)
1241
+ throw new Error('managed Shared source disappeared');
1242
+ const sourceRelationships = target.report.relationships.filter((item) => item.targetId === target.target.id && item.slot === preview.source.slot);
1243
+ const source = sourceRelationships.length === 1 ? sourceRelationships[0] : undefined;
1244
+ const dependencies = source ? removalDependencies(target, source) : [];
1245
+ const blockers = removalBlockers(target, skill, source, sourceRelationships, dependencies);
1246
+ if (blockers.length > 0)
1247
+ throw new Error(blockers.join('; '));
1248
+ if (contentFingerprint(preview.source.path).hash !== preview.source.fingerprint ||
1249
+ contentFingerprint(target.lockFile).hash !== preview.preconditions.lock.hash ||
1250
+ contentFingerprint(target.report.stateFile).hash !== preview.preconditions.policy.hash ||
1251
+ !sameDependencies(preview.dependencies, dependencies))
1252
+ throw concurrentModification('Shared source or dependent Relationships changed after preview');
1253
+ assertRemovalDependenciesAllowed(target, dependencies);
1254
+ const staged = stageDependencies(target, dependencies);
1255
+ const temporaryManifest = `${preview.recovery.manifest}.tmp-${process.pid}`;
1256
+ try {
1257
+ fs.mkdirSync(path.dirname(preview.recovery.manifest), { recursive: true });
1258
+ fs.writeFileSync(temporaryManifest, JSON.stringify({
1259
+ version: 1,
1260
+ targetId: preview.targetId,
1261
+ slot: preview.source.slot,
1262
+ sourcePath: preview.source.path,
1263
+ sourceFingerprint: preview.source.fingerprint,
1264
+ lockFingerprint: preview.preconditions.lock.hash,
1265
+ provenance: preview.source.provenance,
1266
+ dependencies: preview.dependencies,
1267
+ stagingRoot: staged?.stagingRoot,
1268
+ }, null, 2) + '\n');
1269
+ fs.renameSync(temporaryManifest, preview.recovery.manifest);
1270
+ removeDependencyState(target, dependencies);
1271
+ }
1272
+ catch (error) {
1273
+ fs.rmSync(temporaryManifest, { force: true });
1274
+ fs.rmSync(preview.recovery.manifest, { force: true });
1275
+ staged?.rollback();
1276
+ throw error;
1277
+ }
1278
+ try {
1279
+ staged?.commit();
1280
+ }
1281
+ catch (error) {
1282
+ throw Object.assign(new Error(`Relationship cascade cleanup failed: ${error.message}`), {
1283
+ code: 'apply_failed',
1284
+ details: {
1285
+ completedWork: dependencies.map(({ targetId, slot }) => `deleted ${targetId}/${slot}`),
1286
+ recovery: preview.recovery,
1287
+ stagingRoot: staged?.stagingRoot,
1288
+ },
1289
+ });
1290
+ }
1291
+ const final = scan(home, projectPath);
1292
+ return {
1293
+ actual: actualSummary(final, target.target.id, [preview.source.slot]),
1294
+ drift: [],
1295
+ recoveryManifest: preview.recovery.manifest,
1296
+ completedWork: dependencies.map(({ targetId, slot }) => `deleted ${targetId}/${slot}`),
1297
+ };
1298
+ });
1299
+ }
1300
+ catch (error) {
1301
+ const failure = error;
1302
+ let actual = 'rescan unavailable';
1303
+ let remainingDependencies = preview.dependencies.map(({ targetId, slot }) => `${targetId}/${slot}`);
1304
+ try {
1305
+ const final = scan(home, projectPath);
1306
+ actual = actualSummary(final, preview.targetId, [preview.source.slot]);
1307
+ const paths = new Set(final.relationships.map(({ path: relationshipPath }) => relationshipPath));
1308
+ remainingDependencies = preview.dependencies
1309
+ .filter(({ path: dependencyPath }) => paths.has(dependencyPath))
1310
+ .map(({ targetId, slot }) => `${targetId}/${slot}`);
1311
+ }
1312
+ catch {
1313
+ // Keep the original failure primary and report that the rescan was unavailable.
1314
+ }
1315
+ failure.details = {
1316
+ ...failure.details,
1317
+ actual,
1318
+ desired: preview.currentTruth.desired,
1319
+ source: preview.source,
1320
+ remainingDependencies,
1321
+ recovery: preview.recovery,
1322
+ completedWork: preview.dependencies
1323
+ .filter(({ targetId, slot }) => !remainingDependencies.includes(`${targetId}/${slot}`))
1324
+ .map(({ targetId, slot }) => `deleted ${targetId}/${slot}`),
1325
+ };
1326
+ throw failure;
1327
+ }
1328
+ }
1329
+ export function sharedRemove(home, names, options = {}) {
1330
+ const { sourceConfirmed = false, projectPath, expected, nonInteractive = false } = options;
1331
+ const preview = planSharedRemove(home, names, projectPath);
1332
+ if (!sourceConfirmed)
1333
+ throw new Error('confirm source deletion separately');
1334
+ if (!preview.cascadeConfirmed)
1335
+ throw new Error('confirm the Relationship cascade first with --cascade');
1336
+ if (preview.dependencies.length > 0)
1337
+ throw concurrentModification('new dependent Relationships appeared after cascade confirmation');
1338
+ if (expected && (expected.targetId !== preview.targetId ||
1339
+ expected.source.slot !== preview.source.slot ||
1340
+ expected.source.path !== preview.source.path ||
1341
+ expected.source.fingerprint !== preview.source.fingerprint ||
1342
+ expected.source.provenance !== preview.source.provenance))
1343
+ throw concurrentModification('Shared source changed after preview');
1344
+ assertRemovalUnblocked(preview);
1345
+ try {
1346
+ const result = guardedSkillsOp(home, projectPath, {
1347
+ name: 'remove',
1348
+ capture: nonInteractive,
1349
+ restoreOnFailureOnly: true,
1350
+ select(target) {
1351
+ const selected = managedSelection(target, names);
1352
+ const skill = selected[0];
1353
+ if (!skill)
1354
+ throw new Error('managed Shared source disappeared');
1355
+ if (contentFingerprint(target.lockFile).hash !== preview.preconditions.lock.hash ||
1356
+ contentFingerprint(target.report.stateFile).hash !== preview.preconditions.policy.hash ||
1357
+ npxSkillsProvenanceLabel(skill.provenance) !== preview.source.provenance)
1358
+ throw concurrentModification('Shared source lock, provenance, or policy changed after preview');
1359
+ if (!manifestMatches(preview.recovery.manifest, preview.targetId, preview.source.slot, preview.source.fingerprint, preview.preconditions.lock.hash))
1360
+ throw new Error('Relationship cascade confirmation is stale or missing');
1361
+ const sourceRelationships = target.report.relationships.filter((item) => item.targetId === target.target.id && item.slot === preview.source.slot);
1362
+ const source = sourceRelationships.length === 1 ? sourceRelationships[0] : undefined;
1363
+ const dependencies = source ? removalDependencies(target, source) : [];
1364
+ if (!source || source.form !== 'local' || source.path !== preview.source.path ||
1365
+ contentFingerprint(source.path).hash !== preview.source.fingerprint)
1366
+ throw concurrentModification('Shared source changed after preview');
1367
+ if (dependencies.length > 0)
1368
+ throw concurrentModification('new dependent Relationships appeared after cascade confirmation');
1369
+ const blockers = removalBlockers(target, skill, source, sourceRelationships, dependencies);
1370
+ if (blockers.length > 0)
1371
+ throw new Error(blockers.join('; '));
1372
+ return selected;
1373
+ },
1374
+ args: (selected, global) => {
1375
+ const skill = selected[0];
1376
+ if (!skill)
1377
+ throw new Error('managed Shared source disappeared');
1378
+ return npxSkillsRemoveArgs([skill.name], global);
1379
+ },
1380
+ check(target, selected, _drift, { result, failure }) {
1381
+ target.report = scan(home, projectPath);
1382
+ const skill = selected[0];
1383
+ if (!skill)
1384
+ throw new Error('managed Shared source disappeared');
1385
+ const remaining = target.report.relationships.some((item) => item.targetId === target.target.id && item.slot === skill.slot);
1386
+ const lockRemains = readNpxSkillsLock(target.lockFile).some(({ slot }) => slot === skill.slot);
1387
+ if (!failure && result?.status === 0 && (remaining || lockRemains))
1388
+ throw new Error(`verification failed: source=${remaining ? 'present' : 'missing'} lock=${lockRemains ? 'present' : 'removed'}`);
1389
+ },
1390
+ after(target, selected) {
1391
+ const skill = selected[0];
1392
+ if (!skill)
1393
+ throw new Error('managed Shared source disappeared');
1394
+ updateBaseIntent(target, [skill.slot]);
1395
+ fs.rmSync(preview.recovery.manifest, { force: true });
1396
+ },
1397
+ });
1398
+ return { ...result, completedWork: ['Relationship cascade', `deleted ${preview.source.name}`] };
1399
+ }
1400
+ catch (error) {
1401
+ const failure = error;
1402
+ failure.details = {
1403
+ ...failure.details,
1404
+ source: preview.source,
1405
+ recovery: preview.recovery,
1406
+ completedWork: ['Relationship cascade'],
1407
+ remainingWork: [`delete Shared source ${preview.source.name}`],
1408
+ };
1409
+ throw failure;
1410
+ }
1411
+ }