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/README.md +45 -188
- package/docs/package-library-compatibility.md +194 -0
- package/package.json +4 -2
- package/skills-content/skillshop/SKILL.md +30 -9
- package/src/cli.js +13 -16
- package/src/github-library.js +310 -0
- package/src/legacy-cli.js +689 -33
- package/src/personal-library.js +591 -0
- package/templates/skillshop-library/README.md +38 -0
- package/templates/skillshop-library/skills/example/SKILL.md +8 -0
- package/templates/skillshop-library/skills/example/references/example.md +3 -0
- package/templates/skillshop-library/skills/example/scripts/run.sh +2 -0
- package/templates/skillshop-library/skillshop-library.json +4 -0
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' ||
|
|
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' ||
|
|
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, {
|
|
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.
|
|
760
|
-
|
|
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
|
|
764
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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] = {
|
|
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
|
|
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 =
|
|
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;
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1252
|
-
|
|
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
|
|
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
|
|
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
|
}
|