@akshar5/skillsync 0.2.0 → 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 (3) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/src/cli.js +225 -45
package/README.md CHANGED
@@ -140,7 +140,7 @@ View skills installed on this device:
140
140
  skillsync installed
141
141
  ```
142
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.
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
144
 
145
145
  Check current vault/device state:
146
146
 
@@ -188,7 +188,7 @@ Sync the vault and reapply local links:
188
188
  skillsync sync
189
189
  ```
190
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.
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
192
 
193
193
  Scan configured target folders for already-installed local skills:
194
194
 
@@ -231,7 +231,7 @@ Skill names are vault-wide identifiers. Installing `my-skill` on two devices mea
231
231
 
232
232
  ## Detected versus managed skills
233
233
 
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`.
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.
235
235
 
236
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`:
237
237
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akshar5/skillsync",
3
- "version": "0.2.0",
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": {
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';
@@ -297,25 +297,75 @@ async function listSkills() {
297
297
  console.log('No skills in vault yet. Add one with: skillsync add <skill-folder>');
298
298
  return;
299
299
  }
300
+ const localByName = new Map(localSkillEntries(device, registry).map((skill) => [skill.name, skill]));
300
301
  for (const name of names) {
301
- const targets = device.installed[name]?.join(', ');
302
- 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
+ }
303
340
  }
304
- }
305
341
 
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]),
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)),
312
347
  }))
313
348
  .sort((a, b) => a.name.localeCompare(b.name));
314
349
  }
315
350
 
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'}`;
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(', ');
319
369
  }
320
370
 
