@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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +21 -6
  3. package/package.json +2 -1
  4. 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 AksharP5/skills
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 AksharP5/skills
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 `AksharP5/skills`.
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 AksharP5/skills
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 AksharP5/skills
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
- `installed` skills are SkillSync-managed 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`.
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",
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 targets = device.installed[name]?.join(', ');
294
- console.log(`${targets ? '✓' : '○'} ${name}${targets ? ` [${targets}]` : ''}`);
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: Math.min(12, available.length),
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 installedNames = new Set(Object.keys(device.installed || {}));
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: Math.min(14, Math.max(7, names.length)),
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 targets = device.installed[name] || [];
630
- const targetLabel = targets.length ? ` [${targets.join(', ')}]` : '';
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: installedNames.has(name),
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) && !installedNames.has(name));
644
- const toUninstall = names.filter((name) => !selectedNames.has(name) && installedNames.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
- for (const skillName of toUninstall) {
660
- await uninstallSkill({ vaultPath: config.repoPath, deviceId: config.deviceId, skillName });
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: Math.min(12, targetNames.length),
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
- console.log('\nDevices');
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
- console.log(`- ${device.display_name} (${device.device_id}) — ${detected} local, ${installed} managed — ${targets} — last seen ${device.last_seen || 'never'}`);
692
- }
693
- console.log('');
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({ message: 'Targets on this device', choices, loop: false, pageSize: Math.min(10, choices.length) }));
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
  }