skillshop 2.2.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
@@ -14,6 +14,23 @@ import {
14
14
  composeAgentsInstructions,
15
15
  hasProjectInstructions
16
16
  } from './agents-instructions.js';
17
+ import {
18
+ PersonalLibraryError,
19
+ importTrackedPersonalSkill,
20
+ inspectPersonalLibrary,
21
+ initPersonalLibrary,
22
+ librarySelectionFileName,
23
+ listPersonalSkills,
24
+ resolvePersonalSkill,
25
+ resolveRecordedPersonalSkill,
26
+ selectedLibraryRoot,
27
+ validatePersonalSkillDirectory
28
+ } from './personal-library.js';
29
+ import { downloadPersonalSkill, GitHubLibraryError } from './github-library.js';
30
+ import {
31
+ assertRegularPath, readInstallationManifest, writeInstallationManifest,
32
+ readInstallationIndex, registerInstallationDirectory, sameOwner
33
+ } from './installation-state.js';
17
34
 
18
35
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
36
  const skillsContentRoot = path.join(packageRoot, 'skills-content');
@@ -33,6 +50,7 @@ const projectTemplateName = 'AGENTS.template.md';
33
50
  const defaultProjectTemplate = '# Project instructions\n\n{{ blocks }}\n';
34
51
  const internalUpgradeRefreshCommand = '__skillshop_upgrade_refresh';
35
52
  const internalUpgradeRefreshToken = 'skillshop-managed-upgrade-v1';
53
+ const personalLibraryTemplateRoot = path.join(packageRoot, 'templates', 'skillshop-library');
36
54
  const globalAgentsTargets = [
37
55
  path.join('.codex', 'AGENTS.md'),
38
56
  path.join('.agents', 'AGENTS.md'),
@@ -46,7 +64,82 @@ function lockPathFor(root) {
46
64
  }
47
65
 
48
66
  function hasLock(root) {
49
- 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
+ }
50
143
  }
51
144
  const agentOptions = [
52
145
  { id: 'copilot', label: 'Copilot', local: '.github/skills', global: '.copilot/skills' },
@@ -129,9 +222,16 @@ function printHelp() {
129
222
  skillshop update [name]
130
223
  skillshop update --push
131
224
  skillshop migrate [root ...]
225
+ skillshop library init [directory]
226
+ skillshop library list
227
+ skillshop download @<owner>/<skill> [--force]
228
+ skillshop install @<owner>/<skill> [--global]
229
+ skillshop sync
132
230
  skillshop list
133
231
 
134
232
  Install, uninstall, and update skills from the local registry.
233
+ Personal folder libraries are the source of truth for user-authored skills. Initialize or select one with skillshop library init [directory], list it with skillshop library list, download a source with skillshop download @owner/skill, and install it with skillshop install @owner/skill. Bare names install from the selected library.
234
+ skillshop sync propagates personal source edits and removals without fetching upstream. It can also adopt tracked legacy bundled copies into the selected library when all surviving copies agree; init itself never migrates.
135
235
 
136
236
  Agent instructions:
137
237
  skillshop agents init creates a neutral .agents/instructions/AGENTS.template.md and renders it into a separate managed region in the project-root AGENTS.md. Existing text and prior managed guidance are preserved. Edit the template and sibling *.block.md sources, not the generated region.
@@ -332,19 +432,38 @@ function emptyLock() {
332
432
 
333
433
  function readLock(root) {
334
434
  const lockPath = lockPathFor(root);
335
- if (!fs.existsSync(lockPath)) return emptyLock();
336
-
337
- let lock;
338
- try {
339
- lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
340
- } catch (error) {
341
- 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
+ }
342
458
  }
343
-
344
- if (![1, 2].includes(lock?.lockfileVersion) || lock.registry !== 'local' || !lock.skills || Array.isArray(lock.skills)) {
345
- 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)])] };
346
466
  }
347
-
348
467
  return lock;
349
468
  }
350
469
 
@@ -388,22 +507,8 @@ function localSkillDirectories(root, lock, cwd) {
388
507
  }
389
508
 
390
509
  function writeLock(root, lock) {
391
- const skills = Object.fromEntries(
392
- Object.entries(lock.skills)
393
- .sort(([left], [right]) => left.localeCompare(right))
394
- .map(([name, entry]) => [name, {
395
- version: entry.version,
396
- author: entry.author,
397
- paths: lockedTargets(root, name, entry).map((target) => relativePath(root, target)).sort()
398
- }])
399
- );
400
- const stableLock = { lockfileVersion: 2, registry: 'local', skills };
401
- const lockPath = path.join(root, lockFileName);
402
- const contents = `${JSON.stringify(stableLock, null, 2)}\n`;
403
- if (fs.existsSync(lockPath) && fs.readFileSync(lockPath, 'utf8') === contents) return;
404
- const temporaryPath = `${lockPath}.tmp-${process.pid}`;
405
- fs.writeFileSync(temporaryPath, contents);
406
- fs.renameSync(temporaryPath, lockPath);
510
+ saveDirectoryOwnership(root, lock);
511
+ syncGitExclude(root, lock);
407
512
  }
408
513
 
