@akshar5/skillsync 0.1.2 → 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.
Files changed (3) hide show
  1. package/README.md +10 -0
  2. package/package.json +1 -1
  3. package/src/cli.js +92 -41
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
@@ -124,6 +126,14 @@ List available skills:
124
126
  skillsync list
125
127
  ```
126
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
+
127
137
  Check current vault/device state:
128
138
 
129
139
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akshar5/skillsync",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Local-first GitHub-backed skill vault sync client for AI agent skills.",
5
5
  "type": "module",
6
6
  "repository": {
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)) {
@@ -142,13 +160,16 @@ async function setup(rest) {
142
160
  if (nameArg || yes || !process.stdin.isTTY) {
143
161
  repo = await ensureOwnedVaultRepo(owner, nameArg || 'skills');
144
162
  } else {
145
- const setupMode = await select({
163
+ const setupMode = await promptWithEscape(select({
146
164
  message: 'Which skills vault do you want to use?',
165
+ loop: false,
166
+ pageSize: 2,
147
167
  choices: [
148
168
  { name: 'Use an existing GitHub repo', value: 'existing' },
149
169
  { name: `Create or use ${owner}/<name>`, value: 'owned' },
150
170
  ],
151
- });
171
+ }));
172
+ if (!setupMode) return;
152
173
  if (setupMode === 'existing') {
153
174
  const existingRepo = await input({ message: 'Existing vault repo (owner/repo or URL):', default: `${owner}/skills` });
154
175
  repo = await resolveRepoCloneUrl(existingRepo);
@@ -434,10 +455,13 @@ async function chooseInstallTargets(config, rest) {
434
455
  if (hasFlag(rest, '--no-install') || hasFlag(rest, '--yes') || hasFlag(rest, '-y') || !process.stdin.isTTY || !available.length) return [];
435
456
  const shouldInstall = await confirm({ message: 'Install on this device now?', default: true });
436
457
  if (!shouldInstall) return [];
437
- return checkbox({
458
+ return promptWithEscape(checkbox({
438
459
  message: 'Choose local targets',
460
+ loop: false,
461
+ pageSize: Math.min(12, available.length),
439
462
  choices: available.map((target) => ({ name: target, value: target, checked: true })),
440
- });
463
+ instructions: 'Space toggles targets. Enter confirms. Esc cancels.',
464
+ }), []);
441
465
  }
442
466
 
443
467
  async function target(rest) {
@@ -561,20 +585,22 @@ async function runUi() {
561
585
 
562
586
  while (true) {
563
587
  await refreshChangedRegistryEntries(config.repoPath);
564
- const choice = await select({
588
+ const choice = await promptWithEscape(select({
565
589
  message: 'SkillSync',
590
+ loop: false,
591
+ pageSize: 8,
566
592
  choices: [
567
- { name: 'Browse/install skills', value: 'skills' },
568
- { name: 'Devices', value: 'devices' },
569
- { name: 'Targets', value: 'targets' },
570
- { name: 'Add skill from folder', value: 'add' },
571
- { name: 'Import Hermes skills', value: 'import-hermes' },
572
- { name: 'Scan local targets', value: 'scan' },
573
- { 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.' },
574
600
  { name: 'Quit', value: 'quit' },
575
601
  ],
576
- });
577
- if (choice === 'quit') return;
602
+ }));
603
+ if (!choice || choice === 'quit') return;
578
604
  if (choice === 'skills') await skillsScreen(config);
579
605
  if (choice === 'devices') await devicesScreen(config);
580
606
  if (choice === 'targets') await targetsScreen(config);
@@ -593,41 +619,66 @@ async function skillsScreen(config) {
593
619
  console.log('\nNo skills in vault yet. Use Add/import first.\n');
594
620
  return;
595
621
  }
596
- const skill = await select({
597
- message: `Available skills on ${device.display_name}`,
598
- choices: names.map((name) => ({
599
- name: `${device.installed[name] ? '✓' : '○'} ${name}${device.installed[name] ? ` [${device.installed[name].join(', ')}]` : ''}`,
600
- value: name,
601
- })).concat([{ name: 'Back', value: null }]),
602
- });
603
- if (!skill) return;
604
- const action = await select({
605
- message: skill,
606
- choices: [
607
- { name: device.installed[skill] ? 'Remove from this device' : 'Install on this device', value: 'toggle' },
608
- { name: 'Delete from vault', value: 'delete' },
609
- { name: 'Back', value: 'back' },
610
- ],
611
- });
612
- if (action === 'toggle') {
613
- if (device.installed[skill]) await uninstall([skill]);
614
- else {
615
- const targets = await chooseTargets(config);
616
- await install([skill, '--target', targets.join(',')]);
617
- }
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;
648
+ }
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 });
618
658
  }
619
- if (action === 'delete') await deleteSkill([skill]);
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`);
620
668
  }
621
669
 
622
670
  async function chooseTargets(config) {
623
671
  const device = await loadDevice(config.repoPath, config.deviceId);
624
672
  const targetNames = Object.keys(device.targets);
625
673
  if (!targetNames.length) throw new Error('No targets configured. Add one from the Targets screen.');
626
- return checkbox({
674
+ return promptWithEscape(checkbox({
627
675
  message: 'Install into which targets?',
676
+ loop: false,
677
+ pageSize: Math.min(12, targetNames.length),
628
678
  choices: targetNames.map((name) => ({ name, value: name, checked: true })),
629
679
  required: true,
630
- });
680
+ instructions: 'Space toggles targets. Enter confirms. Esc cancels.',
681
+ }), []);
631
682
  }
632
683
 
633
684
  async function devicesScreen(config) {
@@ -651,8 +702,8 @@ async function targetsScreen(config) {
651
702
  { name: 'Add target', value: 'add' },
652
703
  { name: 'Back', value: 'back' },
653
704
  ]);
654
- const choice = await select({ message: 'Targets on this device', choices });
655
- 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;
656
707
  if (choice === 'add') {
657
708
  const name = await input({ message: 'Target name (codex, claude, hermes, custom):' });
658
709
  const targetPath = await input({ message: 'Target skill directory path:' });