skillshop 4.0.0 → 4.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/src/legacy-cli.js CHANGED
@@ -27,6 +27,10 @@ import {
27
27
  validatePersonalSkillDirectory
28
28
  } from './personal-library.js';
29
29
  import { downloadPersonalSkill, GitHubLibraryError } from './github-library.js';
30
+ import {
31
+ assertRegularPath, readInstallationManifest, writeInstallationManifest,
32
+ readInstallationIndex, registerInstallationDirectory, sameOwner
33
+ } from './installation-state.js';
30
34
 
31
35
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
36
  const skillsContentRoot = path.join(packageRoot, 'skills-content');
@@ -60,7 +64,82 @@ function lockPathFor(root) {
60
64
  }
61
65
 
62
66
  function hasLock(root) {
63
- return fs.existsSync(path.join(root, lockFileName)) || fs.existsSync(path.join(root, legacyLockFileName));
67
+ return fs.existsSync(path.join(root, lockFileName)) || fs.existsSync(path.join(root, legacyLockFileName)) ||
68
+ installationDirectories(root).some((directory) => fs.existsSync(path.join(directory, lockFileName)));
69
+ }
70
+
71
+ function installationDirectories(root = null) {
72
+ const base = root ?? os.homedir();
73
+ const directories = agentOptions.map((agent) => path.join(base, root ? agent.local : agent.global));
74
+ for (const item of readInstallationIndex(skillshopHome())) {
75
+ if (root ? item.scope === 'project' && canonicalPath(item.root) === canonicalPath(root) : item.scope === 'global') {
76
+ assertLocalDestination(base, item.directory, 'indexed installation directory');
77
+ directories.push(item.directory);
78
+ }
79
+ }
80
+ return [...new Set(directories.map((directory) => path.resolve(directory)))];
81
+ }
82
+
83
+ function cliOwner(root = null) {
84
+ return { kind: 'skillshop', id: root ? 'project' : 'global' };
85
+ }
86
+
87
+ function installationManifestExists(directory) {
88
+ try { fs.lstatSync(path.join(directory, lockFileName)); return true; }
89
+ catch (error) { if (error.code === 'ENOENT') return false; throw error; }
90
+ }
91
+
92
+ function directoryOwnedEntries(root = null) {
93
+ const result = [];
94
+ for (const directory of installationDirectories(root)) {
95
+ if (!installationManifestExists(directory)) continue;
96
+ for (const [name, entry] of Object.entries(readInstallationManifest(directory).skills)) {
97
+ if (sameOwner(entry.owner, cliOwner(root))) result.push({ name, entry, target: path.join(directory, name) });
98
+ }
99
+ }
100
+ return result;
101
+ }
102
+
103
+ function saveDirectoryOwnership(root, lock) {
104
+ const owner = cliOwner(root);
105
+ const directories = new Set(installationDirectories(root));
106
+ const wanted = new Map();
107
+ for (const [name, entry] of Object.entries(lock.skills)) {
108
+ const targets = root ? lockedTargets(root, name, entry) : globalTargets(name, entry);
109
+ for (const target of targets) {
110
+ assertRegularPath(path.dirname(target));
111
+ const directory = path.dirname(target);
112
+ directories.add(directory);
113
+ const metadata = root ? entry : { ...entry, ...entry.copies?.[target] };
114
+ const copy = { owner, ...(typeof metadata.version === 'string' ? { version: metadata.version } : {}),
115
+ ...(typeof metadata.author === 'string' ? { author: metadata.author } : {}),
116
+ ...(metadata.source ? { source: metadata.source } : {}) };
117
+ if (!wanted.has(directory)) wanted.set(directory, {});
118
+ wanted.get(directory)[name] = copy;
119
+ }
120
+ }
121
+ // Validate every directory before changing ownership in any of them.
122
+ const planned = [];
123
+ for (const directory of directories) {
124
+ if (!wanted.has(directory) && !installationManifestExists(directory)) continue;
125
+ const manifest = readInstallationManifest(directory);
126
+ const skills = Object.fromEntries(Object.entries(manifest.skills).filter(([, entry]) => !sameOwner(entry.owner, owner)));
127
+ for (const [name, entry] of Object.entries(wanted.get(directory) ?? {})) {
128
+ if (skills[name]) throw new CliError(`${path.join(directory, name)} is owned by ${skills[name].owner.kind}:${skills[name].owner.id}`);
129
+ skills[name] = entry;
130
+ }
131
+ if (Object.keys(skills).length || fs.existsSync(path.join(directory, lockFileName))) planned.push({ directory, manifest: { manifestVersion: 1, skills } });
132
+ }
133
+ for (const { directory, manifest } of planned) {
134
+ writeInstallationManifest(directory, manifest);
135
+ registerInstallationDirectory(directory, { root, home: skillshopHome() });
136
+ }
137
+ const legacyFiles = root ? [path.join(root, lockFileName), path.join(root, legacyLockFileName)]
138
+ : [path.join(skillshopHome(), globalSkillsFileName)];
139
+ for (const file of legacyFiles) {
140
+ assertRegularPath(file);
141
+ fs.rmSync(file, { force: true });
142
+ }
64
143
  }
