@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.
- package/README.md +3 -3
- package/package.json +1 -1
- 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
|
|
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
|
-
`
|
|
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
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
|
|
302
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
|
317
|
-
const
|
|
318
|
-
|
|
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
|
|
327
|
-
if (!
|
|
328
|
-
console.log('No
|
|
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(`
|
|
332
|
-
for (const skill of
|
|
333
|
-
console.log(`- ${
|
|
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,
|
|
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
|
|
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
|
|
672
|
-
const
|
|
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:
|
|
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) && !
|
|
685
|
-
const toUninstall = names.filter((name) => !selectedNames.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
|
-
|
|
701
|
-
|
|
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
|
|
716
|
-
if (!
|
|
717
|
-
|
|
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: `
|
|
791
|
+
message: `Local skills on ${device.display_name}`,
|
|
723
792
|
loop: false,
|
|
724
|
-
pageSize: promptPageSize(
|
|
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:
|
|
727
|
-
name:
|
|
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: '
|
|
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
|
|
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
|
|
760
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
789
|
-
}
|
|
790
|
-
|
|
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) {
|