@akshar5/skillsync 0.1.2 → 0.2.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +30 -5
  3. package/package.json +2 -1
  4. package/src/cli.js +194 -41
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:
@@ -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
@@ -86,7 +88,7 @@ npm install -g @akshar5/skillsync
86
88
  Connect to an existing vault:
87
89
 
88
90
  ```bash
89
- skillsync setup --repo AksharP5/skills
91
+ skillsync setup --repo OWNER/skills
90
92
  ```
91
93
 
92
94
  Or create/select a vault repo interactively:
@@ -97,7 +99,7 @@ skillsync setup
97
99
 
98
100
  If you run plain `skillsync setup` in an interactive terminal, it first asks which vault type to use:
99
101
 
100
- - 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`.
101
103
  - Choose `Create or use OWNER/<name>`, then enter a repo name like `skills`.
102
104
 
103
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.
@@ -105,7 +107,7 @@ If you run plain `skillsync setup` in an interactive terminal, it first asks whi
105
107
  You can also run commands without a global install:
106
108
 
107
109
  ```bash
108
- npx @akshar5/skillsync setup --repo AksharP5/skills
110
+ npx @akshar5/skillsync setup --repo OWNER/skills
109
111
  npx @akshar5/skillsync add https://github.com/example-org/example-skill --skill example-skill
