skillshop 2.1.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
@@ -3,10 +3,30 @@
3
3
  import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
- import { execFileSync } from 'node:child_process';
6
+ import { execFileSync, spawnSync } from 'node:child_process';
7
7
  import { createInterface } from 'node:readline/promises';
8
8
  import { fileURLToPath } from 'node:url';
9
- import { AgentsInstructionsError, applyInstructions, applyLocalFragments, composeAgentsInstructions } from './agents-instructions.js';
9
+ import {
10
+ AgentsInstructionsError,
11
+ applyInstructions,
12
+ applyLocalFragments,
13
+ applyProjectInstructions,
14
+ composeAgentsInstructions,
15
+ hasProjectInstructions
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';
10
30
 
11
31
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
32
  const skillsContentRoot = path.join(packageRoot, 'skills-content');
@@ -21,6 +41,12 @@ const excludeBlockStart = '# >>> skillshop managed dependencies >>>';
21
41
  const excludeBlockEnd = '# <<< skillshop managed dependencies <<<';
22
42
  const legacyExcludeBlockStart = '# >>> skillz managed dependencies >>>';
23
43
  const legacyExcludeBlockEnd = '# <<< skillz managed dependencies <<<';
44
+ const projectTemplateDirectory = path.join('.agents', 'instructions');
45
+ const projectTemplateName = 'AGENTS.template.md';
46
+ const defaultProjectTemplate = '# Project instructions\n\n{{ blocks }}\n';
47
+ const internalUpgradeRefreshCommand = '__skillshop_upgrade_refresh';
48
+ const internalUpgradeRefreshToken = 'skillshop-managed-upgrade-v1';
49
+ const personalLibraryTemplateRoot = path.join(packageRoot, 'templates', 'skillshop-library');
24
50
  const globalAgentsTargets = [
25
51
  path.join('.codex', 'AGENTS.md'),
26
52
  path.join('.agents', 'AGENTS.md'),
@@ -107,7 +133,9 @@ function printHelp() {
107
133
  console.log(`Usage:
108
134
  skillshop init
109
135
  skillshop config agents
110
- skillshop agents [--global]
136
+ skillshop agents init [--project <directory>]
137
+ skillshop agents [--project <directory>] [--check]
138
+ skillshop agents --global
111
139
  skillshop <name> [--version <version>] [--global]
112
140
  skillshop install <name> [--version <version>] [--global]
113
141
  skillshop uninstall <name> [--global]
@@ -115,12 +143,32 @@ function printHelp() {
115
143
  skillshop update [name]
116
144
  skillshop update --push
117
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
118
151
  skillshop list
119
152
 
120
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.
156
+
157
+ Agent instructions:
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.
159
+ Plain skillshop agents keeps the bounded bundled behavior until project sources exist; afterward it renders project sources. Missing sources in an initialized project are errors.
160
+ skillshop agents --check reports whether generated output is current and never writes. skillshop agents --global retains user-level instruction updates.
161
+ The default boundary is the nearest Git/worktree root, or the current directory outside Git. --project selects another directory relative to the current working directory. Nested projects need an explicit selection.
162
+ Source, block, template, and output paths containing symlink components, including dangling links, are rejected.
163
+ Templates accept {{ include "name.block.md" }}, {{ include 'name.block.md' }}, legacy {{ fragments["name.block.md"] }}, and {{ blocks }} for unreferenced blocks. Inserted block text is literal.
164
+
165
+ Upgrade:
166
+ skillshop upgrade updates the npm installation that supplied the CLI, then refreshes recorded Skillshop-managed copies with the newly installed CLI. skillshop init is optional and upgrade does not generate AGENTS.md files.
121
167
 
122
168
  Options:
123
169
  -g, --global Use configured system skill directories without a project lockfile
170
+ --project <directory> Select the agents boundary relative to the current directory
171
+ --check Check project-generated instructions without writing
124
172
  --push Update every registered repository
125
173
  -v, --version <version> Require an exact skill version
126
174
  -h, --help Show this help
@@ -314,7 +362,8 @@ function readLock(root) {
314
362
  throw new CliError(`cannot parse ${lockPath}: ${error.message}`);
315
363
  }
316
364
 
317
- 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)) {
318
367
  throw new CliError(`${lockPath} is not a supported skillshop lockfile`);
319
368
  }
320
369
 
@@ -365,8 +414,9 @@ function writeLock(root, lock) {
365
414
  Object.entries(lock.skills)
366
415
  .sort(([left], [right]) => left.localeCompare(right))
367
416
  .map(([name, entry]) => [name, {
368
- version: entry.version,
369
- 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 } : {}),
370
420
  paths: lockedTargets(root, name, entry).map((target) => relativePath(root, target)).sort()
371
421
  }])
372
422
  );
@@ -636,7 +686,8 @@ function readGlobalSkills() {
636
686
  } catch (error) {
637
687
  throw new CliError(`cannot parse ${filePath}: ${error.message}`);
638
688
  }
639
- 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)) {
640
691
  throw new CliError(`${filePath} is not a supported skillshop global manifest`);
641
692
  }
642
693
  return lock;
@@ -661,7 +712,13 @@ function writeGlobalSkills(lock) {
661
712
  const skills = Object.fromEntries(Object.entries(lock.skills).sort(([left], [right]) => left.localeCompare(right))
662
713
  .map(([name, entry]) => {
663
714
  const paths = globalTargets(name, entry).sort();
664
- 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
+ }];
665
722
  }));
666
723
  const filePath = path.join(skillshopHome(), globalSkillsFileName);
667
724
  const contents = `${JSON.stringify({ ...emptyLock(), skills }, null, 2)}\n`;
@@ -682,7 +739,12 @@ function repairTrackedSkills(root, lock) {
682
739
  delete lock.skills[name];
683
740
  continue;
684
741
  }
742
+ if (root && entry.source?.kind === 'personal-library') {
743
+ entry.paths = targets.map((target) => relativePath(root, target));
744
+ continue;
745
+ }
685
746
  for (const target of targets) {
747
+ if (!root && entry.copies?.[target]?.source?.kind === 'personal-library') continue;
686
748
  const manifestPath = path.join(target, 'SKILL.md');
687
749
  assertLocalDestination(root ?? os.homedir(), manifestPath, `manifest for ${name}`);
688
750
  const installation = root ? entry : entry.copies?.[target] ?? {};
@@ -726,35 +788,480 @@ function reconcileCurrentRepository() {
726
788
 
727
789
  function replaceSkillDirectory(sourceDir, targetDir) {
728
790
  const temporaryDir = `${targetDir}.skillshop-tmp-${process.pid}`;
791
+ const backupDir = `${targetDir}.skillshop-backup-${process.pid}`;
729
792
  fs.mkdirSync(path.dirname(targetDir), { recursive: true });
730
793
  fs.rmSync(temporaryDir, { recursive: true, force: true });
794
+ fs.rmSync(backupDir, { recursive: true, force: true });
731
795
  fs.cpSync(sourceDir, temporaryDir, { recursive: true, force: true });
732
- fs.rmSync(targetDir, { recursive: true, force: true });
733
- 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
+ }
807
+ }
808
+
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
+ }
734
1189
  }
735
1190
 
736
- function installAt(name, requestedVersion, targetDir) {
737
- const metadata = skillMetadata(name);
1191
+ function installAt(name, requestedVersion, targetDir, metadata = skillMetadata(name), boundary = path.dirname(targetDir)) {
738
1192
  if (requestedVersion && requestedVersion !== metadata.version) {
739
- throw new CliError(
740
- `${name}@${requestedVersion} is unavailable; local registry provides ${name}@${metadata.version}`
741
- );
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}`);
742
1196
  }
743
1197
 
1198
+ if (metadata.kind === 'personal') {
1199
+ assertNoManagedSymlink(boundary, targetDir, `install destination for ${name}`);
1200
+ assertInstallPathsDoNotOverlap(metadata.sourceDir, targetDir);
1201
+ }
744
1202
  const updating = fs.existsSync(targetDir);
745
1203
  replaceSkillDirectory(metadata.sourceDir, targetDir);
746
1204
  return { ...metadata, targetDir, updating };
747
1205
  }
748
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
+
749
1219
  function lockEntry(root, results) {
750
1220
  const [result] = results;
751
1221
  return {
752
- version: result.version,
753
- 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 } : {}),
754
1225
  paths: results.map(({ targetDir }) => relativePath(root, targetDir)).sort()
755
1226
  };
