@akshar5/skillsync 0.1.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.
package/src/cli.js ADDED
@@ -0,0 +1,649 @@
1
+ #!/usr/bin/env node
2
+ import { checkbox, confirm, input, select } from '@inquirer/prompts';
3
+ import { mkdir, readdir, stat, writeFile } from 'node:fs/promises';
4
+ import { homedir, platform } from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ import { loadConfig, saveConfig, defaultRepoPath } from './core/config.js';
8
+ import { addTarget, applyLinks, defaultDeviceId, installSkill, listDevices, loadDevice, removeTarget, scanTargets, uninstallSkill } from './core/device.js';
9
+ import { ensureDir, exists, expandHome } from './core/fs.js';
10
+ import { cloneRepo, commandExists, commitAllIfChanged, gh, git, isGitRepo, push, run } from './core/git.js';
11
+ import { addSkillToVault, deleteSkillFromVault, ensureVault, loadRegistry, rebuildRegistry, refreshChangedRegistryEntries, validateSkillFolder } from './core/registry.js';
12
+ import { cloneSkillSource, discoverSkillFolders, isRemoteSkillSource, selectDiscoveredSkills } from './core/source.js';
13
+ import { syncVault } from './core/sync.js';
14
+
15
+ const args = process.argv.slice(2);
16
+
17
+ main().catch((error) => {
18
+ console.error(`\nskillsync: ${error.message}`);
19
+ if (process.env.SKILLSYNC_DEBUG) console.error(error.stack);
20
+ process.exit(1);
21
+ });
22
+
23
+ async function main() {
24
+ const [command, ...rest] = args;
25
+ switch (command) {
26
+ case undefined:
27
+ case 'ui':
28
+ return runUi();
29
+ case 'setup':
30
+ return setup(rest);
31
+ case 'connect':
32
+ return connect(rest);
33
+ case 'status':
34
+ return status();
35
+ case 'list':
36
+ return listSkills();
37
+ case 'add':
38
+ return addSkill(rest);
39
+ case 'import':
40
+ return importSkills(rest);
41
+ case 'install':
42
+ return install(rest);
43
+ case 'uninstall':
44
+ return uninstall(rest);
45
+ case 'delete':
46
+ return deleteSkill(rest);
47
+ case 'target':
48
+ return target(rest);
49
+ case 'sync':
50
+ return syncCommand(rest);
51
+ case 'scan':
52
+ return scanCommand();
53
+ case 'doctor':
54
+ return doctor();
55
+ case 'service':
56
+ return service(rest);
57
+ case 'daemon':
58
+ return daemon(rest);
59
+ case 'help':
60
+ case '--help':
61
+ case '-h':
62
+ return help();
63
+ default:
64
+ throw new Error(`Unknown command: ${command}. Run: skillsync help`);
65
+ }
66
+ }
67
+
68
+ function flagValue(rest, flag, fallback = undefined) {
69
+ const index = rest.indexOf(flag);
70
+ if (index === -1) return fallback;
71
+ return rest[index + 1] ?? fallback;
72
+ }
73
+
74
+ function flagList(rest, flags) {
75
+ const values = [];
76
+ for (const flag of flags) {
77
+ const value = flagValue(rest, flag);
78
+ if (value) values.push(...value.split(',').map((item) => item.trim()).filter(Boolean));
79
+ }
80
+ return [...new Set(values)];
81
+ }
82
+
83
+ function hasFlag(rest, flag) {
84
+ return rest.includes(flag);
85
+ }
86
+
87
+ async function configured() {
88
+ const config = await loadConfig();
89
+ if (!config.repoPath || !await exists(config.repoPath)) {
90
+ throw new Error('SkillSync is not set up. Run: skillsync setup');
91
+ }
92
+ await ensureVault(config.repoPath);
93
+ return config;
94
+ }
95
+
96
+ async function resolveRepoCloneUrl(repo) {
97
+ if (/^(git@|https?:\/\/|ssh:\/\/)/.test(repo)) return repo;
98
+ if (!repo.includes('/')) return repo;
99
+ if (!await commandExists('gh')) return repo;
100
+ const { stdout } = await gh(['repo', 'view', repo, '--json', 'isPrivate,sshUrl', '--jq', '.']);
101
+ const view = JSON.parse(stdout);
102
+ if (!view.isPrivate) throw new Error(`${repo} exists but is not private. Make it private before using it as a skill vault.`);
103
+ return view.sshUrl;
104
+ }
105
+
106
+ async function setup(rest) {
107
+ const yes = hasFlag(rest, '--yes') || hasFlag(rest, '-y');
108
+ if (!await commandExists('git')) throw new Error('git is required');
109
+ if (!await commandExists('gh')) throw new Error('gh CLI is required for setup. Install GitHub CLI, run gh auth login, then retry.');
110
+ try {
111
+ await gh(['auth', 'status']);
112
+ } catch {
113
+ throw new Error('gh is not authenticated. Run: gh auth login');
114
+ }
115
+
116
+ const repoArg = flagValue(rest, '--repo');
117
+ const repoPath = expandHome(flagValue(rest, '--path', defaultRepoPath()));
118
+ let repo = repoArg;
119
+ if (!repo) {
120
+ const { stdout: ownerOut } = await gh(['api', 'user', '--jq', '.login']);
121
+ 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 });
135
+ }
136
+ const { stdout: viewOut } = await gh(['repo', 'view', repo, '--json', 'isPrivate,sshUrl', '--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.sshUrl;
140
+ } else {
141
+ repo = await resolveRepoCloneUrl(repo);
142
+ }
143
+
144
+ await mkdir(path.dirname(repoPath), { recursive: true });
145
+ if (!await isGitRepo(repoPath)) {
146
+ console.log(`Cloning ${repo} to ${repoPath}...`);
147
+ await cloneRepo(repo, repoPath);
148
+ }
149
+
150
+ await ensureVault(repoPath);
151
+ await saveConfig({ version: 1, repo, repoPath, deviceId: defaultDeviceId() });
152
+ await maybeAddDetectedTargets(repoPath, defaultDeviceId(), yes);
153
+ await rebuildRegistry(repoPath);
154
+ await commitInitialVault(repoPath);
155
+ await syncVault({ vaultPath: repoPath, deviceId: defaultDeviceId(), pull: false });
156
+
157
+ console.log('\nSkillSync setup complete.');
158
+ console.log(`Vault: ${repoPath}`);
159
+ console.log('Run `skillsync` to open the UI.');
160
+ }
161
+
162
+ async function connect(rest) {
163
+ const repoArg = rest[0];
164
+ if (!repoArg) throw new Error('Usage: skillsync connect <github-repo-or-url>');
165
+ const repo = await resolveRepoCloneUrl(repoArg);
166
+ const repoPath = expandHome(flagValue(rest, '--path', defaultRepoPath()));
167
+ await mkdir(path.dirname(repoPath), { recursive: true });
168
+ await cloneRepo(repo, repoPath);
169
+ await ensureVault(repoPath);
170
+ await saveConfig({ version: 1, repo, repoPath, deviceId: defaultDeviceId() });
171
+ await rebuildRegistry(repoPath);
172
+ console.log(`Connected ${repoPath} to ${repo}`);
173
+ }
174
+
175
+ async function maybeAddDetectedTargets(repoPath, deviceId, yes) {
176
+ const candidates = [
177
+ { name: 'hermes', path: '~/.hermes/skills/personal', scanPath: '~/.hermes/skills' },
178
+ { name: 'claude', path: '~/.claude/skills' },
179
+ { name: 'codex', path: '~/.codex/skills' },
180
+ { name: 'opencode', path: '~/.config/opencode/skills' },
181
+ ];
182
+ const detected = [];
183
+ for (const candidate of candidates) {
184
+ const expanded = expandHome(candidate.path);
185
+ const scanExpanded = expandHome(candidate.scanPath || candidate.path);
186
+ const parentExists = await exists(path.dirname(expanded));
187
+ const dirExists = await exists(expanded);
188
+ const scanExists = await exists(scanExpanded);
189
+ if (dirExists || parentExists || scanExists) detected.push(candidate);
190
+ }
191
+ if (!detected.length) return;
192
+ let selected = detected;
193
+ if (!yes && process.stdin.isTTY) {
194
+ selected = await checkbox({
195
+ message: 'Which local skill targets should SkillSync manage?',
196
+ choices: detected.map((target) => ({ name: `${target.name} (${target.path})`, value: target })),
197
+ });
198
+ }
199
+ for (const targetConfig of selected) {
200
+ await addTarget({
201
+ vaultPath: repoPath,
202
+ deviceId,
203
+ name: targetConfig.name,
204
+ targetPath: targetConfig.path,
205
+ scanPath: targetConfig.scanPath,
206
+ mode: 'symlink',
207
+ });
208
+ }
209
+ }
210
+
211
+ async function commitInitialVault(repoPath) {
212
+ if (!await isGitRepo(repoPath)) return;
213
+ await git(['add', 'README.md', 'registry.json', 'skills', 'devices'], repoPath).catch(() => {});
214
+ const committed = await commitAllIfChanged(repoPath, 'chore: initialize skills vault');
215
+ if (committed) {
216
+ try {
217
+ await push(repoPath);
218
+ } catch {
219
+ await git(['push', '-u', 'origin', 'HEAD:main'], repoPath);
220
+ }
221
+ }
222
+ }
223
+
224
+ async function status() {
225
+ const config = await configured();
226
+ await refreshChangedRegistryEntries(config.repoPath);
227
+ const registry = await loadRegistry(config.repoPath);
228
+ const device = await loadDevice(config.repoPath, config.deviceId);
229
+ const detectedCount = countDetectedSkills(device);
230
+ console.log(`Vault: ${config.repoPath}`);
231
+ console.log(`Device: ${device.display_name} (${device.device_id})`);
232
+ console.log(`Available skills: ${Object.keys(registry.skills).length}`);
233
+ console.log(`Installed here: ${detectedCount} local detected, ${Object.keys(device.installed).length} SkillSync-managed`);
234
+ console.log(`Targets: ${Object.keys(device.targets).join(', ') || 'none'}`);
235
+ }
236
+
237
+ function countDetectedSkills(device) {
238
+ return Object.values(device.detected || {}).reduce((count, skills) => count + (Array.isArray(skills) ? skills.length : 0), 0);
239
+ }
240
+
241
+ async function listSkills() {
242
+ const config = await configured();
243
+ await refreshChangedRegistryEntries(config.repoPath);
244
+ const registry = await loadRegistry(config.repoPath);
245
+ const device = await loadDevice(config.repoPath, config.deviceId);
246
+ const names = Object.keys(registry.skills).sort();
247
+ if (!names.length) {
248
+ console.log('No skills in vault yet. Add one with: skillsync add <skill-folder>');
249
+ return;
250
+ }
251
+ for (const name of names) {
252
+ const targets = device.installed[name]?.join(', ');
253
+ console.log(`${targets ? '✓' : '○'} ${name}${targets ? ` [${targets}]` : ''}`);
254
+ }
255
+ }
256
+
257
+ async function addSkill(rest) {
258
+ const source = rest[0];
259
+ if (!source) throw new Error('Usage: skillsync add <skill-folder-or-git-url> [--skill name] [--target target]');
260
+ const config = await configured();
261
+
262
+ if (isRemoteSkillSource(source)) {
263
+ return addRemoteSkills(source, rest, config);
264
+ }
265
+
266
+ const added = await addSkillToVault({ vaultPath: config.repoPath, sourcePath: expandHome(source), name: flagValue(rest, '--name') });
267
+ const targets = await chooseInstallTargets(config, rest);
268
+ if (targets.length) {
269
+ await installSkill({ vaultPath: config.repoPath, deviceId: config.deviceId, skillName: added.name, targets });
270
+ await applyLinks({ vaultPath: config.repoPath, deviceId: config.deviceId });
271
+ }
272
+ await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false });
273
+ console.log(`Added ${added.name} to the vault${targets.length ? ` and installed to ${targets.join(', ')}` : ''}.`);
274
+ }
275
+
276
+ async function addRemoteSkills(source, rest, config) {
277
+ const wanted = flagList(rest, ['--skill', '-s']);
278
+ const listOnly = hasFlag(rest, '--list') || hasFlag(rest, '-l');
279
+ const fullDepth = hasFlag(rest, '--full-depth');
280
+ const cloned = await cloneSkillSource(source);
281
+ try {
282
+ const discovered = await discoverSkillFolders(cloned.path, { fullDepth });
283
+ if (!discovered.length) throw new Error(`No SKILL.md files found in ${source}`);
284
+ const matching = selectDiscoveredSkills(discovered, wanted);
285
+
286
+ if (listOnly) {
287
+ console.log(`Found ${matching.length} skill${matching.length === 1 ? '' : 's'} in ${source}:`);
288
+ for (const skill of matching) console.log(`- ${skill.name} (${skill.relative})`);
289
+ return;
290
+ }
291
+
292
+ let selected = matching;
293
+ if (!wanted.length && matching.length > 1 && process.stdin.isTTY) {
294
+ selected = await checkbox({
295
+ message: `Select skills to add from ${source}`,
296
+ choices: matching.map((skill) => ({ name: `${skill.name} (${skill.relative})`, value: skill })),
297
+ });
298
+ }
299
+ if (!selected.length) {
300
+ console.log('No skills selected.');
301
+ return;
302
+ }
303
+
304
+ const targets = await chooseInstallTargets(config, rest);
305
+ const added = [];
306
+ for (const skill of selected) {
307
+ const result = await addSkillToVault({ vaultPath: config.repoPath, sourcePath: skill.path, name: skill.name });
308
+ added.push(result.name);
309
+ if (targets.length) {
310
+ await installSkill({ vaultPath: config.repoPath, deviceId: config.deviceId, skillName: result.name, targets });
311
+ }
312
+ }
313
+ if (targets.length) await applyLinks({ vaultPath: config.repoPath, deviceId: config.deviceId });
314
+ await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false });
315
+
316
+ console.log(`Added ${added.length} skill${added.length === 1 ? '' : 's'} to the vault: ${added.join(', ')}`);
317
+ if (targets.length) console.log(`Installed on this device: ${targets.join(', ')}`);
318
+ } finally {
319
+ await cloned.cleanup();
320
+ }
321
+ }
322
+
323
+ async function importSkills(rest) {
324
+ const source = rest[0];
325
+ if (source !== 'hermes') throw new Error('Usage: skillsync import hermes');
326
+ const config = await configured();
327
+ const found = await findSkills(expandHome('~/.hermes/skills'));
328
+ if (!found.length) {
329
+ console.log('No Hermes skills found under ~/.hermes/skills');
330
+ return;
331
+ }
332
+ const selected = process.stdin.isTTY
333
+ ? await checkbox({
334
+ message: 'Select Hermes skills to import into the vault',
335
+ choices: found.map((skill) => ({ name: `${skill.name} (${skill.relative})`, value: skill })),
336
+ })
337
+ : found;
338
+ for (const skill of selected) {
339
+ await addSkillToVault({ vaultPath: config.repoPath, sourcePath: skill.path, name: skill.name });
340
+ console.log(`Imported ${skill.name}`);
341
+ }
342
+ await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false });
343
+ }
344
+
345
+ async function findSkills(root) {
346
+ const results = [];
347
+ if (!await exists(root)) return results;
348
+ async function walk(dir) {
349
+ if (await exists(path.join(dir, 'SKILL.md'))) {
350
+ results.push({ name: path.basename(dir), path: dir, relative: path.relative(root, dir) });
351
+ return;
352
+ }
353
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
354
+ for (const entry of entries) {
355
+ if (entry.isDirectory() && !entry.name.startsWith('.')) await walk(path.join(dir, entry.name));
356
+ }
357
+ }
358
+ await walk(root);
359
+ return results.sort((a, b) => a.name.localeCompare(b.name));
360
+ }
361
+
362
+ async function install(rest) {
363
+ const skillName = rest[0];
364
+ if (!skillName) throw new Error('Usage: skillsync install <skill> [--target codex,claude]');
365
+ const config = await configured();
366
+ const targets = parseTargets(rest);
367
+ await installSkill({ vaultPath: config.repoPath, deviceId: config.deviceId, skillName, targets });
368
+ await applyLinks({ vaultPath: config.repoPath, deviceId: config.deviceId });
369
+ await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false });
370
+ console.log(`Installed ${skillName} on this device.`);
371
+ }
372
+
373
+ async function uninstall(rest) {
374
+ const skillName = rest[0];
375
+ if (!skillName) throw new Error('Usage: skillsync uninstall <skill>');
376
+ const config = await configured();
377
+ await uninstallSkill({ vaultPath: config.repoPath, deviceId: config.deviceId, skillName, targets: parseTargets(rest) });
378
+ await applyLinks({ vaultPath: config.repoPath, deviceId: config.deviceId });
379
+ await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false });
380
+ console.log(`Removed ${skillName} from this device.`);
381
+ }
382
+
383
+ async function deleteSkill(rest) {
384
+ const skillName = rest[0];
385
+ if (!skillName) throw new Error('Usage: skillsync delete <skill>');
386
+ const yes = hasFlag(rest, '--yes') || hasFlag(rest, '-y');
387
+ if (!yes && process.stdin.isTTY) {
388
+ const typed = await input({ message: `Delete ${skillName} from the vault and all devices? Type the skill name to confirm:` });
389
+ if (typed !== skillName) {
390
+ console.log('Cancelled.');
391
+ return;
392
+ }
393
+ } else if (!yes) {
394
+ throw new Error('Refusing to delete without --yes in non-interactive mode');
395
+ }
396
+ const config = await configured();
397
+ await deleteSkillFromVault({ vaultPath: config.repoPath, skillName });
398
+ await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false });
399
+ console.log(`Deleted ${skillName} from the vault.`);
400
+ }
401
+
402
+ function parseTargets(rest) {
403
+ const raw = flagValue(rest, '--target') || flagValue(rest, '-t');
404
+ if (!raw) return undefined;
405
+ return raw.split(',').map((item) => item.trim()).filter(Boolean);
406
+ }
407
+
408
+ async function chooseInstallTargets(config, rest) {
409
+ const explicit = parseTargets(rest);
410
+ const device = await loadDevice(config.repoPath, config.deviceId);
411
+ const available = Object.keys(device.targets || {}).sort();
412
+ if (explicit?.includes('*') || hasFlag(rest, '--all-targets')) return available;
413
+ if (explicit) return explicit;
414
+ if (hasFlag(rest, '--no-install') || hasFlag(rest, '--yes') || hasFlag(rest, '-y') || !process.stdin.isTTY || !available.length) return [];
415
+ const shouldInstall = await confirm({ message: 'Install on this device now?', default: true });
416
+ if (!shouldInstall) return [];
417
+ return checkbox({
418
+ message: 'Choose local targets',
419
+ choices: available.map((target) => ({ name: target, value: target, checked: true })),
420
+ });
421
+ }
422
+
423
+ async function target(rest) {
424
+ const sub = rest[0];
425
+ if (sub === 'add') {
426
+ const [, name, targetPath] = rest;
427
+ if (!name || !targetPath) throw new Error('Usage: skillsync target add <name> <path> [--mode symlink|copy] [--scan-path path]');
428
+ const config = await configured();
429
+ await addTarget({
430
+ vaultPath: config.repoPath,
431
+ deviceId: config.deviceId,
432
+ name,
433
+ targetPath,
434
+ mode: flagValue(rest, '--mode', 'symlink'),
435
+ scanPath: flagValue(rest, '--scan-path'),
436
+ });
437
+ await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false });
438
+ console.log(`Added target ${name}: ${targetPath}`);
439
+ return;
440
+ }
441
+ if (sub === 'remove') {
442
+ const [, name] = rest;
443
+ if (!name) throw new Error('Usage: skillsync target remove <name>');
444
+ const config = await configured();
445
+ await removeTarget({ vaultPath: config.repoPath, deviceId: config.deviceId, name });
446
+ await applyLinks({ vaultPath: config.repoPath, deviceId: config.deviceId });
447
+ await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false });
448
+ console.log(`Removed target ${name}`);
449
+ return;
450
+ }
451
+ throw new Error('Usage: skillsync target add|remove ...');
452
+ }
453
+
454
+ async function syncCommand(rest) {
455
+ const config = await configured();
456
+ const result = await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: !hasFlag(rest, '--no-pull') });
457
+ console.log(result.committed ? 'Synced and pushed changes.' : 'Synced. No local changes to push.');
458
+ }
459
+
460
+ async function scanCommand() {
461
+ const config = await configured();
462
+ const device = await scanTargets({ vaultPath: config.repoPath, deviceId: config.deviceId });
463
+ await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false });
464
+ console.log(`Scanned local targets: ${countDetectedSkills(device)} skills detected.`);
465
+ }
466
+
467
+ async function doctor() {
468
+ const gitOk = await commandExists('git');
469
+ const ghOk = await commandExists('gh');
470
+ console.log(`${gitOk ? '✓' : '✗'} git`);
471
+ console.log(`${ghOk ? '✓' : '✗'} gh`);
472
+ if (ghOk) {
473
+ try { await gh(['auth', 'status']); console.log('✓ gh auth'); }
474
+ catch { console.log('✗ gh auth'); }
475
+ }
476
+ try {
477
+ const config = await loadConfig();
478
+ console.log(`✓ config: ${config.repoPath}`);
479
+ console.log(`${await isGitRepo(config.repoPath) ? '✓' : '✗'} vault git repo`);
480
+ await refreshChangedRegistryEntries(config.repoPath);
481
+ console.log('✓ registry checked/rebuilt');
482
+ } catch (error) {
483
+ console.log(`✗ config/vault: ${error.message}`);
484
+ }
485
+ }
486
+
487
+ async function service(rest) {
488
+ const sub = rest[0];
489
+ if (sub !== 'install') throw new Error('Usage: skillsync service install');
490
+ const cliPath = path.resolve(process.argv[1]);
491
+ if (platform() === 'darwin') {
492
+ const plistDir = path.join(homedir(), 'Library', 'LaunchAgents');
493
+ await mkdir(plistDir, { recursive: true });
494
+ const plistPath = path.join(plistDir, 'dev.skillsync.daemon.plist');
495
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n <key>Label</key><string>dev.skillsync.daemon</string>\n <key>ProgramArguments</key><array><string>${process.execPath}</string><string>${cliPath}</string><string>daemon</string></array>\n <key>RunAtLoad</key><true/>\n <key>KeepAlive</key><true/>\n</dict></plist>\n`;
496
+ await writeFile(plistPath, plist);
497
+ await run('launchctl', ['load', plistPath]).catch(() => {});
498
+ console.log(`Installed LaunchAgent: ${plistPath}`);
499
+ return;
500
+ }
501
+ const systemdDir = path.join(homedir(), '.config', 'systemd', 'user');
502
+ await mkdir(systemdDir, { recursive: true });
503
+ const unitPath = path.join(systemdDir, 'skillsync.service');
504
+ const unit = `[Unit]\nDescription=SkillSync daemon\n\n[Service]\nType=simple\nExecStart=${process.execPath} ${cliPath} daemon\nRestart=always\nRestartSec=10\n\n[Install]\nWantedBy=default.target\n`;
505
+ await writeFile(unitPath, unit);
506
+ await run('systemctl', ['--user', 'daemon-reload']).catch(() => {});
507
+ await run('systemctl', ['--user', 'enable', '--now', 'skillsync.service']).catch(() => {});
508
+ console.log(`Installed systemd user service: ${unitPath}`);
509
+ }
510
+
511
+ async function daemon(rest) {
512
+ const interval = Number(flagValue(rest, '--interval', '120')) * 1000;
513
+ const config = await configured();
514
+ console.log(`SkillSync daemon started for ${config.repoPath}; interval ${interval / 1000}s`);
515
+ let lastHeartbeat = Date.now();
516
+ while (true) {
517
+ try {
518
+ const now = Date.now();
519
+ const heartbeat = now - lastHeartbeat > 15 * 60 * 1000;
520
+ await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, heartbeat });
521
+ if (heartbeat) lastHeartbeat = now;
522
+ console.log(`[${new Date().toISOString()}] synced`);
523
+ } catch (error) {
524
+ console.error(`[${new Date().toISOString()}] sync failed: ${error.message}`);
525
+ }
526
+ await new Promise((resolve) => setTimeout(resolve, interval));
527
+ }
528
+ }
529
+
530
+ async function runUi() {
531
+ if (!process.stdin.isTTY) return listSkills();
532
+ let config;
533
+ try {
534
+ config = await configured();
535
+ } catch {
536
+ const shouldSetup = await confirm({ message: 'SkillSync is not set up. Run setup now?', default: true });
537
+ if (!shouldSetup) return;
538
+ await setup([]);
539
+ config = await configured();
540
+ }
541
+
542
+ while (true) {
543
+ await refreshChangedRegistryEntries(config.repoPath);
544
+ const choice = await select({
545
+ message: 'SkillSync',
546
+ 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' },
554
+ { name: 'Quit', value: 'quit' },
555
+ ],
556
+ });
557
+ if (choice === 'quit') return;
558
+ if (choice === 'skills') await skillsScreen(config);
559
+ if (choice === 'devices') await devicesScreen(config);
560
+ if (choice === 'targets') await targetsScreen(config);
561
+ if (choice === 'add') await addSkill([await input({ message: 'Skill folder path:' })]);
562
+ if (choice === 'import-hermes') await importSkills(['hermes']);
563
+ if (choice === 'scan') await scanCommand();
564
+ if (choice === 'sync') await syncCommand([]);
565
+ }
566
+ }
567
+
568
+ async function skillsScreen(config) {
569
+ const registry = await loadRegistry(config.repoPath);
570
+ const device = await loadDevice(config.repoPath, config.deviceId);
571
+ const names = Object.keys(registry.skills).sort();
572
+ if (!names.length) {
573
+ console.log('\nNo skills in vault yet. Use Add/import first.\n');
574
+ return;
575
+ }
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
+ }
598
+ }
599
+ if (action === 'delete') await deleteSkill([skill]);
600
+ }
601
+
602
+ async function chooseTargets(config) {
603
+ const device = await loadDevice(config.repoPath, config.deviceId);
604
+ const targetNames = Object.keys(device.targets);
605
+ if (!targetNames.length) throw new Error('No targets configured. Add one from the Targets screen.');
606
+ return checkbox({
607
+ message: 'Install into which targets?',
608
+ choices: targetNames.map((name) => ({ name, value: name, checked: true })),
609
+ required: true,
610
+ });
611
+ }
612
+
613
+ async function devicesScreen(config) {
614
+ const devices = await listDevices(config.repoPath);
615
+ console.log('\nDevices');
616
+ for (const device of devices) {
617
+ const installed = Object.keys(device.installed || {}).length;
618
+ const detected = countDetectedSkills(device);
619
+ const targets = Object.keys(device.targets || {}).join(', ') || 'no targets';
620
+ console.log(`- ${device.display_name} (${device.device_id}) — ${detected} local, ${installed} managed — ${targets} — last seen ${device.last_seen || 'never'}`);
621
+ }
622
+ console.log('');
623
+ }
624
+
625
+ async function targetsScreen(config) {
626
+ const device = await loadDevice(config.repoPath, config.deviceId);
627
+ const choices = Object.entries(device.targets).map(([name, targetConfig]) => ({
628
+ name: `${name}: ${targetConfig.path} (${targetConfig.mode})${targetConfig.scan_path ? ` scan ${targetConfig.scan_path}` : ''}`,
629
+ value: `remove:${name}`,
630
+ })).concat([
631
+ { name: 'Add target', value: 'add' },
632
+ { name: 'Back', value: 'back' },
633
+ ]);
634
+ const choice = await select({ message: 'Targets on this device', choices });
635
+ if (choice === 'back') return;
636
+ if (choice === 'add') {
637
+ const name = await input({ message: 'Target name (codex, claude, hermes, custom):' });
638
+ const targetPath = await input({ message: 'Target skill directory path:' });
639
+ const scanPath = await input({ message: 'Optional scan path for existing skills:', default: '' });
640
+ await target(['add', name, targetPath, ...(scanPath ? ['--scan-path', scanPath] : [])]);
641
+ } else if (choice.startsWith('remove:')) {
642
+ const name = choice.slice('remove:'.length);
643
+ if (await confirm({ message: `Remove target ${name}?`, default: false })) await target(['remove', name]);
644
+ }
645
+ }
646
+
647
+ function help() {
648
+ 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`);
649
+ }
@@ -0,0 +1,35 @@
1
+ import { mkdir } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import { exists, expandHome, readJson, writeJson } from './fs.js';
6
+ import { defaultDeviceId } from './device.js';
7
+
8
+ export function defaultConfigPath() {
9
+ return path.join(homedir(), '.config', 'skillsync', 'config.json');
10
+ }
11
+
12
+ export function defaultRepoPath() {
13
+ return path.join(homedir(), '.skillsync', 'repo');
14
+ }
15
+
16
+ export async function loadConfig(configPath = defaultConfigPath()) {
17
+ const config = await readJson(configPath, null).catch(() => null);
18
+ return {
19
+ version: 1,
20
+ repo: config?.repo || null,
21
+ repoPath: expandHome(config?.repoPath || defaultRepoPath()),
22
+ deviceId: config?.deviceId || defaultDeviceId(),
23
+ };
24
+ }
25
+
26
+ export async function saveConfig(config, configPath = defaultConfigPath()) {
27
+ await mkdir(path.dirname(configPath), { recursive: true });
28
+ await writeJson(configPath, { version: 1, ...config });
29
+ }
30
+
31
+ export async function isConfigured(configPath = defaultConfigPath()) {
32
+ if (!await exists(configPath)) return false;
33
+ const config = await loadConfig(configPath);
34
+ return Boolean(config.repoPath);
35
+ }