110
112
  ```
111
113
 
@@ -113,7 +115,7 @@ If an older SkillSync version failed with `git@github.com: Permission denied (pu
113
115
 
114
116
  ```bash
115
117
  npm install -g @akshar5/skillsync@latest
116
- skillsync setup --repo AksharP5/skills
118
+ skillsync setup --repo OWNER/skills
117
119
  ```
118
120
 
119
121
  ## Common workflows
@@ -124,6 +126,22 @@ 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
+
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 select installed skills, update them from the vault, or uninstall them from the current device.
144
+
127
145
  Check current vault/device state:
128
146
 
129
147
  ```bash
@@ -170,6 +188,8 @@ Sync the vault and reapply local links:
170
188
  skillsync sync
171
189
  ```
172
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.
192
+
173
193
  Scan configured target folders for already-installed local skills:
174
194
 
175
195
  ```bash
@@ -185,6 +205,7 @@ skillsync setup --name skills
185
205
  skillsync
186
206
  skillsync status
187
207
  skillsync list
208
+ skillsync installed
188
209
  skillsync add <skill-folder-or-git-url> --skill <name>
189
210
  skillsync add https://github.com/example-org/example-skill --skill example-skill
190
211
  skillsync import hermes
@@ -204,6 +225,10 @@ skillsync daemon
204
225
  - `skillsync uninstall <skill>` removes the skill from the current device only.
205
226
  - `skillsync delete <skill>` removes the skill from the vault and all device manifests.
206
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
+
207
232
  ## Detected versus managed skills
208
233
 
209
234
  `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`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akshar5/skillsync",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
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
@@ -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':
@@ -84,6 +86,30 @@ function hasFlag(rest, flag) {
84
86
  return rest.includes(flag);
85
87
  }
86
88
 
89
+ async function promptWithEscape(promptPromise, escapeValue = null) {
90
+ if (!process.stdin.isTTY) return promptPromise;
91
+ const onKeypress = (_value, key) => {
92
+ if (key?.name === 'escape' && typeof promptPromise.cancel === 'function') {
93
+ promptPromise.cancel();
94
+ }
95
+ };
96
+ process.stdin.on('keypress', onKeypress);
97
+ try {
98
+ return await promptPromise;
99
+ } catch (error) {
100
+ if (error?.name === 'CancelPromptError') return escapeValue;
101
+ throw error;
102
+ } finally {
103
+ process.stdin.off('keypress', onKeypress);
104
+ }
105
+ }
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
+
87
113
  async function configured() {
88
114
  const config = await loadConfig();
89
115
  if (!config.repoPath || !await exists(config.repoPath)) {
@@ -142,13 +168,16 @@ async function setup(rest) {
142
168
  if (nameArg || yes || !process.stdin.isTTY) {
143
169
  repo = await ensureOwnedVaultRepo(owner, nameArg || 'skills');
144
170
  } else {
145
- const setupMode = await select({
171
+ const setupMode = await promptWithEscape(select({
146
172
  message: 'Which skills vault do you want to use?',
173
+ loop: false,
174
+ pageSize: 2,
147
175
  choices: [
148
176
  { name: 'Use an existing GitHub repo', value: 'existing' },
149
177
  { name: `Create or use ${owner}/<name>`, value: 'owned' },
150
178
  ],
151
- });
179
+ }));
180
+ if (!setupMode) return;
152
181
  if (setupMode === 'existing') {
153
182
  const existingRepo = await input({ message: 'Existing vault repo (owner/repo or URL):', default: `${owner}/skills` });
154
183
  repo = await resolveRepoCloneUrl(existingRepo);
@@ -274,6 +303,37 @@ async function listSkills() {
274
303
  }
275
304
  }
276
305
 
306
+ function installedSkillEntries(device, registry) {
307
+ return Object.entries(device.installed || {})
308
+ .map(([name, targets]) => ({
309
+ name,
310
+ targets: Array.isArray(targets) ? targets : [],
311
+ inVault: Boolean(registry.skills[name]),
312
+ }))
313
+ .sort((a, b) => a.name.localeCompare(b.name));
314
+ }
315
+
316
+ function installedSkillLabel(skill) {
317
+ const targetLabel = skill.targets.length ? skill.targets.join(', ') : 'no targets';
318
+ return `${skill.name} [${targetLabel}]${skill.inVault ? '' : ' missing from vault'}`;
319
+ }
320
+
321
+ async function installedCommand() {
322
+ const config = await configured();
323
+ await refreshChangedRegistryEntries(config.repoPath);
324
+ const registry = await loadRegistry(config.repoPath);
325
+ const device = await loadDevice(config.repoPath, config.deviceId);
326
+ const installed = installedSkillEntries(device, registry);
327
+ if (!installed.length) {
328
+ console.log('No SkillSync-managed skills installed on this device.');
329
+ return;
330
+ }
331
+ console.log(`Installed on ${device.display_name}:`);
332
+ for (const skill of installed) {
333
+ console.log(`- ${installedSkillLabel(skill)}`);
334
+ }
335
+ }
336
+
277
337
  async function addSkill(rest) {
278
338
  const source = rest[0];
279
339
  if (!source) throw new Error('Usage: skillsync add <skill-folder-or-git-url> [--skill name] [--target target]');
@@ -434,10 +494,13 @@ async function chooseInstallTargets(config, rest) {
434
494
  if (hasFlag(rest, '--no-install') || hasFlag(rest, '--yes') || hasFlag(rest, '-y') || !process.stdin.isTTY || !available.length) return [];
435
495
  const shouldInstall = await confirm({ message: 'Install on this device now?', default: true });
436
496
  if (!shouldInstall) return [];
437
- return checkbox({
497
+ return promptWithEscape(checkbox({
438
498
  message: 'Choose local targets',
499
+ loop: false,
500
+ pageSize: promptPageSize(available.length, { min: 6, max: 18 }),
439
501
  choices: available.map((target) => ({ name: target, value: target, checked: true })),
440
- });
502
+ instructions: 'Space toggles targets. Enter confirms. Esc cancels.',
503
+ }), []);
441
504
  }
442
505
 
443
506
  async function target(rest) {
@@ -561,21 +624,26 @@ async function runUi() {
561
624
 
562
625
  while (true) {
563
626
  await refreshChangedRegistryEntries(config.repoPath);
564
- const choice = await select({
627
+ const menuChoices = [
628
+ { name: 'Browse/install skills', value: 'skills', description: 'Select multiple vault skills to install or remove here.' },
629
+ { name: 'Installed on this device', value: 'installed', description: 'View, update, or uninstall SkillSync-managed local skills.' },
630
+ { name: 'Devices', value: 'devices', description: 'Show devices known to the vault.' },
631
+ { name: 'Targets', value: 'targets', description: 'Manage local agent skill folders.' },
632
+ { name: 'Add skill from folder', value: 'add', description: 'Copy a local SKILL.md folder into the vault.' },
633
+ { name: 'Import Hermes skills', value: 'import-hermes', description: 'Import detected Hermes skills into the vault.' },
634
+ { name: 'Scan local targets', value: 'scan', description: 'Refresh detected local skills.' },
635
+ { name: 'Sync now', value: 'sync', description: 'Pull, link, scan, commit, and push vault changes.' },
636
+ { name: 'Quit', value: 'quit' },
637
+ ];
638
+ const choice = await promptWithEscape(select({
565
639
  message: 'SkillSync',
566
- 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' },
574
- { name: 'Quit', value: 'quit' },
575
- ],
576
- });
577
- if (choice === 'quit') return;
640
+ loop: false,
641
+ pageSize: promptPageSize(menuChoices.length, { min: 8, max: 12 }),
642
+ choices: menuChoices,
643
+ }));
644
+ if (!choice || choice === 'quit') return;
578
645
  if (choice === 'skills') await skillsScreen(config);
646
+ if (choice === 'installed') await installedScreen(config);
579
647
  if (choice === 'devices') await devicesScreen(config);
580
648
  if (choice === 'targets') await targetsScreen(config);
581
649
  if (choice === 'add') await addSkill([await input({ message: 'Skill folder path:' })]);
@@ -593,41 +661,121 @@ async function skillsScreen(config) {
593
661
  console.log('\nNo skills in vault yet. Use Add/import first.\n');
594
662
  return;
595
663
  }
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,
664
+ const installedNames = new Set(Object.keys(device.installed || {}));
665
+ const selected = await promptWithEscape(checkbox({
666
+ message: `Install skills on ${device.display_name}`,
667
+ loop: false,
668
+ pageSize: promptPageSize(names.length, { min: 10, max: 32, reservedRows: 5 }),
669
+ instructions: 'Space toggles skills. Enter applies changes. Esc goes back.',
670
+ choices: names.map((name) => {
671
+ const targets = device.installed[name] || [];
672
+ const targetLabel = targets.length ? ` [${targets.join(', ')}]` : '';
673
+ return {
674
+ name: `${name}${targetLabel}`,
675
+ short: name,
676
+ value: name,
677
+ checked: installedNames.has(name),
678
+ };
679
+ }),
680
+ }));
681
+ if (!selected) return;
682
+
683
+ const selectedNames = new Set(selected);
684
+ const toInstall = names.filter((name) => selectedNames.has(name) && !installedNames.has(name));
685
+ const toUninstall = names.filter((name) => !selectedNames.has(name) && installedNames.has(name));
686
+ if (!toInstall.length && !toUninstall.length) {
687
+ console.log('\nNo install changes.\n');
688
+ return;
689
+ }
690
+
691
+ let targets = [];
692
+ if (toInstall.length) {
693
+ targets = await chooseTargets(config);
694
+ if (!targets.length) return;
695
+ }
696
+
697
+ for (const skillName of toInstall) {
698
+ await installSkill({ vaultPath: config.repoPath, deviceId: config.deviceId, skillName, targets });
699
+ }
700
+ for (const skillName of toUninstall) {
701
+ await uninstallSkill({ vaultPath: config.repoPath, deviceId: config.deviceId, skillName });
702
+ }
703
+ await applyLinks({ vaultPath: config.repoPath, deviceId: config.deviceId });
704
+ await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false });
705
+
706
+ const installedText = toInstall.length ? `Installed ${toInstall.join(', ')} to ${targets.join(', ')}.` : '';
707
+ const removedText = toUninstall.length ? `Removed ${toUninstall.join(', ')} from this device.` : '';
708
+ console.log(`\n${[installedText, removedText].filter(Boolean).join(' ')}\n`);
709
+ }
710
+
711
+ async function installedScreen(config) {
712
+ await refreshChangedRegistryEntries(config.repoPath);
713
+ const registry = await loadRegistry(config.repoPath);
714
+ const device = await loadDevice(config.repoPath, config.deviceId);
715
+ const installed = installedSkillEntries(device, registry);
716
+ if (!installed.length) {
717
+ console.log('\nNo SkillSync-managed skills installed on this device.\n');
718
+ return;
719
+ }
720
+
721
+ const selected = await promptWithEscape(checkbox({
722
+ message: `Installed skills on ${device.display_name}`,
723
+ loop: false,
724
+ pageSize: promptPageSize(installed.length, { min: 8, max: 28, reservedRows: 5 }),
725
+ instructions: 'Space selects skills. Enter chooses an action. Esc goes back.',
726
+ choices: installed.map((skill) => ({
727
+ name: installedSkillLabel(skill),
728
+ short: skill.name,
729
+ value: skill.name,
730
+ checked: false,
731
+ })),
732
+ }), []);
733
+ if (!selected.length) return;
734
+
735
+ const action = await promptWithEscape(select({
736
+ message: `Manage ${selected.length} selected skill${selected.length === 1 ? '' : 's'}`,
737
+ loop: false,
738
+ pageSize: 3,
606
739
  choices: [
607
- { name: device.installed[skill] ? 'Remove from this device' : 'Install on this device', value: 'toggle' },
608
- { name: 'Delete from vault', value: 'delete' },
740
+ { name: 'Update from vault', value: 'update', description: 'Pull the vault and reapply local installed links.' },
741
+ { name: 'Uninstall from this device', value: 'uninstall', description: 'Remove selected skills from this device only.' },
609
742
  { name: 'Back', value: 'back' },
610
743
  ],
744
+ }));
745
+ if (!action || action === 'back') return;
746
+
747
+ if (action === 'update') {
748
+ await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: true });
749
+ console.log(`\nUpdated installed skills from the vault. Requested: ${selected.join(', ')}.\n`);
750
+ return;
751
+ }
752
+
753
+ const confirmed = await confirm({
754
+ message: `Uninstall ${selected.join(', ')} from this device?`,
755
+ default: false,
611
756
  });
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
- }
757
+ if (!confirmed) return;
758
+
759
+ for (const skillName of selected) {
760
+ await uninstallSkill({ vaultPath: config.repoPath, deviceId: config.deviceId, skillName });
618
761
  }
619
- if (action === 'delete') await deleteSkill([skill]);
762
+ await applyLinks({ vaultPath: config.repoPath, deviceId: config.deviceId });
763
+ await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false });
764
+ console.log(`\nRemoved ${selected.join(', ')} from this device.\n`);
620
765
  }
621
766
 
622
767
  async function chooseTargets(config) {
623
768
  const device = await loadDevice(config.repoPath, config.deviceId);
624
769
  const targetNames = Object.keys(device.targets);
625
770
  if (!targetNames.length) throw new Error('No targets configured. Add one from the Targets screen.');
626
- return checkbox({
771
+ return promptWithEscape(checkbox({
627
772
  message: 'Install into which targets?',
773
+ loop: false,
774
+ pageSize: promptPageSize(targetNames.length, { min: 6, max: 18 }),
628
775
  choices: targetNames.map((name) => ({ name, value: name, checked: true })),
629
776
  required: true,
630
- });
777
+ instructions: 'Space toggles targets. Enter confirms. Esc cancels.',
778
+ }), []);
631
779
  }
632
780
 
633
781
  async function devicesScreen(config) {
@@ -651,8 +799,13 @@ async function targetsScreen(config) {
651
799
  { name: 'Add target', value: 'add' },
652
800
  { name: 'Back', value: 'back' },
653
801
  ]);
654
- const choice = await select({ message: 'Targets on this device', choices });
655
- if (choice === 'back') return;
802
+ const choice = await promptWithEscape(select({
803
+ message: 'Targets on this device',
804
+ choices,
805
+ loop: false,
806
+ pageSize: promptPageSize(choices.length, { min: 6, max: 18 }),
807
+ }));
808
+ if (!choice || choice === 'back') return;
656
809
  if (choice === 'add') {
657
810
  const name = await input({ message: 'Target name (codex, claude, hermes, custom):' });
658
811
  const targetPath = await input({ message: 'Target skill directory path:' });
@@ -665,5 +818,5 @@ async function targetsScreen(config) {
665
818
  }
666
819
 
667
820
  function help() {
668
- 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`);
821
+ 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`);
669
822
  }