skillshop 2.2.0 → 4.0.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,19 @@ 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';
17
30
 
18
31
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
32
  const skillsContentRoot = path.join(packageRoot, 'skills-content');
@@ -33,6 +46,7 @@ const projectTemplateName = 'AGENTS.template.md';
33
46
  const defaultProjectTemplate = '# Project instructions\n\n{{ blocks }}\n';
34
47
  const internalUpgradeRefreshCommand = '__skillshop_upgrade_refresh';
35
48
  const internalUpgradeRefreshToken = 'skillshop-managed-upgrade-v1';
49
+ const personalLibraryTemplateRoot = path.join(packageRoot, 'templates', 'skillshop-library');
36
50
  const globalAgentsTargets = [
37
51
  path.join('.codex', 'AGENTS.md'),
38
52
  path.join('.agents', 'AGENTS.md'),
@@ -129,9 +143,16 @@ function printHelp() {
129
143
  skillshop update [name]
130
144
  skillshop update --push
131
145
  skillshop migrate [root ...]
146
+ skillshop library init [directory]
147
+ skillshop library list
148
+ skillshop download @<owner>/<skill> [--force]
149
+ skillshop install @<owner>/<skill> [--global]
150
+ skillshop sync
132
151
  skillshop list
133
152
 
134
153
  Install, uninstall, and update skills from the local registry.
154
+ 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.
155
+ 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
156
 
136
157
  Agent instructions:
137
158
  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.
@@ -341,7 +362,8 @@ function readLock(root) {
341
362
  throw new CliError(`cannot parse ${lockPath}: ${error.message}`);
342
363
  }
343
364
 
344
- if (![1, 2].includes(lock?.lockfileVersion) || lock.registry !== 'local' || !lock.skills || Array.isArray(lock.skills)) {
365
+ if (![1, 2].includes(lock?.lockfileVersion) || lock.registry !== 'local' ||
366
+ !lock.skills || typeof lock.skills !== 'object' || Array.isArray(lock.skills)) {
345
367
  throw new CliError(`${lockPath} is not a supported skillshop lockfile`);
346
368
  }
347
369
 
@@ -392,8 +414,9 @@ function writeLock(root, lock) {
392
414
  Object.entries(lock.skills)
393
415
  .sort(([left], [right]) => left.localeCompare(right))
394
416
  .map(([name, entry]) => [name, {
395
- version: entry.version,
396
- author: entry.author,
417
+ ...(typeof entry.version === 'string' ? { version: entry.version } : {}),
418
+ ...(typeof entry.author === 'string' ? { author: entry.author } : {}),
419
+ ...(entry.source ? { source: entry.source } : {}),
397
420
  paths: lockedTargets(root, name, entry).map((target) => relativePath(root, target)).sort()
398
421
  }])
399
422
  );
@@ -663,7 +686,8 @@ function readGlobalSkills() {
663
686
  } catch (error) {
664
687
  throw new CliError(`cannot parse ${filePath}: ${error.message}`);
665
688
  }
666
- if (lock?.lockfileVersion !== 2 || lock.registry !== 'local' || !lock.skills || Array.isArray(lock.skills)) {
689
+ if (lock?.lockfileVersion !== 2 || lock.registry !== 'local' ||
690
+ !lock.skills || typeof lock.skills !== 'object' || Array.isArray(lock.skills)) {
667
691
  throw new CliError(`${filePath} is not a supported skillshop global manifest`);
668
692
  }
669
693
  return lock;
@@ -688,7 +712,13 @@ function writeGlobalSkills(lock) {
688
712
  const skills = Object.fromEntries(Object.entries(lock.skills).sort(([left], [right]) => left.localeCompare(right))
689
713
  .map(([name, entry]) => {
690
714
  const paths = globalTargets(name, entry).sort();
691
- return [name, { paths, copies: Object.fromEntries(paths.map((target) => [target, entry.copies?.[target] ?? {}])) }];
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
+ }];
692
722
  }));
693
723
  const filePath = path.join(skillshopHome(), globalSkillsFileName);
694
724
  const contents = `${JSON.stringify({ ...emptyLock(), skills }, null, 2)}\n`;
@@ -709,7 +739,12 @@ function repairTrackedSkills(root, lock) {
709
739
  delete lock.skills[name];
710
740
  continue;
711
741
  }
742
+ if (root && entry.source?.kind === 'personal-library') {
743
+ entry.paths = targets.map((target) => relativePath(root, target));
744
+ continue;
745
+ }
712
746
  for (const target of targets) {
747
+ if (!root && entry.copies?.[target]?.source?.kind === 'personal-library') continue;
713
748
  const manifestPath = path.join(target, 'SKILL.md');
714
749
  assertLocalDestination(root ?? os.homedir(), manifestPath, `manifest for ${name}`);
715
750
  const installation = root ? entry : entry.copies?.[target] ?? {};
@@ -753,35 +788,480 @@ function reconcileCurrentRepository() {
753
788
 
754
789
  function replaceSkillDirectory(sourceDir, targetDir) {
755
790
  const temporaryDir = `${targetDir}.skillshop-tmp-${process.pid}`;
791
+ const backupDir = `${targetDir}.skillshop-backup-${process.pid}`;
756
792
  fs.mkdirSync(path.dirname(targetDir), { recursive: true });
757
793
  fs.rmSync(temporaryDir, { recursive: true, force: true });
794
+ fs.rmSync(backupDir, { recursive: true, force: true });
758
795
  fs.cpSync(sourceDir, temporaryDir, { recursive: true, force: true });
759
- fs.rmSync(targetDir, { recursive: true, force: true });
760
- fs.renameSync(temporaryDir, targetDir);
796
+ const hadTarget = fs.existsSync(targetDir);
797
+ try {
798
+ if (hadTarget) fs.renameSync(targetDir, backupDir);
799
+ fs.renameSync(temporaryDir, targetDir);
800
+ fs.rmSync(backupDir, { recursive: true, force: true });
801
+ } catch (error) {
802
+ if (hadTarget && !fs.existsSync(targetDir) && fs.existsSync(backupDir)) fs.renameSync(backupDir, targetDir);
803
+ throw error;
804
+ } finally {
805
+ fs.rmSync(temporaryDir, { recursive: true, force: true });
806
+ }
761
807
  }
762
808
 
763
- function installAt(name, requestedVersion, targetDir) {
764
- const metadata = skillMetadata(name);
809
+ function personalTreeSnapshot(root, label) {
810
+ const entries = [];
811
+ const visit = (directory, relative = '') => {
812
+ const stat = fs.lstatSync(directory);
813
+ if (stat.isSymbolicLink()) throw new CliError(`${label} contains a symlink: ${directory}`);
814
+ if (stat.isDirectory()) {
815
+ entries.push({ path: relative, kind: 'directory', mode: stat.mode & 0o777 });
816
+ for (const name of fs.readdirSync(directory).sort()) {
817
+ visit(path.join(directory, name), relative ? `${relative}/${name}` : name);
818
+ }
819
+ return;
820
+ }
821
+ if (!stat.isFile()) throw new CliError(`${label} contains a non-regular entry: ${directory}`);
822
+ entries.push({
823
+ path: relative,
824
+ kind: 'file',
825
+ mode: stat.mode & 0o777,
826
+ contents: fs.readFileSync(directory).toString('base64')
827
+ });
828
+ };
829
+ visit(root);
830
+ return entries;
831
+ }
832
+
833
+ function replaceSyncedSkillDirectory(sourceDir, targetDir) {
834
+ const parent = path.dirname(targetDir);
835
+ const transaction = fs.mkdtempSync(path.join(parent, `.skillshop-sync-${process.pid}-`));
836
+ const staged = path.join(transaction, 'skill');
837
+ const backup = path.join(transaction, 'previous-skill');
838
+ let backedUp = false;
839
+ let installed = false;
840
+ try {
841
+ fs.cpSync(sourceDir, staged, { recursive: true, errorOnExist: true, force: false });
842
+ personalTreeSnapshot(staged, 'staged personal skill');
843
+ fs.renameSync(targetDir, backup);
844
+ backedUp = true;
845
+ fs.renameSync(staged, targetDir);
846
+ installed = true;
847
+ } catch (error) {
848
+ if (installed) fs.rmSync(targetDir, { recursive: true, force: true });
849
+ if (backedUp && fs.existsSync(backup)) fs.renameSync(backup, targetDir);
850
+ throw error;
851
+ } finally {
852
+ // Once the new directory is installed, backup cleanup cannot invalidate
853
+ // that successful replacement or remove its only surviving content.
854
+ try { fs.rmSync(transaction, { recursive: true, force: true }); } catch {}
855
+ }
856
+ }
857
+
858
+ function recordedPersonalSkill(name, source) {
859
+ try {
860
+ const resolved = resolveRecordedPersonalSkill(source);
861
+ if (!resolved) throw new CliError(`recorded personal source for ${name} is missing from ${source.root}`);
862
+ return resolved;
863
+ } catch (error) {
864
+ if (/personal skill .* does not exist|\/SKILL\.md is missing/.test(error.message)) {
865
+ throw new CliError(`recorded personal source for ${name} is missing from ${source.root}`);
866
+ }
867
+ throw error;
868
+ }
869
+ }
870
+
871
+ function isLegacyBundleSource(source) {
872
+ return source == null || ['bundle', 'bundled'].includes(source.kind);
873
+ }
874
+
875
+ function syncPersonalCopy(name, source, target, boundary) {
876
+ assertNoManagedSymlink(boundary, target, `sync destination for ${name}`);
877
+ assertInstallPathsDoNotOverlap(source.sourceDir, target);
878
+ let targetStat;
879
+ try {
880
+ targetStat = fs.lstatSync(target);
881
+ } catch (error) {
882
+ if (error.code === 'ENOENT') return 'missing';
883
+ throw error;
884
+ }
885
+ if (!targetStat.isDirectory() || targetStat.isSymbolicLink()) {
886
+ throw new CliError(`sync destination for ${name} is not a regular skill directory: ${target}`);
887
+ }
888
+ const sourceTree = personalTreeSnapshot(source.sourceDir, `personal source for ${name}`);
889
+ const targetTree = personalTreeSnapshot(target, `sync destination for ${name}`);
890
+ if (JSON.stringify(sourceTree) === JSON.stringify(targetTree)) return 'current';
891
+ replaceSyncedSkillDirectory(source.sourceDir, target);
892
+ return 'updated';
893
+ }
894
+
895
+ function syncPersonalCopies(cwd) {
896
+ const failures = [];
897
+ const roots = new Map();
898
+ const currentRoot = findLockRoot(cwd, { required: false });
899
+ if (currentRoot) roots.set(canonicalPath(currentRoot), canonicalPath(currentRoot));
900
+ let ownershipComplete = true;
901
+ try {
902
+ for (const registeredRoot of readRegistry().repositories) {
903
+ const canonical = canonicalPath(registeredRoot);
904
+ roots.set(canonical, canonical);
905
+ }
906
+ } catch (error) {
907
+ failures.push(`project registry: ${error.message}`);
908
+ ownershipComplete = false;
909
+ }
910
+
911
+ const contexts = [];
912
+ for (const root of roots.values()) {
913
+ if (!hasLock(root)) continue;
914
+ try {
915
+ const lock = readLock(root);
916
+ contexts.push({ kind: 'project', root, lock, original: JSON.stringify(lock) });
917
+ } catch (error) {
918
+ failures.push(`project ${root}: ${error.message}`);
919
+ ownershipComplete = false;
920
+ }
921
+ }
922
+ let globalLock = null;
923
+ try {
924
+ globalLock = readGlobalSkills();
925
+ } catch (error) {
926
+ failures.push(`global manifest: ${error.message}`);
927
+ ownershipComplete = false;
928
+ }
929
+ if (globalLock) contexts.push({ kind: 'global', lock: globalLock, original: JSON.stringify(globalLock) });
930
+
931
+ let updated = 0;
932
+ let current = 0;
933
+ let pruned = 0;
934
+ let selectedRoot = null;
935
+ try {
936
+ selectedRoot = selectedLibraryRoot(skillshopHome());
937
+ } catch (error) {
938
+ failures.push(`selected personal library: ${error.message}`);
939
+ }
940
+ const libraryInfo = new Map();
941
+ const getLibrarySkill = (name) => {
942
+ if (!selectedRoot) return null;
943
+ if (!libraryInfo.has(name)) {
944
+ libraryInfo.set(name, resolvePersonalSkill(name, skillshopHome(), selectedRoot));
945
+ }
946
+ return libraryInfo.get(name);
947
+ };
948
+ const isUnknownSource = (source) => source != null && source.kind !== 'personal-library' && !isLegacyBundleSource(source);
949
+ const legacyGroups = new Map();
950
+ const personalItems = [];
951
+ const unknownSourceNames = new Set();
952
+
953
+ for (const context of contexts) {
954
+ for (const [name, entry] of Object.entries(context.lock.skills)) {
955
+ if (context.kind === 'project') {
956
+ let targets;
957
+ try { targets = lockedTargets(context.root, name, entry); }
958
+ catch (error) { failures.push(`project ${name}: ${error.message}`); ownershipComplete = false; continue; }
959
+ if (entry.source?.kind === 'personal-library') {
960
+ personalItems.push({ kind: 'project', context, name, entry, sourceRecord: entry.source, targets });
961
+ } else if (isLegacyBundleSource(entry.source)) {
962
+ legacyGroups.set(name, [...(legacyGroups.get(name) ?? []), { kind: 'project', context, name, entry, targets }]);
963
+ } else if (isUnknownSource(entry.source)) unknownSourceNames.add(name);
964
+ continue;
965
+ }
966
+
967
+ let targets;
968
+ try { targets = globalTargets(name, entry); }
969
+ catch (error) { failures.push(`global ${name}: ${error.message}`); ownershipComplete = false; continue; }
970
+ for (const target of targets) {
971
+ const copy = entry.copies?.[target] ?? {};
972
+ const sourceRecord = copy.source ?? entry.source;
973
+ if (sourceRecord?.kind === 'personal-library') {
974
+ personalItems.push({ kind: 'global', context, name, entry, copy, target, sourceRecord });
975
+ } else if (isLegacyBundleSource(sourceRecord)) {
976
+ legacyGroups.set(name, [...(legacyGroups.get(name) ?? []), { kind: 'global', context, name, entry, copy, target }]);
977
+ } else if (isUnknownSource(sourceRecord)) unknownSourceNames.add(name);
978
+ }
979
+ }
980
+ }
981
+
982
+ const updateLocalOwnership = (context, name, entry, source, targets) => {
983
+ const kept = targets.filter((target) => target.present).map(({ target }) => relativePath(context.root, target)).sort();
984
+ if (!kept.length) {
985
+ delete context.lock.skills[name];
986
+ return;
987
+ }
988
+ entry.paths = kept;
989
+ entry.source = source.source;
990
+ if (typeof source.author === 'string') entry.author = source.author;
991
+ else delete entry.author;
992
+ if (typeof source.version === 'string') entry.version = source.version;
993
+ else delete entry.version;
994
+ };
995
+ const updateGlobalOwnership = (entry, copy, target, source) => {
996
+ copy.source = source.source;
997
+ if (typeof source.author === 'string') copy.author = source.author;
998
+ else delete copy.author;
999
+ if (typeof source.version === 'string') copy.version = source.version;
1000
+ else delete copy.version;
1001
+ entry.copies ??= {};
1002
+ entry.copies[target] = copy;
1003
+ };
1004
+ const pruneMissing = (item) => {
1005
+ pruned += 1;
1006
+ if (item.kind === 'project') {
1007
+ const targets = lockedTargets(item.context.root, item.name, item.entry).filter((target) => target !== item.target);
1008
+ if (targets.length) item.entry.paths = targets.map((target) => relativePath(item.context.root, target));
1009
+ else delete item.context.lock.skills[item.name];
1010
+ console.log(`Pruned missing project copy ${item.target}`);
1011
+ } else {
1012
+ delete item.entry.copies[item.target];
1013
+ const paths = item.entry.paths.filter((target) => target !== item.target);
1014
+ if (paths.length) item.entry.paths = paths;
1015
+ else delete item.context.lock.skills[item.name];
1016
+ console.log(`Pruned missing global copy ${item.target}`);
1017
+ }
1018
+ };
1019
+
1020
+ for (const item of personalItems) {
1021
+ const { name, sourceRecord } = item;
1022
+ let source;
1023
+ try { source = recordedPersonalSkill(name, sourceRecord); }
1024
+ catch (error) { failures.push(`${item.kind} ${name}: ${error.message}`); continue; }
1025
+ const targets = item.kind === 'project' ? item.targets : [item.target];
1026
+ const prepared = [];
1027
+ let failed = false;
1028
+ for (const target of targets) {
1029
+ try {
1030
+ const boundary = item.kind === 'project' ? item.context.root : os.homedir();
1031
+ assertNoManagedSymlink(boundary, target, `sync destination for ${name}`);
1032
+ assertInstallPathsDoNotOverlap(source.sourceDir, target);
1033
+ let stat;
1034
+ try { stat = fs.lstatSync(target); }
1035
+ catch (error) { if (error.code === 'ENOENT') { prepared.push({ target, present: false }); continue; } throw error; }
1036
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new CliError(`sync destination for ${name} is not a regular skill directory: ${target}`);
1037
+ personalTreeSnapshot(target, `sync destination for ${name}`);
1038
+ prepared.push({ target, present: true });
1039
+ } catch (error) { failures.push(`${item.kind} ${name} at ${target}: ${error.message}`); failed = true; }
1040
+ }
1041
+ if (failed) continue;
1042
+ for (const target of prepared) {
1043
+ if (!target.present) { pruneMissing({ ...item, target: target.target }); continue; }
1044
+ try {
1045
+ const result = syncPersonalCopy(name, source, target.target, item.kind === 'project' ? item.context.root : os.homedir());
1046
+ if (result === 'updated') { updated += 1; console.log(`Synced ${item.kind} copy ${target.target}`); }
1047
+ else current += 1;
1048
+ } catch (error) { failures.push(`${item.kind} ${name} at ${target.target}: ${error.message}`); failed = true; }
1049
+ }
1050
+ if (failed) continue;
1051
+ if (item.kind === 'project') updateLocalOwnership(item.context, name, item.entry, source, prepared);
1052
+ else updateGlobalOwnership(item.entry, item.copy, item.target, source);
1053
+ }
1054
+
1055
+ for (const [name, group] of legacyGroups) {
1056
+ const root = selectedRoot;
1057
+ if (!root) {
1058
+ failures.push(`${name}: select a personal library with "skillshop library init" before adopting legacy copies`);
1059
+ continue;
1060
+ }
1061
+ let source;
1062
+ try { source = getLibrarySkill(name); }
1063
+ catch (error) { failures.push(`${name}: selected library source is unsafe or invalid: ${error.message}`); continue; }
1064
+ if (!source && unknownSourceNames.has(name)) {
1065
+ failures.push(`${name}: an ownership record has an unknown source kind; legacy copies were left unchanged`);
1066
+ continue;
1067
+ }
1068
+
1069
+ const prepared = [];
1070
+ let failed = false;
1071
+ for (const item of group) {
1072
+ const boundary = item.kind === 'project' ? item.context.root : os.homedir();
1073
+ const target = item.kind === 'project' ? item.targets : [item.target];
1074
+ for (const destination of target) {
1075
+ try {
1076
+ assertNoManagedSymlink(boundary, destination, `legacy copy for ${name}`);
1077
+ if (source) assertInstallPathsDoNotOverlap(source.sourceDir, destination);
1078
+ else assertInstallPathsDoNotOverlap(destination, path.join(root, 'skills', name));
1079
+ let stat;
1080
+ try { stat = fs.lstatSync(destination); }
1081
+ catch (error) { if (error.code === 'ENOENT') { prepared.push({ item, target: destination, present: false }); continue; } throw error; }
1082
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new CliError(`legacy copy for ${name} is not a regular skill directory: ${destination}`);
1083
+ validatePersonalSkillDirectory(boundary, destination, name);
1084
+ const snapshot = personalTreeSnapshot(destination, `legacy copy for ${name}`);
1085
+ prepared.push({ item, target: destination, present: true, snapshot });
1086
+ } catch (error) { failures.push(`${name} at ${destination}: ${error.message}`); failed = true; }
1087
+ }
1088
+ }
1089
+ if (failed) continue;
1090
+
1091
+ const present = prepared.filter(({ present: exists }) => exists);
1092
+ if (!present.length) {
1093
+ for (const item of group) {
1094
+ if (item.kind === 'project') {
1095
+ const existing = item.targets.filter((target) => fs.existsSync(target));
1096
+ const missing = item.targets.filter((target) => !fs.existsSync(target));
1097
+ for (const target of missing) {
1098
+ pruned += 1;
1099
+ console.log(`Pruned missing project copy ${target}`);
1100
+ }
1101
+ if (missing.length) {
1102
+ item.entry.paths = existing.map((target) => relativePath(item.context.root, target));
1103
+ if (!existing.length) delete item.context.lock.skills[name];
1104
+ }
1105
+ } else if (!fs.existsSync(item.target)) pruneMissing(item);
1106
+ }
1107
+ continue;
1108
+ }
1109
+
1110
+ if (!source) {
1111
+ if (!ownershipComplete) {
1112
+ failures.push(`${name}: cannot import a missing source because one or more ownership manifests could not be read`);
1113
+ continue;
1114
+ }
1115
+ const expected = JSON.stringify(present[0].snapshot);
1116
+ if (present.some(({ snapshot }) => JSON.stringify(snapshot) !== expected)) {
1117
+ failures.push(`${name}: tracked legacy copies diverge; resolve them before adoption`);
1118
+ continue;
1119
+ }
1120
+ try {
1121
+ const skillDirectory = path.join(inspectPersonalLibrary(root).skillsDirectory, name);
1122
+ for (const copy of present) assertInstallPathsDoNotOverlap(copy.target, skillDirectory);
1123
+ importTrackedPersonalSkill(root, present[0].target, name);
1124
+ source = resolvePersonalSkill(name, skillshopHome(), root);
1125
+ } catch (error) { failures.push(`${name}: could not import agreeing legacy copies: ${error.message}`); continue; }
1126
+ for (const copy of present.slice(1)) {
1127
+ if (JSON.stringify(copy.snapshot) !== JSON.stringify(present[0].snapshot)) {
1128
+ failures.push(`${name}: tracked legacy copies changed during adoption`);
1129
+ failed = true;
1130
+ break;
1131
+ }
1132
+ }
1133
+ if (failed) continue;
1134
+ console.log(`Adopted ${name} from ${present.length} agreeing tracked copy/copies into ${root}`);
1135
+ }
1136
+
1137
+ const localEntryResults = new Map();
1138
+ for (const copy of prepared) {
1139
+ if (!copy.present) { pruneMissing({ ...copy.item, target: copy.target }); continue; }
1140
+ let result;
1141
+ try {
1142
+ result = source ? syncPersonalCopy(name, source, copy.target, copy.item.kind === 'project' ? copy.item.context.root : os.homedir()) : 'current';
1143
+ } catch (error) {
1144
+ failures.push(`${copy.item.kind} ${name} at ${copy.target}: ${error.message}`);
1145
+ failed = true;
1146
+ if (copy.item.kind === 'project') {
1147
+ const state = localEntryResults.get(copy.item) ?? { successes: [], failed: false };
1148
+ state.failed = true;
1149
+ localEntryResults.set(copy.item, state);
1150
+ }
1151
+ continue;
1152
+ }
1153
+ if (result === 'updated') { updated += 1; console.log(`Synced adopted ${name} copy ${copy.target}`); }
1154
+ else current += 1;
1155
+ if (copy.item.kind === 'project') {
1156
+ const state = localEntryResults.get(copy.item) ?? { successes: [], failed: false };
1157
+ state.successes.push(copy.target);
1158
+ localEntryResults.set(copy.item, state);
1159
+ } else updateGlobalOwnership(copy.item.entry, copy.item.copy, copy.target, source);
1160
+ }
1161
+ for (const [item, state] of localEntryResults) {
1162
+ if (state.failed) continue;
1163
+ if (!state.successes.length) continue;
1164
+ item.entry.source = source.source;
1165
+ if (typeof source.author === 'string') item.entry.author = source.author;
1166
+ else delete item.entry.author;
1167
+ if (typeof source.version === 'string') item.entry.version = source.version;
1168
+ else delete item.entry.version;
1169
+ item.entry.paths = state.successes.map((target) => relativePath(item.context.root, target)).sort();
1170
+ }
1171
+ }
1172
+
1173
+ for (const context of contexts) {
1174
+ if (context.kind === 'project' && JSON.stringify(context.lock) !== context.original) {
1175
+ try { writeLock(context.root, context.lock); }
1176
+ catch (error) { failures.push(`project ${context.root} state: ${error.message}`); }
1177
+ }
1178
+ }
1179
+
1180
+ if (globalLock && JSON.stringify(globalLock) !== contexts.find(({ kind }) => kind === 'global')?.original) {
1181
+ try { writeGlobalSkills(globalLock); }
1182
+ catch (error) { failures.push(`global state: ${error.message}`); }
1183
+ }
1184
+ console.log(`Personal sync: ${updated} updated, ${current} current, ${pruned} missing destination(s) pruned.`);
1185
+ if (failures.length) {
1186
+ for (const failure of failures) console.error(`skillshop: ${failure}`);
1187
+ throw new CliError(`sync completed with ${failures.length} failure(s); ${updated} copy/copies updated, ${current} current, ${pruned} missing destination(s) pruned`);
1188
+ }
1189
+ }
1190
+
1191
+ function installAt(name, requestedVersion, targetDir, metadata = skillMetadata(name), boundary = path.dirname(targetDir)) {
765
1192
  if (requestedVersion && requestedVersion !== metadata.version) {
766
- throw new CliError(
767
- `${name}@${requestedVersion} is unavailable; local registry provides ${name}@${metadata.version}`
768
- );
1193
+ throw new CliError(metadata.kind === 'personal'
1194
+ ? `${name}@${requestedVersion} is unavailable in the personal library${metadata.version ? `; library version is ${metadata.version}` : ''}`
1195
+ : `${name}@${requestedVersion} is unavailable; local registry provides ${name}@${metadata.version}`);
769
1196
  }
770
1197
 
1198
+ if (metadata.kind === 'personal') {
1199
+ assertNoManagedSymlink(boundary, targetDir, `install destination for ${name}`);
1200
+ assertInstallPathsDoNotOverlap(metadata.sourceDir, targetDir);
1201
+ }
771
1202
  const updating = fs.existsSync(targetDir);
772
1203
  replaceSkillDirectory(metadata.sourceDir, targetDir);
773
1204
  return { ...metadata, targetDir, updating };
774
1205
  }
775
1206
 
1207
+ function assertInstallPathsDoNotOverlap(sourceDir, targetDir) {
1208
+ const source = path.resolve(sourceDir);
1209
+ const target = path.resolve(targetDir);
1210
+ const within = (parent, child) => {
1211
+ const relative = path.relative(parent, child);
1212
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
1213
+ };
1214
+ if (within(source, target) || within(target, source)) {
1215
+ throw new CliError(`install destination overlaps personal source directory: ${source}`);
1216
+ }
1217
+ }
1218
+
776
1219
  function lockEntry(root, results) {
777
1220
  const [result] = results;
778
1221
  return {
779
- version: result.version,
780
- author: result.author,
1222
+ ...(typeof result.version === 'string' ? { version: result.version } : {}),
1223
+ ...(typeof result.author === 'string' ? { author: result.author } : {}),
1224
+ ...(result.source ? { source: result.source } : {}),
781
1225
  paths: results.map(({ targetDir }) => relativePath(root, targetDir)).sort()
782
1226
  };
783
1227
  }
784
1228
 
1229
+ function resolvedInstallSource(name, stateHome, { existing = null, requested = false, selectedRoot = undefined, personalOnly = false } = {}) {
1230
+ const allRecordedSources = [existing?.source, ...Object.values(existing?.copies ?? {}).map((copy) => copy?.source)]
1231
+ .filter((source) => source != null);
1232
+ if (personalOnly && allRecordedSources.some((source) => source.kind !== 'personal-library' && !isLegacyBundleSource(source))) {
1233
+ throw new CliError(`${name} has an ownership record with an unknown source kind; refusing to replace it`);
1234
+ }
1235
+ const recordedSources = allRecordedSources
1236
+ .filter((source) => source?.kind === 'personal-library');
1237
+ if (requested) {
1238
+ const selected = resolvePersonalSkill(name, stateHome, selectedRoot ?? null);
1239
+ if (selected) {
1240
+ if (recordedSources.some((source) => path.resolve(source.root) !== path.resolve(selected.source.root) || source.skillDirectory !== selected.source.skillDirectory)) {
1241
+ throw new CliError(`${name} is already owned by a different personal library; uninstall it before changing sources`);
1242
+ }
1243
+ return selected;
1244
+ }
1245
+ if (personalOnly && selectedRoot && !recordedSources.length) {
1246
+ throw new CliError(`${name} is not present in the selected personal library`);
1247
+ }
1248
+ }
1249
+ if (recordedSources.length) {
1250
+ const identities = new Set(recordedSources.map((source) => `${path.resolve(source.root)}\0${source.skillDirectory ?? ''}\0${source.skill ?? name}`));
1251
+ if (identities.size !== 1) throw new CliError(`${name} has multiple personal library sources; refusing to choose one implicitly`);
1252
+ const source = recordedSources[0];
1253
+ const recorded = recordedPersonalSkill(name, source);
1254
+ if (recorded.name !== name) throw new CliError(`recorded personal source for ${name} is unavailable`);
1255
+ return recorded;
1256
+ }
1257
+ if (requested && selectedRoot === undefined) {
1258
+ const selected = resolvePersonalSkill(name, stateHome);
1259
+ if (selected) return selected;
1260
+ }
1261
+ if (personalOnly) throw new CliError(`${name} is not present in the selected or recorded personal library`);
1262
+ return skillMetadata(name);
1263
+ }
1264
+
785
1265
  function installGlobal(name, requestedVersion) {
786
1266
  const config = readConfig();
787
1267
  const skillDirectories = config
@@ -789,16 +1269,37 @@ function installGlobal(name, requestedVersion) {
789
1269
  : [path.join(os.homedir(), '.agents', 'skills')];
790
1270
 
791
1271
  const globalSkills = readGlobalSkills();
792
- for (const skillDirectory of skillDirectories) {
1272
+ const selectedRoot = selectedLibraryRoot(skillshopHome());
1273
+ const existing = globalSkills.skills[name];
1274
+ const recordedPersonal = [existing?.source, ...Object.values(existing?.copies ?? {}).map((copy) => copy?.source)]
1275
+ .some((source) => source?.kind === 'personal-library');
1276
+ const personalOnly = Boolean(selectedRoot) || recordedPersonal;
1277
+ const metadata = resolvedInstallSource(name, skillshopHome(), { existing, requested: true, selectedRoot, personalOnly });
1278
+ const managedTargets = existing ? new Set(globalTargets(name, existing).map(canonicalDestination)) : new Set();
1279
+ const targetPairs = skillDirectories.map((skillDirectory) => {
793
1280
  const targetDir = path.join(skillDirectory, name);
794
1281
  assertLocalDestination(os.homedir(), targetDir, `global installation for ${name}`);
795
- const result = installAt(name, requestedVersion, targetDir);
1282
+ if (metadata.kind === 'personal') {
1283
+ assertNoManagedSymlink(os.homedir(), targetDir, `global install destination for ${name}`);
1284
+ assertInstallPathsDoNotOverlap(metadata.sourceDir, targetDir);
1285
+ }
1286
+ if (metadata.kind === 'personal' && fs.existsSync(targetDir) && !managedTargets.has(canonicalDestination(targetDir))) {
1287
+ throw new CliError(`${targetDir} exists and is not a Skillshop-managed copy of ${name}`);
1288
+ }
1289
+ return { skillDirectory, targetDir };
1290
+ });
1291
+ for (const { skillDirectory, targetDir } of targetPairs) {
1292
+ const result = installAt(name, requestedVersion, targetDir, metadata, os.homedir());
796
1293
  const entry = globalSkills.skills[name] ?? { paths: [], copies: {} };
797
1294
  entry.paths = [...new Set([...entry.paths, targetDir])];
798
- entry.copies[targetDir] = { author: result.author, version: result.version };
1295
+ entry.copies[targetDir] = {
1296
+ ...(typeof result.author === 'string' ? { author: result.author } : {}),
1297
+ ...(typeof result.version === 'string' ? { version: result.version } : {}),
1298
+ ...(result.source ? { source: result.source } : {})
1299
+ };
799
1300
  globalSkills.skills[name] = entry;
800
1301
  writeGlobalSkills(globalSkills);
801
- console.log(`${result.updating ? 'Updated' : 'Installed'} ${name}@${result.version} at ${targetDir}`);
1302
+ console.log(`${result.updating ? 'Updated' : 'Installed'} ${name}@${result.version ?? 'personal'} at ${targetDir}`);
802
1303
  }
803
1304
  }
804
1305
 
@@ -1004,10 +1505,22 @@ function installLocal(name, requestedVersion, cwd) {
1004
1505
  const root = findLockRoot(cwd) ?? projectRoot(cwd);
1005
1506
  const lock = readLock(root);
1006
1507
  const config = readConfig();
1508
+ const selectedRoot = selectedLibraryRoot(skillshopHome());
1007
1509
  const skillDirectories = localSkillDirectories(root, lock, cwd);
1008
- const names = [...new Set([...Object.keys(lock.skills), name, 'skillshop'])].sort();
1510
+ const existing = lock.skills[name];
1511
+ const recordedPersonal = existing?.source?.kind === 'personal-library';
1512
+ const personalOnly = Boolean(selectedRoot) || recordedPersonal;
1513
+ if (personalOnly && existing?.source && existing.source.kind !== 'personal-library' && !isLegacyBundleSource(existing.source)) {
1514
+ throw new CliError(`${name} has an ownership record with an unknown source kind; refusing to replace it`);
1515
+ }
1516
+ const names = personalOnly ? [name] : [...new Set([...Object.keys(lock.skills), name, 'skillshop'])].sort();
1009
1517
  const installs = names.map((skillName) => {
1010
- const metadata = skillMetadata(skillName);
1518
+ const metadata = resolvedInstallSource(skillName, skillshopHome(), {
1519
+ existing: lock.skills[skillName],
1520
+ requested: skillName === name,
1521
+ selectedRoot,
1522
+ personalOnly: skillName === name && personalOnly
1523
+ });
1011
1524
  const targetDirs = config || !lock.skills[skillName]
1012
1525
  ? skillDirectories.map((directory) => path.join(directory, skillName))
1013
1526
  : lockedTargets(root, skillName, lock.skills[skillName]);
@@ -1019,23 +1532,29 @@ function installLocal(name, requestedVersion, cwd) {
1019
1532
  const requestedMetadata = installs.find(({ metadata }) => metadata.name === name).metadata;
1020
1533
  if (requestedVersion && requestedVersion !== requestedMetadata.version) {
1021
1534
  throw new CliError(
1022
- `${name}@${requestedVersion} is unavailable; local registry provides ${name}@${requestedMetadata.version}`
1535
+ `${name}@${requestedVersion} is unavailable; personal skills must declare that exact version or use a bundled skill`
1023
1536
  );
1024
1537
  }
1025
1538
  for (const { metadata, targetDirs, previousTargets } of installs) {
1026
1539
  assertTargetsAvailable(metadata.name, targetDirs, previousTargets);
1540
+ for (const targetDir of targetDirs) {
1541
+ if (metadata.kind === 'personal') {
1542
+ assertNoManagedSymlink(root, targetDir, `install destination for ${metadata.name}`);
1543
+ assertInstallPathsDoNotOverlap(metadata.sourceDir, targetDir);
1544
+ }
1545
+ }
1027
1546
  }
1028
1547
 
1029
1548
  for (const { metadata, targetDirs, previousTargets } of installs) {
1030
1549
  const version = metadata.name === name ? requestedVersion : null;
1031
- const results = targetDirs.map((targetDir) => installAt(metadata.name, version, targetDir));
1550
+ const results = targetDirs.map((targetDir) => installAt(metadata.name, version, targetDir, metadata, root));
1032
1551
  for (const staleTarget of staleTargets(previousTargets, targetDirs)) {
1033
1552
  fs.rmSync(staleTarget, { recursive: true, force: true });
1034
1553
  }
1035
1554
  lock.skills[metadata.name] = lockEntry(root, results);
1036
1555
  for (const result of results) {
1037
1556
  console.log(
1038
- `${result.updating ? 'Updated' : 'Installed'} ${result.name}@${result.version} at ${result.targetDir}`
1557
+ `${result.updating ? 'Updated' : 'Installed'} ${result.name}@${result.version ?? 'personal'} at ${result.targetDir}`
1039
1558
  );
1040
1559
  }
1041
1560
  }
@@ -1102,7 +1621,10 @@ function updateLocal(requestedName, cwd, options = {}) {
1102
1621
  const skillDirectories = localSkillDirectories(root, lock, cwd);
1103
1622
 
1104
1623
  const updates = [...new Set(names)].sort().map((name) => {
1105
- const metadata = skillMetadata(name);
1624
+ let metadata;
1625
+ if (lock.skills[name]?.source?.kind === 'personal-library') {
1626
+ metadata = recordedPersonalSkill(name, lock.skills[name].source);
1627
+ } else metadata = skillMetadata(name);
1106
1628
  const targetDirs = config
1107
1629
  ? skillDirectories.map((directory) => path.join(directory, name))
1108
1630
  : lockedTargets(root, name, lock.skills[name]);
@@ -1111,16 +1633,22 @@ function updateLocal(requestedName, cwd, options = {}) {
1111
1633
  });
1112
1634
  for (const { metadata, targetDirs, previousTargets } of updates) {
1113
1635
  assertTargetsAvailable(metadata.name, targetDirs, previousTargets);
1636
+ for (const targetDir of targetDirs) {
1637
+ if (metadata.kind === 'personal') {
1638
+ assertNoManagedSymlink(root, targetDir, `update destination for ${metadata.name}`);
1639
+ assertInstallPathsDoNotOverlap(metadata.sourceDir, targetDir);
1640
+ }
1641
+ }
1114
1642
  }
1115
1643
 
1116
1644
  for (const { metadata, targetDirs, previousTargets } of updates) {
1117
- const results = targetDirs.map((targetDir) => installAt(metadata.name, null, targetDir));
1645
+ const results = targetDirs.map((targetDir) => installAt(metadata.name, null, targetDir, metadata, root));
1118
1646
  for (const staleTarget of staleTargets(previousTargets, targetDirs)) {
1119
1647
  fs.rmSync(staleTarget, { recursive: true, force: true });
1120
1648
  }
1121
1649
  lock.skills[metadata.name] = lockEntry(root, results);
1122
1650
  for (const result of results) {
1123
- console.log(`Updated ${result.name}@${result.version} at ${result.targetDir}`);
1651
+ console.log(`Updated ${result.name}@${result.version ?? 'personal'} at ${result.targetDir}`);
1124
1652
  }
1125
1653
  }
1126
1654
 
@@ -1238,8 +1766,18 @@ function refreshManagedLock(root, lock, { global = false, summary }) {
1238
1766
  for (const name of Object.keys(lock.skills).sort()) {
1239
1767
  const entry = lock.skills[name];
1240
1768
  try {
1769
+ if (root && entry.source?.kind === 'personal-library') {
1770
+ summary.unchanged += (Array.isArray(entry.paths) ? entry.paths.length : 1);
1771
+ continue;
1772
+ }
1773
+ if (global && entry.paths?.length && entry.paths.every((target) => entry.copies?.[target]?.source?.kind === 'personal-library')) {
1774
+ summary.unchanged += entry.paths.length;
1775
+ continue;
1776
+ }
1241
1777
  const metadata = skillMetadata(name);
1242
- const targets = global ? globalTargets(name, entry) : lockedTargets(root, name, entry);
1778
+ const allTargets = global ? globalTargets(name, entry) : lockedTargets(root, name, entry);
1779
+ const personalTargets = global ? allTargets.filter((target) => entry.copies?.[target]?.source?.kind === 'personal-library') : [];
1780
+ const targets = global ? allTargets.filter((target) => !personalTargets.includes(target)) : allTargets;
1243
1781
  const base = global ? os.homedir() : root;
1244
1782
  const existingTargets = [];
1245
1783
  for (const target of targets) {
@@ -1248,8 +1786,13 @@ function refreshManagedLock(root, lock, { global = false, summary }) {
1248
1786
  }
1249
1787
 
1250
1788
  if (!existingTargets.length) {
1251
- delete lock.skills[name];
1252
- changed = true;
1789
+ if (personalTargets.length) {
1790
+ entry.paths = personalTargets;
1791
+ changed = true;
1792
+ } else {
1793
+ delete lock.skills[name];
1794
+ changed = true;
1795
+ }
1253
1796
  continue;
1254
1797
  }
1255
1798
 
@@ -1273,7 +1816,7 @@ function refreshManagedLock(root, lock, { global = false, summary }) {
1273
1816
  }
1274
1817
 
1275
1818
  if (global) {
1276
- const keptTargets = skillFailed ? targets : successfulTargets;
1819
+ const keptTargets = [...personalTargets, ...(skillFailed ? targets : successfulTargets)];
1277
1820
  entry.paths = keptTargets;
1278
1821
  entry.copies ??= {};
1279
1822
  for (const target of successfulTargets) {
@@ -1498,6 +2041,12 @@ function parseArgs(argv) {
1498
2041
  }
1499
2042
 
1500
2043
  function listSkills() {
2044
+ const personalRoot = selectedLibraryRoot(skillshopHome());
2045
+ if (personalRoot) {
2046
+ const library = inspectPersonalLibrary(personalRoot);
2047
+ for (const skill of library.skills) console.log(`${skill.name} — ${skill.description}`);
2048
+ return;
2049
+ }
1501
2050
  for (const name of availableSkills()) {
1502
2051
  const metadata = skillMetadata(name);
1503
2052
  console.log(`${name}@${metadata.version} — ${metadata.author}`);
@@ -1638,8 +2187,13 @@ async function main(argv) {
1638
2187
  return;
1639
2188
  }
1640
2189
 
1641
- const { positional, globalInstall, pushUpdate, requestedVersion, selectedProject, checkProject, agentsTargets } = parseArgs(argv);
2190
+ const forceCount = argv.filter((argument) => argument === '--force').length;
2191
+ if (forceCount > 1) throw new CliError('--force may only be specified once');
2192
+ const parsedArgv = forceCount ? argv.filter((argument) => argument !== '--force') : argv;
2193
+ const { positional, globalInstall, pushUpdate, requestedVersion, selectedProject, checkProject, agentsTargets } = parseArgs(parsedArgv);
1642
2194
  const command = positional[0];
2195
+ const forceDownload = forceCount === 1;
2196
+ if (forceDownload && command !== 'download') throw new CliError('--force is only valid with download');
1643
2197
 
1644
2198
  if ((selectedProject || checkProject) && command !== 'agents') {
1645
2199
  throw new CliError('--project and --check are only valid with agents');
@@ -1681,6 +2235,53 @@ async function main(argv) {
1681
2235
  return;
1682
2236
  }
1683
2237
 
2238
+ if (command === 'library') {
2239
+ if (requestedVersion || globalInstall || pushUpdate || agentsTargets.length) {
2240
+ throw new CliError('library commands do not accept install or update options');
2241
+ }
2242
+ if (positional[1] === 'init' && positional.length <= 3) {
2243
+ const directory = positional[2] ? path.resolve(process.cwd(), positional[2]) : undefined;
2244
+ const result = initPersonalLibrary(directory, skillshopHome(), personalLibraryTemplateRoot);
2245
+ console.log(`Initialized personal skill library at ${result.root}`);
2246
+ console.log(`Skills directory: ${result.skillsDirectory}`);
2247
+ return;
2248
+ }
2249
+ if (positional[1] === 'list' && positional.length === 2) {
2250
+ const result = listPersonalSkills(skillshopHome());
2251
+ console.log(`Personal skill library: ${result.root}`);
2252
+ if (!result.skills.length) console.log('No personal skills found.');
2253
+ for (const skill of result.skills) console.log(`${skill.name}: ${skill.description}`);
2254
+ return;
2255
+ }
2256
+ throw new CliError('use "skillshop library init [directory]" or "skillshop library list"');
2257
+ }
2258
+
2259
+ if (command === 'download') {
2260
+ if (positional.length !== 2 || requestedVersion || globalInstall || pushUpdate || selectedProject || checkProject || agentsTargets.length) {
2261
+ throw new CliError('download expects @<owner>/<skill> [--force] and does not accept install or update options');
2262
+ }
2263
+ const result = downloadPersonalSkill(positional[1], {
2264
+ stateHome: skillshopHome(),
2265
+ templateRoot: personalLibraryTemplateRoot,
2266
+ force: forceDownload
2267
+ });
2268
+ if (result.alreadyPresent) {
2269
+ console.log(`${result.name} is already downloaded from ${result.origin.repository} at ${result.root}`);
2270
+ } else {
2271
+ console.log(`Downloaded ${positional[1]} from ${result.origin.repository} at ${result.origin.commit}`);
2272
+ console.log(`Personal source: ${result.target}`);
2273
+ }
2274
+ return;
2275
+ }
2276
+
2277
+ if (command === 'sync') {
2278
+ if (positional.length !== 1 || requestedVersion || globalInstall || pushUpdate || selectedProject || checkProject || agentsTargets.length) {
2279
+ throw new CliError('sync does not accept a skill name or install options');
2280
+ }
2281
+ syncPersonalCopies(process.cwd());
2282
+ return;
2283
+ }
2284
+
1684
2285
  if (command === 'upgrade') {
1685
2286
  if (positional.length !== 1 || requestedVersion || globalInstall || pushUpdate || agentsTargets.length) {
1686
2287
  throw new CliError('upgrade does not accept arguments or options');
@@ -1763,6 +2364,17 @@ async function main(argv) {
1763
2364
  }
1764
2365
  if (pushUpdate) throw new CliError('--push is only valid with update');
1765
2366
 
2367
+ if (installAliases.has(command) && (spec.includes('/') || spec.startsWith('@'))) {
2368
+ if (requestedVersion) throw new CliError('qualified GitHub installs do not accept --version');
2369
+ const result = downloadPersonalSkill(spec, {
2370
+ stateHome: skillshopHome(),
2371
+ templateRoot: personalLibraryTemplateRoot
2372
+ });
2373
+ if (globalInstall) installGlobal(result.name, null);
2374
+ else installLocal(result.name, null, process.cwd());
2375
+ return;
2376
+ }
2377
+
1766
2378
  const { name, version } = parseSkillSpec(spec, requestedVersion);
1767
2379
  validateSkillName(name);
1768
2380
  if (globalInstall) installGlobal(name, version);
@@ -1799,10 +2411,54 @@ function isUpgradeFlowInvocation(argv) {
1799
2411
  }
1800
2412
  }
1801
2413
 
2414
+ function isPersonalLibraryInvocation(argv) {
2415
+ if (argv[0] === '--help' || argv[0] === '-h') return false;
2416
+ try {
2417
+ const args = argv.filter((argument) => argument !== '--help' && argument !== '-h' && argument !== '--force');
2418
+ const positional = parseArgs(args).positional;
2419
+ const command = positional[0];
2420
+ if (command === 'library' || command === 'download' || command === 'sync') return true;
2421
+ const remoteInstall = ['install', 'i', 'add'].includes(command) && positional.length === 2 &&
2422
+ (positional[1].includes('/') || positional[1].startsWith('@'));
2423
+ // Remote-reference attempts may download before installing. Do not reconcile
2424
+ // unrelated project/global state around that operation: a failed fetch
2425
+ // or validation error must leave those managed copies untouched as well.
2426
+ if (remoteInstall) return true;
2427
+ const stateHome = skillshopHome();
2428
+ const selectionPath = path.join(stateHome, librarySelectionFileName);
2429
+ let selectionIntent = false;
2430
+ try {
2431
+ // A saved selection expresses personal-mode intent even when the file is
2432
+ // malformed, symlinked, or points at a library that has since vanished.
2433
+ // main() remains responsible for reporting the validation error.
2434
+ fs.lstatSync(selectionPath);
2435
+ selectionIntent = true;
2436
+ } catch (error) {
2437
+ if (error.code !== 'ENOENT') selectionIntent = true;
2438
+ }
2439
+ if (selectionIntent && (
2440
+ (['list', 'ls'].includes(command) && positional.length === 1) ||
2441
+ (['install', 'i', 'add'].includes(command) && positional.length === 2)
2442
+ )) return true;
2443
+ const activePersonalRoot = selectedLibraryRoot(stateHome);
2444
+ if (!activePersonalRoot && !selectionIntent) return false;
2445
+ if (['list', 'ls'].includes(command) && positional.length === 1) return true;
2446
+ if (['install', 'i', 'add'].includes(command) && positional.length === 2) return true;
2447
+ const legacyCommands = new Set([
2448
+ 'init', 'config', 'list', 'ls', 'upgrade', 'update', 'up', 'uninstall', 'remove', 'rm',
2449
+ 'agents', 'migrate', 'library', 'download', 'sync', 'help'
2450
+ ]);
2451
+ return positional.length === 1 && !legacyCommands.has(command);
2452
+ } catch {
2453
+ return false;
2454
+ }
2455
+ }
2456
+
1802
2457
  export async function runLegacyCli(argv) {
1803
2458
  const isolatedAgentsCommand = isIsolatedAgentsInvocation(argv);
1804
2459
  const upgradeFlow = isUpgradeFlowInvocation(argv);
1805
- const skipLegacyReconciliation = isolatedAgentsCommand || upgradeFlow;
2460
+ const isolatedLibraryCommand = isPersonalLibraryInvocation(argv);
2461
+ const skipLegacyReconciliation = isolatedAgentsCommand || upgradeFlow || isolatedLibraryCommand;
1806
2462
  try {
1807
2463
  if (!skipLegacyReconciliation) reconcileCurrentRepository();
1808
2464
  try {
@@ -1811,7 +2467,7 @@ export async function runLegacyCli(argv) {
1811
2467
  if (!skipLegacyReconciliation) reconcileCurrentRepository();
1812
2468
  }
1813
2469
  } catch (error) {
1814
- if (!(error instanceof CliError) && !(error instanceof AgentsInstructionsError)) throw error;
2470
+ if (!(error instanceof CliError) && !(error instanceof AgentsInstructionsError) && !(error instanceof PersonalLibraryError) && !(error instanceof GitHubLibraryError)) throw error;
1815
2471
  console.error(`skillshop: ${error.message}`);
1816
2472
  process.exitCode = 1;
1817
2473
  }