sra-skills 0.14.13 → 0.16.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.
Files changed (4) hide show
  1. package/index.mjs +359 -65
  2. package/lib.mjs +36 -0
  3. package/manage.mjs +21 -6
  4. package/package.json +1 -1
package/index.mjs CHANGED
@@ -9,11 +9,198 @@ import {
9
9
  mergeCredentials, warnMissingCredentials,
10
10
  runToolSetup, runPostInstall, runPostUninstall, promptSkillDeps, findSkillsWithDeps,
11
11
  ensureIdentity, resolveSkillDependencyClosure, planSkillRemovals,
12
+ loadProfiles, getProfileSkills, validateProfileSkills,
12
13
  } from './lib.mjs';
13
14
  import { existsSync, readFileSync, writeFileSync, mkdirSync, lstatSync, unlinkSync, rmSync } from 'fs';
14
15
  import { join, basename, resolve } from 'path';
15
16
  import { execSync } from 'child_process';
16
17
 
18
+ // --- Profile helpers (identity/role detection) ---
19
+
20
+ const IDENTITY_CACHE = join(SRA_HOME, 'identity.json');
21
+ const IDENTITY_TTL_HOURS = 24;
22
+ const WHOIS_API_URL = 'https://issues.aiassistant.mpsra.shopee.io/api/whois';
23
+ const WHOIS_API_TOKEN = 'wczlkTZazZCWG80wq9OGNH2J8To-bZ6NXOee3dDQX_E';
24
+ const WHOIS_TIMEOUT = 5000;
25
+
26
+ const DEPT_TO_TEAM = {
27
+ 'search': 'search',
28
+ 'recommendation': 'rcmd',
29
+ 'ads': 'ads',
30
+ 'engineering & architecture': 'ea',
31
+ };
32
+
33
+ function parseTeamFromDept(departmentPath) {
34
+ if (!departmentPath) return null;
35
+ const parts = departmentPath.split('/').map(p => p.trim());
36
+ const dept = (parts.length > 1 ? parts[parts.length - 1] : parts[0]).toLowerCase();
37
+ for (const [keyword, teamKey] of Object.entries(DEPT_TO_TEAM)) {
38
+ if (dept.includes(keyword)) return teamKey;
39
+ }
40
+ return null;
41
+ }
42
+
43
+ function readIdentityCache() {
44
+ if (!existsSync(IDENTITY_CACHE)) return null;
45
+ try {
46
+ const data = JSON.parse(readFileSync(IDENTITY_CACHE, 'utf8'));
47
+ if (typeof data !== 'object' || !data.profile_key) return null;
48
+ return data;
49
+ } catch { return null; }
50
+ }
51
+
52
+ function writeIdentityCache(email, team, role, profileKey) {
53
+ mkdirSync(SRA_HOME, { recursive: true });
54
+ writeFileSync(IDENTITY_CACHE, JSON.stringify({
55
+ email, team, role, profile_key: profileKey,
56
+ detected_at: new Date().toISOString(),
57
+ }, null, 2) + '\n');
58
+ }
59
+
60
+ async function fetchRoleFromApi(email) {
61
+ try {
62
+ const url = `${WHOIS_API_URL}?email=${encodeURIComponent(email)}`;
63
+ const controller = new AbortController();
64
+ const timeout = setTimeout(() => controller.abort(), WHOIS_TIMEOUT);
65
+ const resp = await fetch(url, {
66
+ signal: controller.signal,
67
+ headers: { Accept: 'application/json', Authorization: `Bearer ${WHOIS_API_TOKEN}` },
68
+ });
69
+ clearTimeout(timeout);
70
+ const result = await resp.json();
71
+ if (!result.success) return null;
72
+ const data = result.data || {};
73
+ const team = parseTeamFromDept(data.department_path || '');
74
+ const role = (data.role || '').toLowerCase().trim();
75
+ if (team && role) return [team, role];
76
+ } catch {}
77
+ return null;
78
+ }
79
+
80
+ async function detectRole(email) {
81
+ if (!email) return 'other';
82
+ const cache = readIdentityCache();
83
+ if (cache && cache.email === email && cache.detected_at) {
84
+ try {
85
+ const age = Date.now() - new Date(cache.detected_at).getTime();
86
+ if (age < IDENTITY_TTL_HOURS * 3600 * 1000) return cache.profile_key;
87
+ } catch {}
88
+ }
89
+ const result = await fetchRoleFromApi(email);
90
+ if (result) {
91
+ const [team, role] = result;
92
+ const profileKey = `${team}/${role}`;
93
+ writeIdentityCache(email, team, role, profileKey);
94
+ return profileKey;
95
+ }
96
+ if (cache && cache.email === email) return cache.profile_key || 'other';
97
+ return 'other';
98
+ }
99
+
100
+ function getUserEmail() {
101
+ const credPath = join(HOME, '.config', 'sra', 'credentials.json');
102
+ if (!existsSync(credPath)) return null;
103
+ try {
104
+ const data = JSON.parse(readFileSync(credPath, 'utf8'));
105
+ return data.default?.identity || null;
106
+ } catch { return null; }
107
+ }
108
+
109
+ function computeSkillDiff(allDiscovered, profileSkillNames, enabled, disabled) {
110
+ const discoveredNames = new Set(allDiscovered.map(s => s.name));
111
+ const effEnabled = new Set([...enabled].filter(n => discoveredNames.has(n)));
112
+ const effDisabled = new Set([...disabled].filter(n => discoveredNames.has(n)));
113
+ const hasProfile = profileSkillNames !== null;
114
+
115
+ const keep = new Set();
116
+ const newProfile = new Set();
117
+ const newOther = new Set();
118
+ const removedFromProfile = new Set();
119
+ const forcedDisabled = new Set();
120
+
121
+ for (const s of allDiscovered) {
122
+ const { name } = s;
123
+ if (effEnabled.has(name)) {
124
+ if (hasProfile && profileSkillNames.size && !profileSkillNames.has(name)) {
125
+ removedFromProfile.add(name);
126
+ } else {
127
+ keep.add(name);
128
+ }
129
+ } else if (effDisabled.has(name)) {
130
+ forcedDisabled.add(name);
131
+ } else {
132
+ if (hasProfile && profileSkillNames.size && profileSkillNames.has(name)) {
133
+ newProfile.add(name);
134
+ } else {
135
+ newOther.add(name);
136
+ }
137
+ }
138
+ }
139
+ return { keep, newProfile, newOther, removedFromProfile, forcedDisabled };
140
+ }
141
+
142
+ function parseSkillsFile(path) {
143
+ try {
144
+ const result = new Set();
145
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
146
+ const t = line.trim();
147
+ if (t && !t.startsWith('#')) result.add(t);
148
+ }
149
+ return result;
150
+ } catch (e) {
151
+ console.error(`Error reading skills file ${path}: ${e.message}`);
152
+ process.exit(1);
153
+ }
154
+ }
155
+
156
+ async function selectSkillsWithProfile(skills, profileSkillNames, profileKey, profilesData) {
157
+ if (!process.stdin.isTTY) return [skills.filter(s => profileSkillNames.has(s.name)), profileKey];
158
+ const label = profilesData?.profiles?.[profileKey]?.label || profileKey;
159
+ console.log(`\n检测到角色: ${label} (${profileKey})`);
160
+ const choices = [];
161
+ const recommended = skills.filter(s => profileSkillNames.has(s.name));
162
+ const others = skills.filter(s => !profileSkillNames.has(s.name));
163
+ if (recommended.length) {
164
+ choices.push({ name: `── 推荐 (profile + core) ──`, value: null, disabled: '' });
165
+ for (const s of recommended) choices.push({ name: s.name, value: s.name, checked: true });
166
+ }
167
+ if (others.length) {
168
+ choices.push({ name: `── 其他可用 ──`, value: null, disabled: '' });
169
+ for (const s of others) choices.push({ name: s.name, value: s.name, checked: false });
170
+ }
171
+ const selected = await checkbox({
172
+ message: `Select skills (${recommended.length} recommended / ${skills.length} total)`,
173
+ choices, pageSize: 20,
174
+ });
175
+ return [skills.filter(s => selected.includes(s.name)), profileKey];
176
+ }
177
+
178
+ async function promptSkillChanges(diff, allSkills, profileSkillNames, skipPrompt) {
179
+ const { newProfile, newOther, removedFromProfile } = diff;
180
+ if (!newProfile.size && !newOther.size && !removedFromProfile.size) return [new Set(), new Set()];
181
+ if (skipPrompt || !process.stdin.isTTY) return [newProfile, new Set()];
182
+
183
+ const choices = [];
184
+ for (const name of [...newProfile].sort()) {
185
+ choices.push({ name: `${name} \x1b[32m← NEW (推荐)\x1b[0m`, value: `enable:${name}`, checked: true });
186
+ }
187
+ for (const name of [...newOther].sort()) {
188
+ choices.push({ name: `${name} \x1b[32m← NEW\x1b[0m`, value: `enable:${name}`, checked: false });
189
+ }
190
+ for (const name of [...removedFromProfile].sort()) {
191
+ choices.push({ name: `${name} \x1b[33m← 不再推荐\x1b[0m`, value: `keep:${name}`, checked: true });
192
+ }
193
+
194
+ const selected = await checkbox({ message: 'Skill 变更:', choices, pageSize: 20 });
195
+ const selectedSet = new Set(selected);
196
+ const toEnable = new Set();
197
+ const toDisable = new Set();
198
+ for (const name of newProfile) { if (selectedSet.has(`enable:${name}`)) toEnable.add(name); }
199
+ for (const name of newOther) { if (selectedSet.has(`enable:${name}`)) toEnable.add(name); }
200
+ for (const name of removedFromProfile) { if (!selectedSet.has(`keep:${name}`)) toDisable.add(name); }
201
+ return [toEnable, toDisable];
202
+ }
203
+
17
204
  async function selectSkills(skills, previouslyEnabled = null, previouslyDisabled = null) {
18
205
  if (!process.stdin.isTTY) return skills;
19
206
  const enabledSet = previouslyEnabled instanceof Set ? previouslyEnabled : null;
@@ -100,20 +287,25 @@ function getOptionValue(args, flag) {
100
287
 
101
288
  function parseSkillsFilter(args) {
102
289
  const raw = getOptionValue(args, '--skills') || process.env.SRA_SKILLS || '';
103
- if (!raw.trim()) return null;
104
- return new Set(raw.split(',').map(s => s.trim()).filter(Boolean));
290
+ if (raw.trim()) return new Set(raw.split(',').map(s => s.trim()).filter(Boolean));
291
+ const skillsFile = getOptionValue(args, '--skills-file');
292
+ if (skillsFile) return parseSkillsFile(skillsFile);
293
+ return null;
105
294
  }
106
295
 
107
296
  if (cmd === 'add') {
108
297
  const isLocal = cmdArgs.includes('--local');
109
- const skipPrompt = cmdArgs.includes('-y') || cmdArgs.includes('--yes');
298
+ const skipPrompt = cmdArgs.includes('-y') || cmdArgs.includes('--yes') || cmdArgs.includes('--non-interactive');
299
+ const installAll = cmdArgs.includes('--all');
300
+ const resetFlag = cmdArgs.includes('--reset');
110
301
  const defaultIdentity = getOptionValue(cmdArgs, '--default-identity');
302
+ const explicitProfile = getOptionValue(cmdArgs, '--profile');
111
303
  const skillsFilter = parseSkillsFilter(cmdArgs);
112
304
  const tools = parseToolFlag(cmdArgs) || detectTools();
113
305
  let url, name, repoPath, existingRef;
114
306
 
115
307
  // Values consumed by named options must not be mistaken for the positional URL/path argument
116
- const _namedOptionFlags = ['--tool', '--name', '--ref', '--default-identity', '--skills'];
308
+ const _namedOptionFlags = ['--tool', '--name', '--ref', '--default-identity', '--skills', '--skills-file', '--profile'];
117
309
  const _consumedValues = new Set(_namedOptionFlags.map(f => getOptionValue(cmdArgs, f)).filter(Boolean));
118
310
  const _positional = a => !a.startsWith('-') && !_consumedValues.has(a);
119
311
 
@@ -144,7 +336,12 @@ if (cmd === 'add') {
144
336
  ' --tool <tool> Target AI tool (claude, cursor, codex; auto-detected)\n' +
145
337
  ' --name <name> Override local repo name\n' +
146
338
  ' --skills <a,b,...> Install only specific skills (comma-separated)\n' +
147
- ' -y, --yes Skip interactive prompts\n'
339
+ ' --skills-file <path> Install skills listed in file (one per line)\n' +
340
+ ' --profile <key> Explicit profile (e.g. rcmd/algo), skip auto-detection\n' +
341
+ ' --all Install all skills (ignores profile)\n' +
342
+ ' --reset Clear disabled_skills and re-run profile selection\n' +
343
+ ' -y, --yes Non-interactive mode, install by profile defaults\n' +
344
+ ' --non-interactive Same as -y\n'
148
345
  );
149
346
  process.exit(1);
150
347
  }
@@ -211,17 +408,25 @@ if (cmd === 'add') {
211
408
  }
212
409
  }
213
410
 
214
- // Read previous selections if re-adding same repo
411
+ const allDiscovered = discoverSkills(repoPath);
412
+ const discoveredNames = new Set(allDiscovered.map(s => s.name));
215
413
  const manifest = readManifest();
216
414
  const existingRepo = manifest.repos[name];
217
- const previouslyEnabled = existingRepo ? new Set(existingRepo.skills || []) : null;
218
- const previouslyDisabled = existingRepo ? new Set(existingRepo.disabled_skills || []) : null;
219
415
 
220
- const allDiscovered = discoverSkills(repoPath);
416
+ const isRepeat = existingRepo != null && !resetFlag;
417
+ let enabled = isRepeat ? new Set(existingRepo.skills || []) : new Set();
418
+ let disabled = isRepeat ? new Set(existingRepo.disabled_skills || []) : new Set();
419
+ const savedProfile = isRepeat ? existingRepo.profile || null : null;
420
+
421
+ // Merge credentials first (backs up malformed files), then collect identity
422
+ const credPath = mergeCredentials(repoPath);
423
+ await ensureIdentity(skipPrompt, defaultIdentity);
424
+
425
+ let profileKeyUsed = null;
221
426
  let skills;
427
+
222
428
  if (skillsFilter) {
223
- const knownNames = new Set(allDiscovered.map(s => s.name));
224
- const unknown = [...skillsFilter].filter(n => !knownNames.has(n));
429
+ const unknown = [...skillsFilter].filter(n => !discoveredNames.has(n));
225
430
  if (unknown.length) console.warn(`Warning: requested skill(s) not found in repo: ${unknown.join(', ')}`);
226
431
  skills = allDiscovered.filter(s => skillsFilter.has(s.name));
227
432
  if (!skills.length) {
@@ -229,25 +434,66 @@ if (cmd === 'add') {
229
434
  allDiscovered.map(s => s.name).join(', '));
230
435
  process.exit(1);
231
436
  }
232
- } else if (!skipPrompt) {
233
- skills = await selectSkills(allDiscovered, previouslyEnabled, previouslyDisabled);
234
- } else {
437
+ } else if (installAll) {
235
438
  skills = allDiscovered;
439
+ } else {
440
+ const profilesData = loadProfiles(repoPath);
441
+ if (!profilesData) {
442
+ if (isRepeat) {
443
+ skills = allDiscovered.filter(s => !disabled.has(s.name));
444
+ } else if (skipPrompt) {
445
+ skills = allDiscovered;
446
+ } else {
447
+ skills = await selectSkills(allDiscovered);
448
+ }
449
+ } else {
450
+ if (explicitProfile) {
451
+ profileKeyUsed = explicitProfile;
452
+ } else if (savedProfile && isRepeat) {
453
+ profileKeyUsed = savedProfile;
454
+ } else {
455
+ const email = defaultIdentity || getUserEmail();
456
+ profileKeyUsed = await detectRole(email);
457
+ }
458
+ let [resolvedKey, profileSkillNames] = getProfileSkills(profilesData, profileKeyUsed);
459
+ profileKeyUsed = resolvedKey;
460
+ profileSkillNames = validateProfileSkills(profileSkillNames, discoveredNames);
461
+
462
+ if (isRepeat) {
463
+ const diff = computeSkillDiff(allDiscovered, profileSkillNames, enabled, disabled);
464
+ const [toEnable, toDisable] = skipPrompt
465
+ ? [diff.newProfile, new Set()]
466
+ : await promptSkillChanges(diff, allDiscovered, profileSkillNames, false);
467
+ const finalEnabled = new Set([...diff.keep, ...diff.removedFromProfile, ...toEnable]);
468
+ for (const n of toDisable) finalEnabled.delete(n);
469
+ skills = allDiscovered.filter(s => finalEnabled.has(s.name));
470
+ disabled = new Set([...disabled, ...toDisable].filter(n => !toEnable.has(n) && discoveredNames.has(n)));
471
+ } else {
472
+ if (skipPrompt) {
473
+ skills = allDiscovered.filter(s => profileSkillNames.has(s.name));
474
+ } else {
475
+ [skills, profileKeyUsed] = await selectSkillsWithProfile(
476
+ allDiscovered, profileSkillNames, profileKeyUsed, profilesData);
477
+ }
478
+ }
479
+ }
236
480
  }
481
+
237
482
  const resolvedInitial = resolveSkillDependencyClosure(allDiscovered, skills.map(s => s.name));
238
483
  requireResolvedDependencies(name, resolvedInitial);
239
484
  logAutoEnabledDependencies(resolvedInitial.autoAdded);
240
485
  skills = resolvedInitial.skills;
486
+ for (const dep of resolvedInitial.autoAdded) disabled.delete(dep);
487
+
241
488
  console.log(`Installing ${skills.length} skills, tools: [${tools.join(', ')}]`);
242
489
  linkSkills(skills, tools);
243
- const credPath = mergeCredentials(repoPath);
244
- await ensureIdentity(skipPrompt, defaultIdentity);
245
490
  runToolSetup(repoPath, tools);
246
491
  runPostInstall(repoPath);
247
492
 
248
- // Compute disabled_skills from unselected skills
249
493
  const selectedNames = new Set(skills.map(s => s.name));
250
- const disabledSkills = allDiscovered.filter(s => !selectedNames.has(s.name)).map(s => s.name);
494
+ const disabledList = profileKeyUsed != null
495
+ ? [...disabled].sort()
496
+ : [...discoveredNames].filter(n => !selectedNames.has(n)).sort();
251
497
 
252
498
  const actualBranch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: repoPath, encoding: 'utf8' }).trim();
