@yottameta/yotta-skills-plugin 0.24.2 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,583 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * YottaSkills Hub core (standard: yotta-skills-hub/v1).
5
+ *
6
+ * The hub is a local canonical skill store. Host directories receive links
7
+ * (Windows junction / POSIX symlink) to the hub entries. This module is
8
+ * deliberately filesystem-only and zero-dependency.
9
+ */
10
+
11
+ const crypto = require('crypto');
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+ const path = require('path');
15
+ const scanLib = require('./skills-scan');
16
+ const skillsCliLock = require('./skills-cli-lock');
17
+
18
+ const STANDARD_ID = 'yotta-skills-hub/v1';
19
+ const STATE_VERSION = 1;
20
+ const STATE_FILE = '.yotta-hub.json';
21
+ const LINKS_FILE = '.yotta-links.json';
22
+ const AUDIT_FILE = '.yotta-hub-audit.jsonl';
23
+ const SKILL_FILE = 'SKILL.md';
24
+
25
+ const HASH_SKIP = new Set([
26
+ '.git', 'node_modules', '__pycache__', '.pytest_cache', '.mypy_cache',
27
+ '.yottaskills-staging', '.yottaskills-backup',
28
+ ]);
29
+
30
+ function nowIso() {
31
+ return new Date().toISOString();
32
+ }
33
+
34
+ function resolveHubDir(opts) {
35
+ const options = opts || {};
36
+ const explicit = options.hub || process.env.YOTTA_SKILLS_HUB;
37
+ if (explicit) return path.resolve(explicit);
38
+ return path.join(options.homeDir || os.homedir(), '.yottaskills', 'hub');
39
+ }
40
+
41
+ function hubPaths(hubDir) {
42
+ const root = path.resolve(hubDir);
43
+ return {
44
+ root,
45
+ state: path.join(root, STATE_FILE),
46
+ links: path.join(root, LINKS_FILE),
47
+ audit: path.join(root, AUDIT_FILE),
48
+ };
49
+ }
50
+
51
+ function readJson(file, fallback) {
52
+ try {
53
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
54
+ } catch (_) {
55
+ return fallback;
56
+ }
57
+ }
58
+
59
+ function writeJsonAtomic(file, value) {
60
+ fs.mkdirSync(path.dirname(file), { recursive: true });
61
+ const tmp = file + '.tmp-' + process.pid;
62
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', 'utf8');
63
+ fs.renameSync(tmp, file);
64
+ }
65
+
66
+ function readHubState(hubDir) {
67
+ const fallback = {
68
+ standard: STANDARD_ID,
69
+ version: STATE_VERSION,
70
+ updatedAt: null,
71
+ skills: {},
72
+ };
73
+ const value = readJson(hubPaths(hubDir).state, fallback);
74
+ if (!value || typeof value !== 'object') return fallback;
75
+ if (!value.skills || typeof value.skills !== 'object') value.skills = {};
76
+ value.standard = value.standard || STANDARD_ID;
77
+ value.version = value.version || STATE_VERSION;
78
+ return value;
79
+ }
80
+
81
+ function writeHubState(hubDir, state) {
82
+ const next = {
83
+ standard: STANDARD_ID,
84
+ version: STATE_VERSION,
85
+ updatedAt: nowIso(),
86
+ skills: (state && state.skills) || {},
87
+ };
88
+ writeJsonAtomic(hubPaths(hubDir).state, next);
89
+ return next;
90
+ }
91
+
92
+ function readLinkState(hubDir) {
93
+ const fallback = {
94
+ standard: STANDARD_ID,
95
+ version: STATE_VERSION,
96
+ updatedAt: null,
97
+ links: [],
98
+ };
99
+ const value = readJson(hubPaths(hubDir).links, fallback);
100
+ if (!value || typeof value !== 'object') return fallback;
101
+ if (!Array.isArray(value.links)) value.links = [];
102
+ value.standard = value.standard || STANDARD_ID;
103
+ value.version = value.version || STATE_VERSION;
104
+ return value;
105
+ }
106
+
107
+ function writeLinkState(hubDir, state) {
108
+ const next = {
109
+ standard: STANDARD_ID,
110
+ version: STATE_VERSION,
111
+ updatedAt: nowIso(),
112
+ links: Array.isArray(state && state.links) ? state.links : [],
113
+ };
114
+ writeJsonAtomic(hubPaths(hubDir).links, next);
115
+ return next;
116
+ }
117
+
118
+ function appendAudit(hubDir, entry) {
119
+ const file = hubPaths(hubDir).audit;
120
+ fs.mkdirSync(path.dirname(file), { recursive: true });
121
+ fs.appendFileSync(file, JSON.stringify({ at: nowIso(), ...entry }) + '\n', 'utf8');
122
+ }
123
+
124
+ function safeRealpath(target) {
125
+ try {
126
+ return fs.realpathSync.native(target);
127
+ } catch (_) {
128
+ try {
129
+ return fs.realpathSync(target);
130
+ } catch (_) {
131
+ return path.resolve(target);
132
+ }
133
+ }
134
+ }
135
+
136
+ function samePath(left, right) {
137
+ const a = safeRealpath(left);
138
+ const b = safeRealpath(right);
139
+ if (process.platform === 'win32') return a.toLowerCase() === b.toLowerCase();
140
+ return a === b;
141
+ }
142
+
143
+ function isInside(root, target) {
144
+ const base = safeRealpath(root);
145
+ const candidate = safeRealpath(target);
146
+ const relative = path.relative(base, candidate);
147
+ if (relative === '') return true;
148
+ return !relative.startsWith('..') && !path.isAbsolute(relative);
149
+ }
150
+
151
+ function hashTree(dir) {
152
+ const hash = crypto.createHash('sha256');
153
+ const walk = (current, relative) => {
154
+ const entries = fs.readdirSync(current, { withFileTypes: true })
155
+ .sort((a, b) => a.name.localeCompare(b.name));
156
+ for (const entry of entries) {
157
+ if (HASH_SKIP.has(entry.name)) continue;
158
+ if (entry.name.startsWith('.yottaskills-')) continue;
159
+ if (/\.py[co]$/.test(entry.name)) continue;
160
+ const full = path.join(current, entry.name);
161
+ const rel = relative ? path.join(relative, entry.name) : entry.name;
162
+ if (entry.isSymbolicLink()) {
163
+ hash.update('L\0' + rel + '\0' + fs.readlinkSync(full) + '\0');
164
+ } else if (entry.isDirectory()) {
165
+ hash.update('D\0' + rel + '\0');
166
+ walk(full, rel);
167
+ } else if (entry.isFile()) {
168
+ hash.update('F\0' + rel + '\0');
169
+ hash.update(fs.readFileSync(full));
170
+ hash.update('\0');
171
+ }
172
+ }
173
+ };
174
+ walk(path.resolve(dir), '');
175
+ return hash.digest('hex');
176
+ }
177
+
178
+ function scanHubSkills(hubDir) {
179
+ const root = path.resolve(hubDir);
180
+ const result = [];
181
+ let entries;
182
+ try {
183
+ entries = fs.readdirSync(root, { withFileTypes: true });
184
+ } catch (_) {
185
+ return result;
186
+ }
187
+ for (const entry of entries) {
188
+ if (entry.name.startsWith('.')) continue;
189
+ if (entry.name.includes('.yottaskills-backup-') || entry.name.includes('.yottaskills-import-')) continue;
190
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
191
+ const dir = path.join(root, entry.name);
192
+ let stat;
193
+ try {
194
+ stat = fs.statSync(dir);
195
+ } catch (_) {
196
+ continue;
197
+ }
198
+ if (!stat.isDirectory()) continue;
199
+ let text;
200
+ try {
201
+ text = fs.readFileSync(path.join(dir, SKILL_FILE), 'utf8');
202
+ } catch (_) {
203
+ continue;
204
+ }
205
+ const fm = scanLib.parseFrontmatter(text) || {};
206
+ const slug = String(fm.name || entry.name).trim();
207
+ result.push({
208
+ slug,
209
+ dir,
210
+ dirName: entry.name,
211
+ version: fm.version ? String(fm.version).trim() : '',
212
+ description: fm.description ? String(fm.description).trim() : '',
213
+ treeHash: hashTree(dir),
214
+ slugMismatch: slug !== entry.name,
215
+ });
216
+ }
217
+ return result.sort((a, b) => a.slug.localeCompare(b.slug));
218
+ }
219
+
220
+ function manifestSlugs(manifest) {
221
+ const set = new Set();
222
+ const list = Array.isArray(manifest) ? manifest : (manifest && manifest.skills) || [];
223
+ for (const item of list) {
224
+ if (item && item.slug) set.add(item.slug);
225
+ }
226
+ return set;
227
+ }
228
+
229
+ function syncHubState(hubDir, options) {
230
+ const opts = options || {};
231
+ const previous = readHubState(hubDir);
232
+ const family = manifestSlugs(opts.manifest);
233
+ const scanned = scanHubSkills(hubDir);
234
+ const upstream = skillsCliLock.readLock({ homeDir: opts.homeDir, env: opts.env });
235
+ const now = nowIso();
236
+ const skills = {};
237
+ const seen = new Set();
238
+
239
+ for (const item of scanned) {
240
+ seen.add(item.slug);
241
+ const old = previous.skills[item.slug] || {};
242
+ const origin = old.origin || (family.has(item.slug) ? 'yotta' : 'external');
243
+ const upstreamEntry = upstream.available ? upstream.skills[item.slug] : null;
244
+ skills[item.slug] = {
245
+ slug: item.slug,
246
+ origin,
247
+ version: item.version || old.version || null,
248
+ treeHash: item.treeHash,
249
+ dir: item.dir,
250
+ description: item.description || old.description || '',
251
+ source: (upstreamEntry && upstreamEntry.source) || old.source || (origin === 'yotta' ? 'npm' : 'local'),
252
+ sourceType: (upstreamEntry && upstreamEntry.sourceType) || old.sourceType || null,
253
+ sourceUrl: (upstreamEntry && upstreamEntry.sourceUrl) || old.sourceUrl || null,
254
+ upstreamSkillHash: (upstreamEntry && upstreamEntry.skillFolderHash) || old.upstreamSkillHash || null,
255
+ upstreamInstalledAt: (upstreamEntry && upstreamEntry.installedAt) || old.upstreamInstalledAt || null,
256
+ upstreamUpdatedAt: (upstreamEntry && upstreamEntry.updatedAt) || old.upstreamUpdatedAt || null,
257
+ sourcePath: old.sourcePath || null,
258
+ sourceAgent: old.sourceAgent || null,
259
+ importedAt: old.importedAt || now,
260
+ updatedAt: now,
261
+ scanVerdict: old.scanVerdict || null,
262
+ inPlace: Boolean(old.inPlace),
263
+ status: 'present',
264
+ slugMismatch: item.slugMismatch,
265
+ };
266
+ }
267
+ for (const [slug, old] of Object.entries(previous.skills)) {
268
+ if (seen.has(slug)) continue;
269
+ skills[slug] = { ...old, status: 'missing', updatedAt: now };
270
+ }
271
+
272
+ const state = { skills, upstreamLock: { path: upstream.path, available: upstream.available, version: upstream.version } };
273
+ writeHubState(hubDir, state);
274
+ return { state: readHubState(hubDir), scanned };
275
+ }
276
+
277
+ function createDirLink(target, linkPath) {
278
+ fs.mkdirSync(path.dirname(linkPath), { recursive: true });
279
+ const type = process.platform === 'win32' ? 'junction' : 'dir';
280
+ fs.symlinkSync(path.resolve(target), linkPath, type);
281
+ }
282
+
283
+ function removeDirLink(linkPath) {
284
+ try {
285
+ fs.unlinkSync(linkPath);
286
+ } catch (error) {
287
+ if (error && (error.code === 'EPERM' || error.code === 'EISDIR')) {
288
+ fs.rmdirSync(linkPath);
289
+ return;
290
+ }
291
+ throw error;
292
+ }
293
+ }
294
+
295
+ function classifyTarget(target, hubDir) {
296
+ let stat;
297
+ try {
298
+ stat = fs.lstatSync(target);
299
+ } catch (_) {
300
+ return { kind: 'missing' };
301
+ }
302
+ if (stat.isSymbolicLink()) {
303
+ const real = safeRealpath(target);
304
+ return {
305
+ kind: 'link',
306
+ target: real,
307
+ inHub: isInside(hubDir, real),
308
+ };
309
+ }
310
+ if (stat.isDirectory()) return { kind: 'directory' };
311
+ return { kind: 'file' };
312
+ }
313
+
314
+ function linkSkills(options) {
315
+ const opts = options || {};
316
+ const hubDir = path.resolve(opts.hubDir);
317
+ const targetDir = path.resolve(opts.targetDir);
318
+ const dryRun = Boolean(opts.dryRun);
319
+ const force = Boolean(opts.force);
320
+ const slugs = Array.isArray(opts.slugs) && opts.slugs.length > 0
321
+ ? opts.slugs
322
+ : scanHubSkills(hubDir).map((item) => item.slug);
323
+ const hubBySlug = new Map(scanHubSkills(hubDir).map((item) => [item.slug, item]));
324
+ const links = readLinkState(hubDir).links.slice();
325
+ const results = [];
326
+
327
+ for (const slug of slugs) {
328
+ const skill = hubBySlug.get(slug);
329
+ if (!skill) {
330
+ results.push({ slug, status: 'missing', note: 'hub 中没有该技能' });
331
+ continue;
332
+ }
333
+ const target = path.join(targetDir, slug);
334
+ const current = classifyTarget(target, hubDir);
335
+ let backup = null;
336
+ if (current.kind === 'link' && current.inHub && samePath(current.target, skill.dir)) {
337
+ results.push({ slug, status: 'linked', target, note: '已链接到 hub' });
338
+ continue;
339
+ }
340
+ if (current.kind !== 'missing') {
341
+ if (!force) {
342
+ results.push({
343
+ slug,
344
+ status: 'conflict',
345
+ target,
346
+ note: current.kind === 'directory'
347
+ ? '目标已存在真目录(默认跳过;确认后可加 --force 备份并替换)'
348
+ : current.kind === 'link'
349
+ ? '目标已存在其他链接(默认跳过;确认后可加 --force 替换)'
350
+ : '目标已存在普通文件(拒绝覆盖)',
351
+ });
352
+ continue;
353
+ }
354
+ if (current.kind === 'directory') {
355
+ if (dryRun) {
356
+ results.push({ slug, status: 'would-backup', target, note: '将备份真目录后建立链接' });
357
+ continue;
358
+ }
359
+ backup = target + '.yottaskills-backup-' + Date.now();
360
+ fs.renameSync(target, backup);
361
+ } else if (current.kind === 'link') {
362
+ if (dryRun) {
363
+ results.push({ slug, status: 'would-replace', target, note: '将替换已有链接' });
364
+ continue;
365
+ }
366
+ removeDirLink(target);
367
+ } else {
368
+ results.push({ slug, status: 'conflict', target, note: '目标已存在普通文件(拒绝覆盖)' });
369
+ continue;
370
+ }
371
+ }
372
+ if (dryRun) {
373
+ results.push({ slug, status: 'would-link', target, note: '将建立链接' });
374
+ continue;
375
+ }
376
+ try {
377
+ createDirLink(skill.dir, target);
378
+ const index = links.findIndex((item) => item.slug === slug && samePath(item.dir || '', targetDir));
379
+ const record = {
380
+ slug,
381
+ agent: opts.agentId || null,
382
+ label: opts.label || null,
383
+ dir: targetDir,
384
+ target,
385
+ hubDir: skill.dir,
386
+ linkedAt: nowIso(),
387
+ backup,
388
+ };
389
+ if (index >= 0) links[index] = record;
390
+ else links.push(record);
391
+ results.push({ slug, status: 'linked', target, note: backup ? '已备份原目录并建立链接' : '已建立链接', backup });
392
+ appendAudit(hubDir, { event: 'link', slug, target, hubDir: skill.dir, backup });
393
+ } catch (error) {
394
+ results.push({
395
+ slug,
396
+ status: 'error',
397
+ target,
398
+ note: error.message + '(未静默降级为复制;请检查目录权限或改用 --dir 指定可写目录)',
399
+ });
400
+ }
401
+ }
402
+ if (!dryRun) {
403
+ writeLinkState(hubDir, { links });
404
+ try {
405
+ syncHubState(hubDir, { homeDir: opts.homeDir, env: opts.env });
406
+ } catch (_) { /* link state is already durable; state refresh is best-effort */ }
407
+ }
408
+ return { hubDir, targetDir, dryRun, results };
409
+ }
410
+
411
+ function unlinkSkills(options) {
412
+ const opts = options || {};
413
+ const hubDir = path.resolve(opts.hubDir);
414
+ const targetDir = path.resolve(opts.targetDir);
415
+ const dryRun = Boolean(opts.dryRun);
416
+ const slugs = Array.isArray(opts.slugs) && opts.slugs.length > 0
417
+ ? opts.slugs
418
+ : readLinkState(hubDir).links
419
+ .filter((item) => samePath(item.dir || '', targetDir))
420
+ .map((item) => item.slug);
421
+ const unique = [...new Set(slugs)];
422
+ const links = readLinkState(hubDir).links.slice();
423
+ const results = [];
424
+
425
+ for (const slug of unique) {
426
+ const target = path.join(targetDir, slug);
427
+ const current = classifyTarget(target, hubDir);
428
+ if (current.kind === 'missing') {
429
+ results.push({ slug, status: 'missing', target, note: '目标不存在' });
430
+ continue;
431
+ }
432
+ if (current.kind !== 'link') {
433
+ results.push({ slug, status: 'refused', target, note: '目标不是链接;fail-closed,拒绝删除' });
434
+ continue;
435
+ }
436
+ if (!current.inHub) {
437
+ results.push({ slug, status: 'refused', target, note: '链接目标不在 hub 内;fail-closed,拒绝删除' });
438
+ continue;
439
+ }
440
+ if (dryRun) {
441
+ results.push({ slug, status: 'would-unlink', target, note: '将删除链接(不动 hub 真源)' });
442
+ continue;
443
+ }
444
+ try {
445
+ removeDirLink(target);
446
+ for (let i = links.length - 1; i >= 0; i--) {
447
+ if (links[i].slug === slug && samePath(links[i].dir || '', targetDir)) links.splice(i, 1);
448
+ }
449
+ results.push({ slug, status: 'unlinked', target, note: '已删除链接(hub 真源保留)' });
450
+ appendAudit(hubDir, { event: 'unlink', slug, target });
451
+ } catch (error) {
452
+ results.push({ slug, status: 'error', target, note: error.message });
453
+ }
454
+ }
455
+ if (!dryRun) writeLinkState(hubDir, { links });
456
+ return { hubDir, targetDir, dryRun, results };
457
+ }
458
+
459
+ function linkStatus(hubDir) {
460
+ const state = readLinkState(hubDir);
461
+ return state.links.map((item) => {
462
+ const current = classifyTarget(item.target, hubDir);
463
+ return {
464
+ ...item,
465
+ status: current.kind === 'link' && current.inHub ? 'ok'
466
+ : current.kind === 'missing' ? 'broken'
467
+ : 'drift',
468
+ actual: current,
469
+ };
470
+ });
471
+ }
472
+
473
+ function status(options) {
474
+ const opts = options || {};
475
+ const hubDir = path.resolve(opts.hubDir);
476
+ const synced = syncHubState(hubDir, { manifest: opts.manifest, homeDir: opts.homeDir, env: opts.env });
477
+ const links = linkStatus(hubDir);
478
+ const skills = Object.values(synced.state.skills)
479
+ .sort((a, b) => a.slug.localeCompare(b.slug))
480
+ .map((skill) => ({
481
+ ...skill,
482
+ links: links.filter((link) => link.slug === skill.slug),
483
+ }));
484
+ return {
485
+ standard: STANDARD_ID,
486
+ hubDir,
487
+ skills,
488
+ links,
489
+ summary: {
490
+ skills: skills.filter((skill) => skill.status === 'present').length,
491
+ external: skills.filter((skill) => skill.origin === 'external').length,
492
+ links: links.length,
493
+ brokenLinks: links.filter((link) => link.status !== 'ok').length,
494
+ },
495
+ };
496
+ }
497
+
498
+ function doctor(options) {
499
+ const opts = options || {};
500
+ const hubDir = path.resolve(opts.hubDir);
501
+ const checks = [];
502
+ const add = (id, ok, severity, message, hint) => {
503
+ checks.push({ id, ok: Boolean(ok), severity, message, hint: hint || null });
504
+ };
505
+
506
+ add('hub_dir', fs.existsSync(hubDir), 'error',
507
+ fs.existsSync(hubDir) ? 'Hub 目录存在' : 'Hub 目录不存在',
508
+ fs.existsSync(hubDir) ? null : '先运行 yotta-skills hub install 或 hub adopt --apply');
509
+ if (fs.existsSync(hubDir)) {
510
+ try {
511
+ fs.accessSync(hubDir, fs.constants.W_OK);
512
+ add('hub_writable', true, 'error', 'Hub 目录可写');
513
+ } catch (_) {
514
+ add('hub_writable', false, 'error', 'Hub 目录不可写', '检查目录权限后重试');
515
+ }
516
+ }
517
+
518
+ const synced = syncHubState(hubDir, {
519
+ manifest: opts.manifest,
520
+ homeDir: opts.homeDir,
521
+ env: opts.env,
522
+ });
523
+ const stateSkills = Object.values(synced.state.skills || {});
524
+ if (fs.existsSync(hubDir) && stateSkills.length === 0) {
525
+ add('hub_nonempty', false, 'error', 'Hub 目录存在但没有任何技能',
526
+ '先运行 yotta-skills hub install 或 hub adopt --apply');
527
+ }
528
+ for (const skill of stateSkills) {
529
+ add('skill_present:' + skill.slug, skill.status === 'present', 'error',
530
+ skill.status === 'present' ? skill.slug + ' 目标存在' : skill.slug + ' 目标缺失',
531
+ skill.status === 'present' ? null : '重新收编 / 重装该技能,或删除对应链接');
532
+ if (skill.slugMismatch) {
533
+ add('slug_match:' + skill.slug, false, 'warning',
534
+ skill.slug + ' 的 frontmatter name 与目录名不一致',
535
+ '把目录名改为 frontmatter name,或修正 SKILL.md');
536
+ }
537
+ }
538
+
539
+ const links = linkStatus(hubDir);
540
+ for (const link of links) {
541
+ add('link:' + link.slug + ':' + link.dir, link.status === 'ok', link.status === 'ok' ? 'info' : 'error',
542
+ link.status === 'ok' ? '链接正常: ' + link.slug : '链接异常(' + link.status + '): ' + link.slug,
543
+ link.status === 'ok' ? null : '运行 hub link --all 重建,或 hub unlink 清理断链');
544
+ }
545
+
546
+ const errors = checks.filter((check) => !check.ok && check.severity === 'error');
547
+ const warnings = checks.filter((check) => !check.ok && check.severity === 'warning');
548
+ return {
549
+ ok: errors.length === 0,
550
+ hubDir,
551
+ checks,
552
+ summary: {
553
+ errors: errors.length,
554
+ warnings: warnings.length,
555
+ info: checks.filter((check) => check.severity === 'info').length,
556
+ },
557
+ };
558
+ }
559
+
560
+ module.exports = {
561
+ STANDARD_ID,
562
+ STATE_FILE,
563
+ LINKS_FILE,
564
+ AUDIT_FILE,
565
+ resolveHubDir,
566
+ hubPaths,
567
+ readHubState,
568
+ writeHubState,
569
+ readLinkState,
570
+ writeLinkState,
571
+ appendAudit,
572
+ hashTree,
573
+ scanHubSkills,
574
+ syncHubState,
575
+ createDirLink,
576
+ removeDirLink,
577
+ classifyTarget,
578
+ linkSkills,
579
+ unlinkSkills,
580
+ linkStatus,
581
+ status,
582
+ doctor,
583
+ };
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Read-only bridge for the official Vercel Labs `skills` CLI lock file.
5
+ *
6
+ * The lock is optional. When present, its source metadata is merged into the
7
+ * YottaSkills Hub ledger so the Hub can show where a skill came from without
8
+ * owning or rewriting the upstream lock.
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+
15
+ const LOCK_FILE = '.skill-lock.json';
16
+ const CURRENT_VERSION = 3;
17
+
18
+ function lockPath(options) {
19
+ const opts = options || {};
20
+ const home = opts.homeDir || os.homedir();
21
+ const env = opts.env || process.env;
22
+ if (env.XDG_STATE_HOME) return path.join(env.XDG_STATE_HOME, 'skills', LOCK_FILE);
23
+ return path.join(home, '.agents', LOCK_FILE);
24
+ }
25
+
26
+ function readLock(options) {
27
+ const file = lockPath(options);
28
+ let value;
29
+ try {
30
+ value = JSON.parse(fs.readFileSync(file, 'utf8'));
31
+ } catch (_) {
32
+ return { available: false, path: file, version: null, skills: {} };
33
+ }
34
+ if (!value || typeof value !== 'object' || !value.skills || typeof value.skills !== 'object') {
35
+ return { available: false, path: file, version: null, skills: {} };
36
+ }
37
+ return {
38
+ available: true,
39
+ path: file,
40
+ version: Number(value.version || 0),
41
+ compatible: Number(value.version || 0) >= CURRENT_VERSION,
42
+ skills: value.skills,
43
+ lastSelectedAgents: Array.isArray(value.lastSelectedAgents) ? value.lastSelectedAgents : [],
44
+ };
45
+ }
46
+
47
+ module.exports = {
48
+ LOCK_FILE,
49
+ CURRENT_VERSION,
50
+ lockPath,
51
+ readLock,
52
+ };