@akshar5/skillsync 0.1.1 → 0.1.3
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 +18 -3
- package/package.json +1 -1
- package/src/cli.js +127 -56
package/README.md
CHANGED
|
@@ -43,6 +43,8 @@ Open the interactive UI:
|
|
|
43
43
|
skillsync
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
+
In the interactive UI, use arrow keys to move, Space to toggle checklist items, Enter to apply, and Esc to go back.
|
|
47
|
+
|
|
46
48
|
## Requirements
|
|
47
49
|
|
|
48
50
|
- Node.js 20 or newer
|
|
@@ -89,13 +91,18 @@ Connect to an existing vault:
|
|
|
89
91
|
skillsync setup --repo AksharP5/skills
|
|
90
92
|
```
|
|
91
93
|
|
|
92
|
-
Or create/select a vault repo
|
|
94
|
+
Or create/select a vault repo interactively:
|
|
93
95
|
|
|
94
96
|
```bash
|
|
95
|
-
skillsync setup
|
|
97
|
+
skillsync setup
|
|
96
98
|
```
|
|
97
99
|
|
|
98
|
-
If you run plain `skillsync setup` in an interactive terminal, it asks
|
|
100
|
+
If you run plain `skillsync setup` in an interactive terminal, it first asks which vault type to use:
|
|
101
|
+
|
|
102
|
+
- Choose `Use an existing GitHub repo`, then enter a repo like `AksharP5/skills`.
|
|
103
|
+
- Choose `Create or use OWNER/<name>`, then enter a repo name like `skills`.
|
|
104
|
+
|
|
105
|
+
`setup --name skills` is the non-interactive form of the second option. It creates `OWNER/skills` as a private GitHub repo if it does not exist. If it exists, SkillSync verifies it is private before using it.
|
|
99
106
|
|
|
100
107
|
You can also run commands without a global install:
|
|
101
108
|
|
|
@@ -119,6 +126,14 @@ List available skills:
|
|
|
119
126
|
skillsync list
|
|
120
127
|
```
|
|
121
128
|
|
|
129
|
+
Browse and install multiple vault skills at once:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
skillsync
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Choose `Browse/install skills`, press Space to select every skill you want installed on this device, then press Enter to apply the changes.
|
|
136
|
+
|
|
122
137
|
Check current vault/device state:
|
|
123
138
|
|
|
124
139
|
```bash
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -84,6 +84,24 @@ function hasFlag(rest, flag) {
|
|
|
84
84
|
return rest.includes(flag);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
async function promptWithEscape(promptPromise, escapeValue = null) {
|
|
88
|
+
if (!process.stdin.isTTY) return promptPromise;
|
|
89
|
+
const onKeypress = (_value, key) => {
|
|
90
|
+
if (key?.name === 'escape' && typeof promptPromise.cancel === 'function') {
|
|
91
|
+
promptPromise.cancel();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
process.stdin.on('keypress', onKeypress);
|
|
95
|
+
try {
|
|
96
|
+
return await promptPromise;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error?.name === 'CancelPromptError') return escapeValue;
|
|
99
|
+
throw error;
|
|
100
|
+
} finally {
|
|
101
|
+
process.stdin.off('keypress', onKeypress);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
87
105
|
async function configured() {
|
|
88
106
|
const config = await loadConfig();
|
|
89
107
|
if (!config.repoPath || !await exists(config.repoPath)) {
|
|
@@ -97,12 +115,31 @@ async function resolveRepoCloneUrl(repo) {
|
|
|
97
115
|
if (/^(git@|https?:\/\/|ssh:\/\/)/.test(repo)) return repo;
|
|
98
116
|
if (!repo.includes('/')) return repo;
|
|
99
117
|
if (!await commandExists('gh')) return repo;
|
|
118
|
+
return verifiedRepoCloneUrl(repo);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function verifiedRepoCloneUrl(repo) {
|
|
100
122
|
const { stdout } = await gh(['repo', 'view', repo, '--json', 'isPrivate,url', '--jq', '.']);
|
|
101
123
|
const view = JSON.parse(stdout);
|
|
102
124
|
if (!view.isPrivate) throw new Error(`${repo} exists but is not private. Make it private before using it as a skill vault.`);
|
|
103
125
|
return view.url;
|
|
104
126
|
}
|
|
105
127
|
|
|
128
|
+
async function ensureOwnedVaultRepo(owner, name) {
|
|
129
|
+
const repo = `${owner}/${name}`;
|
|
130
|
+
let repoExists = true;
|
|
131
|
+
try {
|
|
132
|
+
await gh(['repo', 'view', repo]);
|
|
133
|
+
} catch {
|
|
134
|
+
repoExists = false;
|
|
135
|
+
}
|
|
136
|
+
if (!repoExists) {
|
|
137
|
+
console.log(`Creating private GitHub repo ${repo}...`);
|
|
138
|
+
await gh(['repo', 'create', repo, '--private', '--description', 'Private AI agent skills vault'], undefined, { inherit: true });
|
|
139
|
+
}
|
|
140
|
+
return verifiedRepoCloneUrl(repo);
|
|
141
|
+
}
|
|
142
|
+
|
|
106
143
|
async function setup(rest) {
|
|
107
144
|
const yes = hasFlag(rest, '--yes') || hasFlag(rest, '-y');
|
|
108
145
|
if (!await commandExists('git')) throw new Error('git is required');
|
|
@@ -119,24 +156,28 @@ async function setup(rest) {
|
|
|
119
156
|
if (!repo) {
|
|
120
157
|
const { stdout: ownerOut } = await gh(['api', 'user', '--jq', '.login']);
|
|
121
158
|
const owner = ownerOut.trim();
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
159
|
+
const nameArg = flagValue(rest, '--name');
|
|
160
|
+
if (nameArg || yes || !process.stdin.isTTY) {
|
|
161
|
+
repo = await ensureOwnedVaultRepo(owner, nameArg || 'skills');
|
|
162
|
+
} else {
|
|
163
|
+
const setupMode = await promptWithEscape(select({
|
|
164
|
+
message: 'Which skills vault do you want to use?',
|
|
165
|
+
loop: false,
|
|
166
|
+
pageSize: 2,
|
|
167
|
+
choices: [
|
|
168
|
+
{ name: 'Use an existing GitHub repo', value: 'existing' },
|
|
169
|
+
{ name: `Create or use ${owner}/<name>`, value: 'owned' },
|
|
170
|
+
],
|
|
171
|
+
}));
|
|
172
|
+
if (!setupMode) return;
|
|
173
|
+
if (setupMode === 'existing') {
|
|
174
|
+
const existingRepo = await input({ message: 'Existing vault repo (owner/repo or URL):', default: `${owner}/skills` });
|
|
175
|
+
repo = await resolveRepoCloneUrl(existingRepo);
|
|
176
|
+
} else {
|
|
177
|
+
const name = await input({ message: `Vault repo name under ${owner}:`, default: 'skills' });
|
|
178
|
+
repo = await ensureOwnedVaultRepo(owner, name);
|
|
179
|
+
}
|
|
135
180
|
}
|
|
136
|
-
const { stdout: viewOut } = await gh(['repo', 'view', repo, '--json', 'isPrivate,url', '--jq', '.']);
|
|
137
|
-
const view = JSON.parse(viewOut);
|
|
138
|
-
if (!view.isPrivate) throw new Error(`${repo} exists but is not private. Make it private before using it as a skill vault.`);
|
|
139
|
-
repo = view.url;
|
|
140
181
|
} else {
|
|
141
182
|
repo = await resolveRepoCloneUrl(repo);
|
|
142
183
|
}
|
|
@@ -414,10 +455,13 @@ async function chooseInstallTargets(config, rest) {
|
|
|
414
455
|
if (hasFlag(rest, '--no-install') || hasFlag(rest, '--yes') || hasFlag(rest, '-y') || !process.stdin.isTTY || !available.length) return [];
|
|
415
456
|
const shouldInstall = await confirm({ message: 'Install on this device now?', default: true });
|
|
416
457
|
if (!shouldInstall) return [];
|
|
417
|
-
return checkbox({
|
|
458
|
+
return promptWithEscape(checkbox({
|
|
418
459
|
message: 'Choose local targets',
|
|
460
|
+
loop: false,
|
|
461
|
+
pageSize: Math.min(12, available.length),
|
|
419
462
|
choices: available.map((target) => ({ name: target, value: target, checked: true })),
|
|
420
|
-
|
|
463
|
+
instructions: 'Space toggles targets. Enter confirms. Esc cancels.',
|
|
464
|
+
}), []);
|
|
421
465
|
}
|
|
422
466
|
|
|
423
467
|
async function target(rest) {
|
|
@@ -541,20 +585,22 @@ async function runUi() {
|
|
|
541
585
|
|
|
542
586
|
while (true) {
|
|
543
587
|
await refreshChangedRegistryEntries(config.repoPath);
|
|
544
|
-
const choice = await select({
|
|
588
|
+
const choice = await promptWithEscape(select({
|
|
545
589
|
message: 'SkillSync',
|
|
590
|
+
loop: false,
|
|
591
|
+
pageSize: 8,
|
|
546
592
|
choices: [
|
|
547
|
-
{ name: 'Browse/install skills', value: 'skills' },
|
|
548
|
-
{ name: 'Devices', value: 'devices' },
|
|
549
|
-
{ name: 'Targets', value: 'targets' },
|
|
550
|
-
{ name: 'Add skill from folder', value: 'add' },
|
|
551
|
-
{ name: 'Import Hermes skills', value: 'import-hermes' },
|
|
552
|
-
{ name: 'Scan local targets', value: 'scan' },
|
|
553
|
-
{ name: 'Sync now', value: 'sync' },
|
|
593
|
+
{ name: 'Browse/install skills', value: 'skills', description: 'Select multiple vault skills to install or remove here.' },
|
|
594
|
+
{ name: 'Devices', value: 'devices', description: 'Show devices known to the vault.' },
|
|
595
|
+
{ name: 'Targets', value: 'targets', description: 'Manage local agent skill folders.' },
|
|
596
|
+
{ name: 'Add skill from folder', value: 'add', description: 'Copy a local SKILL.md folder into the vault.' },
|
|
597
|
+
{ name: 'Import Hermes skills', value: 'import-hermes', description: 'Import detected Hermes skills into the vault.' },
|
|
598
|
+
{ name: 'Scan local targets', value: 'scan', description: 'Refresh detected local skills.' },
|
|
599
|
+
{ name: 'Sync now', value: 'sync', description: 'Pull, link, scan, commit, and push vault changes.' },
|
|
554
600
|
{ name: 'Quit', value: 'quit' },
|
|
555
601
|
],
|
|
556
|
-
});
|
|
557
|
-
if (choice === 'quit') return;
|
|
602
|
+
}));
|
|
603
|
+
if (!choice || choice === 'quit') return;
|
|
558
604
|
if (choice === 'skills') await skillsScreen(config);
|
|
559
605
|
if (choice === 'devices') await devicesScreen(config);
|
|
560
606
|
if (choice === 'targets') await targetsScreen(config);
|
|
@@ -573,41 +619,66 @@ async function skillsScreen(config) {
|
|
|
573
619
|
console.log('\nNo skills in vault yet. Use Add/import first.\n');
|
|
574
620
|
return;
|
|
575
621
|
}
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
622
|
+
const installedNames = new Set(Object.keys(device.installed || {}));
|
|
623
|
+
const selected = await promptWithEscape(checkbox({
|
|
624
|
+
message: `Install skills on ${device.display_name}`,
|
|
625
|
+
loop: false,
|
|
626
|
+
pageSize: Math.min(14, Math.max(7, names.length)),
|
|
627
|
+
instructions: 'Space toggles skills. Enter applies changes. Esc goes back.',
|
|
628
|
+
choices: names.map((name) => {
|
|
629
|
+
const targets = device.installed[name] || [];
|
|
630
|
+
const targetLabel = targets.length ? ` [${targets.join(', ')}]` : '';
|
|
631
|
+
return {
|
|
632
|
+
name: `${name}${targetLabel}`,
|
|
633
|
+
short: name,
|
|
634
|
+
value: name,
|
|
635
|
+
checked: installedNames.has(name),
|
|
636
|
+
description: targets.length ? `Installed in ${targets.join(', ')}` : 'Not installed on this device',
|
|
637
|
+
};
|
|
638
|
+
}),
|
|
639
|
+
}));
|
|
640
|
+
if (!selected) return;
|
|
641
|
+
|
|
642
|
+
const selectedNames = new Set(selected);
|
|
643
|
+
const toInstall = names.filter((name) => selectedNames.has(name) && !installedNames.has(name));
|
|
644
|
+
const toUninstall = names.filter((name) => !selectedNames.has(name) && installedNames.has(name));
|
|
645
|
+
if (!toInstall.length && !toUninstall.length) {
|
|
646
|
+
console.log('\nNo install changes.\n');
|
|
647
|
+
return;
|
|
598
648
|
}
|
|
599
|
-
|
|
649
|
+
|
|
650
|
+
let targets = [];
|
|
651
|
+
if (toInstall.length) {
|
|
652
|
+
targets = await chooseTargets(config);
|
|
653
|
+
if (!targets.length) return;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
for (const skillName of toInstall) {
|
|
657
|
+
await installSkill({ vaultPath: config.repoPath, deviceId: config.deviceId, skillName, targets });
|
|
658
|
+
}
|
|
659
|
+
for (const skillName of toUninstall) {
|
|
660
|
+
await uninstallSkill({ vaultPath: config.repoPath, deviceId: config.deviceId, skillName });
|
|
661
|
+
}
|
|
662
|
+
await applyLinks({ vaultPath: config.repoPath, deviceId: config.deviceId });
|
|
663
|
+
await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false });
|
|
664
|
+
|
|
665
|
+
const installedText = toInstall.length ? `Installed ${toInstall.join(', ')} to ${targets.join(', ')}.` : '';
|
|
666
|
+
const removedText = toUninstall.length ? `Removed ${toUninstall.join(', ')} from this device.` : '';
|
|
667
|
+
console.log(`\n${[installedText, removedText].filter(Boolean).join(' ')}\n`);
|
|
600
668
|
}
|
|
601
669
|
|
|
602
670
|
async function chooseTargets(config) {
|
|
603
671
|
const device = await loadDevice(config.repoPath, config.deviceId);
|
|
604
672
|
const targetNames = Object.keys(device.targets);
|
|
605
673
|
if (!targetNames.length) throw new Error('No targets configured. Add one from the Targets screen.');
|
|
606
|
-
return checkbox({
|
|
674
|
+
return promptWithEscape(checkbox({
|
|
607
675
|
message: 'Install into which targets?',
|
|
676
|
+
loop: false,
|
|
677
|
+
pageSize: Math.min(12, targetNames.length),
|
|
608
678
|
choices: targetNames.map((name) => ({ name, value: name, checked: true })),
|
|
609
679
|
required: true,
|
|
610
|
-
|
|
680
|
+
instructions: 'Space toggles targets. Enter confirms. Esc cancels.',
|
|
681
|
+
}), []);
|
|
611
682
|
}
|
|
612
683
|
|
|
613
684
|
async function devicesScreen(config) {
|
|
@@ -631,8 +702,8 @@ async function targetsScreen(config) {
|
|
|
631
702
|
{ name: 'Add target', value: 'add' },
|
|
632
703
|
{ name: 'Back', value: 'back' },
|
|
633
704
|
]);
|
|
634
|
-
const choice = await select({ message: 'Targets on this device', choices });
|
|
635
|
-
if (choice === 'back') return;
|
|
705
|
+
const choice = await promptWithEscape(select({ message: 'Targets on this device', choices, loop: false, pageSize: Math.min(10, choices.length) }));
|
|
706
|
+
if (!choice || choice === 'back') return;
|
|
636
707
|
if (choice === 'add') {
|
|
637
708
|
const name = await input({ message: 'Target name (codex, claude, hermes, custom):' });
|
|
638
709
|
const targetPath = await input({ message: 'Target skill directory path:' });
|