65
144
  const agentOptions = [
66
145
  { id: 'copilot', label: 'Copilot', local: '.github/skills', global: '.copilot/skills' },
@@ -353,20 +432,38 @@ function emptyLock() {
353
432
 
354
433
  function readLock(root) {
355
434
  const lockPath = lockPathFor(root);
356
- if (!fs.existsSync(lockPath)) return emptyLock();
357
-
358
- let lock;
359
- try {
360
- lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
361
- } catch (error) {
362
- throw new CliError(`cannot parse ${lockPath}: ${error.message}`);
435
+ let lock = emptyLock();
436
+ if (fs.existsSync(lockPath)) {
437
+ assertRegularPath(lockPath);
438
+ const olderPath = path.join(root, legacyLockFileName);
439
+ if (lockPath !== olderPath && fs.existsSync(olderPath)) {
440
+ assertRegularPath(olderPath);
441
+ if (!fs.readFileSync(lockPath).equals(fs.readFileSync(olderPath))) {
442
+ throw new CliError(`${root} has conflicting .skillshop-lock and .skillz-lock records; reconcile them before migrating`);
443
+ }
444
+ }
445
+ try { lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); }
446
+ catch (error) { throw new CliError(`cannot parse ${lockPath}: ${error.message}`); }
447
+ if (![1, 2].includes(lock?.lockfileVersion) || lock.registry !== 'local' ||
448
+ !lock.skills || typeof lock.skills !== 'object' || Array.isArray(lock.skills)) {
449
+ throw new CliError(`${lockPath} is not a supported skillshop lockfile`);
450
+ }
451
+ for (const [name, entry] of Object.entries(lock.skills)) {
452
+ for (const target of lockedTargets(root, name, entry)) {
453
+ assertRegularPath(target);
454
+ const claim = readInstallationManifest(path.dirname(target)).skills[name];
455
+ if (claim && !sameOwner(claim.owner, cliOwner(root))) throw new CliError(`${target} is owned by ${claim.owner.kind}:${claim.owner.id}`);
456
+ }
457
+ }
363
458
  }
364
-
365
- if (![1, 2].includes(lock?.lockfileVersion) || lock.registry !== 'local' ||
366
- !lock.skills || typeof lock.skills !== 'object' || Array.isArray(lock.skills)) {
367
- throw new CliError(`${lockPath} is not a supported skillshop lockfile`);
459
+ for (const { name, entry, target } of directoryOwnedEntries(root)) {
460
+ const { owner, ...metadata } = entry;
461
+ const previous = lock.skills[name];
462
+ if (previous && JSON.stringify(previous.source ?? null) !== JSON.stringify(metadata.source ?? null)) {
463
+ throw new CliError(`${name} has conflicting installation sources in ${root}`);
464
+ }
465
+ lock.skills[name] = { ...metadata, paths: [...new Set([...(previous?.paths ?? (previous?.path ? [previous.path] : [])), relativePath(root, target)])] };
368
466
  }
369
-
370
467
  return lock;
371
468
  }
372
469
 
@@ -410,23 +507,8 @@ function localSkillDirectories(root, lock, cwd) {
410
507
  }
411
508
 
412
509
  function writeLock(root, lock) {
413
- const skills = Object.fromEntries(
414
- Object.entries(lock.skills)
415
- .sort(([left], [right]) => left.localeCompare(right))
416
- .map(([name, entry]) => [name, {
417
- ...(typeof entry.version === 'string' ? { version: entry.version } : {}),
418
- ...(typeof entry.author === 'string' ? { author: entry.author } : {}),
419
- ...(entry.source ? { source: entry.source } : {}),
420
- paths: lockedTargets(root, name, entry).map((target) => relativePath(root, target)).sort()
421
- }])
422
- );
423
- const stableLock = { lockfileVersion: 2, registry: 'local', skills };
424
- const lockPath = path.join(root, lockFileName);
425
- const contents = `${JSON.stringify(stableLock, null, 2)}\n`;
426
- if (fs.existsSync(lockPath) && fs.readFileSync(lockPath, 'utf8') === contents) return;
427
- const temporaryPath = `${lockPath}.tmp-${process.pid}`;
428
- fs.writeFileSync(temporaryPath, contents);
429
- fs.renameSync(temporaryPath, lockPath);
510
+ saveDirectoryOwnership(root, lock);
511
+ syncGitExclude(root, lock);
430
512
  }
431
513
 
432
514
  function registryPath() {
@@ -497,9 +579,14 @@ function staleTargets(previousTargets, targetDirs) {
497
579
  return previousTargets.filter((target) => !destinations.has(canonicalDestination(target)));
498
580
  }
499
581
 
500
- function assertTargetsAvailable(name, targetDirs, previousTargets) {
582
+ function assertTargetsAvailable(name, targetDirs, previousTargets, root) {
501
583
  const managedDestinations = new Set(previousTargets.map(canonicalDestination));
502
584
  for (const targetDir of targetDirs) {
585
+ assertRegularPath(targetDir);
586
+ const claim = readInstallationManifest(path.dirname(targetDir)).skills[name];
587
+ if (claim && !sameOwner(claim.owner, cliOwner(root))) {
588
+ throw new CliError(`${targetDir} is owned by ${claim.owner.kind}:${claim.owner.id}`);
589
+ }
503
590
  if (fs.existsSync(targetDir) && !managedDestinations.has(canonicalDestination(targetDir))) {
504
591
  throw new CliError(`${targetDir} exists and is not managed as ${name} in ${lockFileName}`);
505
592
  }
@@ -538,7 +625,11 @@ function managedGitPaths(root, lock) {
538
625
  const gitRoot = canonicalPath(gitOutput(root, ['rev-parse', '--show-toplevel']));
539
626
  const scope = relativePath(gitRoot, canonicalPath(root));
540
627
  const scopedPath = (target) => relativePath(gitRoot, path.join(root, target));
541
- const paths = [scopedPath(lockFileName)];
628
+ const paths = [];
629
+ if (fs.existsSync(path.join(root, lockFileName))) paths.push(scopedPath(lockFileName));
630
+ for (const directory of installationDirectories(root)) {
631
+ if (fs.existsSync(path.join(directory, lockFileName))) paths.push(relativePath(gitRoot, path.join(directory, lockFileName)));
632
+ }
542
633
  if (fs.existsSync(path.join(root, legacyLockFileName))) paths.push(scopedPath(legacyLockFileName));
543
634
 
544
635
  for (const [name, entry] of Object.entries(lock.skills)) {
@@ -679,16 +770,34 @@ function repairInstalledManifest(name, entry, manifestPath) {
679
770
 
680
771
  function readGlobalSkills() {
681
772
  const filePath = path.join(skillshopHome(), globalSkillsFileName);
682
- if (!fs.existsSync(filePath)) return emptyLock();
683
- let lock;
684
- try {
685
- lock = JSON.parse(fs.readFileSync(filePath, 'utf8'));
686
- } catch (error) {
687
- throw new CliError(`cannot parse ${filePath}: ${error.message}`);
773
+ let lock = emptyLock();
774
+ if (fs.existsSync(filePath)) {
775
+ assertRegularPath(filePath);
776
+ try { lock = JSON.parse(fs.readFileSync(filePath, 'utf8')); }
777
+ catch (error) { throw new CliError(`cannot parse ${filePath}: ${error.message}`); }
778
+ if (lock?.lockfileVersion !== 2 || lock.registry !== 'local' ||
779
+ !lock.skills || typeof lock.skills !== 'object' || Array.isArray(lock.skills)) {
780
+ throw new CliError(`${filePath} is not a supported skillshop global manifest`);
781
+ }
782
+ for (const [name, entry] of Object.entries(lock.skills)) {
783
+ for (const target of globalTargets(name, entry)) {
784
+ assertRegularPath(target);
785
+ const claim = readInstallationManifest(path.dirname(target)).skills[name];
786
+ if (claim && !sameOwner(claim.owner, cliOwner())) throw new CliError(`${target} is owned by ${claim.owner.kind}:${claim.owner.id}`);
787
+ }
788
+ }
688
789
  }
689
- if (lock?.lockfileVersion !== 2 || lock.registry !== 'local' ||
690
- !lock.skills || typeof lock.skills !== 'object' || Array.isArray(lock.skills)) {
691
- throw new CliError(`${filePath} is not a supported skillshop global manifest`);
790
+ for (const { name, entry, target } of directoryOwnedEntries()) {
791
+ const { owner, ...metadata } = entry;
792
+ const previous = lock.skills[name] ?? { paths: [], copies: {} };
793
+ const oldCopy = previous.copies?.[target];
794
+ if (oldCopy && JSON.stringify(oldCopy.source ?? previous.source ?? null) !== JSON.stringify(metadata.source ?? null)) {
795
+ throw new CliError(`${name} has conflicting global installation sources`);
796
+ }
797
+ previous.paths = [...new Set([...previous.paths, target])];
798
+ previous.copies ??= {};
799
+ previous.copies[target] = metadata;
800
+ lock.skills[name] = previous;
692
801
  }
693
802
  return lock;
694
803
  }
@@ -709,24 +818,7 @@ function globalTargets(name, entry) {
709
818
  }
710
819
 
711
820
  function writeGlobalSkills(lock) {
712
- const skills = Object.fromEntries(Object.entries(lock.skills).sort(([left], [right]) => left.localeCompare(right))
713
- .map(([name, entry]) => {
714
- const paths = globalTargets(name, entry).sort();
715
- return [name, {
716
- ...(typeof entry.version === 'string' ? { version: entry.version } : {}),
717
- ...(typeof entry.author === 'string' ? { author: entry.author } : {}),
718
- ...(entry.source ? { source: entry.source } : {}),
719
- paths,
720
- copies: Object.fromEntries(paths.map((target) => [target, entry.copies?.[target] ?? {}]))
721
- }];
722
- }));
723
- const filePath = path.join(skillshopHome(), globalSkillsFileName);
724
- const contents = `${JSON.stringify({ ...emptyLock(), skills }, null, 2)}\n`;
725
- if (fs.existsSync(filePath) && fs.readFileSync(filePath, 'utf8') === contents) return;
726
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
727
- const temporaryPath = `${filePath}.tmp-${process.pid}`;
728
- fs.writeFileSync(temporaryPath, contents);
729
- fs.renameSync(temporaryPath, filePath);
821
+ saveDirectoryOwnership(null, lock);
730
822
  }
731
823
 
732
824
  function repairTrackedSkills(root, lock) {
@@ -771,7 +863,7 @@ function reconcileRepository(root, { register = true } = {}) {
771
863
  }
772
864
 
773
865
  function reconcileCurrentRepository() {
774
- if (fs.existsSync(path.join(skillshopHome(), globalSkillsFileName))) {
866
+ if (fs.existsSync(path.join(skillshopHome(), globalSkillsFileName)) || installationDirectories().some((directory) => fs.existsSync(path.join(directory, lockFileName)))) {
775
867
  const globalSkills = readGlobalSkills();
776
868
  repairTrackedSkills(null, globalSkills);
777
869
  writeGlobalSkills(globalSkills);
@@ -1171,13 +1263,13 @@ function syncPersonalCopies(cwd) {
1171
1263
  }
1172
1264
 
1173
1265
  for (const context of contexts) {
1174
- if (context.kind === 'project' && JSON.stringify(context.lock) !== context.original) {
1266
+ if (context.kind === 'project' && (JSON.stringify(context.lock) !== context.original || fs.existsSync(lockPathFor(context.root)))) {
1175
1267
  try { writeLock(context.root, context.lock); }
1176
1268
  catch (error) { failures.push(`project ${context.root} state: ${error.message}`); }
1177
1269
  }
1178
1270
  }
1179
1271
 
1180
- if (globalLock && JSON.stringify(globalLock) !== contexts.find(({ kind }) => kind === 'global')?.original) {
1272
+ if (globalLock && (JSON.stringify(globalLock) !== contexts.find(({ kind }) => kind === 'global')?.original || fs.existsSync(path.join(skillshopHome(), globalSkillsFileName)))) {
1181
1273
  try { writeGlobalSkills(globalLock); }
1182
1274
  catch (error) { failures.push(`global state: ${error.message}`); }
1183
1275
  }
@@ -1283,9 +1375,11 @@ function installGlobal(name, requestedVersion) {
1283
1375
  assertNoManagedSymlink(os.homedir(), targetDir, `global install destination for ${name}`);
1284
1376
  assertInstallPathsDoNotOverlap(metadata.sourceDir, targetDir);
1285
1377
  }
1286
- if (metadata.kind === 'personal' && fs.existsSync(targetDir) && !managedTargets.has(canonicalDestination(targetDir))) {
1378
+ if (fs.existsSync(targetDir) && !managedTargets.has(canonicalDestination(targetDir))) {
1287
1379
  throw new CliError(`${targetDir} exists and is not a Skillshop-managed copy of ${name}`);
1288
1380
  }
1381
+ const claim = readInstallationManifest(skillDirectory).skills[name];
1382
+ if (claim && !sameOwner(claim.owner, cliOwner())) throw new CliError(`${targetDir} is owned by ${claim.owner.kind}:${claim.owner.id}`);
1289
1383
  return { skillDirectory, targetDir };
1290
1384
  });
1291
1385
  for (const { skillDirectory, targetDir } of targetPairs) {
@@ -1536,7 +1630,7 @@ function installLocal(name, requestedVersion, cwd) {
1536
1630
  );
1537
1631
  }
1538
1632
  for (const { metadata, targetDirs, previousTargets } of installs) {
1539
- assertTargetsAvailable(metadata.name, targetDirs, previousTargets);
1633
+ assertTargetsAvailable(metadata.name, targetDirs, previousTargets, root);
1540
1634
  for (const targetDir of targetDirs) {
1541
1635
  if (metadata.kind === 'personal') {
1542
1636
  assertNoManagedSymlink(root, targetDir, `install destination for ${metadata.name}`);
@@ -1561,31 +1655,26 @@ function installLocal(name, requestedVersion, cwd) {
1561
1655
 
1562
1656
  writeLock(root, lock);
1563
1657
  maintainRepository(root, lock);
1564
- console.log(`Wrote ${path.join(root, lockFileName)}`);
1658
+ console.log(`Wrote installation manifests for ${root}`);
1565
1659
  }
1566
1660
 
1567
1661
  function uninstallGlobal(name) {
1568
- const config = readConfig();
1569
- const skillDirectories = config
1570
- ? configuredSkillDirectories(config, 'global')
1571
- : [path.join(os.homedir(), '.agents', 'skills')];
1572
- const targets = skillDirectories.map((directory) => path.join(directory, name));
1573
- const installedTargets = targets.filter((target) => fs.existsSync(target));
1574
- if (installedTargets.length === 0) {
1575
- throw new CliError(`${name} is not installed globally in the configured skill directories`);
1576
- }
1577
-
1578
- for (const targetDir of installedTargets) {
1579
- fs.rmSync(targetDir, { recursive: true });
1580
- console.log(`Uninstalled ${name} from ${targetDir}`);
1581
- }
1582
1662
  const globalSkills = readGlobalSkills();
1583
- if (globalSkills.skills[name]) {
1584
- const remaining = staleTargets(globalTargets(name, globalSkills.skills[name]), installedTargets);
1585
- if (remaining.length) globalSkills.skills[name].paths = remaining;
1586
- else delete globalSkills.skills[name];
1587
- writeGlobalSkills(globalSkills);
1663
+ const entry = globalSkills.skills[name];
1664
+ if (!entry) throw new CliError(`${name} is not a Skillshop-managed global installation`);
1665
+ const config = readConfig();
1666
+ const directories = config ? configuredSkillDirectories(config, 'global') : [path.join(os.homedir(), '.agents', 'skills')];
1667
+ const targets = globalTargets(name, entry).filter((target) => directories.includes(path.dirname(target)));
1668
+ if (!targets.length) throw new CliError(`${name} is not installed globally in the configured skill directories`);
1669
+ for (const target of targets) assertRegularPath(target);
1670
+ for (const target of targets) {
1671
+ fs.rmSync(target, { recursive: true, force: true });
1672
+ console.log(`Uninstalled ${name} from ${target}`);
1588
1673
  }
1674
+ const remaining = globalTargets(name, entry).filter((target) => !targets.includes(target));
1675
+ if (remaining.length) entry.paths = remaining;
1676
+ else delete globalSkills.skills[name];
1677
+ writeGlobalSkills(globalSkills);
1589
1678
  }
1590
1679
 
1591
1680
  function uninstallLocal(name, cwd) {
@@ -1604,7 +1693,7 @@ function uninstallLocal(name, cwd) {
1604
1693
  maintainRepository(root, lock);
1605
1694
 
1606
1695
  for (const targetDir of targetDirs) console.log(`Uninstalled ${name} from ${targetDir}`);
1607
- console.log(`Updated ${path.join(root, lockFileName)}`);
1696
+ console.log(`Updated installation manifests for ${root}`);
1608
1697
  }
1609
1698
 
1610
1699
  function updateLocal(requestedName, cwd, options = {}) {
@@ -1632,7 +1721,7 @@ function updateLocal(requestedName, cwd, options = {}) {
1632
1721
  return { metadata, targetDirs, previousTargets };
1633
1722
  });
1634
1723
  for (const { metadata, targetDirs, previousTargets } of updates) {
1635
- assertTargetsAvailable(metadata.name, targetDirs, previousTargets);
1724
+ assertTargetsAvailable(metadata.name, targetDirs, previousTargets, root);
1636
1725
  for (const targetDir of targetDirs) {
1637
1726
  if (metadata.kind === 'personal') {
1638
1727
  assertNoManagedSymlink(root, targetDir, `update destination for ${metadata.name}`);
@@ -1654,7 +1743,7 @@ function updateLocal(requestedName, cwd, options = {}) {
1654
1743
 
1655
1744
  writeLock(root, lock);
1656
1745
  maintainRepository(root, lock, options);
1657
- console.log(`Updated ${path.join(root, lockFileName)}`);
1746
+ console.log(`Updated installation manifests for ${root}`);
1658
1747
  }
1659
1748
 
1660
1749
  function updateAllRepositories() {
@@ -1861,6 +1950,7 @@ function refreshManagedCopies(scope) {
1861
1950
  } else {
1862
1951
  try {
1863
1952
  const lock = readLock(owner.root);
1953
+ if (fs.existsSync(lockPathFor(owner.root))) writeLock(owner.root, lock);
1864
1954
  refreshManagedLock(owner.root, lock, { summary });
1865
1955
  } catch (error) {
1866
1956
  summary.failed.push(`${owner.root}: ${error.message}`);
@@ -1869,6 +1959,7 @@ function refreshManagedCopies(scope) {
1869
1959
  } else if (scope === 'global') {
1870
1960
  try {
1871
1961
  const globalLock = readGlobalSkills();
1962
+ if (fs.existsSync(path.join(skillshopHome(), globalSkillsFileName))) writeGlobalSkills(globalLock);
1872
1963
  refreshManagedLock(null, globalLock, { global: true, summary });
1873
1964
  } catch (error) {
1874
1965
  summary.failed.push(`global managed copies: ${error.message}`);
@@ -1880,6 +1971,7 @@ function refreshManagedCopies(scope) {
1880
1971
  if (!hasLock(root)) continue;
1881
1972
  try {
1882
1973
  const lock = readLock(root);
1974
+ if (fs.existsSync(lockPathFor(root))) writeLock(root, lock);
1883
1975
  refreshManagedLock(root, lock, { summary });
1884
1976
  } catch (error) {
1885
1977
  summary.failed.push(`${root}: ${error.message}`);
@@ -1922,11 +2014,18 @@ function discoverLockRoots(startPath, roots) {
1922
2014
  }
1923
2015
 
1924
2016
  if (stat.isFile()) {
2017
+ if (path.basename(startPath) === lockFileName) {
2018
+ try { if (JSON.parse(fs.readFileSync(startPath, 'utf8')).manifestVersion === 1) return; } catch {}
2019
+ }
1925
2020
  if ([lockFileName, legacyLockFileName].includes(path.basename(startPath))) roots.add(canonicalPath(path.dirname(startPath)));
1926
2021
  return;
1927
2022
  }
1928
2023
  if (!stat.isDirectory()) return;
1929
2024
 
2025
+ // An installation manifest is not a nested project ownership file.
2026
+ if (path.basename(startPath) === 'skills' && fs.existsSync(path.join(startPath, lockFileName))) {
2027
+ try { if (JSON.parse(fs.readFileSync(path.join(startPath, lockFileName), 'utf8')).manifestVersion === 1) return; } catch {}
2028
+ }
1930
2029
  if (hasLock(startPath)) roots.add(canonicalPath(startPath));
1931
2030
 
1932
2031
  let entries;
@@ -1,8 +1,9 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- import { importSkill } from './import.js';
4
+ import { importSkill, resolveSkill } from './import.js';
5
5
  import { normalizeSkillSpecifier, skillName } from './specifier.js';
6
+ import { assertInstallationOwner, assertRegularPath, readInstallationManifest, writeInstallationManifest, sameOwner } from './installation-state.js';
6
7
 
7
8
  function declaredUserSkills(packageRoot) {
8
9
  const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
@@ -30,6 +31,7 @@ export async function installUserSkill({
30
31
  targetDir: path.resolve(userSkillsDir, skillName(normalized)),
31
32
  cwd,
32
33
  overwrite: true,
34
+ ownerKind: 'package',
33
35
  });
34
36
  }
35
37
 
@@ -40,9 +42,40 @@ export async function installDeclaredUserSkills({
40
42
  } = {}) {
41
43
  const packageRoot = path.resolve(cwd, library);
42
44
  const declared = declaredUserSkills(packageRoot);
45
+ const directory = path.resolve(userSkillsDir ?? path.join(os.homedir(), '.agents', 'skills'));
46
+ const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
47
+ const owner = { kind: 'package', id: pkg.name || packageRoot };
48
+ // Check the complete declaration before copying its first skill.
49
+ for (const specifier of declared) {
50
+ const skill = await resolveSkill({ library: packageRoot, specifier, cwd });
51
+ assertInstallationOwner(directory, skill.name, owner);
52
+ }
53
+ const keep = new Set(declared.map(skillName));
54
+ const obsolete = Object.entries(readInstallationManifest(directory).skills)
55
+ .filter(([name, entry]) => sameOwner(entry.owner, owner) && !keep.has(name)).map(([name]) => name);
56
+ for (const name of obsolete) assertRegularPath(path.join(directory, name));
43
57
  const results = [];
44
58
  for (const specifier of declared) {
45
59
  results.push(await installUserSkill({ library: packageRoot, specifier, cwd, userSkillsDir }));
46
60
  }
61
+ if (obsolete.length) {
62
+ const manifest = readInstallationManifest(directory);
63
+ for (const name of obsolete) {
64
+ fs.rmSync(path.join(directory, name), { recursive: true, force: true });
65
+ delete manifest.skills[name];
66
+ }
67
+ writeInstallationManifest(directory, manifest);
68
+ }
47
69
  return results;
48
70
  }
71
+
72
+ export function uninstallUserSkill({ library, specifier, cwd = process.cwd(), userSkillsDir = path.join(os.homedir(), '.agents', 'skills') }) {
73
+ const packageRoot = path.resolve(cwd, library);
74
+ const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
75
+ const name = skillName(normalizeSkillSpecifier(specifier));
76
+ const directory = path.resolve(userSkillsDir);
77
+ const manifest = assertInstallationOwner(directory, name, { kind: 'package', id: pkg.name || packageRoot }, { requireOwned: true });
78
+ fs.rmSync(path.join(directory, name), { recursive: true, force: true });
79
+ delete manifest.skills[name];
80
+ writeInstallationManifest(directory, manifest);
81
+ }
@@ -36,3 +36,5 @@ skillshop sync # update recorded local and global copies
36
36
  `sync` is also the explicit migration step for tracked copies made by older bundled Skillshop installs; `library init` never migrates. A source already in the selected library wins and updates the tracked copies. If the source is missing, Skillshop imports it only when every surviving tracked project and global copy agrees in paths, file bytes, and modes. Conflicts, unknown source kinds, malformed ownership state, unmanaged directories, and package-import snapshots are preserved and reported. Missing recorded destinations are pruned, never recreated.
37
37
 
38
38
  The `skillsDirectory` field can name another safe relative directory inside this library. Set it to `.` to use skill folders directly in the library root.
39
+
40
+ Installation ownership is recorded in `.skillshop-lock` inside each destination skills directory, not at the library or project root. Project dependency declarations (`skillshop.json`) and their reproducibility lock (`skillshop-lock.json`) are a separate opt-in workflow.