253
499
  const savedRef = actualBranch !== 'HEAD' ? actualBranch : (getOptionValue(cmdArgs, '--ref') || existingRef || 'release');
@@ -256,7 +502,8 @@ if (cmd === 'add') {
256
502
  ...(credPath && { credential_path: credPath }),
257
503
  version: getVersion(repoPath),
258
504
  skills: skills.map(s => s.name),
259
- disabled_skills: disabledSkills,
505
+ disabled_skills: disabledList,
506
+ profile: profileKeyUsed,
260
507
  updated_at: new Date().toISOString()
261
508
  };
262
509
  manifest.tools = tools;
@@ -292,14 +539,18 @@ if (cmd === 'add') {
292
539
  } else if (cmd === 'update') {
293
540
  const m = readManifest();
294
541
  const tools = parseToolFlag(cmdArgs) || m.tools || detectTools();
295
- const skipPrompt = cmdArgs.includes('-y') || cmdArgs.includes('--yes');
542
+ const skipPrompt = cmdArgs.includes('-y') || cmdArgs.includes('--yes') || cmdArgs.includes('--non-interactive');
296
543
  const forceUpdate = cmdArgs.includes('--force') || cmdArgs.includes('-f');
544
+ const installAllFlag = cmdArgs.includes('--all');
545
+ const resetFlag = cmdArgs.includes('--reset');
297
546
  const identityIdx = cmdArgs.indexOf('--default-identity');
298
547
  const defaultIdentity = identityIdx >= 0 ? cmdArgs[identityIdx + 1] : null;
299
- const target = cmdArgs.find(a => !a.startsWith('-'));
548
+ const _consumedUpdateValues = new Set([defaultIdentity, getOptionValue(cmdArgs, '--tool')].filter(Boolean));
549
+ const target = cmdArgs.find(a => !a.startsWith('-') && !_consumedUpdateValues.has(a));
300
550
  const targets = target ? [target] : Object.keys(m.repos);
301
551
  const depsEntries = [];
302
552
  const updatedRepos = new Set();
553
+ const processedRepos = new Set();
303
554
  for (const name of targets) {
304
555
  const repo = m.repos[name];
305
556
  if (!repo) { console.error(`Repo "${name}" not found`); continue; }
@@ -307,30 +558,44 @@ if (cmd === 'add') {
307
558
  const isCloned = !!repo.url; // --local installs have no url
308
559
  if (isCloned) {
309
560
  const prevRev = execSync('git rev-parse HEAD', { cwd: repo.path, encoding: 'utf8' }).trim();
310
- const ref = repo.ref || 'release';
561
+ let ref = repo.ref || 'release';
311
562
  if (!repo.ref) repo.ref = ref; // backfill for pre-v2.0.1 installs
312
563
  const current = execSync('git rev-parse --abbrev-ref HEAD', { cwd: repo.path, encoding: 'utf8' }).trim();
313
564
  let activeBranch = ref;
314
565
  // Try to fetch as tag first; if that fails, treat as branch
315
566
  let isTag = false;
567
+ let updateOk = false;
568
+ let fetchOk = false;
316
569
  try {
317
570
  execSync(`git fetch --depth 1 origin "refs/tags/${ref}:refs/tags/${ref}"`, { cwd: repo.path, stdio: 'pipe' });
318
571
  isTag = true;
572
+ fetchOk = true;
319
573
  } catch {
320
574
  try {
321
575
  execSync(`git fetch --depth 1 origin "${ref}"`, { cwd: repo.path, stdio: 'pipe' });
576
+ fetchOk = true;
322
577
  } catch {
323
578
  console.log(` ⚠ Ref "${ref}" not found on remote, staying on ${current}`);
324
579
  activeBranch = current;
580
+ if (current !== 'HEAD') {
581
+ console.log(` → Auto-correcting manifest ref: "${ref}" → "${current}"`);
582
+ repo.ref = current;
583
+ ref = current;
584
+ try {
585
+ execSync(`git fetch --depth 1 origin "${current}"`, { cwd: repo.path, stdio: 'pipe' });
586
+ fetchOk = true;
587
+ } catch {}
588
+ }
325
589
  }
326
590
  }
327
- if (activeBranch === ref) {
591
+ if (fetchOk && activeBranch === ref) {
328
592
  try {
329
593
  if (isTag) {
330
594
  execSync(`git checkout "tags/${ref}" --detach`, { cwd: repo.path, stdio: 'pipe' });
331
595
  } else {
332
596
  execSync(`git checkout -B "${ref}" FETCH_HEAD`, { cwd: repo.path, stdio: 'pipe' });
333
597
  }
598
+ updateOk = true;
334
599
  if (current !== ref && !(current === 'HEAD' && isTag)) console.log(` Switched to ${ref}`);
335
600
  } catch (e) {
336
601
  console.error(` ⚠ git update failed: ${e.stderr?.toString().trim() || e.message}`);
@@ -338,9 +603,12 @@ if (cmd === 'add') {
338
603
  }
339
604
  try { execSync('git fetch --tags', { cwd: repo.path, stdio: 'ignore' }); } catch {}
340
605
  const newRev = execSync('git rev-parse HEAD', { cwd: repo.path, encoding: 'utf8' }).trim();
341
- if (prevRev === newRev && !forceUpdate) {
342
- console.log(` Already up to date (${newRev.slice(0, 7)})`);
343
- repo.updated_at = new Date().toISOString();
606
+ if (prevRev === newRev && !forceUpdate && !installAllFlag && !resetFlag) {
607
+ if (updateOk) {
608
+ console.log(` Already up to date (${newRev.slice(0, 7)})`);
609
+ repo.updated_at = new Date().toISOString();
610
+ processedRepos.add(name);
611
+ }
344
612
  continue;
345
613
  }
346
614
  console.log(` Updated ${prevRev.slice(0, 7)} → ${newRev.slice(0, 7)}`);
@@ -355,13 +623,12 @@ if (cmd === 'add') {
355
623
  console.log(` "url": "${remoteUrl || '<git-remote-url>'}",`);
356
624
  console.log(` Or: git -C ${repo.path} pull, then re-run ./scripts/install.sh`);
357
625
  repo.updated_at = new Date().toISOString();
358
- continue;
359
626
  }
360
627
  const allSkills = discoverSkills(repo.path);
361
628
  const saved = repo.skills;
362
- const disabled = new Set(repo.disabled_skills || []);
629
+ let disabled = new Set(repo.disabled_skills || []);
363
630
  const discovered = new Set(allSkills.map(s => s.name));
364
- // Clean up stale skills (in manifest but no longer on disk — handles renames)
631
+ // Clean up stale skills (in manifest but no longer on disk)
365
632
  if (saved) {
366
633
  const stale = saved.filter(n => !discovered.has(n)).map(n => ({ name: n }));
367
634
  if (stale.length) {
@@ -369,34 +636,56 @@ if (cmd === 'add') {
369
636
  stale.forEach(s => console.log(` ✕ ${s.name} (removed)`));
370
637
  }
371
638
  }
372
- // Purge disabled entries that no longer exist on disk (deleted/renamed)
373
- if (disabled.size) {
374
- for (const n of [...disabled]) { if (!discovered.has(n)) disabled.delete(n); }
375
- }
639
+ // Purge disabled entries that no longer exist on disk
640
+ for (const n of [...disabled]) { if (!discovered.has(n)) disabled.delete(n); }
376
641
 
377
- // Detect new skills (not previously known not in saved skills or disabled list)
378
- const previouslyKnown = new Set([...(saved || []), ...disabled]);
379
- const newSkills = allSkills.filter(s => !previouslyKnown.has(s.name));
642
+ if (resetFlag) disabled = new Set();
380
643
 
381
- // Prompt user for new skills
382
- const selectedNew = await promptNewSkills(newSkills, skipPrompt);
383
- // Use name-based comparison (not object reference) for safety
384
- const selectedNewNames = new Set(selectedNew.map(s => s.name));
385
- const rejectedNew = newSkills.filter(s => !selectedNewNames.has(s.name));
386
-
387
- // IMPORTANT: disabled must be fully populated (including rejectedNew) BEFORE
388
- // the final skills filter runs, otherwise rejected skills leak into enabled list
389
- rejectedNew.forEach(s => disabled.add(s.name));
390
- repo.disabled_skills = [...disabled].filter(n => discovered.has(n));
644
+ let skills;
645
+ if (installAllFlag) {
646
+ skills = allSkills;
647
+ repo.profile = null;
648
+ } else {
649
+ const profilesData = loadProfiles(repo.path);
650
+ let profileKey = repo.profile || null;
651
+
652
+ if (profilesData && profileKey) {
653
+ const [resolvedKey, profileSkillNames] = getProfileSkills(profilesData, profileKey);
654
+ if (resolvedKey !== profileKey) repo.profile = resolvedKey;
655
+ const validatedProfileSkills = validateProfileSkills(profileSkillNames, discovered);
656
+
657
+ const enabledSet = saved ? new Set(saved) : new Set();
658
+ const diff = computeSkillDiff(allSkills, validatedProfileSkills, enabledSet, disabled);
659
+ const [toEnable, toDisable] = skipPrompt
660
+ ? [diff.newProfile, new Set()]
661
+ : await promptSkillChanges(diff, allSkills, validatedProfileSkills, false);
662
+
663
+ const finalEnabled = new Set([...diff.keep, ...diff.removedFromProfile, ...toEnable]);
664
+ for (const n of toDisable) finalEnabled.delete(n);
665
+ skills = allSkills.filter(s => finalEnabled.has(s.name));
666
+ disabled = new Set([...disabled, ...toDisable].filter(n => !toEnable.has(n) && discovered.has(n)));
667
+ } else {
668
+ // No profile — legacy: detect new skills, prompt, install all except disabled
669
+ const previouslyKnown = new Set([...(saved || []), ...disabled]);
670
+ const newSkills = allSkills.filter(s => !previouslyKnown.has(s.name));
671
+ const selectedNew = await promptNewSkills(newSkills, skipPrompt);
672
+ const selectedNewNames = new Set(selectedNew.map(s => s.name));
673
+ const rejectedNew = newSkills.filter(s => !selectedNewNames.has(s.name));
674
+ rejectedNew.forEach(s => disabled.add(s.name));
675
+ skills = allSkills.filter(s => !disabled.has(s.name));
676
+ }
677
+ }
391
678
 
392
- // Link: all non-disabled skills (previously enabled + newly selected)
393
- let skills = allSkills.filter(s => !disabled.has(s.name));
394
679
  const resolvedUpdate = resolveSkillDependencyClosure(allSkills, skills.map(s => s.name));
395
680
  requireResolvedDependencies(name, resolvedUpdate);
396
681
  resolvedUpdate.autoAdded.forEach(dep => disabled.delete(dep));
397
682
  logAutoEnabledDependencies(resolvedUpdate.autoAdded);
398
683
  skills = resolvedUpdate.skills;
399
- repo.disabled_skills = [...disabled].filter(n => discovered.has(n) && !skills.some(s => s.name === n));
684
+ repo.disabled_skills = [...disabled].filter(n => discovered.has(n) && !skills.some(s => s.name === n)).sort();
685
+ // Unlink previously enabled skills that are now disabled
686
+ const prevEnabled = new Set(saved || []);
687
+ const newlyDisabled = [...prevEnabled].filter(n => !skills.some(s => s.name === n));
688
+ if (newlyDisabled.length) unlinkSkills(newlyDisabled.map(n => ({ name: n })), tools);
400
689
  linkSkills(skills, tools);
401
690
  const updCredPath = mergeCredentials(repo.path);
402
691
  if (updCredPath) {
@@ -426,22 +715,18 @@ if (cmd === 'add') {
426
715
  const repo = Object.values(m.repos).find(r => r.path === repoPath);
427
716
  warnMissingCredentials(skills, repo?.credential_path || DEFAULT_CRED_PATH, repoPath);
428
717
  }
429
- // Clean up pending files: remove updated skills, then purge any entries
430
- // no longer present in the manifest (renamed/removed skills).
431
- const allKnownSkills = new Set(Object.values(m.repos).flatMap(r => r.skills || []));
432
- const updatedSkills = new Set(
433
- [...updatedRepos].flatMap(name => m.repos[name]?.skills || [])
434
- );
435
- const cacheDir = join(SRA_HOME, 'update-cache');
436
- for (const fname of ['bulk-pending', 'hook-pending']) {
437
- const f = join(cacheDir, fname);
438
- if (!existsSync(f)) continue;
439
- const remaining = readFileSync(f, 'utf8').split('\n')
440
- .filter(l => l && !updatedSkills.has(l) && allKnownSkills.has(l));
718
+ const availableFile = join(SRA_HOME, 'updates', 'available');
719
+ if (existsSync(availableFile)) {
720
+ const remaining = readFileSync(availableFile, 'utf8').split('\n')
721
+ .filter(l => {
722
+ if (!l.trim()) return false;
723
+ try { const n = JSON.parse(l).name; return !updatedRepos.has(n) && !processedRepos.has(n); }
724
+ catch { return false; }
725
+ });
441
726
  if (remaining.length > 0) {
442
- writeFileSync(f, remaining.join('\n') + '\n');
727
+ writeFileSync(availableFile, remaining.join('\n') + '\n');
443
728
  } else {
444
- unlinkSync(f);
729
+ unlinkSync(availableFile);
445
730
  }
446
731
  }
447
732
 
@@ -661,10 +946,12 @@ Commands:
661
946
  const m = readManifest();
662
947
  for (const [name, info] of Object.entries(m.repos)) {
663
948
  const allSkills = discoverSkills(info.path);
949
+ const hasSkillsField = Array.isArray(info.skills);
950
+ const installed = new Set(info.skills || []);
664
951
  const disabled = new Set(info.disabled_skills || []);
665
952
  console.log(`${name} (${info.version || 'unknown'}):`);
666
953
  allSkills.forEach(s => {
667
- const enabled = !disabled.has(s.name);
954
+ const enabled = hasSkillsField ? installed.has(s.name) : !disabled.has(s.name);
668
955
  console.log(` ${enabled ? '' : '(disabled) '}${s.name}`);
669
956
  });
670
957
  }
@@ -694,6 +981,8 @@ Commands:
694
981
  remove <repo-name> Unlink skills, remove repo
695
982
  update [repo-name] Git pull, re-link, re-configure (skips if up to date)
696
983
  -f, --force Force re-link even if no changes
984
+ --all Install all skills (ignores profile)
985
+ --reset Clear disabled_skills, re-apply profile
697
986
  manage Interactive skill manager (TUI)
698
987
  skill add <name...> Enable skill(s) from an installed repo
699
988
  skill remove <name...> Disable skill(s) (remove symlinks)
@@ -710,5 +999,10 @@ Options:
710
999
  --ref <branch|tag> Git branch or tag (default: release)
711
1000
  --skills <list> Comma-separated skills to install (e.g. sra-release,sra-ego-job-submit)
712
1001
  Also reads from SRA_SKILLS env var
713
- -y, --yes Skip interactive selection (install all)`);
1002
+ --skills-file <path> Install skills listed in file (one per line)
1003
+ --profile <key> Explicit profile (e.g. rcmd/algo), skip auto-detection
1004
+ --all Install all skills (ignores profile)
1005
+ --reset Clear disabled_skills and re-run profile selection
1006
+ -y, --yes Non-interactive mode, install by profile defaults
1007
+ --non-interactive Same as -y`);
714
1008
  }
package/lib.mjs CHANGED
@@ -818,3 +818,39 @@ export async function promptSkillDeps(entries) {
818
818
  console.log(` To install later: bash "${script}"`);
819
819
  }
820
820
  }
821
+
822
+ // --- Profile helpers ---
823
+
824
+ export function loadProfiles(repoPath) {
825
+ const p = join(repoPath, 'config', 'profiles.json');
826
+ if (!existsSync(p)) return null;
827
+ try {
828
+ const data = JSON.parse(readFileSync(p, 'utf8'));
829
+ if (typeof data !== 'object' || !data.profiles) return null;
830
+ return data;
831
+ } catch {
832
+ console.error(`Warning: ${p} is invalid, skipping profiles`);
833
+ return null;
834
+ }
835
+ }
836
+
837
+ export function getProfileSkills(profilesData, profileKey) {
838
+ if (!profilesData) return [null, null];
839
+ const core = new Set(profilesData.core || []);
840
+ const profiles = profilesData.profiles || {};
841
+ if (profileKey in profiles) {
842
+ return [profileKey, new Set([...core, ...(profiles[profileKey].skills || [])])];
843
+ }
844
+ if ('other' in profiles) {
845
+ return ['other', new Set([...core, ...(profiles['other'].skills || [])])];
846
+ }
847
+ return ['other', core];
848
+ }
849
+
850
+ export function validateProfileSkills(profileSkillNames, discoveredNames) {
851
+ const missing = [...profileSkillNames].filter(n => !discoveredNames.has(n));
852
+ if (missing.length) {
853
+ console.error(` Warning: profile references unknown skills: ${missing.sort().join(', ')}`);
854
+ }
855
+ return new Set([...profileSkillNames].filter(n => discoveredNames.has(n)));
856
+ }
package/manage.mjs CHANGED
@@ -3,6 +3,8 @@ import {
3
3
  readManifest, writeManifest, discoverSkills, linkSkills, unlinkSkills,
4
4
  parseSkillFrontmatter, TOOLS, REPOS_DIR, reportEvent, detectTools,
5
5
  getVersion, mergeCredentials, runToolSetup, runPostInstall,
6
+ loadProfiles, getProfileSkills, validateProfileSkills,
7
+ resolveSkillDependencyClosure,
6
8
  } from './lib.mjs';
7
9
  import { spawn } from 'child_process';
8
10
  import { existsSync, lstatSync, unlinkSync, rmSync } from 'fs';
@@ -348,12 +350,25 @@ export async function main() {
348
350
  const stale = saved.filter(n => !discovered.has(n)).map(n => ({ name: n }));
349
351
  if (stale.length) quiet(() => unlinkSkills(stale, tools));
350
352
 
351
- repo.disabled_skills = [...disabled].filter(n => discovered.has(n));
352
-
353
- // New skills (not in saved or disabled) are auto-enabled in TUI context
354
- const previouslyKnown = new Set([...saved, ...(repo.disabled_skills || [])]);
355
- const newSkills = allSkills.filter(s => !previouslyKnown.has(s.name));
356
- const skills = allSkills.filter(s => !disabled.has(s.name));
353
+ const savedSet = new Set(saved);
354
+ let skills;
355
+ let newSkills;
356
+ const profilesData = repo.profile ? loadProfiles(repo.path) : null;
357
+ if (repo.profile && profilesData) {
358
+ const [, ps] = getProfileSkills(profilesData, repo.profile);
359
+ const profileSkills = ps ? validateProfileSkills(ps, discovered) : new Set();
360
+ newSkills = allSkills.filter(s => !savedSet.has(s.name) && !disabled.has(s.name) && profileSkills.has(s.name));
361
+ const enabledNames = new Set([...saved.filter(n => !disabled.has(n)), ...newSkills.map(s => s.name)]);
362
+ skills = allSkills.filter(s => enabledNames.has(s.name));
363
+ const resolved = resolveSkillDependencyClosure(allSkills, skills.map(s => s.name));
364
+ skills = resolved.skills;
365
+ for (const dep of resolved.autoAdded) disabled.delete(dep);
366
+ } else {
367
+ const previouslyKnown = new Set([...saved, ...(repo.disabled_skills || [])]);
368
+ newSkills = allSkills.filter(s => !previouslyKnown.has(s.name));
369
+ skills = allSkills.filter(s => !disabled.has(s.name));
370
+ }
371
+ repo.disabled_skills = [...disabled].filter(n => discovered.has(n) && !skills.some(s => s.name === n));
357
372
  quiet(() => {
358
373
  linkSkills(skills, tools);
359
374
  mergeCredentials(repo.path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sra-skills",
3
- "version": "0.14.13",
3
+ "version": "0.16.0",
4
4
  "description": "SRA agent skills installer — manage AI skill repos",
5
5
  "bin": {
6
6
  "sra": "index.mjs",