409
514
  function registryPath() {
@@ -474,9 +579,14 @@ function staleTargets(previousTargets, targetDirs) {
474
579
  return previousTargets.filter((target) => !destinations.has(canonicalDestination(target)));
475
580
  }
476
581
 
477
- function assertTargetsAvailable(name, targetDirs, previousTargets) {
582
+ function assertTargetsAvailable(name, targetDirs, previousTargets, root) {
478
583
  const managedDestinations = new Set(previousTargets.map(canonicalDestination));
479
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
+ }
480
590
  if (fs.existsSync(targetDir) && !managedDestinations.has(canonicalDestination(targetDir))) {
481
591
  throw new CliError(`${targetDir} exists and is not managed as ${name} in ${lockFileName}`);
482
592
  }
@@ -515,7 +625,11 @@ function managedGitPaths(root, lock) {
515
625
  const gitRoot = canonicalPath(gitOutput(root, ['rev-parse', '--show-toplevel']));
516
626
  const scope = relativePath(gitRoot, canonicalPath(root));
517
627
  const scopedPath = (target) => relativePath(gitRoot, path.join(root, target));
518
- 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
+ }
519
633
  if (fs.existsSync(path.join(root, legacyLockFileName))) paths.push(scopedPath(legacyLockFileName));
520
634
 
521
635
  for (const [name, entry] of Object.entries(lock.skills)) {
@@ -656,15 +770,34 @@ function repairInstalledManifest(name, entry, manifestPath) {
656
770
 
657
771
  function readGlobalSkills() {
658
772
  const filePath = path.join(skillshopHome(), globalSkillsFileName);
659
- if (!fs.existsSync(filePath)) return emptyLock();
660
- let lock;
661
- try {
662
- lock = JSON.parse(fs.readFileSync(filePath, 'utf8'));
663
- } catch (error) {
664
- 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
+ }
665
789
  }
666
- if (lock?.lockfileVersion !== 2 || lock.registry !== 'local' || !lock.skills || Array.isArray(lock.skills)) {
667
- 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;
668
801
  }
669
802
  return lock;
670
803
  }
@@ -685,18 +818,7 @@ function globalTargets(name, entry) {
685
818
  }
686
819
 
687
820
  function writeGlobalSkills(lock) {
688
- const skills = Object.fromEntries(Object.entries(lock.skills).sort(([left], [right]) => left.localeCompare(right))
689
- .map(([name, entry]) => {
690
- const paths = globalTargets(name, entry).sort();
691
- return [name, { paths, copies: Object.fromEntries(paths.map((target) => [target, entry.copies?.[target] ?? {}])) }];
692
- }));
693
- const filePath = path.join(skillshopHome(), globalSkillsFileName);
694
- const contents = `${JSON.stringify({ ...emptyLock(), skills }, null, 2)}\n`;
695
- if (fs.existsSync(filePath) && fs.readFileSync(filePath, 'utf8') === contents) return;
696
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
697
- const temporaryPath = `${filePath}.tmp-${process.pid}`;
698
- fs.writeFileSync(temporaryPath, contents);
699
- fs.renameSync(temporaryPath, filePath);
821
+ saveDirectoryOwnership(null, lock);
700
822
  }
701
823
 
702
824
  function repairTrackedSkills(root, lock) {
@@ -709,7 +831,12 @@ function repairTrackedSkills(root, lock) {
709
831
  delete lock.skills[name];
710
832
  continue;
711
833
  }
834
+ if (root && entry.source?.kind === 'personal-library') {
835
+ entry.paths = targets.map((target) => relativePath(root, target));
836
+ continue;
837
+ }
712
838
  for (const target of targets) {
839
+ if (!root && entry.copies?.[target]?.source?.kind === 'personal-library') continue;
713
840
  const manifestPath = path.join(target, 'SKILL.md');
714
841
  assertLocalDestination(root ?? os.homedir(), manifestPath, `manifest for ${name}`);
715
842
  const installation = root ? entry : entry.copies?.[target] ?? {};
@@ -736,7 +863,7 @@ function reconcileRepository(root, { register = true } = {}) {
736
863
  }
737
864
 
738
865
  function reconcileCurrentRepository() {
739
- if (fs.existsSync(path.join(skillshopHome(), globalSkillsFileName))) {
866
+ if (fs.existsSync(path.join(skillshopHome(), globalSkillsFileName)) || installationDirectories().some((directory) => fs.existsSync(path.join(directory, lockFileName)))) {
740
867
  const globalSkills = readGlobalSkills();
741
868
  repairTrackedSkills(null, globalSkills);
742
869
  writeGlobalSkills(globalSkills);
@@ -753,35 +880,480 @@ function reconcileCurrentRepository() {
753
880
 
754
881
  function replaceSkillDirectory(sourceDir, targetDir) {
755
882
  const temporaryDir = `${targetDir}.skillshop-tmp-${process.pid}`;
883
+ const backupDir = `${targetDir}.skillshop-backup-${process.pid}`;
756
884
  fs.mkdirSync(path.dirname(targetDir), { recursive: true });
757
885
  fs.rmSync(temporaryDir, { recursive: true, force: true });
886
+ fs.rmSync(backupDir, { recursive: true, force: true });
758
887
  fs.cpSync(sourceDir, temporaryDir, { recursive: true, force: true });
759
- fs.rmSync(targetDir, { recursive: true, force: true });
760
- fs.renameSync(temporaryDir, targetDir);
888
+ const hadTarget = fs.existsSync(targetDir);
889
+ try {
890
+ if (hadTarget) fs.renameSync(targetDir, backupDir);
891
+ fs.renameSync(temporaryDir, targetDir);
892
+ fs.rmSync(backupDir, { recursive: true, force: true });
893
+ } catch (error) {
894
+ if (hadTarget && !fs.existsSync(targetDir) && fs.existsSync(backupDir)) fs.renameSync(backupDir, targetDir);
895
+ throw error;
896
+ } finally {
897
+ fs.rmSync(temporaryDir, { recursive: true, force: true });
898
+ }
899
+ }
900
+
901
+ function personalTreeSnapshot(root, label) {
902
+ const entries = [];
903
+ const visit = (directory, relative = '') => {
904
+ const stat = fs.lstatSync(directory);
905
+ if (stat.isSymbolicLink()) throw new CliError(`${label} contains a symlink: ${directory}`);
906
+ if (stat.isDirectory()) {
907
+ entries.push({ path: relative, kind: 'directory', mode: stat.mode & 0o777 });
908
+ for (const name of fs.readdirSync(directory).sort()) {
909
+ visit(path.join(directory, name), relative ? `${relative}/${name}` : name);
910
+ }
911
+ return;
912
+ }
913
+ if (!stat.isFile()) throw new CliError(`${label} contains a non-regular entry: ${directory}`);
914
+ entries.push({
915
+ path: relative,
916
+ kind: 'file',
917
+ mode: stat.mode & 0o777,
918
+ contents: fs.readFileSync(directory).toString('base64')
919
+ });
920
+ };
921
+ visit(root);
922
+ return entries;
923
+ }
924
+
925
+ function replaceSyncedSkillDirectory(sourceDir, targetDir) {
926
+ const parent = path.dirname(targetDir);
927
+ const transaction = fs.mkdtempSync(path.join(parent, `.skillshop-sync-${process.pid}-`));
928
+ const staged = path.join(transaction, 'skill');
929
+ const backup = path.join(transaction, 'previous-skill');
930
+ let backedUp = false;
931
+ let installed = false;
932
+ try {
933
+ fs.cpSync(sourceDir, staged, { recursive: true, errorOnExist: true, force: false });
934
+ personalTreeSnapshot(staged, 'staged personal skill');
935
+ fs.renameSync(targetDir, backup);
936
+ backedUp = true;
937
+ fs.renameSync(staged, targetDir);
938
+ installed = true;
939
+ } catch (error) {
940
+ if (installed) fs.rmSync(targetDir, { recursive: true, force: true });
941
+ if (backedUp && fs.existsSync(backup)) fs.renameSync(backup, targetDir);
942
+ throw error;
943
+ } finally {
944
+ // Once the new directory is installed, backup cleanup cannot invalidate
945
+ // that successful replacement or remove its only surviving content.
946
+ try { fs.rmSync(transaction, { recursive: true, force: true }); } catch {}
947
+ }
948
+ }
949
+
950
+ function recordedPersonalSkill(name, source) {
951
+ try {
952
+ const resolved = resolveRecordedPersonalSkill(source);
953
+ if (!resolved) throw new CliError(`recorded personal source for ${name} is missing from ${source.root}`);
954
+ return resolved;
955
+ } catch (error) {
956
+ if (/personal skill .* does not exist|\/SKILL\.md is missing/.test(error.message)) {
957
+ throw new CliError(`recorded personal source for ${name} is missing from ${source.root}`);
958
+ }
959
+ throw error;
960
+ }
961
+ }
962
+
963
+ function isLegacyBundleSource(source) {
964
+ return source == null || ['bundle', 'bundled'].includes(source.kind);
965
+ }
966
+
967
+ function syncPersonalCopy(name, source, target, boundary) {
968
+ assertNoManagedSymlink(boundary, target, `sync destination for ${name}`);
969
+ assertInstallPathsDoNotOverlap(source.sourceDir, target);
970
+ let targetStat;
971
+ try {
972
+ targetStat = fs.lstatSync(target);
973
+ } catch (error) {
974
+ if (error.code === 'ENOENT') return 'missing';
975
+ throw error;
976
+ }
977
+ if (!targetStat.isDirectory() || targetStat.isSymbolicLink()) {
978
+ throw new CliError(`sync destination for ${name} is not a regular skill directory: ${target}`);
979
+ }
980
+ const sourceTree = personalTreeSnapshot(source.sourceDir, `personal source for ${name}`);
981
+ const targetTree = personalTreeSnapshot(target, `sync destination for ${name}`);
982
+ if (JSON.stringify(sourceTree) === JSON.stringify(targetTree)) return 'current';
983
+ replaceSyncedSkillDirectory(source.sourceDir, target);
984
+ return 'updated';
985
+ }
986
+
987
+ function syncPersonalCopies(cwd) {
988
+ const failures = [];
989
+ const roots = new Map();
990
+ const currentRoot = findLockRoot(cwd, { required: false });
991
+ if (currentRoot) roots.set(canonicalPath(currentRoot), canonicalPath(currentRoot));
992
+ let ownershipComplete = true;
993
+ try {
994
+ for (const registeredRoot of readRegistry().repositories) {
995
+ const canonical = canonicalPath(registeredRoot);
996
+ roots.set(canonical, canonical);
997
+ }
998
+ } catch (error) {
999
+ failures.push(`project registry: ${error.message}`);
1000
+ ownershipComplete = false;
1001
+ }
1002
+
1003
+ const contexts = [];
1004
+ for (const root of roots.values()) {
1005
+ if (!hasLock(root)) continue;
1006
+ try {
1007
+ const lock = readLock(root);
1008
+ contexts.push({ kind: 'project', root, lock, original: JSON.stringify(lock) });
1009
+ } catch (error) {
1010
+ failures.push(`project ${root}: ${error.message}`);
1011
+ ownershipComplete = false;
1012
+ }
1013
+ }
1014
+ let globalLock = null;
1015
+ try {
1016
+ globalLock = readGlobalSkills();
1017
+ } catch (error) {
1018
+ failures.push(`global manifest: ${error.message}`);
1019
+ ownershipComplete = false;
1020
+ }
1021
+ if (globalLock) contexts.push({ kind: 'global', lock: globalLock, original: JSON.stringify(globalLock) });
1022
+
1023
+ let updated = 0;
1024
+ let current = 0;
1025
+ let pruned = 0;
1026
+ let selectedRoot = null;
1027
+ try {
1028
+ selectedRoot = selectedLibraryRoot(skillshopHome());
1029
+ } catch (error) {
1030
+ failures.push(`selected personal library: ${error.message}`);
1031
+ }
1032
+ const libraryInfo = new Map();
1033
+ const getLibrarySkill = (name) => {
1034
+ if (!selectedRoot) return null;
1035
+ if (!libraryInfo.has(name)) {
1036
+ libraryInfo.set(name, resolvePersonalSkill(name, skillshopHome(), selectedRoot));
1037
+ }
1038
+ return libraryInfo.get(name);
1039
+ };
1040
+ const isUnknownSource = (source) => source != null && source.kind !== 'personal-library' && !isLegacyBundleSource(source);
1041
+ const legacyGroups = new Map();
1042
+ const personalItems = [];
1043
+ const unknownSourceNames = new Set();
1044
+
1045
+ for (const context of contexts) {
1046
+ for (const [name, entry] of Object.entries(context.lock.skills)) {
1047
+ if (context.kind === 'project') {
1048
+ let targets;
1049
+ try { targets = lockedTargets(context.root, name, entry); }
1050
+ catch (error) { failures.push(`project ${name}: ${error.message}`); ownershipComplete = false; continue; }
1051
+ if (entry.source?.kind === 'personal-library') {
1052
+ personalItems.push({ kind: 'project', context, name, entry, sourceRecord: entry.source, targets });
1053
+ } else if (isLegacyBundleSource(entry.source)) {
1054
+ legacyGroups.set(name, [...(legacyGroups.get(name) ?? []), { kind: 'project', context, name, entry, targets }]);
1055
+ } else if (isUnknownSource(entry.source)) unknownSourceNames.add(name);
1056
+ continue;
1057
+ }
1058
+
1059
+ let targets;
1060
+ try { targets = globalTargets(name, entry); }
1061
+ catch (error) { failures.push(`global ${name}: ${error.message}`); ownershipComplete = false; continue; }
1062
+ for (const target of targets) {
1063
+ const copy = entry.copies?.[target] ?? {};
1064
+ const sourceRecord = copy.source ?? entry.source;
1065
+ if (sourceRecord?.kind === 'personal-library') {
1066
+ personalItems.push({ kind: 'global', context, name, entry, copy, target, sourceRecord });
1067
+ } else if (isLegacyBundleSource(sourceRecord)) {
1068
+ legacyGroups.set(name, [...(legacyGroups.get(name) ?? []), { kind: 'global', context, name, entry, copy, target }]);
1069
+ } else if (isUnknownSource(sourceRecord)) unknownSourceNames.add(name);
1070
+ }
1071
+ }
1072
+ }
1073
+
1074
+ const updateLocalOwnership = (context, name, entry, source, targets) => {
1075
+ const kept = targets.filter((target) => target.present).map(({ target }) => relativePath(context.root, target)).sort();
1076
+ if (!kept.length) {
1077
+ delete context.lock.skills[name];
1078
+ return;
1079
+ }
1080
+ entry.paths = kept;
1081
+ entry.source = source.source;
1082
+ if (typeof source.author === 'string') entry.author = source.author;
1083
+ else delete entry.author;
1084
+ if (typeof source.version === 'string') entry.version = source.version;
1085
+ else delete entry.version;
1086
+ };
1087
+ const updateGlobalOwnership = (entry, copy, target, source) => {
1088
+ copy.source = source.source;
1089
+ if (typeof source.author === 'string') copy.author = source.author;
1090
+ else delete copy.author;
1091
+ if (typeof source.version === 'string') copy.version = source.version;
1092
+ else delete copy.version;
1093
+ entry.copies ??= {};
1094
+ entry.copies[target] = copy;
1095
+ };
1096
+ const pruneMissing = (item) => {
1097
+ pruned += 1;
1098
+ if (item.kind === 'project') {
1099
+ const targets = lockedTargets(item.context.root, item.name, item.entry).filter((target) => target !== item.target);
1100
+ if (targets.length) item.entry.paths = targets.map((target) => relativePath(item.context.root, target));
1101
+ else delete item.context.lock.skills[item.name];
1102
+ console.log(`Pruned missing project copy ${item.target}`);
1103
+ } else {
1104
+ delete item.entry.copies[item.target];
1105
+ const paths = item.entry.paths.filter((target) => target !== item.target);
1106
+ if (paths.length) item.entry.paths = paths;
1107
+ else delete item.context.lock.skills[item.name];
1108
+ console.log(`Pruned missing global copy ${item.target}`);
1109
+ }
1110
+ };
1111
+
1112
+ for (const item of personalItems) {
1113
+ const { name, sourceRecord } = item;
1114
+ let source;
1115
+ try { source = recordedPersonalSkill(name, sourceRecord); }
1116
+ catch (error) { failures.push(`${item.kind} ${name}: ${error.message}`); continue; }
1117
+ const targets = item.kind === 'project' ? item.targets : [item.target];
1118
+ const prepared = [];
1119
+ let failed = false;
1120
+ for (const target of targets) {
1121
+ try {
1122
+ const boundary = item.kind === 'project' ? item.context.root : os.homedir();
1123
+ assertNoManagedSymlink(boundary, target, `sync destination for ${name}`);
1124
+ assertInstallPathsDoNotOverlap(source.sourceDir, target);
1125
+ let stat;
1126
+ try { stat = fs.lstatSync(target); }
1127
+ catch (error) { if (error.code === 'ENOENT') { prepared.push({ target, present: false }); continue; } throw error; }
1128
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new CliError(`sync destination for ${name} is not a regular skill directory: ${target}`);
1129
+ personalTreeSnapshot(target, `sync destination for ${name}`);
1130
+ prepared.push({ target, present: true });
1131
+ } catch (error) { failures.push(`${item.kind} ${name} at ${target}: ${error.message}`); failed = true; }
1132
+ }
1133
+ if (failed) continue;
1134
+ for (const target of prepared) {
1135
+ if (!target.present) { pruneMissing({ ...item, target: target.target }); continue; }
1136
+ try {
1137
+ const result = syncPersonalCopy(name, source, target.target, item.kind === 'project' ? item.context.root : os.homedir());
1138
+ if (result === 'updated') { updated += 1; console.log(`Synced ${item.kind} copy ${target.target}`); }
1139
+ else current += 1;
1140
+ } catch (error) { failures.push(`${item.kind} ${name} at ${target.target}: ${error.message}`); failed = true; }
1141
+ }
1142
+ if (failed) continue;
1143
+ if (item.kind === 'project') updateLocalOwnership(item.context, name, item.entry, source, prepared);
1144
+ else updateGlobalOwnership(item.entry, item.copy, item.target, source);
1145
+ }
1146
+
1147
+ for (const [name, group] of legacyGroups) {
1148
+ const root = selectedRoot;
1149
+ if (!root) {
1150
+ failures.push(`${name}: select a personal library with "skillshop library init" before adopting legacy copies`);
1151
+ continue;
1152
+ }
1153
+ let source;
1154
+ try { source = getLibrarySkill(name); }
1155
+ catch (error) { failures.push(`${name}: selected library source is unsafe or invalid: ${error.message}`); continue; }
1156
+ if (!source && unknownSourceNames.has(name)) {
1157
+ failures.push(`${name}: an ownership record has an unknown source kind; legacy copies were left unchanged`);
1158
+ continue;
1159
+ }
1160
+
1161
+ const prepared = [];
1162
+ let failed = false;
1163
+ for (const item of group) {
1164
+ const boundary = item.kind === 'project' ? item.context.root : os.homedir();
1165
+ const target = item.kind === 'project' ? item.targets : [item.target];
1166
+ for (const destination of target) {
1167
+ try {
1168
+ assertNoManagedSymlink(boundary, destination, `legacy copy for ${name}`);
1169
+ if (source) assertInstallPathsDoNotOverlap(source.sourceDir, destination);
1170
+ else assertInstallPathsDoNotOverlap(destination, path.join(root, 'skills', name));
1171
+ let stat;
1172
+ try { stat = fs.lstatSync(destination); }
1173
+ catch (error) { if (error.code === 'ENOENT') { prepared.push({ item, target: destination, present: false }); continue; } throw error; }
1174
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new CliError(`legacy copy for ${name} is not a regular skill directory: ${destination}`);
1175
+ validatePersonalSkillDirectory(boundary, destination, name);
1176
+ const snapshot = personalTreeSnapshot(destination, `legacy copy for ${name}`);
1177
+ prepared.push({ item, target: destination, present: true, snapshot });
1178
+ } catch (error) { failures.push(`${name} at ${destination}: ${error.message}`); failed = true; }
1179
+ }
1180
+ }
1181
+ if (failed) continue;
1182
+
1183
+ const present = prepared.filter(({ present: exists }) => exists);
1184
+ if (!present.length) {
1185
+ for (const item of group) {
1186
+ if (item.kind === 'project') {
1187
+ const existing = item.targets.filter((target) => fs.existsSync(target));
1188
+ const missing = item.targets.filter((target) => !fs.existsSync(target));
1189
+ for (const target of missing) {
1190
+ pruned += 1;
1191
+ console.log(`Pruned missing project copy ${target}`);
1192
+ }
1193
+ if (missing.length) {
1194
+ item.entry.paths = existing.map((target) => relativePath(item.context.root, target));
1195
+ if (!existing.length) delete item.context.lock.skills[name];
1196
+ }
1197
+ } else if (!fs.existsSync(item.target)) pruneMissing(item);
1198
+ }
1199
+ continue;
1200
+ }
1201
+
1202
+ if (!source) {
1203
+ if (!ownershipComplete) {
1204
+ failures.push(`${name}: cannot import a missing source because one or more ownership manifests could not be read`);
1205
+ continue;
1206
+ }
1207
+ const expected = JSON.stringify(present[0].snapshot);
1208
+ if (present.some(({ snapshot }) => JSON.stringify(snapshot) !== expected)) {
1209
+ failures.push(`${name}: tracked legacy copies diverge; resolve them before adoption`);
1210
+ continue;
1211
+ }
1212
+ try {
1213
+ const skillDirectory = path.join(inspectPersonalLibrary(root).skillsDirectory, name);
1214
+ for (const copy of present) assertInstallPathsDoNotOverlap(copy.target, skillDirectory);
1215
+ importTrackedPersonalSkill(root, present[0].target, name);
1216
+ source = resolvePersonalSkill(name, skillshopHome(), root);
1217
+ } catch (error) { failures.push(`${name}: could not import agreeing legacy copies: ${error.message}`); continue; }
1218
+ for (const copy of present.slice(1)) {
1219
+ if (JSON.stringify(copy.snapshot) !== JSON.stringify(present[0].snapshot)) {
1220
+ failures.push(`${name}: tracked legacy copies changed during adoption`);
1221
+ failed = true;
1222
+ break;
1223
+ }
1224
+ }
1225
+ if (failed) continue;
1226
+ console.log(`Adopted ${name} from ${present.length} agreeing tracked copy/copies into ${root}`);
1227
+ }
1228
+
1229
+ const localEntryResults = new Map();
1230
+ for (const copy of prepared) {
1231
+ if (!copy.present) { pruneMissing({ ...copy.item, target: copy.target }); continue; }
1232
+ let result;
1233
+ try {
1234
+ result = source ? syncPersonalCopy(name, source, copy.target, copy.item.kind === 'project' ? copy.item.context.root : os.homedir()) : 'current';
1235
+ } catch (error) {
1236
+ failures.push(`${copy.item.kind} ${name} at ${copy.target}: ${error.message}`);
1237
+ failed = true;
1238
+ if (copy.item.kind === 'project') {
1239
+ const state = localEntryResults.get(copy.item) ?? { successes: [], failed: false };
1240
+ state.failed = true;
1241
+ localEntryResults.set(copy.item, state);
1242
+ }
1243
+ continue;
1244
+ }
1245
+ if (result === 'updated') { updated += 1; console.log(`Synced adopted ${name} copy ${copy.target}`); }
1246
+ else current += 1;
1247
+ if (copy.item.kind === 'project') {
1248
+ const state = localEntryResults.get(copy.item) ?? { successes: [], failed: false };
1249
+ state.successes.push(copy.target);
1250
+ localEntryResults.set(copy.item, state);
1251
+ } else updateGlobalOwnership(copy.item.entry, copy.item.copy, copy.target, source);
1252
+ }
1253
+ for (const [item, state] of localEntryResults) {
1254
+ if (state.failed) continue;
1255
+ if (!state.successes.length) continue;
1256
+ item.entry.source = source.source;
1257
+ if (typeof source.author === 'string') item.entry.author = source.author;
1258
+ else delete item.entry.author;
1259
+ if (typeof source.version === 'string') item.entry.version = source.version;
1260
+ else delete item.entry.version;
1261
+ item.entry.paths = state.successes.map((target) => relativePath(item.context.root, target)).sort();
1262
+ }
1263
+ }
1264
+
1265
+ for (const context of contexts) {
1266
+ if (context.kind === 'project' && (JSON.stringify(context.lock) !== context.original || fs.existsSync(lockPathFor(context.root)))) {
1267
+ try { writeLock(context.root, context.lock); }
1268
+ catch (error) { failures.push(`project ${context.root} state: ${error.message}`); }
1269
+ }
1270
+ }
1271
+
1272
+ if (globalLock && (JSON.stringify(globalLock) !== contexts.find(({ kind }) => kind === 'global')?.original || fs.existsSync(path.join(skillshopHome(), globalSkillsFileName)))) {
1273
+ try { writeGlobalSkills(globalLock); }
1274
+ catch (error) { failures.push(`global state: ${error.message}`); }
1275
+ }
1276
+ console.log(`Personal sync: ${updated} updated, ${current} current, ${pruned} missing destination(s) pruned.`);
1277
+ if (failures.length) {
1278
+ for (const failure of failures) console.error(`skillshop: ${failure}`);
1279
+ throw new CliError(`sync completed with ${failures.length} failure(s); ${updated} copy/copies updated, ${current} current, ${pruned} missing destination(s) pruned`);
1280
+ }
761
1281
  }
762
1282
 
763
- function installAt(name, requestedVersion, targetDir) {
764
- const metadata = skillMetadata(name);
1283
+ function installAt(name, requestedVersion, targetDir, metadata = skillMetadata(name), boundary = path.dirname(targetDir)) {
765
1284
  if (requestedVersion && requestedVersion !== metadata.version) {
766
- throw new CliError(
767
- `${name}@${requestedVersion} is unavailable; local registry provides ${name}@${metadata.version}`
768
- );
1285
+ throw new CliError(metadata.kind === 'personal'
1286
+ ? `${name}@${requestedVersion} is unavailable in the personal library${metadata.version ? `; library version is ${metadata.version}` : ''}`
1287
+ : `${name}@${requestedVersion} is unavailable; local registry provides ${name}@${metadata.version}`);
769
1288
  }
770
1289
 
1290
+ if (metadata.kind === 'personal') {
1291
+ assertNoManagedSymlink(boundary, targetDir, `install destination for ${name}`);
1292
+ assertInstallPathsDoNotOverlap(metadata.sourceDir, targetDir);
1293
+ }
771
1294
  const updating = fs.existsSync(targetDir);
772
1295
  replaceSkillDirectory(metadata.sourceDir, targetDir);
773
1296
  return { ...metadata, targetDir, updating };
774
1297
  }
775
1298
 
1299
+ function assertInstallPathsDoNotOverlap(sourceDir, targetDir) {
1300
+ const source = path.resolve(sourceDir);
1301
+ const target = path.resolve(targetDir);
1302
+ const within = (parent, child) => {
1303
+ const relative = path.relative(parent, child);
1304
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
1305
+ };
1306
+ if (within(source, target) || within(target, source)) {
1307
+ throw new CliError(`install destination overlaps personal source directory: ${source}`);
1308
+ }
1309
+ }
1310
+
776
1311
  function lockEntry(root, results) {
777
1312
  const [result] = results;
778
1313
  return {
779
- version: result.version,
780
- author: result.author,
1314
+ ...(typeof result.version === 'string' ? { version: result.version } : {}),
1315
+ ...(typeof result.author === 'string' ? { author: result.author } : {}),
1316
+ ...(result.source ? { source: result.source } : {}),
781
1317
  paths: results.map(({ targetDir }) => relativePath(root, targetDir)).sort()
782
1318
  };
783
1319
  }
784
1320
 
1321
+ function resolvedInstallSource(name, stateHome, { existing = null, requested = false, selectedRoot = undefined, personalOnly = false } = {}) {
1322
+ const allRecordedSources = [existing?.source, ...Object.values(existing?.copies ?? {}).map((copy) => copy?.source)]
1323
+ .filter((source) => source != null);
1324
+ if (personalOnly && allRecordedSources.some((source) => source.kind !== 'personal-library' && !isLegacyBundleSource(source))) {
1325
+ throw new CliError(`${name} has an ownership record with an unknown source kind; refusing to replace it`);
1326
+ }
1327
+ const recordedSources = allRecordedSources
1328
+ .filter((source) => source?.kind === 'personal-library');
1329
+ if (requested) {
1330
+ const selected = resolvePersonalSkill(name, stateHome, selectedRoot ?? null);
1331
+ if (selected) {
1332
+ if (recordedSources.some((source) => path.resolve(source.root) !== path.resolve(selected.source.root) || source.skillDirectory !== selected.source.skillDirectory)) {
1333
+ throw new CliError(`${name} is already owned by a different personal library; uninstall it before changing sources`);
1334
+ }
1335
+ return selected;
1336
+ }
1337
+ if (personalOnly && selectedRoot && !recordedSources.length) {
1338
+ throw new CliError(`${name} is not present in the selected personal library`);
1339
+ }
1340
+ }
1341
+ if (recordedSources.length) {
1342
+ const identities = new Set(recordedSources.map((source) => `${path.resolve(source.root)}\0${source.skillDirectory ?? ''}\0${source.skill ?? name}`));
1343
+ if (identities.size !== 1) throw new CliError(`${name} has multiple personal library sources; refusing to choose one implicitly`);
1344
+ const source = recordedSources[0];
1345
+ const recorded = recordedPersonalSkill(name, source);
1346
+ if (recorded.name !== name) throw new CliError(`recorded personal source for ${name} is unavailable`);
1347
+ return recorded;
1348
+ }
1349
+ if (requested && selectedRoot === undefined) {
1350
+ const selected = resolvePersonalSkill(name, stateHome);
1351
+ if (selected) return selected;
1352
+ }
1353
+ if (personalOnly) throw new CliError(`${name} is not present in the selected or recorded personal library`);
1354
+ return skillMetadata(name);
1355
+ }
1356
+
785
1357
  function installGlobal(name, requestedVersion) {
786
1358
  const config = readConfig();
787
1359
  const skillDirectories = config
@@ -789,16 +1361,39 @@ function installGlobal(name, requestedVersion) {
789
1361
  : [path.join(os.homedir(), '.agents', 'skills')];
790
1362
 
791
1363
  const globalSkills = readGlobalSkills();
792
- for (const skillDirectory of skillDirectories) {
1364
+ const selectedRoot = selectedLibraryRoot(skillshopHome());
1365
+ const existing = globalSkills.skills[name];
1366
+ const recordedPersonal = [existing?.source, ...Object.values(existing?.copies ?? {}).map((copy) => copy?.source)]
1367
+ .some((source) => source?.kind === 'personal-library');
1368
+ const personalOnly = Boolean(selectedRoot) || recordedPersonal;
1369
+ const metadata = resolvedInstallSource(name, skillshopHome(), { existing, requested: true, selectedRoot, personalOnly });
1370
+ const managedTargets = existing ? new Set(globalTargets(name, existing).map(canonicalDestination)) : new Set();
1371
+ const targetPairs = skillDirectories.map((skillDirectory) => {
793
1372
  const targetDir = path.join(skillDirectory, name);
794
1373
  assertLocalDestination(os.homedir(), targetDir, `global installation for ${name}`);
795
- const result = installAt(name, requestedVersion, targetDir);
1374
+ if (metadata.kind === 'personal') {
1375
+ assertNoManagedSymlink(os.homedir(), targetDir, `global install destination for ${name}`);
1376
+ assertInstallPathsDoNotOverlap(metadata.sourceDir, targetDir);
1377
+ }
1378
+ if (fs.existsSync(targetDir) && !managedTargets.has(canonicalDestination(targetDir))) {
1379
+ throw new CliError(`${targetDir} exists and is not a Skillshop-managed copy of ${name}`);
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}`);
1383
+ return { skillDirectory, targetDir };
1384
+ });
1385
+ for (const { skillDirectory, targetDir } of targetPairs) {
1386
+ const result = installAt(name, requestedVersion, targetDir, metadata, os.homedir());
796
1387
  const entry = globalSkills.skills[name] ?? { paths: [], copies: {} };
797
1388
  entry.paths = [...new Set([...entry.paths, targetDir])];
798
- entry.copies[targetDir] = { author: result.author, version: result.version };
1389
+ entry.copies[targetDir] = {
1390
+ ...(typeof result.author === 'string' ? { author: result.author } : {}),
1391
+ ...(typeof result.version === 'string' ? { version: result.version } : {}),
1392
+ ...(result.source ? { source: result.source } : {})
1393
+ };
799
1394
  globalSkills.skills[name] = entry;
800
1395
  writeGlobalSkills(globalSkills);
801
- console.log(`${result.updating ? 'Updated' : 'Installed'} ${name}@${result.version} at ${targetDir}`);
1396
+ console.log(`${result.updating ? 'Updated' : 'Installed'} ${name}@${result.version ?? 'personal'} at ${targetDir}`);
802
1397
  }
803
1398
  }
804
1399
 
@@ -1004,10 +1599,22 @@ function installLocal(name, requestedVersion, cwd) {
1004
1599
  const root = findLockRoot(cwd) ?? projectRoot(cwd);
1005
1600
  const lock = readLock(root);
1006
1601
  const config = readConfig();
1602
+ const selectedRoot = selectedLibraryRoot(skillshopHome());
1007
1603
  const skillDirectories = localSkillDirectories(root, lock, cwd);
1008
- const names = [...new Set([...Object.keys(lock.skills), name, 'skillshop'])].sort();
1604
+ const existing = lock.skills[name];
1605
+ const recordedPersonal = existing?.source?.kind === 'personal-library';
1606
+ const personalOnly = Boolean(selectedRoot) || recordedPersonal;
1607
+ if (personalOnly && existing?.source && existing.source.kind !== 'personal-library' && !isLegacyBundleSource(existing.source)) {
1608
+ throw new CliError(`${name} has an ownership record with an unknown source kind; refusing to replace it`);
1609
+ }
1610
+ const names = personalOnly ? [name] : [...new Set([...Object.keys(lock.skills), name, 'skillshop'])].sort();
1009
1611
  const installs = names.map((skillName) => {
1010
- const metadata = skillMetadata(skillName);
1612
+ const metadata = resolvedInstallSource(skillName, skillshopHome(), {
1613
+ existing: lock.skills[skillName],
1614
+ requested: skillName === name,
1615
+ selectedRoot,
1616
+ personalOnly: skillName === name && personalOnly
1617
+ });
1011
1618
  const targetDirs = config || !lock.skills[skillName]
1012
1619
  ? skillDirectories.map((directory) => path.join(directory, skillName))
1013
1620
  : lockedTargets(root, skillName, lock.skills[skillName]);
@@ -1019,54 +1626,55 @@ function installLocal(name, requestedVersion, cwd) {
1019
1626
  const requestedMetadata = installs.find(({ metadata }) => metadata.name === name).metadata;
1020
1627
  if (requestedVersion && requestedVersion !== requestedMetadata.version) {
1021
1628
  throw new CliError(
1022
- `${name}@${requestedVersion} is unavailable; local registry provides ${name}@${requestedMetadata.version}`
1629
+ `${name}@${requestedVersion} is unavailable; personal skills must declare that exact version or use a bundled skill`
1023
1630
  );
1024
1631
  }
1025
1632
  for (const { metadata, targetDirs, previousTargets } of installs) {
1026
- assertTargetsAvailable(metadata.name, targetDirs, previousTargets);
1633
+ assertTargetsAvailable(metadata.name, targetDirs, previousTargets, root);
1634
+ for (const targetDir of targetDirs) {
1635
+ if (metadata.kind === 'personal') {
1636
+ assertNoManagedSymlink(root, targetDir, `install destination for ${metadata.name}`);
1637
+ assertInstallPathsDoNotOverlap(metadata.sourceDir, targetDir);
1638
+ }
1639
+ }
1027
1640
  }
1028
1641
 
1029
1642
  for (const { metadata, targetDirs, previousTargets } of installs) {
1030
1643
  const version = metadata.name === name ? requestedVersion : null;
1031
- const results = targetDirs.map((targetDir) => installAt(metadata.name, version, targetDir));
1644
+ const results = targetDirs.map((targetDir) => installAt(metadata.name, version, targetDir, metadata, root));
1032
1645
  for (const staleTarget of staleTargets(previousTargets, targetDirs)) {
1033
1646
  fs.rmSync(staleTarget, { recursive: true, force: true });
1034
1647
  }
1035
1648
  lock.skills[metadata.name] = lockEntry(root, results);
1036
1649
  for (const result of results) {
1037
1650
  console.log(
1038
- `${result.updating ? 'Updated' : 'Installed'} ${result.name}@${result.version} at ${result.targetDir}`
1651
+ `${result.updating ? 'Updated' : 'Installed'} ${result.name}@${result.version ?? 'personal'} at ${result.targetDir}`
1039
1652
  );
1040
1653
  }
1041
1654
  }
1042
1655
 
1043
1656
  writeLock(root, lock);
1044
1657
  maintainRepository(root, lock);
1045
- console.log(`Wrote ${path.join(root, lockFileName)}`);
1658
+ console.log(`Wrote installation manifests for ${root}`);
1046
1659
  }
1047
1660
 
1048
1661
  function uninstallGlobal(name) {
1049
- const config = readConfig();
1050
- const skillDirectories = config
1051
- ? configuredSkillDirectories(config, 'global')
1052
- : [path.join(os.homedir(), '.agents', 'skills')];
1053
- const targets = skillDirectories.map((directory) => path.join(directory, name));
1054
- const installedTargets = targets.filter((target) => fs.existsSync(target));
1055
- if (installedTargets.length === 0) {
1056
- throw new CliError(`${name} is not installed globally in the configured skill directories`);
1057
- }
1058
-
1059
- for (const targetDir of installedTargets) {
1060
- fs.rmSync(targetDir, { recursive: true });
1061
- console.log(`Uninstalled ${name} from ${targetDir}`);
1062
- }
1063
1662
  const globalSkills = readGlobalSkills();
1064
- if (globalSkills.skills[name]) {
1065
- const remaining = staleTargets(globalTargets(name, globalSkills.skills[name]), installedTargets);
1066
- if (remaining.length) globalSkills.skills[name].paths = remaining;
1067
- else delete globalSkills.skills[name];
1068
- 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}`);
1069
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);
1070
1678
  }
1071
1679
 
1072
1680
  function uninstallLocal(name, cwd) {
@@ -1085,7 +1693,7 @@ function uninstallLocal(name, cwd) {
1085
1693
  maintainRepository(root, lock);
1086
1694
 
1087
1695
  for (const targetDir of targetDirs) console.log(`Uninstalled ${name} from ${targetDir}`);
1088
- console.log(`Updated ${path.join(root, lockFileName)}`);
1696
+ console.log(`Updated installation manifests for ${root}`);
1089
1697
  }
1090
1698
 
1091
1699
  function updateLocal(requestedName, cwd, options = {}) {
@@ -1102,7 +1710,10 @@ function updateLocal(requestedName, cwd, options = {}) {
1102
1710
  const skillDirectories = localSkillDirectories(root, lock, cwd);
1103
1711
 
1104
1712
  const updates = [...new Set(names)].sort().map((name) => {
1105
- const metadata = skillMetadata(name);
1713
+ let metadata;
1714
+ if (lock.skills[name]?.source?.kind === 'personal-library') {
1715
+ metadata = recordedPersonalSkill(name, lock.skills[name].source);
1716
+ } else metadata = skillMetadata(name);
1106
1717
  const targetDirs = config
1107
1718
  ? skillDirectories.map((directory) => path.join(directory, name))
1108
1719
  : lockedTargets(root, name, lock.skills[name]);
@@ -1110,23 +1721,29 @@ function updateLocal(requestedName, cwd, options = {}) {
1110
1721
  return { metadata, targetDirs, previousTargets };
1111
1722
  });
1112
1723
  for (const { metadata, targetDirs, previousTargets } of updates) {
1113
- assertTargetsAvailable(metadata.name, targetDirs, previousTargets);
1724
+ assertTargetsAvailable(metadata.name, targetDirs, previousTargets, root);
1725
+ for (const targetDir of targetDirs) {
1726
+ if (metadata.kind === 'personal') {
1727
+ assertNoManagedSymlink(root, targetDir, `update destination for ${metadata.name}`);
1728
+ assertInstallPathsDoNotOverlap(metadata.sourceDir, targetDir);
1729
+ }
1730
+ }
1114
1731
  }
1115
1732
 
1116
1733
  for (const { metadata, targetDirs, previousTargets } of updates) {
1117
- const results = targetDirs.map((targetDir) => installAt(metadata.name, null, targetDir));
1734
+ const results = targetDirs.map((targetDir) => installAt(metadata.name, null, targetDir, metadata, root));
1118
1735
  for (const staleTarget of staleTargets(previousTargets, targetDirs)) {
1119
1736
  fs.rmSync(staleTarget, { recursive: true, force: true });
1120
1737
  }
1121
1738
  lock.skills[metadata.name] = lockEntry(root, results);
1122
1739
  for (const result of results) {
1123
- console.log(`Updated ${result.name}@${result.version} at ${result.targetDir}`);
1740
+ console.log(`Updated ${result.name}@${result.version ?? 'personal'} at ${result.targetDir}`);
1124
1741
  }
1125
1742
  }
1126
1743
 
1127
1744
  writeLock(root, lock);
1128
1745
  maintainRepository(root, lock, options);
1129
- console.log(`Updated ${path.join(root, lockFileName)}`);
1746
+ console.log(`Updated installation manifests for ${root}`);
1130
1747
  }
1131
1748
 
1132
1749
  function updateAllRepositories() {
@@ -1238,8 +1855,18 @@ function refreshManagedLock(root, lock, { global = false, summary }) {
1238
1855
  for (const name of Object.keys(lock.skills).sort()) {
1239
1856
  const entry = lock.skills[name];
1240
1857
  try {
1858
+ if (root && entry.source?.kind === 'personal-library') {
1859
+ summary.unchanged += (Array.isArray(entry.paths) ? entry.paths.length : 1);
1860
+ continue;
1861
+ }
1862
+ if (global && entry.paths?.length && entry.paths.every((target) => entry.copies?.[target]?.source?.kind === 'personal-library')) {
1863
+ summary.unchanged += entry.paths.length;
1864
+ continue;
1865
+ }
1241
1866
  const metadata = skillMetadata(name);
1242
- const targets = global ? globalTargets(name, entry) : lockedTargets(root, name, entry);
1867
+ const allTargets = global ? globalTargets(name, entry) : lockedTargets(root, name, entry);
1868
+ const personalTargets = global ? allTargets.filter((target) => entry.copies?.[target]?.source?.kind === 'personal-library') : [];
1869
+ const targets = global ? allTargets.filter((target) => !personalTargets.includes(target)) : allTargets;
1243
1870
  const base = global ? os.homedir() : root;
1244
1871
  const existingTargets = [];
1245
1872
  for (const target of targets) {
@@ -1248,8 +1875,13 @@ function refreshManagedLock(root, lock, { global = false, summary }) {
1248
1875
  }
1249
1876
 
1250
1877
  if (!existingTargets.length) {
1251
- delete lock.skills[name];
1252
- changed = true;
1878
+ if (personalTargets.length) {
1879
+ entry.paths = personalTargets;
1880
+ changed = true;
1881
+ } else {
1882
+ delete lock.skills[name];
1883
+ changed = true;
1884
+ }
1253
1885
  continue;
1254
1886
  }
1255
1887
 
@@ -1273,7 +1905,7 @@ function refreshManagedLock(root, lock, { global = false, summary }) {
1273
1905
  }
1274
1906
 
1275
1907
  if (global) {
1276
- const keptTargets = skillFailed ? targets : successfulTargets;
1908
+ const keptTargets = [...personalTargets, ...(skillFailed ? targets : successfulTargets)];
1277
1909
  entry.paths = keptTargets;
1278
1910
  entry.copies ??= {};
1279
1911
  for (const target of successfulTargets) {
@@ -1318,6 +1950,7 @@ function refreshManagedCopies(scope) {
1318
1950
  } else {
1319
1951
  try {
1320
1952
  const lock = readLock(owner.root);
1953
+ if (fs.existsSync(lockPathFor(owner.root))) writeLock(owner.root, lock);
1321
1954
  refreshManagedLock(owner.root, lock, { summary });
1322
1955
  } catch (error) {
1323
1956
  summary.failed.push(`${owner.root}: ${error.message}`);
@@ -1326,6 +1959,7 @@ function refreshManagedCopies(scope) {
1326
1959
  } else if (scope === 'global') {
1327
1960
  try {
1328
1961
  const globalLock = readGlobalSkills();
1962
+ if (fs.existsSync(path.join(skillshopHome(), globalSkillsFileName))) writeGlobalSkills(globalLock);
1329
1963
  refreshManagedLock(null, globalLock, { global: true, summary });
1330
1964
  } catch (error) {
1331
1965
  summary.failed.push(`global managed copies: ${error.message}`);
@@ -1337,6 +1971,7 @@ function refreshManagedCopies(scope) {
1337
1971
  if (!hasLock(root)) continue;
1338
1972
  try {
1339
1973
  const lock = readLock(root);
1974
+ if (fs.existsSync(lockPathFor(root))) writeLock(root, lock);
1340
1975
  refreshManagedLock(root, lock, { summary });
1341
1976
  } catch (error) {
1342
1977
  summary.failed.push(`${root}: ${error.message}`);
@@ -1379,11 +2014,18 @@ function discoverLockRoots(startPath, roots) {
1379
2014
  }
1380
2015
 
1381
2016
  if (stat.isFile()) {
2017
+ if (path.basename(startPath) === lockFileName) {
2018
+ try { if (JSON.parse(fs.readFileSync(startPath, 'utf8')).manifestVersion === 1) return; } catch {}
2019
+ }
1382
2020
  if ([lockFileName, legacyLockFileName].includes(path.basename(startPath))) roots.add(canonicalPath(path.dirname(startPath)));
1383
2021
  return;
1384
2022
  }
1385
2023
  if (!stat.isDirectory()) return;
1386
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
+ }
1387
2029
  if (hasLock(startPath)) roots.add(canonicalPath(startPath));
1388
2030
 
1389
2031
  let entries;
@@ -1498,6 +2140,12 @@ function parseArgs(argv) {
1498
2140
  }
1499
2141
 
1500
2142
  function listSkills() {
2143
+ const personalRoot = selectedLibraryRoot(skillshopHome());
2144
+ if (personalRoot) {
2145
+ const library = inspectPersonalLibrary(personalRoot);
2146
+ for (const skill of library.skills) console.log(`${skill.name} — ${skill.description}`);
2147
+ return;
2148
+ }
1501
2149
  for (const name of availableSkills()) {
1502
2150
  const metadata = skillMetadata(name);
1503
2151
  console.log(`${name}@${metadata.version} — ${metadata.author}`);
@@ -1638,8 +2286,13 @@ async function main(argv) {
1638
2286
  return;
1639
2287
  }
1640
2288
 
1641
- const { positional, globalInstall, pushUpdate, requestedVersion, selectedProject, checkProject, agentsTargets } = parseArgs(argv);
2289
+ const forceCount = argv.filter((argument) => argument === '--force').length;
2290
+ if (forceCount > 1) throw new CliError('--force may only be specified once');
2291
+ const parsedArgv = forceCount ? argv.filter((argument) => argument !== '--force') : argv;
2292
+ const { positional, globalInstall, pushUpdate, requestedVersion, selectedProject, checkProject, agentsTargets } = parseArgs(parsedArgv);
1642
2293
  const command = positional[0];
2294
+ const forceDownload = forceCount === 1;
2295
+ if (forceDownload && command !== 'download') throw new CliError('--force is only valid with download');
1643
2296
 
1644
2297
  if ((selectedProject || checkProject) && command !== 'agents') {
1645
2298
  throw new CliError('--project and --check are only valid with agents');
@@ -1681,6 +2334,53 @@ async function main(argv) {
1681
2334
  return;
1682
2335
  }
1683
2336
 
2337
+ if (command === 'library') {
2338
+ if (requestedVersion || globalInstall || pushUpdate || agentsTargets.length) {
2339
+ throw new CliError('library commands do not accept install or update options');
2340
+ }
2341
+ if (positional[1] === 'init' && positional.length <= 3) {
2342
+ const directory = positional[2] ? path.resolve(process.cwd(), positional[2]) : undefined;
2343
+ const result = initPersonalLibrary(directory, skillshopHome(), personalLibraryTemplateRoot);
2344
+ console.log(`Initialized personal skill library at ${result.root}`);
2345
+ console.log(`Skills directory: ${result.skillsDirectory}`);
2346
+ return;
2347
+ }
2348
+ if (positional[1] === 'list' && positional.length === 2) {
2349
+ const result = listPersonalSkills(skillshopHome());
2350
+ console.log(`Personal skill library: ${result.root}`);
2351
+ if (!result.skills.length) console.log('No personal skills found.');
2352
+ for (const skill of result.skills) console.log(`${skill.name}: ${skill.description}`);
2353
+ return;
2354
+ }
2355
+ throw new CliError('use "skillshop library init [directory]" or "skillshop library list"');
2356
+ }
2357
+
2358
+ if (command === 'download') {
2359
+ if (positional.length !== 2 || requestedVersion || globalInstall || pushUpdate || selectedProject || checkProject || agentsTargets.length) {
2360
+ throw new CliError('download expects @<owner>/<skill> [--force] and does not accept install or update options');
2361
+ }
2362
+ const result = downloadPersonalSkill(positional[1], {
2363
+ stateHome: skillshopHome(),
2364
+ templateRoot: personalLibraryTemplateRoot,
2365
+ force: forceDownload
2366
+ });
2367
+ if (result.alreadyPresent) {
2368
+ console.log(`${result.name} is already downloaded from ${result.origin.repository} at ${result.root}`);
2369
+ } else {
2370
+ console.log(`Downloaded ${positional[1]} from ${result.origin.repository} at ${result.origin.commit}`);
2371
+ console.log(`Personal source: ${result.target}`);
2372
+ }
2373
+ return;
2374
+ }
2375
+
2376
+ if (command === 'sync') {
2377
+ if (positional.length !== 1 || requestedVersion || globalInstall || pushUpdate || selectedProject || checkProject || agentsTargets.length) {
2378
+ throw new CliError('sync does not accept a skill name or install options');
2379
+ }
2380
+ syncPersonalCopies(process.cwd());
2381
+ return;
2382
+ }
2383
+
1684
2384
  if (command === 'upgrade') {
1685
2385
  if (positional.length !== 1 || requestedVersion || globalInstall || pushUpdate || agentsTargets.length) {
1686
2386
  throw new CliError('upgrade does not accept arguments or options');
@@ -1763,6 +2463,17 @@ async function main(argv) {
1763
2463
  }
1764
2464
  if (pushUpdate) throw new CliError('--push is only valid with update');
1765
2465
 
2466
+ if (installAliases.has(command) && (spec.includes('/') || spec.startsWith('@'))) {
2467
+ if (requestedVersion) throw new CliError('qualified GitHub installs do not accept --version');
2468
+ const result = downloadPersonalSkill(spec, {
2469
+ stateHome: skillshopHome(),
2470
+ templateRoot: personalLibraryTemplateRoot
2471
+ });
2472
+ if (globalInstall) installGlobal(result.name, null);
2473
+ else installLocal(result.name, null, process.cwd());
2474
+ return;
2475
+ }
2476
+
1766
2477
  const { name, version } = parseSkillSpec(spec, requestedVersion);
1767
2478
  validateSkillName(name);
1768
2479
  if (globalInstall) installGlobal(name, version);
@@ -1799,10 +2510,54 @@ function isUpgradeFlowInvocation(argv) {
1799
2510
  }
1800
2511
  }
1801
2512
 
2513
+ function isPersonalLibraryInvocation(argv) {
2514
+ if (argv[0] === '--help' || argv[0] === '-h') return false;
2515
+ try {
2516
+ const args = argv.filter((argument) => argument !== '--help' && argument !== '-h' && argument !== '--force');
2517
+ const positional = parseArgs(args).positional;
2518
+ const command = positional[0];
2519
+ if (command === 'library' || command === 'download' || command === 'sync') return true;
2520
+ const remoteInstall = ['install', 'i', 'add'].includes(command) && positional.length === 2 &&
2521
+ (positional[1].includes('/') || positional[1].startsWith('@'));
2522
+ // Remote-reference attempts may download before installing. Do not reconcile
2523
+ // unrelated project/global state around that operation: a failed fetch
2524
+ // or validation error must leave those managed copies untouched as well.
2525
+ if (remoteInstall) return true;
2526
+ const stateHome = skillshopHome();
2527
+ const selectionPath = path.join(stateHome, librarySelectionFileName);
2528
+ let selectionIntent = false;
2529
+ try {
2530
+ // A saved selection expresses personal-mode intent even when the file is
2531
+ // malformed, symlinked, or points at a library that has since vanished.
2532
+ // main() remains responsible for reporting the validation error.
2533
+ fs.lstatSync(selectionPath);
2534
+ selectionIntent = true;
2535
+ } catch (error) {
2536
+ if (error.code !== 'ENOENT') selectionIntent = true;
2537
+ }
2538
+ if (selectionIntent && (
2539
+ (['list', 'ls'].includes(command) && positional.length === 1) ||
2540
+ (['install', 'i', 'add'].includes(command) && positional.length === 2)
2541
+ )) return true;
2542
+ const activePersonalRoot = selectedLibraryRoot(stateHome);
2543
+ if (!activePersonalRoot && !selectionIntent) return false;
2544
+ if (['list', 'ls'].includes(command) && positional.length === 1) return true;
2545
+ if (['install', 'i', 'add'].includes(command) && positional.length === 2) return true;
2546
+ const legacyCommands = new Set([
2547
+ 'init', 'config', 'list', 'ls', 'upgrade', 'update', 'up', 'uninstall', 'remove', 'rm',
2548
+ 'agents', 'migrate', 'library', 'download', 'sync', 'help'
2549
+ ]);
2550
+ return positional.length === 1 && !legacyCommands.has(command);
2551
+ } catch {
2552
+ return false;
2553
+ }
2554
+ }
2555
+
1802
2556
  export async function runLegacyCli(argv) {
1803
2557
  const isolatedAgentsCommand = isIsolatedAgentsInvocation(argv);
1804
2558
  const upgradeFlow = isUpgradeFlowInvocation(argv);
1805
- const skipLegacyReconciliation = isolatedAgentsCommand || upgradeFlow;
2559
+ const isolatedLibraryCommand = isPersonalLibraryInvocation(argv);
2560
+ const skipLegacyReconciliation = isolatedAgentsCommand || upgradeFlow || isolatedLibraryCommand;
1806
2561
  try {
1807
2562
  if (!skipLegacyReconciliation) reconcileCurrentRepository();
1808
2563
  try {
@@ -1811,7 +2566,7 @@ export async function runLegacyCli(argv) {
1811
2566
  if (!skipLegacyReconciliation) reconcileCurrentRepository();
1812
2567
  }
1813
2568
  } catch (error) {
1814
- if (!(error instanceof CliError) && !(error instanceof AgentsInstructionsError)) throw error;
2569
+ if (!(error instanceof CliError) && !(error instanceof AgentsInstructionsError) && !(error instanceof PersonalLibraryError) && !(error instanceof GitHubLibraryError)) throw error;
1815
2570
  console.error(`skillshop: ${error.message}`);
1816
2571
  process.exitCode = 1;
1817
2572
  }