@synsci/cli 1.0.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.
@@ -0,0 +1,611 @@
1
+ import { existsSync, mkdirSync, symlinkSync, readdirSync, readFileSync, writeFileSync, rmSync, lstatSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join, basename, dirname } from 'path';
4
+ import { execSync } from 'child_process';
5
+ import chalk from 'chalk';
6
+ import ora from 'ora';
7
+
8
+ const REPO_URL = 'https://github.com/Orchestra-Research/AI-research-SKILLs';
9
+ const CANONICAL_DIR = join(homedir(), '.syntheticsciences', 'skills');
10
+ const LOCK_FILE = join(homedir(), '.syntheticsciences', '.lock.json');
11
+
12
+ /**
13
+ * Ensure the canonical skills directory exists
14
+ */
15
+ function ensureCanonicalDir() {
16
+ const orchestraDir = join(homedir(), '.syntheticsciences');
17
+ if (!existsSync(orchestraDir)) {
18
+ mkdirSync(orchestraDir, { recursive: true });
19
+ }
20
+ if (!existsSync(CANONICAL_DIR)) {
21
+ mkdirSync(CANONICAL_DIR, { recursive: true });
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Read lock file
27
+ */
28
+ function readLock() {
29
+ if (existsSync(LOCK_FILE)) {
30
+ try {
31
+ return JSON.parse(readFileSync(LOCK_FILE, 'utf8'));
32
+ } catch {
33
+ return { version: null, installedAt: null, skills: [] };
34
+ }
35
+ }
36
+ return { version: null, installedAt: null, skills: [] };
37
+ }
38
+
39
+ /**
40
+ * Write lock file
41
+ */
42
+ function writeLock(data) {
43
+ writeFileSync(LOCK_FILE, JSON.stringify(data, null, 2));
44
+ }
45
+
46
+ /**
47
+ * Download skills from GitHub
48
+ */
49
+ async function downloadSkills(categories, spinner) {
50
+ ensureCanonicalDir();
51
+
52
+ // Clone or update the repository to a temp location
53
+ const tempDir = join(homedir(), '.syntheticsciences', '.temp-clone');
54
+
55
+ try {
56
+ if (existsSync(tempDir)) {
57
+ rmSync(tempDir, { recursive: true, force: true });
58
+ }
59
+
60
+ spinner.text = 'Cloning repository...';
61
+ execSync(`git clone --depth 1 ${REPO_URL}.git ${tempDir}`, {
62
+ stdio: 'pipe',
63
+ });
64
+
65
+ const skills = [];
66
+
67
+ // Copy selected categories
68
+ for (const categoryId of categories) {
69
+ const categoryPath = join(tempDir, categoryId);
70
+ if (!existsSync(categoryPath)) continue;
71
+
72
+ const targetCategoryPath = join(CANONICAL_DIR, categoryId);
73
+ if (!existsSync(targetCategoryPath)) {
74
+ mkdirSync(targetCategoryPath, { recursive: true });
75
+ }
76
+
77
+ // Check if it's a standalone skill (SKILL.md directly in category)
78
+ const standaloneSkillPath = join(categoryPath, 'SKILL.md');
79
+ if (existsSync(standaloneSkillPath)) {
80
+ // Copy the entire category as a standalone skill
81
+ spinner.text = `Downloading ${categoryId}...`;
82
+ execSync(`cp -r "${categoryPath}"/* "${targetCategoryPath}/"`, { stdio: 'pipe' });
83
+ skills.push({ category: categoryId, skill: categoryId, standalone: true });
84
+ } else {
85
+ // It's a nested category with multiple skills
86
+ const entries = readdirSync(categoryPath, { withFileTypes: true });
87
+ for (const entry of entries) {
88
+ if (entry.isDirectory()) {
89
+ const skillPath = join(categoryPath, entry.name, 'SKILL.md');
90
+ if (existsSync(skillPath)) {
91
+ spinner.text = `Downloading ${entry.name}...`;
92
+ const targetSkillPath = join(targetCategoryPath, entry.name);
93
+ if (!existsSync(targetSkillPath)) {
94
+ mkdirSync(targetSkillPath, { recursive: true });
95
+ }
96
+ execSync(`cp -r "${join(categoryPath, entry.name)}"/* "${targetSkillPath}/"`, { stdio: 'pipe' });
97
+ skills.push({ category: categoryId, skill: entry.name, standalone: false });
98
+ }
99
+ }
100
+ }
101
+ }
102
+ }
103
+
104
+ // Cleanup
105
+ rmSync(tempDir, { recursive: true, force: true });
106
+
107
+ return skills;
108
+ } catch (error) {
109
+ if (existsSync(tempDir)) {
110
+ rmSync(tempDir, { recursive: true, force: true });
111
+ }
112
+ throw error;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Create symlinks for an agent
118
+ */
119
+ function createSymlinks(agent, skills, spinner) {
120
+ const agentSkillsPath = agent.skillsPath;
121
+
122
+ // Ensure agent skills directory exists
123
+ if (!existsSync(agentSkillsPath)) {
124
+ mkdirSync(agentSkillsPath, { recursive: true });
125
+ }
126
+
127
+ let linkedCount = 0;
128
+
129
+ for (const skill of skills) {
130
+ const sourcePath = skill.standalone
131
+ ? join(CANONICAL_DIR, skill.category)
132
+ : join(CANONICAL_DIR, skill.category, skill.skill);
133
+
134
+ const linkName = skill.standalone ? skill.category : skill.skill;
135
+ const linkPath = join(agentSkillsPath, linkName);
136
+
137
+ // Remove existing symlink if present
138
+ if (existsSync(linkPath)) {
139
+ rmSync(linkPath, { recursive: true, force: true });
140
+ }
141
+
142
+ try {
143
+ symlinkSync(sourcePath, linkPath);
144
+ linkedCount++;
145
+ } catch (error) {
146
+ // Ignore symlink errors (e.g., already exists)
147
+ }
148
+ }
149
+
150
+ return linkedCount;
151
+ }
152
+
153
+ /**
154
+ * Download specific skills from GitHub
155
+ */
156
+ async function downloadSpecificSkills(skillPaths, spinner) {
157
+ ensureCanonicalDir();
158
+
159
+ // Clone or update the repository to a temp location
160
+ const tempDir = join(homedir(), '.syntheticsciences', '.temp-clone');
161
+
162
+ try {
163
+ if (existsSync(tempDir)) {
164
+ rmSync(tempDir, { recursive: true, force: true });
165
+ }
166
+
167
+ spinner.text = 'Cloning repository...';
168
+ execSync(`git clone --depth 1 ${REPO_URL}.git ${tempDir}`, {
169
+ stdio: 'pipe',
170
+ });
171
+
172
+ const skills = [];
173
+
174
+ // Copy selected skills
175
+ for (const skillPath of skillPaths) {
176
+ // skillPath can be like '06-post-training/verl' or '20-ml-paper-writing' (standalone)
177
+ const parts = skillPath.split('/');
178
+ const categoryId = parts[0];
179
+ const skillName = parts[1] || null;
180
+
181
+ const targetCategoryPath = join(CANONICAL_DIR, categoryId);
182
+ if (!existsSync(targetCategoryPath)) {
183
+ mkdirSync(targetCategoryPath, { recursive: true });
184
+ }
185
+
186
+ if (skillName) {
187
+ // Nested skill like '06-post-training/verl'
188
+ const sourcePath = join(tempDir, categoryId, skillName);
189
+ if (existsSync(sourcePath)) {
190
+ spinner.text = `Downloading ${skillName}...`;
191
+ const targetSkillPath = join(targetCategoryPath, skillName);
192
+ if (!existsSync(targetSkillPath)) {
193
+ mkdirSync(targetSkillPath, { recursive: true });
194
+ }
195
+ execSync(`cp -r "${sourcePath}"/* "${targetSkillPath}/"`, { stdio: 'pipe' });
196
+ skills.push({ category: categoryId, skill: skillName, standalone: false });
197
+ }
198
+ } else {
199
+ // Standalone skill like '20-ml-paper-writing'
200
+ const sourcePath = join(tempDir, categoryId);
201
+ if (existsSync(sourcePath)) {
202
+ spinner.text = `Downloading ${categoryId}...`;
203
+ execSync(`cp -r "${sourcePath}"/* "${targetCategoryPath}/"`, { stdio: 'pipe' });
204
+ skills.push({ category: categoryId, skill: categoryId, standalone: true });
205
+ }
206
+ }
207
+ }
208
+
209
+ // Cleanup
210
+ rmSync(tempDir, { recursive: true, force: true });
211
+
212
+ return skills;
213
+ } catch (error) {
214
+ if (existsSync(tempDir)) {
215
+ rmSync(tempDir, { recursive: true, force: true });
216
+ }
217
+ throw error;
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Install specific skills to agents
223
+ */
224
+ export async function installSpecificSkills(skillPaths, agents) {
225
+ const spinner = ora('Downloading from GitHub...').start();
226
+
227
+ try {
228
+ // Download skills
229
+ const skills = await downloadSpecificSkills(skillPaths, spinner);
230
+ spinner.succeed(`Downloaded ${skills.length} skills`);
231
+
232
+ // Create symlinks for each agent
233
+ spinner.start('Creating symlinks...');
234
+
235
+ for (const agent of agents) {
236
+ const count = createSymlinks(agent, skills, spinner);
237
+ console.log(` ${chalk.green('✓')} ${agent.name.padEnd(14)} ${chalk.dim('→')} ${agent.skillsPath.replace(homedir(), '~').padEnd(25)} ${chalk.green(count + ' skills')}`);
238
+ }
239
+
240
+ spinner.stop();
241
+
242
+ // Update lock file
243
+ const lock = readLock();
244
+ lock.version = '1.0.0';
245
+ lock.installedAt = new Date().toISOString();
246
+ lock.skills = [...(lock.skills || []), ...skills];
247
+ lock.agents = agents.map(a => a.id);
248
+ writeLock(lock);
249
+
250
+ return skills.length;
251
+ } catch (error) {
252
+ spinner.fail('Installation failed');
253
+ throw error;
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Install skills to agents
259
+ */
260
+ export async function installSkills(categories, agents) {
261
+ const spinner = ora('Downloading from GitHub...').start();
262
+
263
+ try {
264
+ // Download skills
265
+ const skills = await downloadSkills(categories, spinner);
266
+ spinner.succeed(`Downloaded ${skills.length} skills`);
267
+
268
+ // Create symlinks for each agent
269
+ spinner.start('Creating symlinks...');
270
+ const results = [];
271
+
272
+ for (const agent of agents) {
273
+ const count = createSymlinks(agent, skills, spinner);
274
+ results.push({ agent, count });
275
+ console.log(` ${chalk.green('✓')} ${agent.name.padEnd(14)} ${chalk.dim('→')} ${agent.skillsPath.replace(homedir(), '~').padEnd(25)} ${chalk.green(count + ' skills')}`);
276
+ }
277
+
278
+ spinner.stop();
279
+
280
+ // Update lock file
281
+ const lock = readLock();
282
+ lock.version = '1.0.0';
283
+ lock.installedAt = new Date().toISOString();
284
+ lock.skills = skills;
285
+ lock.agents = agents.map(a => a.id);
286
+ writeLock(lock);
287
+
288
+ return skills.length;
289
+ } catch (error) {
290
+ spinner.fail('Installation failed');
291
+ throw error;
292
+ }
293
+ }
294
+
295
+ /**
296
+ * List installed skills by scanning actual folders
297
+ */
298
+ export function listInstalledSkills() {
299
+ // Check if canonical skills directory exists
300
+ if (!existsSync(CANONICAL_DIR)) {
301
+ console.log(chalk.yellow(' No skills installed yet.'));
302
+ console.log();
303
+ console.log(` Run ${chalk.cyan('npx @syntheticsciences/ai-research-skills')} to install skills.`);
304
+ return;
305
+ }
306
+
307
+ // Scan the actual skills directory
308
+ const categories = readdirSync(CANONICAL_DIR, { withFileTypes: true })
309
+ .filter(d => d.isDirectory())
310
+ .map(d => d.name)
311
+ .sort();
312
+
313
+ if (categories.length === 0) {
314
+ console.log(chalk.yellow(' No skills installed yet.'));
315
+ console.log();
316
+ console.log(` Run ${chalk.cyan('npx @syntheticsciences/ai-research-skills')} to install skills.`);
317
+ return;
318
+ }
319
+
320
+ const byCategory = {};
321
+ let totalSkills = 0;
322
+
323
+ for (const category of categories) {
324
+ const categoryPath = join(CANONICAL_DIR, category);
325
+
326
+ // Check if it's a standalone skill (has SKILL.md directly)
327
+ const standaloneSkill = join(categoryPath, 'SKILL.md');
328
+ if (existsSync(standaloneSkill)) {
329
+ byCategory[category] = [category];
330
+ totalSkills++;
331
+ } else {
332
+ // It's a category with nested skills
333
+ const skills = readdirSync(categoryPath, { withFileTypes: true })
334
+ .filter(d => d.isDirectory() && existsSync(join(categoryPath, d.name, 'SKILL.md')))
335
+ .map(d => d.name)
336
+ .sort();
337
+
338
+ if (skills.length > 0) {
339
+ byCategory[category] = skills;
340
+ totalSkills += skills.length;
341
+ }
342
+ }
343
+ }
344
+
345
+ console.log(chalk.white.bold(` Installed Skills (${totalSkills})`));
346
+ console.log();
347
+
348
+ for (const [category, skills] of Object.entries(byCategory)) {
349
+ console.log(chalk.cyan(` ${category}`));
350
+ for (const skill of skills) {
351
+ if (skill === category) {
352
+ // Standalone skill
353
+ console.log(` ${chalk.dim('●')} ${chalk.white('(standalone)')}`);
354
+ } else {
355
+ console.log(` ${chalk.dim('●')} ${skill}`);
356
+ }
357
+ }
358
+ console.log();
359
+ }
360
+
361
+ // Show storage location
362
+ console.log(chalk.dim(` Location: ${CANONICAL_DIR.replace(homedir(), '~')}`));
363
+ }
364
+
365
+ /**
366
+ * Get all category IDs
367
+ */
368
+ export function getAllCategoryIds() {
369
+ return [
370
+ '01-model-architecture',
371
+ '02-tokenization',
372
+ '03-fine-tuning',
373
+ '04-mechanistic-interpretability',
374
+ '05-data-processing',
375
+ '06-post-training',
376
+ '07-safety-alignment',
377
+ '08-distributed-training',
378
+ '09-infrastructure',
379
+ '10-optimization',
380
+ '11-evaluation',
381
+ '12-inference-serving',
382
+ '13-mlops',
383
+ '14-agents',
384
+ '15-rag',
385
+ '16-prompt-engineering',
386
+ '17-observability',
387
+ '18-multimodal',
388
+ '19-emerging-techniques',
389
+ '20-ml-paper-writing',
390
+ ];
391
+ }
392
+
393
+ /**
394
+ * Get installed skill paths for updating
395
+ * Returns array like ['06-post-training/verl', '20-ml-paper-writing']
396
+ */
397
+ export function getInstalledSkillPaths() {
398
+ if (!existsSync(CANONICAL_DIR)) {
399
+ return [];
400
+ }
401
+
402
+ const skillPaths = [];
403
+ const categories = readdirSync(CANONICAL_DIR, { withFileTypes: true })
404
+ .filter(d => d.isDirectory())
405
+ .map(d => d.name);
406
+
407
+ for (const category of categories) {
408
+ const categoryPath = join(CANONICAL_DIR, category);
409
+
410
+ // Check if it's a standalone skill (has SKILL.md directly)
411
+ const standaloneSkill = join(categoryPath, 'SKILL.md');
412
+ if (existsSync(standaloneSkill)) {
413
+ skillPaths.push(category);
414
+ } else {
415
+ // It's a category with nested skills
416
+ const skills = readdirSync(categoryPath, { withFileTypes: true })
417
+ .filter(d => d.isDirectory() && existsSync(join(categoryPath, d.name, 'SKILL.md')))
418
+ .map(d => d.name);
419
+
420
+ for (const skill of skills) {
421
+ skillPaths.push(`${category}/${skill}`);
422
+ }
423
+ }
424
+ }
425
+
426
+ return skillPaths;
427
+ }
428
+
429
+ /**
430
+ * Update only installed skills (re-download from GitHub)
431
+ */
432
+ export async function updateInstalledSkills(agents) {
433
+ const installedPaths = getInstalledSkillPaths();
434
+
435
+ if (installedPaths.length === 0) {
436
+ console.log(chalk.yellow(' No skills installed to update.'));
437
+ return 0;
438
+ }
439
+
440
+ const spinner = ora('Updating from GitHub...').start();
441
+
442
+ try {
443
+ // Download only the installed skills
444
+ const skills = await downloadSpecificSkills(installedPaths, spinner);
445
+ spinner.succeed(`Updated ${skills.length} skills`);
446
+
447
+ // Re-create symlinks for each agent
448
+ spinner.start('Refreshing symlinks...');
449
+
450
+ for (const agent of agents) {
451
+ const count = createSymlinks(agent, skills, spinner);
452
+ console.log(` ${chalk.green('✓')} ${agent.name.padEnd(14)} ${chalk.dim('→')} ${agent.skillsPath.replace(homedir(), '~').padEnd(25)} ${chalk.green(count + ' skills')}`);
453
+ }
454
+
455
+ spinner.stop();
456
+
457
+ // Update lock file
458
+ const lock = readLock();
459
+ lock.version = '1.0.0';
460
+ lock.installedAt = new Date().toISOString();
461
+ lock.skills = skills;
462
+ lock.agents = agents.map(a => a.id);
463
+ writeLock(lock);
464
+
465
+ return skills.length;
466
+ } catch (error) {
467
+ spinner.fail('Update failed');
468
+ throw error;
469
+ }
470
+ }
471
+
472
+ /**
473
+ * Uninstall all skills
474
+ */
475
+ export async function uninstallAllSkills(agents) {
476
+ const spinner = ora('Removing skills...').start();
477
+
478
+ try {
479
+ // Remove symlinks from each agent
480
+ for (const agent of agents) {
481
+ if (existsSync(agent.skillsPath)) {
482
+ const entries = readdirSync(agent.skillsPath, { withFileTypes: true });
483
+ for (const entry of entries) {
484
+ const linkPath = join(agent.skillsPath, entry.name);
485
+ // Only remove if it's a symlink pointing to our canonical dir
486
+ try {
487
+ const stats = lstatSync(linkPath);
488
+ if (stats.isSymbolicLink()) {
489
+ rmSync(linkPath, { force: true });
490
+ }
491
+ } catch {
492
+ // Ignore errors
493
+ }
494
+ }
495
+ }
496
+ console.log(` ${chalk.green('✓')} Removed symlinks from ${agent.name}`);
497
+ }
498
+
499
+ // Remove canonical skills directory
500
+ if (existsSync(CANONICAL_DIR)) {
501
+ rmSync(CANONICAL_DIR, { recursive: true, force: true });
502
+ console.log(` ${chalk.green('✓')} Removed ${CANONICAL_DIR.replace(homedir(), '~')}`);
503
+ }
504
+
505
+ // Remove lock file
506
+ if (existsSync(LOCK_FILE)) {
507
+ rmSync(LOCK_FILE, { force: true });
508
+ }
509
+
510
+ spinner.stop();
511
+ return true;
512
+ } catch (error) {
513
+ spinner.fail('Uninstall failed');
514
+ throw error;
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Uninstall specific skills
520
+ * @param {Array<string>} skillPaths - Paths like ['06-post-training/verl', '20-ml-paper-writing']
521
+ * @param {Array} agents - List of agents to remove symlinks from
522
+ */
523
+ export async function uninstallSpecificSkills(skillPaths, agents) {
524
+ const spinner = ora('Removing selected skills...').start();
525
+
526
+ try {
527
+ for (const skillPath of skillPaths) {
528
+ const parts = skillPath.split('/');
529
+ const categoryId = parts[0];
530
+ const skillName = parts[1] || null;
531
+
532
+ // Determine the link name (what appears in agent's skills folder)
533
+ const linkName = skillName || categoryId;
534
+
535
+ // Remove symlinks from each agent
536
+ for (const agent of agents) {
537
+ const linkPath = join(agent.skillsPath, linkName);
538
+ try {
539
+ if (existsSync(linkPath)) {
540
+ const stats = lstatSync(linkPath);
541
+ if (stats.isSymbolicLink()) {
542
+ rmSync(linkPath, { force: true });
543
+ }
544
+ }
545
+ } catch {
546
+ // Ignore errors
547
+ }
548
+ }
549
+
550
+ // Remove from canonical directory
551
+ if (skillName) {
552
+ // Nested skill like '06-post-training/verl'
553
+ const skillDir = join(CANONICAL_DIR, categoryId, skillName);
554
+ if (existsSync(skillDir)) {
555
+ rmSync(skillDir, { recursive: true, force: true });
556
+ }
557
+ // Clean up empty category folder
558
+ const categoryDir = join(CANONICAL_DIR, categoryId);
559
+ if (existsSync(categoryDir)) {
560
+ const remaining = readdirSync(categoryDir);
561
+ if (remaining.length === 0) {
562
+ rmSync(categoryDir, { recursive: true, force: true });
563
+ }
564
+ }
565
+ } else {
566
+ // Standalone skill like '20-ml-paper-writing'
567
+ const skillDir = join(CANONICAL_DIR, categoryId);
568
+ if (existsSync(skillDir)) {
569
+ rmSync(skillDir, { recursive: true, force: true });
570
+ }
571
+ }
572
+
573
+ spinner.text = `Removed ${linkName}`;
574
+ }
575
+
576
+ spinner.succeed(`Removed ${skillPaths.length} skill${skillPaths.length !== 1 ? 's' : ''}`);
577
+
578
+ // Update lock file
579
+ const lock = readLock();
580
+ if (lock.skills) {
581
+ lock.skills = lock.skills.filter(s => {
582
+ const path = s.standalone ? s.category : `${s.category}/${s.skill}`;
583
+ return !skillPaths.includes(path);
584
+ });
585
+ writeLock(lock);
586
+ }
587
+
588
+ return skillPaths.length;
589
+ } catch (error) {
590
+ spinner.fail('Uninstall failed');
591
+ throw error;
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Get installed skills with display info for selection
597
+ * Returns array of { path, name, category } for UI
598
+ */
599
+ export function getInstalledSkillsForSelection() {
600
+ const paths = getInstalledSkillPaths();
601
+ return paths.map(path => {
602
+ const parts = path.split('/');
603
+ if (parts.length === 1) {
604
+ // Standalone skill
605
+ return { path, name: parts[0], category: 'Standalone', standalone: true };
606
+ } else {
607
+ // Nested skill
608
+ return { path, name: parts[1], category: parts[0], standalone: false };
609
+ }
610
+ });
611
+ }