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