321
371
  async function installedCommand() {
@@ -323,14 +373,14 @@ async function installedCommand() {
323
373
  await refreshChangedRegistryEntries(config.repoPath);
324
374
  const registry = await loadRegistry(config.repoPath);
325
375
  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.');
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.');
329
379
  return;
330
380
  }
331
- console.log(`Installed on ${device.display_name}:`);
332
- for (const skill of installed) {
333
- console.log(`- ${installedSkillLabel(skill)}`);
381
+ console.log(`Local skills on ${device.display_name}:`);
382
+ for (const skill of localSkills) {
383
+ console.log(`- ${localSkillLabel(skill)}`);
334
384
  }
335
385
  }
336
386
 
@@ -626,7 +676,7 @@ async function runUi() {
626
676
  await refreshChangedRegistryEntries(config.repoPath);
627
677
  const menuChoices = [
628
678
  { 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.' },
679
+ { name: 'Installed on this device', value: 'installed', description: 'View, sync, or uninstall local skills on this device.' },
630
680
  { name: 'Devices', value: 'devices', description: 'Show devices known to the vault.' },
631
681
  { name: 'Targets', value: 'targets', description: 'Manage local agent skill folders.' },
632
682
  { name: 'Add skill from folder', value: 'add', description: 'Copy a local SKILL.md folder into the vault.' },
@@ -661,28 +711,31 @@ async function skillsScreen(config) {
661
711
  console.log('\nNo skills in vault yet. Use Add/import first.\n');
662
712
  return;
663
713
  }
664
- 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));
665
716
  const selected = await promptWithEscape(checkbox({
666
717
  message: `Install skills on ${device.display_name}`,
667
718
  loop: false,
668
719
  pageSize: promptPageSize(names.length, { min: 10, max: 32, reservedRows: 5 }),
669
720
  instructions: 'Space toggles skills. Enter applies changes. Esc goes back.',
670
721
  choices: names.map((name) => {
671
- const targets = device.installed[name] || [];
672
- 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}]` : '';
673
726
  return {
674
727
  name: `${name}${targetLabel}`,
675
728
  short: name,
676
729
  value: name,
677
- checked: installedNames.has(name),
730
+ checked: localVaultNames.has(name),
678
731
  };
679
732
  }),
680
733
  }));
681
734
  if (!selected) return;
682
735
 
683
736
  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));
737
+ const toInstall = names.filter((name) => selectedNames.has(name) && !localVaultNames.has(name));
738
+ const toUninstall = names.filter((name) => !selectedNames.has(name) && localVaultNames.has(name));
686
739
  if (!toInstall.length && !toUninstall.length) {
687
740
  console.log('\nNo install changes.\n');
688
741
  return;
@@ -697,8 +750,16 @@ async function skillsScreen(config) {
697
750
  for (const skillName of toInstall) {
698
751
  await installSkill({ vaultPath: config.repoPath, deviceId: config.deviceId, skillName, targets });
699
752
  }
700
- for (const skillName of toUninstall) {
701
- 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 });
702
763
  }
703
764
  await applyLinks({ vaultPath: config.repoPath, deviceId: config.deviceId });
704
765
  await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false });
@@ -712,19 +773,27 @@ async function installedScreen(config) {
712
773
  await refreshChangedRegistryEntries(config.repoPath);
713
774
  const registry = await loadRegistry(config.repoPath);
714
775
  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');
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
+ }));
718
786
  return;
719
787
  }
788
+ const localByName = new Map(localSkills.map((skill) => [skill.name, skill]));
720
789
 
721
790
  const selected = await promptWithEscape(checkbox({
722
- message: `Installed skills on ${device.display_name}`,
791
+ message: `Local skills on ${device.display_name}`,
723
792
  loop: false,
724
- pageSize: promptPageSize(installed.length, { min: 8, max: 28, reservedRows: 5 }),
793
+ pageSize: promptPageSize(localSkills.length, { min: 8, max: 28, reservedRows: 5 }),
725
794
  instructions: 'Space selects skills. Enter chooses an action. Esc goes back.',
726
- choices: installed.map((skill) => ({
727
- name: installedSkillLabel(skill),
795
+ choices: localSkills.map((skill) => ({
796
+ name: localSkillLabel(skill),
728
797
  short: skill.name,
729
798
  value: skill.name,
730
799
  checked: false,
@@ -737,33 +806,136 @@ async function installedScreen(config) {
737
806
  loop: false,
738
807
  pageSize: 3,
739
808
  choices: [
740
- { name: 'Update from vault', value: 'update', description: 'Pull the vault and reapply local installed links.' },
809
+ { name: 'Sync selected from vault', value: 'update', description: 'Replace same-named local skills with the vault version.' },
741
810
  { name: 'Uninstall from this device', value: 'uninstall', description: 'Remove selected skills from this device only.' },
742
811
  { name: 'Back', value: 'back' },
743
812
  ],
744
813
  }));
745
814
  if (!action || action === 'back') return;
746
815
 
816
+ const selectedSkills = selected.map((name) => localByName.get(name)).filter(Boolean);
747
817
  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`);
818
+ await updateLocalSkillsFromVault({ config, device, selectedSkills });
750
819
  return;
751
820
  }
752
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
+ }
753
825
  const confirmed = await confirm({
754
826
  message: `Uninstall ${selected.join(', ')} from this device?`,
755
827
  default: false,
756
828
  });
757
829
  if (!confirmed) return;
758
830
 
759
- for (const skillName of selected) {
760
- await uninstallSkill({ vaultPath: config.repoPath, deviceId: config.deviceId, skillName });
831
+ for (const skill of selectedSkills) {
832
+ await uninstallLocalSkill({ config, device, skill });
761
833
  }
762
834
  await applyLinks({ vaultPath: config.repoPath, deviceId: config.deviceId });
763
835
  await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false });
764
836
  console.log(`\nRemoved ${selected.join(', ')} from this device.\n`);
765
837
  }
766
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
+
767
939
  async function chooseTargets(config) {
768
940
  const device = await loadDevice(config.repoPath, config.deviceId);
769
941
  const targetNames = Object.keys(device.targets);
@@ -780,14 +952,22 @@ async function chooseTargets(config) {
780
952
 
781
953
  async function devicesScreen(config) {
782
954
  const devices = await listDevices(config.repoPath);
783
- console.log('\nDevices');
784
- for (const device of devices) {
955
+ const choices = devices.map((device) => {
785
956
  const installed = Object.keys(device.installed || {}).length;
786
957
  const detected = countDetectedSkills(device);
787
958
  const targets = Object.keys(device.targets || {}).join(', ') || 'no targets';
788
- console.log(`- ${device.display_name} (${device.device_id}) — ${detected} local, ${installed} managed — ${targets} — last seen ${device.last_seen || 'never'}`);
789
- }
790
- 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
+ }));
791
971
  }
792
972
 
793
973
  async function targetsScreen(config) {