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.
- package/index.mjs +359 -65
- package/lib.mjs +36 -0
- package/manage.mjs +21 -6
- 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 (
|
|
104
|
-
|
|
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
|
-
' -
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
343
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
373
|
-
if (disabled.
|
|
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
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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(
|
|
727
|
+
writeFileSync(availableFile, remaining.join('\n') + '\n');
|
|
443
728
|
} else {
|
|
444
|
-
unlinkSync(
|
|
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
|
-
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
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);
|