756
1227
  }
757
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
+
758
1265
  function installGlobal(name, requestedVersion) {
759
1266
  const config = readConfig();
760
1267
  const skillDirectories = config
@@ -762,41 +1269,120 @@ function installGlobal(name, requestedVersion) {
762
1269
  : [path.join(os.homedir(), '.agents', 'skills')];
763
1270
 
764
1271
  const globalSkills = readGlobalSkills();
765
- 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) => {
766
1280
  const targetDir = path.join(skillDirectory, name);
767
1281
  assertLocalDestination(os.homedir(), targetDir, `global installation for ${name}`);
768
- 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());
769
1293
  const entry = globalSkills.skills[name] ?? { paths: [], copies: {} };
770
1294
  entry.paths = [...new Set([...entry.paths, targetDir])];
771
- 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
+ };
772
1300
  globalSkills.skills[name] = entry;
773
1301
  writeGlobalSkills(globalSkills);
774
- console.log(`${result.updating ? 'Updated' : 'Installed'} ${name}@${result.version} at ${targetDir}`);
1302
+ console.log(`${result.updating ? 'Updated' : 'Installed'} ${name}@${result.version ?? 'personal'} at ${targetDir}`);
775
1303
  }
776
1304
  }
777
1305
 
778
- function nearestAgentsFile(cwd) {
779
- let directory = path.resolve(cwd);
780
- while (true) {
1306
+ function instructionProjectRoot(cwd, selectedProject) {
1307
+ if (selectedProject) {
1308
+ const selected = path.resolve(cwd, selectedProject);
1309
+ let stats;
1310
+ try {
1311
+ stats = fs.statSync(selected);
1312
+ } catch {
1313
+ throw new CliError(`project directory does not exist: ${selected}`);
1314
+ }
1315
+ if (!stats.isDirectory()) throw new CliError(`project path is not a directory: ${selected}`);
1316
+ return canonicalPath(selected);
1317
+ }
1318
+
1319
+ return canonicalPath(projectRoot(cwd, { required: false }) ?? cwd);
1320
+ }
1321
+
1322
+ function isWithinDirectory(root, target) {
1323
+ const relative = path.relative(root, target);
1324
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
1325
+ }
1326
+
1327
+ function nearestAgentsFile(cwd, root) {
1328
+ const start = canonicalPath(cwd);
1329
+ let directory = isWithinDirectory(root, start) ? start : root;
1330
+ while (isWithinDirectory(root, directory)) {
781
1331
  const candidate = path.join(directory, 'AGENTS.md');
782
1332
  if (fs.existsSync(candidate)) return candidate;
783
- const parent = path.dirname(directory);
784
- if (parent === directory) return null;
785
- directory = parent;
1333
+ if (directory === root) break;
1334
+ directory = path.dirname(directory);
786
1335
  }
1336
+ return path.join(root, 'AGENTS.md');
787
1337
  }
788
1338
 
789
- function installAgents(cwd, globalInstall) {
1339
+ function assertLocalInstructionSources(root, contentDirectory) {
1340
+ assertNoInstructionSymlink(root, contentDirectory, 'local instruction source directory');
1341
+ if (!fs.existsSync(contentDirectory)) return;
1342
+ for (const entry of fs.readdirSync(contentDirectory, { withFileTypes: true })) {
1343
+ if (entry.name.endsWith('.block.md')) {
1344
+ const sourcePath = path.join(contentDirectory, entry.name);
1345
+ const label = `local instruction source ${entry.name}`;
1346
+ assertLocalDestination(root, sourcePath, label);
1347
+ assertNoInstructionSymlink(root, sourcePath, label);
1348
+ }
1349
+ }
1350
+ }
1351
+
1352
+ function assertNoInstructionSymlink(root, target, label) {
1353
+ const boundary = path.resolve(root);
1354
+ const absoluteTarget = path.resolve(target);
1355
+ const relative = path.relative(boundary, absoluteTarget);
1356
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
1357
+ throw new CliError(`${label} resolves outside the project`);
1358
+ }
1359
+
1360
+ let current = boundary;
1361
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
1362
+ current = path.join(current, segment);
1363
+ try {
1364
+ if (fs.lstatSync(current).isSymbolicLink()) {
1365
+ throw new CliError(`${label} contains a symlink path component`);
1366
+ }
1367
+ } catch (error) {
1368
+ if (error instanceof CliError) throw error;
1369
+ if (error.code === 'ENOENT') break;
1370
+ throw error;
1371
+ }
1372
+ }
1373
+ }
1374
+
1375
+ function installAgents(cwd, globalInstall, selectedProject) {
790
1376
  const composed = composeAgentsInstructions(agentsContentRoot);
791
1377
  const targetPaths = [];
1378
+ const root = globalInstall ? null : instructionProjectRoot(cwd, selectedProject);
792
1379
  if (globalInstall) {
793
1380
  targetPaths.push(...globalAgentsTargets.map((target) => path.join(os.homedir(), target)));
794
1381
  } else {
795
- const targetPath = nearestAgentsFile(cwd);
796
- if (!targetPath) {
797
- console.log(`No AGENTS.md found at or above ${cwd}`);
798
- return;
799
- }
1382
+ const targetPath = nearestAgentsFile(cwd, root);
1383
+ assertLocalDestination(root, targetPath, 'local agents instructions');
1384
+ assertNoInstructionSymlink(root, targetPath, 'local agents instructions');
1385
+ assertLocalInstructionSources(root, path.dirname(targetPath));
800
1386
  targetPaths.push(targetPath);
801
1387
  }
802
1388
 
@@ -824,14 +1410,117 @@ function installAgents(cwd, globalInstall) {
824
1410
  }
825
1411
  }
826
1412
 
1413
+ function projectInstructionPaths(cwd, selectedProject) {
1414
+ const root = instructionProjectRoot(cwd, selectedProject);
1415
+ const sourceDirectory = path.join(root, projectTemplateDirectory);
1416
+ const templatePath = path.join(sourceDirectory, projectTemplateName);
1417
+ const outputPath = path.join(root, 'AGENTS.md');
1418
+ const paths = [
1419
+ [sourceDirectory, 'project instruction source directory'],
1420
+ [templatePath, 'project instruction template'],
1421
+ [outputPath, 'project agents output']
1422
+ ];
1423
+ for (const [target, label] of paths) {
1424
+ assertLocalDestination(root, target, label);
1425
+ assertNoInstructionSymlink(root, target, label);
1426
+ }
1427
+
1428
+ if (fs.existsSync(sourceDirectory) && !fs.statSync(sourceDirectory).isDirectory()) {
1429
+ throw new CliError(`project instruction source path is not a directory: ${sourceDirectory}`);
1430
+ }
1431
+ if (fs.existsSync(templatePath) && !fs.statSync(templatePath).isFile()) {
1432
+ throw new CliError(`project instruction template is not a file: ${templatePath}`);
1433
+ }
1434
+ if (fs.existsSync(outputPath) && !fs.statSync(outputPath).isFile()) {
1435
+ throw new CliError(`project agents output is not a file: ${outputPath}`);
1436
+ }
1437
+ if (fs.existsSync(sourceDirectory)) {
1438
+ for (const entry of fs.readdirSync(sourceDirectory, { withFileTypes: true })) {
1439
+ if (!entry.name.endsWith('.block.md')) continue;
1440
+ const sourcePath = path.join(sourceDirectory, entry.name);
1441
+ const label = `project instruction source ${entry.name}`;
1442
+ assertLocalDestination(root, sourcePath, label);
1443
+ assertNoInstructionSymlink(root, sourcePath, label);
1444
+ }
1445
+ }
1446
+
1447
+ return { root, sourceDirectory, templatePath, outputPath };
1448
+ }
1449
+
1450
+ function renderProjectAgents(cwd, selectedProject, { initialize = false, check = false } = {}) {
1451
+ let paths = projectInstructionPaths(cwd, selectedProject);
1452
+ if (initialize) {
1453
+ fs.mkdirSync(paths.sourceDirectory, { recursive: true });
1454
+ if (!fs.existsSync(paths.templatePath)) {
1455
+ fs.writeFileSync(paths.templatePath, defaultProjectTemplate);
1456
+ }
1457
+ paths = projectInstructionPaths(cwd, selectedProject);
1458
+ }
1459
+
1460
+ if (!fs.existsSync(paths.templatePath)) {
1461
+ throw new CliError(`project instruction template is missing; run "skillshop agents init" first: ${paths.templatePath}`);
1462
+ }
1463
+
1464
+ const composed = composeAgentsInstructions(paths.sourceDirectory, projectTemplateName).content;
1465
+ const existing = fs.existsSync(paths.outputPath) ? fs.readFileSync(paths.outputPath, 'utf8') : '';
1466
+ const planned = applyProjectInstructions(existing, composed, packageJson.version);
1467
+
1468
+ if (check) {
1469
+ if (fs.existsSync(paths.outputPath) && planned.text === existing) {
1470
+ console.log(`Project agents instructions are current at ${paths.outputPath}`);
1471
+ return;
1472
+ }
1473
+ console.error(
1474
+ `Project agents instructions are ${fs.existsSync(paths.outputPath) ? 'stale' : 'missing'} at ${paths.outputPath}`
1475
+ );
1476
+ process.exitCode = 1;
1477
+ return;
1478
+ }
1479
+
1480
+ if (planned.text === existing) {
1481
+ console.log(`Project agents instructions already current at ${paths.outputPath}`);
1482
+ return;
1483
+ }
1484
+ fs.writeFileSync(paths.outputPath, planned.text);
1485
+ console.log(`${planned.action === 'refreshed' ? 'Refreshed' : 'Rendered'} project agents instructions at ${paths.outputPath}`);
1486
+ }
1487
+
1488
+ function projectAgentsConfigured(cwd, selectedProject) {
1489
+ const root = instructionProjectRoot(cwd, selectedProject);
1490
+ const sourceDirectory = path.join(root, projectTemplateDirectory);
1491
+ const outputPath = path.join(root, 'AGENTS.md');
1492
+ for (const candidate of [sourceDirectory, outputPath]) {
1493
+ try {
1494
+ const stat = fs.lstatSync(candidate);
1495
+ if (candidate === sourceDirectory) return true;
1496
+ if (stat.isFile() && hasProjectInstructions(fs.readFileSync(candidate, 'utf8'))) return true;
1497
+ } catch (error) {
1498
+ if (error.code !== 'ENOENT') throw error;
1499
+ }
1500
+ }
1501
+ return false;
1502
+ }
1503
+
827
1504
  function installLocal(name, requestedVersion, cwd) {
828
1505
  const root = findLockRoot(cwd) ?? projectRoot(cwd);
829
1506
  const lock = readLock(root);
830
1507
  const config = readConfig();
1508
+ const selectedRoot = selectedLibraryRoot(skillshopHome());
831
1509
  const skillDirectories = localSkillDirectories(root, lock, cwd);
832
- 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();
833
1517
  const installs = names.map((skillName) => {
834
- 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
+ });
835
1524
  const targetDirs = config || !lock.skills[skillName]
836
1525
  ? skillDirectories.map((directory) => path.join(directory, skillName))
837
1526
  : lockedTargets(root, skillName, lock.skills[skillName]);
@@ -843,23 +1532,29 @@ function installLocal(name, requestedVersion, cwd) {
843
1532
  const requestedMetadata = installs.find(({ metadata }) => metadata.name === name).metadata;
844
1533
  if (requestedVersion && requestedVersion !== requestedMetadata.version) {
845
1534
  throw new CliError(
846
- `${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`
847
1536
  );
848
1537
  }
849
1538
  for (const { metadata, targetDirs, previousTargets } of installs) {
850
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
+ }
851
1546
  }
852
1547
 
853
1548
  for (const { metadata, targetDirs, previousTargets } of installs) {
854
1549
  const version = metadata.name === name ? requestedVersion : null;
855
- const results = targetDirs.map((targetDir) => installAt(metadata.name, version, targetDir));
1550
+ const results = targetDirs.map((targetDir) => installAt(metadata.name, version, targetDir, metadata, root));
856
1551
  for (const staleTarget of staleTargets(previousTargets, targetDirs)) {
857
1552
  fs.rmSync(staleTarget, { recursive: true, force: true });
858
1553
  }
859
1554
  lock.skills[metadata.name] = lockEntry(root, results);
860
1555
  for (const result of results) {
861
1556
  console.log(
862
- `${result.updating ? 'Updated' : 'Installed'} ${result.name}@${result.version} at ${result.targetDir}`
1557
+ `${result.updating ? 'Updated' : 'Installed'} ${result.name}@${result.version ?? 'personal'} at ${result.targetDir}`
863
1558
  );
864
1559
  }
865
1560
  }
@@ -926,7 +1621,10 @@ function updateLocal(requestedName, cwd, options = {}) {
926
1621
  const skillDirectories = localSkillDirectories(root, lock, cwd);
927
1622
 
928
1623
  const updates = [...new Set(names)].sort().map((name) => {
929
- 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);
930
1628
  const targetDirs = config
931
1629
  ? skillDirectories.map((directory) => path.join(directory, name))
932
1630
  : lockedTargets(root, name, lock.skills[name]);
@@ -935,16 +1633,22 @@ function updateLocal(requestedName, cwd, options = {}) {
935
1633
  });
936
1634
  for (const { metadata, targetDirs, previousTargets } of updates) {
937
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
+ }
938
1642
  }
939
1643
 
940
1644
  for (const { metadata, targetDirs, previousTargets } of updates) {
941
- const results = targetDirs.map((targetDir) => installAt(metadata.name, null, targetDir));
1645
+ const results = targetDirs.map((targetDir) => installAt(metadata.name, null, targetDir, metadata, root));
942
1646
  for (const staleTarget of staleTargets(previousTargets, targetDirs)) {
943
1647
  fs.rmSync(staleTarget, { recursive: true, force: true });
944
1648
  }
945
1649
  lock.skills[metadata.name] = lockEntry(root, results);
946
1650
  for (const result of results) {
947
- console.log(`Updated ${result.name}@${result.version} at ${result.targetDir}`);
1651
+ console.log(`Updated ${result.name}@${result.version ?? 'personal'} at ${result.targetDir}`);
948
1652
  }
949
1653
  }
950
1654
 
@@ -983,6 +1687,223 @@ function updateAllRepositories() {
983
1687
  console.log(`Updated ${retained.length} registered ${retained.length === 1 ? 'repository' : 'repositories'}`);
984
1688
  }
985
1689
 
1690
+ function assertNoManagedSymlink(base, target, label) {
1691
+ const boundary = path.resolve(base);
1692
+ const absoluteTarget = path.resolve(target);
1693
+ const relative = path.relative(boundary, absoluteTarget);
1694
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
1695
+ throw new CliError(`${label} resolves outside its managed root`);
1696
+ }
1697
+
1698
+ let current = boundary;
1699
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
1700
+ current = path.join(current, segment);
1701
+ try {
1702
+ if (fs.lstatSync(current).isSymbolicLink()) {
1703
+ throw new CliError(`${label} contains a symlink path component`);
1704
+ }
1705
+ } catch (error) {
1706
+ if (error instanceof CliError) throw error;
1707
+ if (error.code === 'ENOENT') break;
1708
+ throw error;
1709
+ }
1710
+ }
1711
+ }
1712
+
1713
+ function replaceManagedSkillDirectory(sourceDir, targetDir) {
1714
+ const temporaryDir = `${targetDir}.skillshop-upgrade-tmp-${process.pid}`;
1715
+ const backupDir = `${targetDir}.skillshop-upgrade-backup-${process.pid}`;
1716
+ fs.rmSync(temporaryDir, { recursive: true, force: true });
1717
+ fs.rmSync(backupDir, { recursive: true, force: true });
1718
+ fs.mkdirSync(path.dirname(targetDir), { recursive: true });
1719
+ fs.cpSync(sourceDir, temporaryDir, { recursive: true, force: true });
1720
+
1721
+ const hadTarget = fs.existsSync(targetDir);
1722
+ if (hadTarget) fs.renameSync(targetDir, backupDir);
1723
+ try {
1724
+ fs.renameSync(temporaryDir, targetDir);
1725
+ fs.rmSync(backupDir, { recursive: true, force: true });
1726
+ } catch (error) {
1727
+ if (hadTarget && !fs.existsSync(targetDir) && fs.existsSync(backupDir)) {
1728
+ fs.renameSync(backupDir, targetDir);
1729
+ }
1730
+ throw error;
1731
+ } finally {
1732
+ fs.rmSync(temporaryDir, { recursive: true, force: true });
1733
+ }
1734
+ }
1735
+
1736
+ function upgradeSummary() {
1737
+ return { updated: 0, unchanged: 0, missing: 0, failed: [] };
1738
+ }
1739
+
1740
+ function validateManagedCopy(root, target, name) {
1741
+ assertNoManagedSymlink(root, target, `managed ${name} copy`);
1742
+ let targetStats;
1743
+ try {
1744
+ targetStats = fs.lstatSync(target);
1745
+ } catch (error) {
1746
+ if (error.code === 'ENOENT') return false;
1747
+ throw error;
1748
+ }
1749
+ if (!targetStats.isDirectory()) throw new CliError(`${target} is not a managed skill directory`);
1750
+
1751
+ const manifestPath = path.join(target, 'SKILL.md');
1752
+ assertNoManagedSymlink(root, manifestPath, `managed ${name} manifest`);
1753
+ let manifestStats;
1754
+ try {
1755
+ manifestStats = fs.lstatSync(manifestPath);
1756
+ } catch (error) {
1757
+ if (error.code === 'ENOENT') throw new CliError(`${manifestPath} is missing from a recorded skill copy`);
1758
+ throw error;
1759
+ }
1760
+ if (!manifestStats.isFile()) throw new CliError(`${manifestPath} is not a regular skill manifest`);
1761
+ return true;
1762
+ }
1763
+
1764
+ function refreshManagedLock(root, lock, { global = false, summary }) {
1765
+ let changed = false;
1766
+ for (const name of Object.keys(lock.skills).sort()) {
1767
+ const entry = lock.skills[name];
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
+ }
1777
+ const metadata = skillMetadata(name);
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;
1781
+ const base = global ? os.homedir() : root;
1782
+ const existingTargets = [];
1783
+ for (const target of targets) {
1784
+ if (validateManagedCopy(base, target, name)) existingTargets.push(target);
1785
+ else summary.missing += 1;
1786
+ }
1787
+
1788
+ if (!existingTargets.length) {
1789
+ if (personalTargets.length) {
1790
+ entry.paths = personalTargets;
1791
+ changed = true;
1792
+ } else {
1793
+ delete lock.skills[name];
1794
+ changed = true;
1795
+ }
1796
+ continue;
1797
+ }
1798
+
1799
+ const successfulTargets = [];
1800
+ let skillFailed = false;
1801
+ for (const target of existingTargets) {
1802
+ const recordedVersion = global ? entry.copies?.[target]?.version : entry.version;
1803
+ if (recordedVersion === metadata.version) {
1804
+ summary.unchanged += 1;
1805
+ successfulTargets.push(target);
1806
+ continue;
1807
+ }
1808
+ try {
1809
+ replaceManagedSkillDirectory(metadata.sourceDir, target);
1810
+ summary.updated += 1;
1811
+ successfulTargets.push(target);
1812
+ } catch (error) {
1813
+ summary.failed.push(`${name} at ${target}: ${error.message}`);
1814
+ skillFailed = true;
1815
+ }
1816
+ }
1817
+
1818
+ if (global) {
1819
+ const keptTargets = [...personalTargets, ...(skillFailed ? targets : successfulTargets)];
1820
+ entry.paths = keptTargets;
1821
+ entry.copies ??= {};
1822
+ for (const target of successfulTargets) {
1823
+ const previous = entry.copies[target] ?? {};
1824
+ entry.copies[target] = { ...previous, author: metadata.author, version: metadata.version };
1825
+ }
1826
+ if (!skillFailed) changed = true;
1827
+ if (!entry.paths.length) {
1828
+ delete lock.skills[name];
1829
+ changed = true;
1830
+ }
1831
+ } else if (!skillFailed) {
1832
+ entry.paths = successfulTargets.map((target) => relativePath(root, target)).sort();
1833
+ entry.author = metadata.author;
1834
+ entry.version = metadata.version;
1835
+ changed = true;
1836
+ } else if (existingTargets.length !== targets.length) {
1837
+ entry.paths = existingTargets.map((target) => relativePath(root, target)).sort();
1838
+ changed = true;
1839
+ }
1840
+ } catch (error) {
1841
+ summary.failed.push(`${name}: ${error.message}`);
1842
+ }
1843
+ }
1844
+
1845
+ if (changed) {
1846
+ try {
1847
+ if (global) writeGlobalSkills(lock);
1848
+ else writeLock(root, lock);
1849
+ } catch (error) {
1850
+ summary.failed.push(`could not save ${global ? 'global skill manifest' : path.join(root, lockFileName)}: ${error.message}`);
1851
+ }
1852
+ }
1853
+ }
1854
+
1855
+ function refreshManagedCopies(scope) {
1856
+ const summary = upgradeSummary();
1857
+ if (scope === 'local') {
1858
+ const owner = localPackageOwner();
1859
+ if (!owner) {
1860
+ summary.failed.push('fresh Skillshop package is no longer attached to a local npm dependency');
1861
+ } else {
1862
+ try {
1863
+ const lock = readLock(owner.root);
1864
+ refreshManagedLock(owner.root, lock, { summary });
1865
+ } catch (error) {
1866
+ summary.failed.push(`${owner.root}: ${error.message}`);
1867
+ }
1868
+ }
1869
+ } else if (scope === 'global') {
1870
+ try {
1871
+ const globalLock = readGlobalSkills();
1872
+ refreshManagedLock(null, globalLock, { global: true, summary });
1873
+ } catch (error) {
1874
+ summary.failed.push(`global managed copies: ${error.message}`);
1875
+ }
1876
+
1877
+ try {
1878
+ const registry = readRegistry();
1879
+ for (const root of registry.repositories) {
1880
+ if (!hasLock(root)) continue;
1881
+ try {
1882
+ const lock = readLock(root);
1883
+ refreshManagedLock(root, lock, { summary });
1884
+ } catch (error) {
1885
+ summary.failed.push(`${root}: ${error.message}`);
1886
+ }
1887
+ }
1888
+ } catch (error) {
1889
+ summary.failed.push(`registered project copies: ${error.message}`);
1890
+ }
1891
+ } else {
1892
+ summary.failed.push(`unsupported managed-copy scope: ${scope}`);
1893
+ }
1894
+
1895
+ console.log(
1896
+ `Managed-copy refresh summary: ${summary.updated} updated, ${summary.unchanged} unchanged, ${summary.missing} missing paths pruned, ${summary.failed.length} failed.`
1897
+ );
1898
+ if (summary.failed.length) {
1899
+ for (const failure of summary.failed) console.error(`skillshop: upgrade refresh: ${failure}`);
1900
+ console.error('skillshop: npm upgrade succeeded, but managed-copy refresh was incomplete');
1901
+ process.exitCode = 1;
1902
+ } else {
1903
+ console.log(`Refreshed Skillshop-managed copies for ${scope} scope.`);
1904
+ }
1905
+ }
1906
+
986
1907
  const migrationSkipDirectories = new Set([
987
1908
  '.git',
988
1909
  '.skillshop',
@@ -1074,6 +1995,8 @@ function parseArgs(argv) {
1074
1995
  let pushUpdate = false;
1075
1996
  const agentsTargets = [];
1076
1997
  let requestedVersion = null;
1998
+ let selectedProject = null;
1999
+ let checkProject = false;
1077
2000
 
1078
2001
  for (let index = 0; index < argv.length; index += 1) {
1079
2002
  const argument = argv[index];
@@ -1081,6 +2004,15 @@ function parseArgs(argv) {
1081
2004
  globalInstall = true;
1082
2005
  } else if (argument === '--push') {
1083
2006
  pushUpdate = true;
2007
+ } else if (argument === '--check') {
2008
+ checkProject = true;
2009
+ } else if (argument === '--project') {
2010
+ selectedProject = argv[index + 1];
2011
+ if (!selectedProject) throw new CliError('--project requires a directory');
2012
+ index += 1;
2013
+ } else if (argument.startsWith('--project=')) {
2014
+ selectedProject = argument.slice('--project='.length);
2015
+ if (!selectedProject) throw new CliError('--project requires a directory');
1084
2016
  } else if (argument === '--codex' || argument === '--copilot') {
1085
2017
  throw new CliError(`${argument} is no longer supported; use agents --global`);
1086
2018
  } else if (argument === '--version' || argument === '-v') {
@@ -1097,18 +2029,152 @@ function parseArgs(argv) {
1097
2029
  }
1098
2030
  }
1099
2031
 
1100
- return { positional, globalInstall, pushUpdate, requestedVersion, agentsTargets: [...new Set(agentsTargets)] };
2032
+ return {
2033
+ positional,
2034
+ globalInstall,
2035
+ pushUpdate,
2036
+ requestedVersion,
2037
+ selectedProject,
2038
+ checkProject,
2039
+ agentsTargets: [...new Set(agentsTargets)]
2040
+ };
1101
2041
  }
1102
2042
 
1103
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
+ }
1104
2050
  for (const name of availableSkills()) {
1105
2051
  const metadata = skillMetadata(name);
1106
2052
  console.log(`${name}@${metadata.version} — ${metadata.author}`);
1107
2053
  }
1108
2054
  }
1109
2055
 
2056
+ function packageDependencySection(manifest) {
2057
+ for (const section of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
2058
+ if (Object.hasOwn(manifest?.[section] ?? {}, 'skillshop')) return section;
2059
+ }
2060
+ return null;
2061
+ }
2062
+
2063
+ function localPackageOwner() {
2064
+ const installedPackageRoot = canonicalPath(packageRoot);
2065
+ let current = path.dirname(installedPackageRoot);
2066
+ while (true) {
2067
+ const manifestPath = path.join(current, 'package.json');
2068
+ if (fs.existsSync(manifestPath)) {
2069
+ try {
2070
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
2071
+ const section = packageDependencySection(manifest);
2072
+ const declaredInstall = path.join(current, 'node_modules', 'skillshop');
2073
+ if (section && fs.existsSync(declaredInstall) && canonicalPath(declaredInstall) === installedPackageRoot) {
2074
+ return { root: current, section };
2075
+ }
2076
+ } catch (error) {
2077
+ if (error instanceof SyntaxError) throw new CliError(`cannot parse ${manifestPath}: ${error.message}`);
2078
+ throw error;
2079
+ }
2080
+ }
2081
+
2082
+ const parent = path.dirname(current);
2083
+ if (parent === current) return null;
2084
+ current = parent;
2085
+ }
2086
+ }
2087
+
2088
+ function npmGlobalRoot() {
2089
+ try {
2090
+ return path.resolve(execFileSync('npm', ['root', '--global'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim());
2091
+ } catch (error) {
2092
+ const detail = error.stderr?.toString().trim();
2093
+ throw new CliError(`cannot determine the active npm global package directory${detail ? `: ${detail}` : ''}`);
2094
+ }
2095
+ }
2096
+
2097
+ function npmPrefixFromRoot(globalRoot) {
2098
+ const parent = path.dirname(globalRoot);
2099
+ return path.basename(parent) === 'lib' ? path.dirname(parent) : parent;
2100
+ }
2101
+
2102
+ function resolveUpgradeScope() {
2103
+ const localOwner = localPackageOwner();
2104
+ if (localOwner) return { type: 'local', root: localOwner.root, dependencySection: localOwner.section, packageRoot };
2105
+
2106
+ const insideNodeModules = canonicalPath(packageRoot).split(path.sep).includes('node_modules');
2107
+ const activeGlobalRoot = npmGlobalRoot();
2108
+ if (canonicalPath(path.join(activeGlobalRoot, 'skillshop')) === canonicalPath(packageRoot)) {
2109
+ return { type: 'global', prefix: npmPrefixFromRoot(activeGlobalRoot), packageRoot };
2110
+ }
2111
+ if (insideNodeModules) {
2112
+ throw new CliError(
2113
+ `cannot match this installed Skillshop package to the active npm global prefix (${activeGlobalRoot}); no upgrade was run`
2114
+ );
2115
+ }
2116
+
2117
+ // Preserve the historical source-checkout behavior: install the package into
2118
+ // the active npm global prefix without editing the checkout's package files.
2119
+ return { type: 'global-checkout', packageRoot };
2120
+ }
2121
+
2122
+ function runUpgradeInstall(scope) {
2123
+ const args = scope.type === 'local'
2124
+ ? ['install', `--save-${scope.dependencySection === 'dependencies' ? 'prod' : scope.dependencySection === 'devDependencies' ? 'dev' : scope.dependencySection === 'optionalDependencies' ? 'optional' : 'peer'}`, 'skillshop@latest']
2125
+ : scope.type === 'global'
2126
+ ? ['install', '--global', '--prefix', scope.prefix, 'skillshop@latest']
2127
+ : ['install', '--global', 'skillshop@latest'];
2128
+ try {
2129
+ execFileSync('npm', args, {
2130
+ cwd: scope.type === 'local' ? scope.root : process.cwd(),
2131
+ stdio: 'inherit'
2132
+ });
2133
+ } catch (error) {
2134
+ const status = Number.isInteger(error.status) ? ` (exit ${error.status})` : '';
2135
+ throw new CliError(`npm upgrade failed${status}; managed copies were not refreshed`);
2136
+ }
2137
+ }
2138
+
2139
+ function freshUpgradePackageRoot(scope) {
2140
+ if (scope.type !== 'global-checkout') return scope.packageRoot;
2141
+ return path.join(npmGlobalRoot(), 'skillshop');
2142
+ }
2143
+
1110
2144
  function upgrade() {
1111
- execFileSync('npm', ['install', '--global', 'skillshop'], { stdio: 'inherit' });
2145
+ const scope = resolveUpgradeScope();
2146
+ const scopeLabel = scope.type === 'local'
2147
+ ? `project dependency in ${scope.root}`
2148
+ : scope.type === 'global'
2149
+ ? `global installation at ${scope.packageRoot}`
2150
+ : 'global installation (source-checkout compatibility mode)';
2151
+ console.log(`Upgrading Skillshop ${scopeLabel}...`);
2152
+ runUpgradeInstall(scope);
2153
+
2154
+ let freshCliPath;
2155
+ try {
2156
+ freshCliPath = path.join(freshUpgradePackageRoot(scope), 'src', 'cli.js');
2157
+ if (!fs.statSync(freshCliPath).isFile()) throw new Error('not a file');
2158
+ } catch {
2159
+ throw new CliError('npm upgrade succeeded, but the newly installed Skillshop CLI could not be found for managed-copy refresh');
2160
+ }
2161
+
2162
+ const child = spawnSync(process.execPath, [freshCliPath, internalUpgradeRefreshCommand], {
2163
+ cwd: scope.type === 'local' ? scope.root : process.cwd(),
2164
+ env: {
2165
+ ...process.env,
2166
+ SKILLSHOP_INTERNAL_UPGRADE_TOKEN: internalUpgradeRefreshToken,
2167
+ SKILLSHOP_INTERNAL_UPGRADE_SCOPE: scope.type === 'local' ? 'local' : 'global'
2168
+ },
2169
+ stdio: 'inherit'
2170
+ });
2171
+ if (child.error) {
2172
+ throw new CliError(`npm upgrade succeeded, but fresh Skillshop managed-copy refresh could not start: ${child.error.message}`);
2173
+ }
2174
+ if (child.status !== 0) {
2175
+ const status = child.status === null ? 'without an exit status' : `with exit ${child.status}`;
2176
+ throw new CliError(`npm upgrade succeeded, but fresh Skillshop managed-copy refresh failed ${status}`);
2177
+ }
1112
2178
  }
1113
2179
 
1114
2180
  async function main(argv) {
@@ -1121,8 +2187,29 @@ async function main(argv) {
1121
2187
  return;
1122
2188
  }
1123
2189
 
1124
- const { positional, globalInstall, pushUpdate, requestedVersion, 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);
1125
2194
  const command = positional[0];
2195
+ const forceDownload = forceCount === 1;
2196
+ if (forceDownload && command !== 'download') throw new CliError('--force is only valid with download');
2197
+
2198
+ if ((selectedProject || checkProject) && command !== 'agents') {
2199
+ throw new CliError('--project and --check are only valid with agents');
2200
+ }
2201
+
2202
+ if (command === internalUpgradeRefreshCommand) {
2203
+ if (
2204
+ process.env.SKILLSHOP_INTERNAL_UPGRADE_TOKEN !== internalUpgradeRefreshToken ||
2205
+ positional.length !== 1 ||
2206
+ requestedVersion || globalInstall || pushUpdate || selectedProject || checkProject || agentsTargets.length
2207
+ ) {
2208
+ throw new CliError('invalid internal upgrade continuation');
2209
+ }
2210
+ refreshManagedCopies(process.env.SKILLSHOP_INTERNAL_UPGRADE_SCOPE);
2211
+ return;
2212
+ }
1126
2213
 
1127
2214
  if (command === 'init') {
1128
2215
  if (positional.length !== 1 || requestedVersion || globalInstall || pushUpdate || agentsTargets.length) {
@@ -1148,6 +2235,53 @@ async function main(argv) {
1148
2235
  return;
1149
2236
  }
1150
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
+
1151
2285
  if (command === 'upgrade') {
1152
2286
  if (positional.length !== 1 || requestedVersion || globalInstall || pushUpdate || agentsTargets.length) {
1153
2287
  throw new CliError('upgrade does not accept arguments or options');
@@ -1178,10 +2312,32 @@ async function main(argv) {
1178
2312
  }
1179
2313
 
1180
2314
  if (command === 'agents') {
1181
- if (positional.length !== 1 || requestedVersion || pushUpdate) {
2315
+ const initializeProject = positional.length === 2 && positional[1] === 'init';
2316
+ if (
2317
+ positional.length > 2 ||
2318
+ (positional.length === 2 && !initializeProject) ||
2319
+ requestedVersion ||
2320
+ pushUpdate ||
2321
+ agentsTargets.length
2322
+ ) {
1182
2323
  throw new CliError('agents does not accept skill or update options');
1183
2324
  }
1184
- installAgents(process.cwd(), globalInstall);
2325
+ if (globalInstall) {
2326
+ if (initializeProject || selectedProject || checkProject) {
2327
+ throw new CliError('--global cannot be combined with project agents options');
2328
+ }
2329
+ installAgents(process.cwd(), true);
2330
+ return;
2331
+ }
2332
+ if (initializeProject && checkProject) throw new CliError('agents init does not accept --check');
2333
+ if (initializeProject || checkProject || projectAgentsConfigured(process.cwd(), selectedProject)) {
2334
+ renderProjectAgents(process.cwd(), selectedProject, {
2335
+ initialize: initializeProject,
2336
+ check: checkProject
2337
+ });
2338
+ } else {
2339
+ installAgents(process.cwd(), false, selectedProject);
2340
+ }
1185
2341
  return;
1186
2342
  }
1187
2343
 
@@ -1208,22 +2364,110 @@ async function main(argv) {
1208
2364
  }
1209
2365
  if (pushUpdate) throw new CliError('--push is only valid with update');
1210
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
+
1211
2378
  const { name, version } = parseSkillSpec(spec, requestedVersion);
1212
2379
  validateSkillName(name);
1213
2380
  if (globalInstall) installGlobal(name, version);
1214
2381
  else installLocal(name, version, process.cwd());
1215
2382
  }
1216
2383
 
2384
+ function isIsolatedAgentsInvocation(argv) {
2385
+ // Preserve the top-level help/version routing before looking for a command.
2386
+ // main() handles help anywhere and the standalone version form without parsing.
2387
+ if (argv[0] === '--help' || argv[0] === '-h') return false;
2388
+ if (argv.length === 1 && (argv[0] === '--version' || argv[0] === '-v')) return false;
2389
+
2390
+ // parseArgs accepts project options on either side of the command. Ignore help
2391
+ // flags here because main() handles them before parsing, then classify using
2392
+ // the same positional-command rules as execution.
2393
+ try {
2394
+ const args = argv.filter((argument) => argument !== '--help' && argument !== '-h');
2395
+ return parseArgs(args).positional[0] === 'agents';
2396
+ } catch {
2397
+ return false;
2398
+ }
2399
+ }
2400
+
2401
+ function isUpgradeFlowInvocation(argv) {
2402
+ if (argv.includes('--help') || argv.includes('-h')) return false;
2403
+ if (
2404
+ argv[0] === internalUpgradeRefreshCommand &&
2405
+ process.env.SKILLSHOP_INTERNAL_UPGRADE_TOKEN === internalUpgradeRefreshToken
2406
+ ) return true;
2407
+ try {
2408
+ return parseArgs(argv).positional[0] === 'upgrade';
2409
+ } catch {
2410
+ return false;
2411
+ }
2412
+ }
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
+
1217
2457
  export async function runLegacyCli(argv) {
2458
+ const isolatedAgentsCommand = isIsolatedAgentsInvocation(argv);
2459
+ const upgradeFlow = isUpgradeFlowInvocation(argv);
2460
+ const isolatedLibraryCommand = isPersonalLibraryInvocation(argv);
2461
+ const skipLegacyReconciliation = isolatedAgentsCommand || upgradeFlow || isolatedLibraryCommand;
1218
2462
  try {
1219
- reconcileCurrentRepository();
2463
+ if (!skipLegacyReconciliation) reconcileCurrentRepository();
1220
2464
  try {
1221
2465
  await main(argv);
1222
2466
  } finally {
1223
- reconcileCurrentRepository();
2467
+ if (!skipLegacyReconciliation) reconcileCurrentRepository();
1224
2468
  }
1225
2469
  } catch (error) {
1226
- 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;
1227
2471
  console.error(`skillshop: ${error.message}`);
1228
2472
  process.exitCode = 1;
1229
2473
  }