@akshar5/skillsync 0.1.1 → 0.1.3

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