@akshar5/skillsync 0.1.3 → 0.2.1
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/LICENSE +21 -0
- package/README.md +21 -6
- package/package.json +2 -1
- package/src/cli.js +315 -33
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Akshar Patel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ npm install -g @akshar5/skillsync
|
|
|
15
15
|
Connect this device to an existing private skills vault:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
skillsync setup --repo
|
|
18
|
+
skillsync setup --repo OWNER/skills
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
Add a local skill folder to the vault:
|
|
@@ -88,7 +88,7 @@ npm install -g @akshar5/skillsync
|
|
|
88
88
|
Connect to an existing vault:
|
|
89
89
|
|
|
90
90
|
```bash
|
|
91
|
-
skillsync setup --repo
|
|
91
|
+
skillsync setup --repo OWNER/skills
|
|
92
92
|
```
|
|
93
93
|
|
|
94
94
|
Or create/select a vault repo interactively:
|
|
@@ -99,7 +99,7 @@ skillsync setup
|
|
|
99
99
|
|
|
100
100
|
If you run plain `skillsync setup` in an interactive terminal, it first asks which vault type to use:
|
|
101
101
|
|
|
102
|
-
- Choose `Use an existing GitHub repo`, then enter a repo like `
|
|
102
|
+
- Choose `Use an existing GitHub repo`, then enter a repo like `OWNER/skills`.
|
|
103
103
|
- Choose `Create or use OWNER/<name>`, then enter a repo name like `skills`.
|
|
104
104
|
|
|
105
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.
|
|
@@ -107,7 +107,7 @@ If you run plain `skillsync setup` in an interactive terminal, it first asks whi
|
|
|
107
107
|
You can also run commands without a global install:
|
|
108
108
|
|
|
109
109
|
```bash
|
|
110
|
-
npx @akshar5/skillsync setup --repo
|
|
110
|
+
npx @akshar5/skillsync setup --repo OWNER/skills
|
|
111
111
|
npx @akshar5/skillsync add https://github.com/example-org/example-skill --skill example-skill
|
|
112
112
|
```
|
|
113
113
|
|
|
@@ -115,7 +115,7 @@ If an older SkillSync version failed with `git@github.com: Permission denied (pu
|
|
|
115
115
|
|
|
116
116
|
```bash
|
|
117
117
|
npm install -g @akshar5/skillsync@latest
|
|
118
|
-
skillsync setup --repo
|
|
118
|
+
skillsync setup --repo OWNER/skills
|
|
119
119
|
```
|
|
120
120
|
|
|
121
121
|
## Common workflows
|
|
@@ -134,6 +134,14 @@ skillsync
|
|
|
134
134
|
|
|
135
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
136
|
|
|
137
|
+
View skills installed on this device:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
skillsync installed
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
In the interactive UI, choose `Installed on this device` to see both SkillSync-managed installs and detected local skills. From there you can sync vault-backed skills or uninstall selected skills from the current device. Local-only detected folders require confirmation before SkillSync deletes them.
|
|
144
|
+
|
|
137
145
|
Check current vault/device state:
|
|
138
146
|
|
|
139
147
|
```bash
|
|
@@ -180,6 +188,8 @@ Sync the vault and reapply local links:
|
|
|
180
188
|
skillsync sync
|
|
181
189
|
```
|
|
182
190
|
|
|
191
|
+
If an installed skill changes in the vault, `skillsync sync` pulls the vault and reapplies local projections. Symlink targets point at the current vault copy automatically; copy targets are refreshed when links are reapplied. Vault skills that already exist locally but were not installed by SkillSync are shown as local vault-backed skills and checked in Browse/install. Sync them from the `Installed on this device` screen to replace the same-named local folder with the vault-managed projection.
|
|
192
|
+
|
|
183
193
|
Scan configured target folders for already-installed local skills:
|
|
184
194
|
|
|
185
195
|
```bash
|
|
@@ -195,6 +205,7 @@ skillsync setup --name skills
|
|
|
195
205
|
skillsync
|
|
196
206
|
skillsync status
|
|
197
207
|
skillsync list
|
|
208
|
+
skillsync installed
|
|
198
209
|
skillsync add <skill-folder-or-git-url> --skill <name>
|
|
199
210
|
skillsync add https://github.com/example-org/example-skill --skill example-skill
|
|
200
211
|
skillsync import hermes
|
|
@@ -214,9 +225,13 @@ skillsync daemon
|
|
|
214
225
|
- `skillsync uninstall <skill>` removes the skill from the current device only.
|
|
215
226
|
- `skillsync delete <skill>` removes the skill from the vault and all device manifests.
|
|
216
227
|
|
|
228
|
+
## Skill names
|
|
229
|
+
|
|
230
|
+
Skill names are vault-wide identifiers. Installing `my-skill` on two devices means both devices refer to the same vault skill. You can install the same skill on many devices, but you should not use the same name for two different skills in one vault; adding a skill with an existing name updates/replaces that vault entry.
|
|
231
|
+
|
|
217
232
|
## Detected versus managed skills
|
|
218
233
|
|
|
219
|
-
`
|
|
234
|
+
`managed` skills are SkillSync-owned projections into a target folder. `detected` skills are already present in a local agent's skill tree, such as bundled Hermes skills under `~/.hermes/skills` or skills you installed before setting up SkillSync.
|
|
220
235
|
|
|
221
236
|
For Hermes, use a separate scan path so SkillSync installs personal synced skills into `~/.hermes/skills/personal` while still showing the full Hermes skill inventory from `~/.hermes/skills`:
|
|
222
237
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akshar5/skillsync",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Local-first GitHub-backed skill vault sync client for AI agent skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"skillsync": "src/cli.js"
|
|
16
16
|
},
|
|
17
17
|
"files": [
|
|
18
|
+
"LICENSE",
|
|
18
19
|
"src",
|
|
19
20
|
"README.md"
|
|
20
21
|
],
|
package/src/cli.js
CHANGED
|
@@ -6,7 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
|
|
7
7
|
import { loadConfig, saveConfig, defaultRepoPath } from './core/config.js';
|
|
8
8
|
import { addTarget, applyLinks, defaultDeviceId, installSkill, listDevices, loadDevice, removeTarget, scanTargets, uninstallSkill } from './core/device.js';
|
|
9
|
-
import { ensureDir, exists, expandHome } from './core/fs.js';
|
|
9
|
+
import { ensureDir, exists, expandHome, removePath } from './core/fs.js';
|
|
10
10
|
import { cloneRepo, commandExists, commitAllIfChanged, gh, git, isGitRepo, push, run } from './core/git.js';
|
|
11
11
|
import { addSkillToVault, deleteSkillFromVault, ensureVault, loadRegistry, rebuildRegistry, refreshChangedRegistryEntries, validateSkillFolder } from './core/registry.js';
|
|
12
12
|
import { cloneSkillSource, discoverSkillFolders, isRemoteSkillSource, selectDiscoveredSkills } from './core/source.js';
|
|
@@ -34,6 +34,8 @@ async function main() {
|
|
|
34
34
|
return status();
|
|
35
35
|
case 'list':
|
|
36
36
|
return listSkills();
|
|
37
|
+
case 'installed':
|
|
38
|
+
return installedCommand();
|
|
37
39
|
case 'add':
|
|
38
40
|
return addSkill(rest);
|
|
39
41
|
case 'import':
|
|
@@ -102,6 +104,12 @@ async function promptWithEscape(promptPromise, escapeValue = null) {
|
|
|
102
104
|
}
|
|
103
105
|
}
|
|
104
106
|
|
|
107
|
+
function promptPageSize(itemCount, { min = 8, max = 28, reservedRows = 6 } = {}) {
|
|
108
|
+
const rows = Number(process.stdout.rows) || 30;
|
|
109
|
+
const availableRows = Math.max(min, rows - reservedRows);
|
|
110
|
+
return Math.max(1, Math.min(itemCount, max, availableRows));
|
|
111
|
+
}
|
|
112
|
+
|
|
105
113
|
async function configured() {
|
|
106
114
|
const config = await loadConfig();
|
|
107
115
|
if (!config.repoPath || !await exists(config.repoPath)) {
|
|
@@ -289,9 +297,90 @@ async function listSkills() {
|
|
|
289
297
|
console.log('No skills in vault yet. Add one with: skillsync add <skill-folder>');
|
|
290
298
|
return;
|
|
291
299
|
}
|
|
300
|
+
const localByName = new Map(localSkillEntries(device, registry).map((skill) => [skill.name, skill]));
|
|
292
301
|
for (const name of names) {
|
|
293
|
-
const
|
|
294
|
-
|
|
302
|
+
const localSkill = localByName.get(name);
|
|
303
|
+
const targetSummary = localSkill ? localSkillTargetSummary(localSkill) : '';
|
|
304
|
+
const state = localSkill?.managedTargets.length ? 'managed' : 'local';
|
|
305
|
+
console.log(`${targetSummary ? '✓' : '○'} ${name}${targetSummary ? ` [${state}: ${targetSummary}]` : ''}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function localSkillEntries(device, registry) {
|
|
310
|
+
const byName = new Map();
|
|
311
|
+
const entryFor = (name) => {
|
|
312
|
+
if (!byName.has(name)) {
|
|
313
|
+
byName.set(name, {
|
|
314
|
+
name,
|
|
315
|
+
managedTargets: new Set(),
|
|
316
|
+
detectedTargets: [],
|
|
317
|
+
inVault: Boolean(registry.skills[name]),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
return byName.get(name);
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
for (const [name, targets] of Object.entries(device.installed || {})) {
|
|
324
|
+
const entry = entryFor(name);
|
|
325
|
+
for (const target of Array.isArray(targets) ? targets : []) entry.managedTargets.add(target);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
for (const [targetName, skills] of Object.entries(device.detected || {})) {
|
|
329
|
+
if (!Array.isArray(skills)) continue;
|
|
330
|
+
for (const skill of skills) {
|
|
331
|
+
const entry = entryFor(skill.name);
|
|
332
|
+
entry.inVault = entry.inVault || Boolean(skill.in_vault) || Boolean(registry.skills[skill.name]);
|
|
333
|
+
entry.detectedTargets.push({
|
|
334
|
+
targetName,
|
|
335
|
+
path: skill.path,
|
|
336
|
+
inVault: Boolean(skill.in_vault) || Boolean(registry.skills[skill.name]),
|
|
337
|
+
managed: entry.managedTargets.has(targetName),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return Array.from(byName.values())
|
|
343
|
+
.map((entry) => ({
|
|
344
|
+
...entry,
|
|
345
|
+
managedTargets: [...entry.managedTargets].sort(),
|
|
346
|
+
detectedTargets: entry.detectedTargets.sort((a, b) => a.targetName.localeCompare(b.targetName) || a.path.localeCompare(b.path)),
|
|
347
|
+
}))
|
|
348
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function localSkillLabel(skill) {
|
|
352
|
+
const localTargets = [...new Set(skill.detectedTargets.map((target) => {
|
|
353
|
+
if (!target.path || target.path === skill.name) return target.targetName;
|
|
354
|
+
return `${target.targetName}/${target.path}`;
|
|
355
|
+
}))];
|
|
356
|
+
const parts = [];
|
|
357
|
+
if (skill.managedTargets.length) parts.push(`managed: ${skill.managedTargets.join(', ')}`);
|
|
358
|
+
if (localTargets.length) parts.push(`local: ${localTargets.join(', ')}`);
|
|
359
|
+
parts.push(skill.inVault ? 'in vault' : 'local only');
|
|
360
|
+
return `${skill.name} [${parts.join(' | ')}]`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function localSkillTargetSummary(skill) {
|
|
364
|
+
const targets = new Set([
|
|
365
|
+
...skill.managedTargets,
|
|
366
|
+
...skill.detectedTargets.map((target) => target.targetName),
|
|
367
|
+
]);
|
|
368
|
+
return [...targets].sort().join(', ');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function installedCommand() {
|
|
372
|
+
const config = await configured();
|
|
373
|
+
await refreshChangedRegistryEntries(config.repoPath);
|
|
374
|
+
const registry = await loadRegistry(config.repoPath);
|
|
375
|
+
const device = await loadDevice(config.repoPath, config.deviceId);
|
|
376
|
+
const localSkills = localSkillEntries(device, registry);
|
|
377
|
+
if (!localSkills.length) {
|
|
378
|
+
console.log('No local skills found on this device. Run `skillsync scan` to refresh detected local skills.');
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
console.log(`Local skills on ${device.display_name}:`);
|
|
382
|
+
for (const skill of localSkills) {
|
|
383
|
+
console.log(`- ${localSkillLabel(skill)}`);
|
|
295
384
|
}
|
|
296
385
|
}
|
|
297
386
|
|
|
@@ -458,7 +547,7 @@ async function chooseInstallTargets(config, rest) {
|
|
|
458
547
|
return promptWithEscape(checkbox({
|
|
459
548
|
message: 'Choose local targets',
|
|
460
549
|
loop: false,
|
|
461
|
-
pageSize:
|
|
550
|
+
pageSize: promptPageSize(available.length, { min: 6, max: 18 }),
|
|
462
551
|
choices: available.map((target) => ({ name: target, value: target, checked: true })),
|
|
463
552
|
instructions: 'Space toggles targets. Enter confirms. Esc cancels.',
|
|
464
553
|
}), []);
|
|
@@ -585,23 +674,26 @@ async function runUi() {
|
|
|
585
674
|
|
|
586
675
|
while (true) {
|
|
587
676
|
await refreshChangedRegistryEntries(config.repoPath);
|
|
677
|
+
const menuChoices = [
|
|
678
|
+
{ name: 'Browse/install skills', value: 'skills', description: 'Select multiple vault skills to install or remove here.' },
|
|
679
|
+
{ name: 'Installed on this device', value: 'installed', description: 'View, sync, or uninstall local skills on this device.' },
|
|
680
|
+
{ name: 'Devices', value: 'devices', description: 'Show devices known to the vault.' },
|
|
681
|
+
{ name: 'Targets', value: 'targets', description: 'Manage local agent skill folders.' },
|
|
682
|
+
{ name: 'Add skill from folder', value: 'add', description: 'Copy a local SKILL.md folder into the vault.' },
|
|
683
|
+
{ name: 'Import Hermes skills', value: 'import-hermes', description: 'Import detected Hermes skills into the vault.' },
|
|
684
|
+
{ name: 'Scan local targets', value: 'scan', description: 'Refresh detected local skills.' },
|
|
685
|
+
{ name: 'Sync now', value: 'sync', description: 'Pull, link, scan, commit, and push vault changes.' },
|
|
686
|
+
{ name: 'Quit', value: 'quit' },
|
|
687
|
+
];
|
|
588
688
|
const choice = await promptWithEscape(select({
|
|
589
689
|
message: 'SkillSync',
|
|
590
690
|
loop: false,
|
|
591
|
-
pageSize: 8,
|
|
592
|
-
choices:
|
|
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.' },
|
|
600
|
-
{ name: 'Quit', value: 'quit' },
|
|
601
|
-
],
|
|
691
|
+
pageSize: promptPageSize(menuChoices.length, { min: 8, max: 12 }),
|
|
692
|
+
choices: menuChoices,
|
|
602
693
|
}));
|
|
603
694
|
if (!choice || choice === 'quit') return;
|
|
604
695
|
if (choice === 'skills') await skillsScreen(config);
|
|
696
|
+
if (choice === 'installed') await installedScreen(config);
|
|
605
697
|
if (choice === 'devices') await devicesScreen(config);
|
|
606
698
|
if (choice === 'targets') await targetsScreen(config);
|
|
607
699
|
if (choice === 'add') await addSkill([await input({ message: 'Skill folder path:' })]);
|
|
@@ -619,29 +711,31 @@ async function skillsScreen(config) {
|
|
|
619
711
|
console.log('\nNo skills in vault yet. Use Add/import first.\n');
|
|
620
712
|
return;
|
|
621
713
|
}
|
|
622
|
-
const
|
|
714
|
+
const localByName = new Map(localSkillEntries(device, registry).map((skill) => [skill.name, skill]));
|
|
715
|
+
const localVaultNames = new Set([...localByName.values()].filter((skill) => skill.inVault).map((skill) => skill.name));
|
|
623
716
|
const selected = await promptWithEscape(checkbox({
|
|
624
717
|
message: `Install skills on ${device.display_name}`,
|
|
625
718
|
loop: false,
|
|
626
|
-
pageSize:
|
|
719
|
+
pageSize: promptPageSize(names.length, { min: 10, max: 32, reservedRows: 5 }),
|
|
627
720
|
instructions: 'Space toggles skills. Enter applies changes. Esc goes back.',
|
|
628
721
|
choices: names.map((name) => {
|
|
629
|
-
const
|
|
630
|
-
const
|
|
722
|
+
const localSkill = localByName.get(name);
|
|
723
|
+
const targetSummary = localSkill ? localSkillTargetSummary(localSkill) : '';
|
|
724
|
+
const state = localSkill?.managedTargets.length ? 'managed' : 'local';
|
|
725
|
+
const targetLabel = targetSummary ? ` [${state}: ${targetSummary}]` : '';
|
|
631
726
|
return {
|
|
632
727
|
name: `${name}${targetLabel}`,
|
|
633
728
|
short: name,
|
|
634
729
|
value: name,
|
|
635
|
-
checked:
|
|
636
|
-
description: targets.length ? `Installed in ${targets.join(', ')}` : 'Not installed on this device',
|
|
730
|
+
checked: localVaultNames.has(name),
|
|
637
731
|
};
|
|
638
732
|
}),
|
|
639
733
|
}));
|
|
640
734
|
if (!selected) return;
|
|
641
735
|
|
|
642
736
|
const selectedNames = new Set(selected);
|
|
643
|
-
const toInstall = names.filter((name) => selectedNames.has(name) && !
|
|
644
|
-
const toUninstall = names.filter((name) => !selectedNames.has(name) &&
|
|
737
|
+
const toInstall = names.filter((name) => selectedNames.has(name) && !localVaultNames.has(name));
|
|
738
|
+
const toUninstall = names.filter((name) => !selectedNames.has(name) && localVaultNames.has(name));
|
|
645
739
|
if (!toInstall.length && !toUninstall.length) {
|
|
646
740
|
console.log('\nNo install changes.\n');
|
|
647
741
|
return;
|
|
@@ -656,8 +750,16 @@ async function skillsScreen(config) {
|
|
|
656
750
|
for (const skillName of toInstall) {
|
|
657
751
|
await installSkill({ vaultPath: config.repoPath, deviceId: config.deviceId, skillName, targets });
|
|
658
752
|
}
|
|
659
|
-
|
|
660
|
-
|
|
753
|
+
const localToRemove = toUninstall.map((name) => localByName.get(name)).filter(Boolean);
|
|
754
|
+
if (localToRemove.some(hasUnmanagedDetectedTargets)) {
|
|
755
|
+
const confirmed = await confirm({
|
|
756
|
+
message: 'Remove selected local detected skill folders from this device?',
|
|
757
|
+
default: false,
|
|
758
|
+
});
|
|
759
|
+
if (!confirmed) return;
|
|
760
|
+
}
|
|
761
|
+
for (const skill of localToRemove) {
|
|
762
|
+
await uninstallLocalSkill({ config, device, skill });
|
|
661
763
|
}
|
|
662
764
|
await applyLinks({ vaultPath: config.repoPath, deviceId: config.deviceId });
|
|
663
765
|
await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false });
|
|
@@ -667,6 +769,173 @@ async function skillsScreen(config) {
|
|
|
667
769
|
console.log(`\n${[installedText, removedText].filter(Boolean).join(' ')}\n`);
|
|
668
770
|
}
|
|
669
771
|
|
|
772
|
+
async function installedScreen(config) {
|
|
773
|
+
await refreshChangedRegistryEntries(config.repoPath);
|
|
774
|
+
const registry = await loadRegistry(config.repoPath);
|
|
775
|
+
const device = await loadDevice(config.repoPath, config.deviceId);
|
|
776
|
+
const localSkills = localSkillEntries(device, registry);
|
|
777
|
+
if (!localSkills.length) {
|
|
778
|
+
await promptWithEscape(select({
|
|
779
|
+
message: `Local skills on ${device.display_name}`,
|
|
780
|
+
loop: false,
|
|
781
|
+
pageSize: 1,
|
|
782
|
+
choices: [
|
|
783
|
+
{ name: 'No local skills found. Run Scan local targets, then check again.', value: 'back' },
|
|
784
|
+
],
|
|
785
|
+
}));
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
const localByName = new Map(localSkills.map((skill) => [skill.name, skill]));
|
|
789
|
+
|
|
790
|
+
const selected = await promptWithEscape(checkbox({
|
|
791
|
+
message: `Local skills on ${device.display_name}`,
|
|
792
|
+
loop: false,
|
|
793
|
+
pageSize: promptPageSize(localSkills.length, { min: 8, max: 28, reservedRows: 5 }),
|
|
794
|
+
instructions: 'Space selects skills. Enter chooses an action. Esc goes back.',
|
|
795
|
+
choices: localSkills.map((skill) => ({
|
|
796
|
+
name: localSkillLabel(skill),
|
|
797
|
+
short: skill.name,
|
|
798
|
+
value: skill.name,
|
|
799
|
+
checked: false,
|
|
800
|
+
})),
|
|
801
|
+
}), []);
|
|
802
|
+
if (!selected.length) return;
|
|
803
|
+
|
|
804
|
+
const action = await promptWithEscape(select({
|
|
805
|
+
message: `Manage ${selected.length} selected skill${selected.length === 1 ? '' : 's'}`,
|
|
806
|
+
loop: false,
|
|
807
|
+
pageSize: 3,
|
|
808
|
+
choices: [
|
|
809
|
+
{ name: 'Sync selected from vault', value: 'update', description: 'Replace same-named local skills with the vault version.' },
|
|
810
|
+
{ name: 'Uninstall from this device', value: 'uninstall', description: 'Remove selected skills from this device only.' },
|
|
811
|
+
{ name: 'Back', value: 'back' },
|
|
812
|
+
],
|
|
813
|
+
}));
|
|
814
|
+
if (!action || action === 'back') return;
|
|
815
|
+
|
|
816
|
+
const selectedSkills = selected.map((name) => localByName.get(name)).filter(Boolean);
|
|
817
|
+
if (action === 'update') {
|
|
818
|
+
await updateLocalSkillsFromVault({ config, device, selectedSkills });
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
if (selectedSkills.some(hasUnmanagedDetectedTargets)) {
|
|
823
|
+
console.log('\nLocal-only detected folders are not SkillSync-managed. Removing them deletes those local skill folders from this device only.\n');
|
|
824
|
+
}
|
|
825
|
+
const confirmed = await confirm({
|
|
826
|
+
message: `Uninstall ${selected.join(', ')} from this device?`,
|
|
827
|
+
default: false,
|
|
828
|
+
});
|
|
829
|
+
if (!confirmed) return;
|
|
830
|
+
|
|
831
|
+
for (const skill of selectedSkills) {
|
|
832
|
+
await uninstallLocalSkill({ config, device, skill });
|
|
833
|
+
}
|
|
834
|
+
await applyLinks({ vaultPath: config.repoPath, deviceId: config.deviceId });
|
|
835
|
+
await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false });
|
|
836
|
+
console.log(`\nRemoved ${selected.join(', ')} from this device.\n`);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function hasUnmanagedDetectedTargets(skill) {
|
|
840
|
+
return skill.detectedTargets.some((target) => !target.managed);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function isPathInside(childPath, parentPath) {
|
|
844
|
+
const child = path.resolve(childPath);
|
|
845
|
+
const parent = path.resolve(parentPath);
|
|
846
|
+
return child === parent || child.startsWith(parent + path.sep);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function detectedTargetLocation(device, detectedTarget) {
|
|
850
|
+
const targetConfig = device.targets?.[detectedTarget.targetName];
|
|
851
|
+
if (!targetConfig) return { removable: false, reason: 'target is no longer configured' };
|
|
852
|
+
|
|
853
|
+
const scanRoot = path.resolve(expandHome(targetConfig.scan_path || targetConfig.path));
|
|
854
|
+
const installRoot = path.resolve(expandHome(targetConfig.path));
|
|
855
|
+
const absolutePath = path.resolve(scanRoot, detectedTarget.path || '.');
|
|
856
|
+
if (!isPathInside(absolutePath, scanRoot)) {
|
|
857
|
+
return { removable: false, reason: 'detected path is outside the scan path' };
|
|
858
|
+
}
|
|
859
|
+
if (absolutePath === installRoot || !isPathInside(absolutePath, installRoot)) {
|
|
860
|
+
return { removable: false, reason: 'detected path is outside the configured install folder' };
|
|
861
|
+
}
|
|
862
|
+
return { removable: true, absolutePath };
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function removableDetectedTargets(device, skill, { onlyInVault = false } = {}) {
|
|
866
|
+
return skill.detectedTargets
|
|
867
|
+
.filter((target) => !target.managed)
|
|
868
|
+
.filter((target) => !onlyInVault || target.inVault)
|
|
869
|
+
.map((target) => ({ ...target, ...detectedTargetLocation(device, target) }));
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
async function removeDetectedTargetPaths(targets) {
|
|
873
|
+
const removed = [];
|
|
874
|
+
const skipped = [];
|
|
875
|
+
const seen = new Set();
|
|
876
|
+
for (const target of targets) {
|
|
877
|
+
if (!target.removable) {
|
|
878
|
+
skipped.push(target);
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
if (seen.has(target.absolutePath)) continue;
|
|
882
|
+
seen.add(target.absolutePath);
|
|
883
|
+
if (!await exists(path.join(target.absolutePath, 'SKILL.md'))) {
|
|
884
|
+
skipped.push({ ...target, reason: 'SKILL.md was not found' });
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
await removePath(target.absolutePath);
|
|
888
|
+
removed.push(target);
|
|
889
|
+
}
|
|
890
|
+
return { removed, skipped };
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
async function uninstallLocalSkill({ config, device, skill }) {
|
|
894
|
+
await uninstallSkill({ vaultPath: config.repoPath, deviceId: config.deviceId, skillName: skill.name });
|
|
895
|
+
return removeDetectedTargetPaths(removableDetectedTargets(device, skill));
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
async function updateLocalSkillsFromVault({ config, device, selectedSkills }) {
|
|
899
|
+
const updatable = selectedSkills.filter((skill) => skill.inVault);
|
|
900
|
+
const skipped = selectedSkills.filter((skill) => !skill.inVault).map((skill) => skill.name);
|
|
901
|
+
if (!updatable.length) {
|
|
902
|
+
console.log(`\nNo selected skills are in the vault.${skipped.length ? ` Skipped: ${skipped.join(', ')}.` : ''}\n`);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: true });
|
|
907
|
+
|
|
908
|
+
const replacements = updatable.flatMap((skill) => removableDetectedTargets(device, skill, { onlyInVault: true }).filter((target) => target.removable));
|
|
909
|
+
if (replacements.length) {
|
|
910
|
+
const confirmed = await confirm({
|
|
911
|
+
message: `Replace ${replacements.length} local detected skill folder${replacements.length === 1 ? '' : 's'} with vault-managed links/copies?`,
|
|
912
|
+
default: false,
|
|
913
|
+
});
|
|
914
|
+
if (!confirmed) return;
|
|
915
|
+
await removeDetectedTargetPaths(replacements);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
for (const skill of updatable) {
|
|
919
|
+
const targets = new Set(skill.managedTargets);
|
|
920
|
+
for (const detectedTarget of skill.detectedTargets) {
|
|
921
|
+
const location = detectedTargetLocation(device, detectedTarget);
|
|
922
|
+
if (detectedTarget.inVault && location.removable) targets.add(detectedTarget.targetName);
|
|
923
|
+
}
|
|
924
|
+
if (targets.size) {
|
|
925
|
+
await installSkill({
|
|
926
|
+
vaultPath: config.repoPath,
|
|
927
|
+
deviceId: config.deviceId,
|
|
928
|
+
skillName: skill.name,
|
|
929
|
+
targets: [...targets].sort(),
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
await applyLinks({ vaultPath: config.repoPath, deviceId: config.deviceId });
|
|
935
|
+
await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false });
|
|
936
|
+
console.log(`\nUpdated from vault: ${updatable.map((skill) => skill.name).join(', ')}.${skipped.length ? ` Skipped local-only: ${skipped.join(', ')}.` : ''}\n`);
|
|
937
|
+
}
|
|
938
|
+
|
|
670
939
|
async function chooseTargets(config) {
|
|
671
940
|
const device = await loadDevice(config.repoPath, config.deviceId);
|
|
672
941
|
const targetNames = Object.keys(device.targets);
|
|
@@ -674,7 +943,7 @@ async function chooseTargets(config) {
|
|
|
674
943
|
return promptWithEscape(checkbox({
|
|
675
944
|
message: 'Install into which targets?',
|
|
676
945
|
loop: false,
|
|
677
|
-
pageSize:
|
|
946
|
+
pageSize: promptPageSize(targetNames.length, { min: 6, max: 18 }),
|
|
678
947
|
choices: targetNames.map((name) => ({ name, value: name, checked: true })),
|
|
679
948
|
required: true,
|
|
680
949
|
instructions: 'Space toggles targets. Enter confirms. Esc cancels.',
|
|
@@ -683,14 +952,22 @@ async function chooseTargets(config) {
|
|
|
683
952
|
|
|
684
953
|
async function devicesScreen(config) {
|
|
685
954
|
const devices = await listDevices(config.repoPath);
|
|
686
|
-
|
|
687
|
-
for (const device of devices) {
|
|
955
|
+
const choices = devices.map((device) => {
|
|
688
956
|
const installed = Object.keys(device.installed || {}).length;
|
|
689
957
|
const detected = countDetectedSkills(device);
|
|
690
958
|
const targets = Object.keys(device.targets || {}).join(', ') || 'no targets';
|
|
691
|
-
|
|
692
|
-
}
|
|
693
|
-
|
|
959
|
+
return {
|
|
960
|
+
name: `${device.display_name} (${device.device_id}) [${detected} local, ${installed} managed]`,
|
|
961
|
+
value: device.device_id,
|
|
962
|
+
description: `${targets} — last seen ${device.last_seen || 'never'}`,
|
|
963
|
+
};
|
|
964
|
+
}).concat([{ name: 'Back', value: 'back' }]);
|
|
965
|
+
await promptWithEscape(select({
|
|
966
|
+
message: 'Devices',
|
|
967
|
+
choices,
|
|
968
|
+
loop: false,
|
|
969
|
+
pageSize: promptPageSize(choices.length, { min: 6, max: 18 }),
|
|
970
|
+
}));
|
|
694
971
|
}
|
|
695
972
|
|
|
696
973
|
async function targetsScreen(config) {
|
|
@@ -702,7 +979,12 @@ async function targetsScreen(config) {
|
|
|
702
979
|
{ name: 'Add target', value: 'add' },
|
|
703
980
|
{ name: 'Back', value: 'back' },
|
|
704
981
|
]);
|
|
705
|
-
const choice = await promptWithEscape(select({
|
|
982
|
+
const choice = await promptWithEscape(select({
|
|
983
|
+
message: 'Targets on this device',
|
|
984
|
+
choices,
|
|
985
|
+
loop: false,
|
|
986
|
+
pageSize: promptPageSize(choices.length, { min: 6, max: 18 }),
|
|
987
|
+
}));
|
|
706
988
|
if (!choice || choice === 'back') return;
|
|
707
989
|
if (choice === 'add') {
|
|
708
990
|
const name = await input({ message: 'Target name (codex, claude, hermes, custom):' });
|
|
@@ -716,5 +998,5 @@ async function targetsScreen(config) {
|
|
|
716
998
|
}
|
|
717
999
|
|
|
718
1000
|
function help() {
|
|
719
|
-
console.log(`SkillSync\n\nUsage:\n skillsync setup [--name skills] [--repo owner/repo|url]\n skillsync Open TUI\n skillsync list\n skillsync add <skill-folder-or-git-url> [--skill name] [--target target]\n skillsync import hermes\n skillsync install <skill> [--target codex,claude]\n skillsync uninstall <skill>\n skillsync delete <skill>\n skillsync target add <name> <path> [--mode symlink|copy] [--scan-path path]\n skillsync scan\n skillsync sync\n skillsync service install\n skillsync daemon\n`);
|
|
1001
|
+
console.log(`SkillSync\n\nUsage:\n skillsync setup [--name skills] [--repo owner/repo|url]\n skillsync Open TUI\n skillsync list\n skillsync installed\n skillsync add <skill-folder-or-git-url> [--skill name] [--target target]\n skillsync import hermes\n skillsync install <skill> [--target codex,claude]\n skillsync uninstall <skill>\n skillsync delete <skill>\n skillsync target add <name> <path> [--mode symlink|copy] [--scan-path path]\n skillsync scan\n skillsync sync\n skillsync service install\n skillsync daemon\n`);
|
|
720
1002
|
}
|