@xenonbyte/xsk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +107 -0
  3. package/README.zh-CN.md +107 -0
  4. package/bin/xsk.js +164 -0
  5. package/lib/adapters/claude.js +14 -0
  6. package/lib/adapters/codex.js +14 -0
  7. package/lib/adapters/gemini.js +14 -0
  8. package/lib/adapters/opencode.js +14 -0
  9. package/lib/capability.js +126 -0
  10. package/lib/content-hash.js +13 -0
  11. package/lib/generator.js +44 -0
  12. package/lib/input.js +93 -0
  13. package/lib/install.js +496 -0
  14. package/lib/manifest.js +288 -0
  15. package/lib/ownership.js +85 -0
  16. package/lib/skills.js +58 -0
  17. package/lib/status.js +171 -0
  18. package/lib/uninstall.js +577 -0
  19. package/package.json +36 -0
  20. package/shared/skill-common.md +6 -0
  21. package/skills/archive-req/SKILL.md +37 -0
  22. package/skills/bypass-claude/SKILL.md +53 -0
  23. package/skills/check/SKILL.md +73 -0
  24. package/skills/skill-scaffold/SKILL.md +56 -0
  25. package/skills/think/SKILL.md +56 -0
  26. package/skills/write-req/SKILL.md +63 -0
  27. package/templates/fragments/archive-req.behavior.md +7 -0
  28. package/templates/fragments/archive-req.output.md +1 -0
  29. package/templates/fragments/archive-req.purpose.md +1 -0
  30. package/templates/fragments/archive-req.triggers.md +5 -0
  31. package/templates/fragments/bypass-claude.behavior.md +23 -0
  32. package/templates/fragments/bypass-claude.output.md +1 -0
  33. package/templates/fragments/bypass-claude.purpose.md +1 -0
  34. package/templates/fragments/bypass-claude.triggers.md +5 -0
  35. package/templates/fragments/check.behavior.md +29 -0
  36. package/templates/fragments/check.output.md +13 -0
  37. package/templates/fragments/check.purpose.md +3 -0
  38. package/templates/fragments/check.triggers.md +5 -0
  39. package/templates/fragments/skill-scaffold.behavior.md +24 -0
  40. package/templates/fragments/skill-scaffold.output.md +3 -0
  41. package/templates/fragments/skill-scaffold.purpose.md +1 -0
  42. package/templates/fragments/skill-scaffold.triggers.md +5 -0
  43. package/templates/fragments/think.behavior.md +15 -0
  44. package/templates/fragments/think.output.md +9 -0
  45. package/templates/fragments/think.purpose.md +3 -0
  46. package/templates/fragments/think.triggers.md +6 -0
  47. package/templates/fragments/write-req.behavior.md +33 -0
  48. package/templates/fragments/write-req.output.md +1 -0
  49. package/templates/fragments/write-req.purpose.md +1 -0
  50. package/templates/fragments/write-req.triggers.md +5 -0
  51. package/templates/skill.md.tmpl +22 -0
package/lib/input.js ADDED
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ const ALL_PLATFORMS = ['claude', 'codex', 'opencode', 'gemini'];
4
+ const VALID_PLATFORMS = new Set(ALL_PLATFORMS);
5
+ const COMMANDS = new Set(['install', 'uninstall', 'status', 'doctor', 'version', 'help']);
6
+ const ALLOWED_OPTIONS = {
7
+ version: new Set(),
8
+ help: new Set(),
9
+ install: new Set(['--platform']),
10
+ uninstall: new Set(['--platform']),
11
+ status: new Set(['--platform', '--json']),
12
+ doctor: new Set(['--platform', '--json']),
13
+ };
14
+
15
+ function parsePlatforms(rawValue) {
16
+ const parts = String(rawValue)
17
+ .split(',')
18
+ .map((p) => p.trim())
19
+ .filter((p) => p.length > 0);
20
+ if (parts.length === 0) {
21
+ throw new Error('--platform requires at least one platform');
22
+ }
23
+ const seen = new Set();
24
+ const out = [];
25
+ for (const p of parts) {
26
+ if (!VALID_PLATFORMS.has(p)) {
27
+ throw new Error(`unknown platform: ${p}`);
28
+ }
29
+ if (seen.has(p)) {
30
+ throw new Error(`duplicate platform: ${p}`);
31
+ }
32
+ seen.add(p);
33
+ out.push(p);
34
+ }
35
+ return out;
36
+ }
37
+
38
+ function parse(argv) {
39
+ const args = Array.isArray(argv) ? argv.slice() : [];
40
+ const result = { command: null, platforms: ALL_PLATFORMS.slice(), json: false };
41
+
42
+ if (args.length === 0) {
43
+ result.command = 'help';
44
+ return result;
45
+ }
46
+
47
+ const first = args[0];
48
+
49
+ if (first === '-v' || first === '--version') {
50
+ result.command = 'version';
51
+ } else if (first === '-h' || first === '--help') {
52
+ result.command = 'help';
53
+ } else if (!COMMANDS.has(first)) {
54
+ throw new Error(`unknown command or option: ${first}`);
55
+ } else {
56
+ result.command = first;
57
+ }
58
+
59
+ const allowed = ALLOWED_OPTIONS[result.command];
60
+ const rest = args.slice(1);
61
+ let i = 0;
62
+ while (i < rest.length) {
63
+ const tok = rest[i];
64
+ if (tok === '--json') {
65
+ if (!allowed.has('--json')) {
66
+ throw new Error(`unknown or not-allowed option for ${result.command}: ${tok}`);
67
+ }
68
+ result.json = true;
69
+ i += 1;
70
+ } else if (tok === '--platform') {
71
+ if (!allowed.has('--platform')) {
72
+ throw new Error(`unknown or not-allowed option for ${result.command}: ${tok}`);
73
+ }
74
+ if (i + 1 >= rest.length) {
75
+ throw new Error('--platform requires a value');
76
+ }
77
+ result.platforms = parsePlatforms(rest[i + 1]);
78
+ i += 2;
79
+ } else if (tok.startsWith('--platform=')) {
80
+ if (!allowed.has('--platform')) {
81
+ throw new Error(`unknown or not-allowed option for ${result.command}: --platform`);
82
+ }
83
+ result.platforms = parsePlatforms(tok.slice('--platform='.length));
84
+ i += 1;
85
+ } else {
86
+ throw new Error(`unknown option: ${tok}`);
87
+ }
88
+ }
89
+
90
+ return result;
91
+ }
92
+
93
+ module.exports = { parse, ALL_PLATFORMS };
package/lib/install.js ADDED
@@ -0,0 +1,496 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const os = require('node:os');
6
+
7
+ const { buildSkill } = require('./generator');
8
+ const { skills: ALL_SKILLS } = require('./skills');
9
+ const { create, read, write, validate, manifestPath, assertSafePath, atomicWriteFile, isInsideDir } = require('./manifest');
10
+ const { MARKER, PACKAGE_NAME } = require('./ownership');
11
+ const { uninstallPlatform } = require('./uninstall');
12
+ const { contentSha256 } = require('./content-hash');
13
+
14
+ const ALL_PLATFORMS = ['claude', 'codex', 'opencode', 'gemini'];
15
+
16
+ function packageVersion() {
17
+ try {
18
+ return require('../package.json').version;
19
+ } catch (e) {
20
+ return '0.0.0';
21
+ }
22
+ }
23
+
24
+ function rootFor(platform, platformRoots) {
25
+ if (platformRoots && platformRoots[platform]) {
26
+ return platformRoots[platform];
27
+ }
28
+ const adapter = require(`./adapters/${platform}.js`);
29
+ return adapter.skillsRoot();
30
+ }
31
+
32
+ function writeGeneratedFile(targetPath, content, label) {
33
+ atomicWriteFile(targetPath, content, {
34
+ dirLabel: `${label} dir`,
35
+ tempLabel: `${label} temp file`,
36
+ targetLabel: label,
37
+ });
38
+ }
39
+
40
+ function assertNotSymlink(targetPath, label) {
41
+ assertSafePath(targetPath, label, { allowNonDirectoryTarget: true });
42
+ }
43
+
44
+ function assertReusableBackupRecord(backupRecord, backupDir) {
45
+ const backupPath = path.resolve(backupRecord.backup);
46
+ if (!isInsideDir(backupPath, backupDir)) {
47
+ throw new Error(`refusing to reuse backup outside backup dir: ${backupRecord.backup}`);
48
+ }
49
+ assertSafePath(backupDir, 'backup dir');
50
+ assertSafePath(backupPath, 'backup file', { allowNonDirectoryTarget: true });
51
+ let stat;
52
+ try {
53
+ stat = fs.lstatSync(backupPath);
54
+ } catch (e) {
55
+ if (e && e.code === 'ENOENT') {
56
+ throw new Error(`refusing to repair install with missing backup: ${backupRecord.backup}`);
57
+ }
58
+ throw e;
59
+ }
60
+ if (!stat.isFile()) {
61
+ throw new Error(`refusing to reuse non-file backup: ${backupRecord.backup}`);
62
+ }
63
+ }
64
+
65
+ function ensurePlatformRoot(skillsRoot) {
66
+ assertSafePath(skillsRoot, 'platform root');
67
+ fs.mkdirSync(skillsRoot, { recursive: true });
68
+ assertSafePath(skillsRoot, 'platform root');
69
+ }
70
+
71
+ function ensureSkillDir(skillDir) {
72
+ assertSafePath(skillDir, 'skill dir');
73
+ fs.mkdirSync(skillDir, { recursive: true });
74
+ assertSafePath(skillDir, 'skill dir');
75
+ }
76
+
77
+ function previousInstallState(platform, xskRoot) {
78
+ const empty = {
79
+ backupsByTarget: new Map(),
80
+ installedDirs: new Set(),
81
+ installedFiles: new Set(),
82
+ installedHashesByTarget: new Map(),
83
+ };
84
+ let previous;
85
+ try {
86
+ previous = read(platform, { xskRoot });
87
+ } catch (e) {
88
+ if (!(e instanceof SyntaxError)) {
89
+ throw e;
90
+ }
91
+ throw new Error(`manifest for ${platform} is not valid JSON: ${e.message}`);
92
+ }
93
+ if (previous === null) {
94
+ return empty;
95
+ }
96
+ if (!validate(previous, { expectedPlatform: platform })) {
97
+ throw new Error(`manifest for ${platform} failed shape validation; refusing to install`);
98
+ }
99
+ const installedDirs = new Set(
100
+ previous.installed_paths.filter((p) => {
101
+ const base = path.basename(p);
102
+ return base !== 'SKILL.md' && base !== MARKER;
103
+ }),
104
+ );
105
+ const installedFiles = new Set(
106
+ previous.installed_paths.filter((p) => path.basename(p) === 'SKILL.md'),
107
+ );
108
+ const installedHashes = Array.isArray(previous.installed_hashes) ? previous.installed_hashes : [];
109
+ return {
110
+ backupsByTarget: new Map(previous.backups.map((b) => [b.target, { target: b.target, backup: b.backup }])),
111
+ installedDirs,
112
+ installedFiles,
113
+ installedHashesByTarget: new Map(installedHashes.map((h) => [h.target, h.sha256])),
114
+ };
115
+ }
116
+
117
+ function isPreviouslyInstalledGeneratedContent({ existingContent, builtContent, previousHash, wasInstalled }) {
118
+ if (!wasInstalled) {
119
+ return false;
120
+ }
121
+ if (previousHash && contentSha256(existingContent) === previousHash) {
122
+ return true;
123
+ }
124
+ return Buffer.compare(existingContent, Buffer.from(builtContent, 'utf8')) === 0;
125
+ }
126
+
127
+ function rollback(createdPaths, backups, overwrittenPaths) {
128
+ for (const p of createdPaths.slice().reverse()) {
129
+ try {
130
+ const stat = fs.lstatSync(p);
131
+ if (stat.isDirectory()) {
132
+ fs.rmdirSync(p);
133
+ } else {
134
+ fs.rmSync(p, { force: true });
135
+ }
136
+ } catch (e) {
137
+ /* best effort */
138
+ }
139
+ }
140
+ for (const entry of overwrittenPaths.slice().reverse()) {
141
+ try {
142
+ atomicWriteFile(entry.target, entry.content, {
143
+ dirLabel: 'rollback target dir',
144
+ tempLabel: 'rollback temp file',
145
+ targetLabel: 'rollback target file',
146
+ });
147
+ if (typeof entry.mode === 'number') {
148
+ fs.chmodSync(entry.target, entry.mode);
149
+ }
150
+ } catch (e) {
151
+ /* best effort */
152
+ }
153
+ }
154
+ for (const b of backups) {
155
+ try {
156
+ if (b.backupExisted) {
157
+ assertSafePath(path.dirname(b.backup), 'backup dir');
158
+ fs.mkdirSync(path.dirname(b.backup), { recursive: true });
159
+ assertSafePath(b.backup, 'backup file', { allowNonDirectoryTarget: true });
160
+ fs.writeFileSync(b.backup, b.backupContent, { mode: b.backupMode });
161
+ if (typeof b.backupMode === 'number') {
162
+ fs.chmodSync(b.backup, b.backupMode);
163
+ }
164
+ } else {
165
+ fs.rmSync(b.backup, { force: true });
166
+ }
167
+ } catch (e) {
168
+ /* best effort */
169
+ }
170
+ }
171
+ }
172
+
173
+ function capturePathState(targetPath) {
174
+ try {
175
+ assertSafePath(targetPath, 'transaction snapshot path', { allowNonDirectoryTarget: true });
176
+ const stat = fs.lstatSync(targetPath);
177
+ if (stat.isDirectory()) {
178
+ return { path: targetPath, exists: true, type: 'dir', mode: stat.mode };
179
+ }
180
+ if (stat.isFile()) {
181
+ return { path: targetPath, exists: true, type: 'file', content: fs.readFileSync(targetPath), mode: stat.mode };
182
+ }
183
+ return { path: targetPath, exists: true, type: 'other' };
184
+ } catch (e) {
185
+ if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) {
186
+ return { path: targetPath, exists: false };
187
+ }
188
+ throw e;
189
+ }
190
+ }
191
+
192
+ function restoreAbsentPath(targetPath) {
193
+ try {
194
+ assertSafePath(targetPath, 'transaction rollback path', { allowNonDirectoryTarget: true });
195
+ const stat = fs.lstatSync(targetPath);
196
+ if (stat.isDirectory()) {
197
+ fs.rmdirSync(targetPath);
198
+ } else {
199
+ fs.rmSync(targetPath, { force: true });
200
+ }
201
+ } catch (e) {
202
+ /* best effort */
203
+ }
204
+ }
205
+
206
+ function restorePathState(snapshot) {
207
+ try {
208
+ if (!snapshot.exists) {
209
+ restoreAbsentPath(snapshot.path);
210
+ return;
211
+ }
212
+ if (snapshot.type === 'dir') {
213
+ assertSafePath(snapshot.path, 'transaction rollback dir');
214
+ fs.mkdirSync(snapshot.path, { recursive: true });
215
+ if (typeof snapshot.mode === 'number') {
216
+ fs.chmodSync(snapshot.path, snapshot.mode);
217
+ }
218
+ return;
219
+ }
220
+ if (snapshot.type === 'file') {
221
+ atomicWriteFile(snapshot.path, snapshot.content, {
222
+ dirLabel: 'transaction rollback file dir',
223
+ tempLabel: 'transaction rollback temp file',
224
+ targetLabel: 'transaction rollback file',
225
+ });
226
+ if (typeof snapshot.mode === 'number') {
227
+ fs.chmodSync(snapshot.path, snapshot.mode);
228
+ }
229
+ }
230
+ } catch (e) {
231
+ /* best effort */
232
+ }
233
+ }
234
+
235
+ function capturePlatformSnapshot({ platform, skillsRoot, xskRoot, skills }) {
236
+ const seen = new Set();
237
+ const paths = [];
238
+ const platformBackupDir = path.join(xskRoot, 'install', 'backups', platform);
239
+ const add = (targetPath) => {
240
+ if (!seen.has(targetPath)) {
241
+ seen.add(targetPath);
242
+ paths.push(targetPath);
243
+ }
244
+ };
245
+ add(xskRoot);
246
+ add(path.join(xskRoot, 'manifests'));
247
+ add(manifestPath(platform, { xskRoot }));
248
+ add(path.join(xskRoot, 'install'));
249
+ add(path.join(xskRoot, 'install', 'backups'));
250
+ add(path.join(xskRoot, 'install', 'backups', platform));
251
+ add(skillsRoot);
252
+ for (const skill of skills) {
253
+ const skillDir = path.join(skillsRoot, skill.name);
254
+ add(skillDir);
255
+ add(path.join(skillDir, 'SKILL.md'));
256
+ add(path.join(skillDir, MARKER));
257
+ add(path.join(xskRoot, 'install', 'backups', platform, `${skill.name}.SKILL.md.bak`));
258
+ }
259
+ // Cover the prior manifest's owned paths too, so an uninstall-first reset of
260
+ // skills no longer in the install set can still be rolled back on failure.
261
+ let prior = null;
262
+ try {
263
+ prior = read(platform, { xskRoot });
264
+ } catch (e) {
265
+ prior = null;
266
+ }
267
+ if (prior && typeof prior === 'object') {
268
+ const priorPaths = Array.isArray(prior.installed_paths) ? prior.installed_paths : [];
269
+ for (const p of priorPaths) {
270
+ if (!isInsideDir(p, skillsRoot)) {
271
+ continue;
272
+ }
273
+ add(p);
274
+ const base = path.basename(p);
275
+ if (base === 'SKILL.md' || base === MARKER) {
276
+ add(path.dirname(p));
277
+ }
278
+ }
279
+ const priorBackups = Array.isArray(prior.backups) ? prior.backups : [];
280
+ for (const b of priorBackups) {
281
+ if (b && typeof b.backup === 'string' && isInsideDir(b.backup, platformBackupDir)) {
282
+ add(b.backup);
283
+ }
284
+ if (b && typeof b.target === 'string') {
285
+ if (!isInsideDir(b.target, skillsRoot)) {
286
+ continue;
287
+ }
288
+ add(b.target);
289
+ add(path.dirname(b.target));
290
+ }
291
+ }
292
+ }
293
+ return paths.map((p) => capturePathState(p));
294
+ }
295
+
296
+ function restorePlatformSnapshot(snapshot) {
297
+ for (const entry of snapshot.slice().reverse()) {
298
+ restorePathState(entry);
299
+ }
300
+ }
301
+
302
+ function installPlatform({ platform, skillsRoot, xskRoot, version, skills }) {
303
+ const manifest = create(platform, version);
304
+ const installed = [];
305
+ const backups = [];
306
+ const installedHashes = [];
307
+ const rollbackBackups = [];
308
+ const previousState = previousInstallState(platform, xskRoot);
309
+ const backupsByTarget = previousState.backupsByTarget;
310
+ const installedHashesByTarget = previousState.installedHashesByTarget;
311
+ const backupDir = path.join(xskRoot, 'install', 'backups', platform);
312
+ const createdThisRun = [];
313
+ const overwrittenThisRun = [];
314
+
315
+ try {
316
+ ensurePlatformRoot(skillsRoot);
317
+ for (const skill of skills) {
318
+ const built = buildSkill(skill);
319
+ const skillDir = path.join(skillsRoot, skill.name);
320
+ const skillFile = path.join(skillDir, 'SKILL.md');
321
+ const markerFile = path.join(skillDir, MARKER);
322
+ const skillDirExisted = fs.existsSync(skillDir);
323
+
324
+ ensureSkillDir(skillDir);
325
+ if (!skillDirExisted) {
326
+ createdThisRun.push(skillDir);
327
+ }
328
+ assertNotSymlink(skillFile, 'skill file');
329
+ assertNotSymlink(markerFile, 'marker file');
330
+ const skillFileExisted = fs.existsSync(skillFile);
331
+ const markerFileExisted = fs.existsSync(markerFile);
332
+ let existingSkillContent = null;
333
+ let existingMarkerContent = null;
334
+
335
+ if (skillFileExisted) {
336
+ const stat = fs.statSync(skillFile);
337
+ existingSkillContent = fs.readFileSync(skillFile);
338
+ overwrittenThisRun.push({ target: skillFile, content: existingSkillContent, mode: stat.mode });
339
+ }
340
+ if (markerFileExisted) {
341
+ const stat = fs.statSync(markerFile);
342
+ existingMarkerContent = fs.readFileSync(markerFile);
343
+ overwrittenThisRun.push({ target: markerFile, content: existingMarkerContent, mode: stat.mode });
344
+ }
345
+ if (
346
+ markerFileExisted &&
347
+ Buffer.compare(existingMarkerContent, Buffer.from(`${PACKAGE_NAME}\n`, 'utf8')) !== 0
348
+ ) {
349
+ throw new Error(`refusing to overwrite invalid ownership marker: ${markerFile}`);
350
+ }
351
+
352
+ if (
353
+ skillFileExisted &&
354
+ markerFileExisted &&
355
+ Buffer.compare(existingSkillContent, Buffer.from(built.content, 'utf8')) !== 0
356
+ ) {
357
+ throw new Error(`refusing to overwrite user-edited owned skill: ${skillFile}`);
358
+ }
359
+
360
+ if (skillFileExisted && !markerFileExisted) {
361
+ const previousBackup = backupsByTarget.get(skillFile);
362
+ const markerlessGenerated = isPreviouslyInstalledGeneratedContent({
363
+ existingContent: existingSkillContent,
364
+ builtContent: built.content,
365
+ previousHash: installedHashesByTarget.get(skillFile),
366
+ wasInstalled: previousState.installedFiles.has(skillFile),
367
+ });
368
+ if (previousBackup) {
369
+ if (!markerlessGenerated) {
370
+ throw new Error(`refusing to overwrite drifted user-edited skill: ${skillFile}`);
371
+ }
372
+ assertReusableBackupRecord(previousBackup, backupDir);
373
+ } else if (!markerlessGenerated) {
374
+ assertSafePath(backupDir, 'backup dir');
375
+ fs.mkdirSync(backupDir, { recursive: true });
376
+ const backup = path.join(backupDir, `${skill.name}.SKILL.md.bak`);
377
+ let backupExisted = false;
378
+ let backupContent = null;
379
+ let backupMode = null;
380
+ assertSafePath(backup, 'backup file', { allowNonDirectoryTarget: true });
381
+ if (fs.existsSync(backup)) {
382
+ const stat = fs.lstatSync(backup);
383
+ if (!stat.isFile()) {
384
+ throw new Error(`refusing to overwrite non-file backup: ${backup}`);
385
+ }
386
+ backupExisted = true;
387
+ backupContent = fs.readFileSync(backup);
388
+ backupMode = stat.mode;
389
+ }
390
+ fs.copyFileSync(skillFile, backup);
391
+ const backupRecord = { target: skillFile, backup };
392
+ backupsByTarget.set(skillFile, backupRecord);
393
+ rollbackBackups.push({
394
+ target: skillFile,
395
+ backup,
396
+ backupExisted,
397
+ backupContent,
398
+ backupMode,
399
+ });
400
+ }
401
+ }
402
+
403
+ writeGeneratedFile(skillFile, built.content, 'skill file');
404
+ if (!skillFileExisted) {
405
+ createdThisRun.push(skillFile);
406
+ }
407
+ installedHashes.push({ target: skillFile, sha256: contentSha256(built.content) });
408
+ writeGeneratedFile(markerFile, `${PACKAGE_NAME}\n`, 'marker file');
409
+ if (!markerFileExisted) {
410
+ createdThisRun.push(markerFile);
411
+ }
412
+
413
+ if (!skillDirExisted || (previousState.installedDirs.has(skillDir) && markerFileExisted)) {
414
+ installed.push(skillDir);
415
+ }
416
+ installed.push(skillFile, markerFile);
417
+ const backup = backupsByTarget.get(skillFile);
418
+ if (backup) {
419
+ assertReusableBackupRecord(backup, backupDir);
420
+ backups.push({ target: backup.target, backup: backup.backup });
421
+ }
422
+ }
423
+
424
+ manifest.installed_paths = installed;
425
+ manifest.backups = backups;
426
+ manifest.installed_hashes = installedHashes;
427
+ write(platform, manifest, { xskRoot });
428
+ return { platform, installed, backups, manifest };
429
+ } catch (err) {
430
+ rollback(createdThisRun, rollbackBackups, overwrittenThisRun);
431
+ throw err;
432
+ }
433
+ }
434
+
435
+ function install(options) {
436
+ const opts = options || {};
437
+ const selectedPlatforms = opts.platforms || ALL_PLATFORMS;
438
+ const xskRoot = opts.xskRoot || path.join(os.homedir(), '.xsk');
439
+ const version = opts.version || packageVersion();
440
+ const skillsToInstall = opts.skills || ALL_SKILLS;
441
+
442
+ const summary = { platforms: {} };
443
+ const snapshots = [];
444
+ try {
445
+ for (const platform of selectedPlatforms) {
446
+ const skillsRoot = rootFor(platform, opts.platformRoots);
447
+ const applicable = skillsToInstall.filter((s) => s.platforms.includes(platform));
448
+ if (applicable.length === 0) {
449
+ summary.platforms[platform] = { platform, installed: [], backups: [], manifest: null, skipped: true };
450
+ continue;
451
+ }
452
+ // Snapshot the full platform state (including the prior manifest's owned
453
+ // paths) before any mutation, so the uninstall-first reset and the
454
+ // install roll back together on any failure.
455
+ const snapshot = capturePlatformSnapshot({ platform, skillsRoot, xskRoot, skills: applicable });
456
+ snapshots.push(snapshot);
457
+
458
+ // Uninstall-first: remove the prior owned state before regenerating, so a
459
+ // plain `xsk install` is a full reinstall with no manual uninstall and no
460
+ // orphaned files (skills no longer installed are pruned here). uninstall
461
+ // removes every cleanly-owned file and restores displaced user backups; a
462
+ // partial reset is tolerated, because installPlatform then handles any
463
+ // retained skill in place -- re-adopting a marker-less generated file, or
464
+ // refusing to overwrite a user-edited owned skill (which rolls back). An
465
+ // invalid prior manifest is the one case we refuse outright.
466
+ const reset = uninstallPlatform({ platform, xskRoot, skillsRoot });
467
+ if (reset.invalid) {
468
+ throw new Error(`refusing to reinstall ${platform}: existing manifest is invalid; uninstall or fix it first`);
469
+ }
470
+
471
+ summary.platforms[platform] = installPlatform({
472
+ platform,
473
+ skillsRoot,
474
+ xskRoot,
475
+ version,
476
+ skills: applicable,
477
+ });
478
+ }
479
+ } catch (err) {
480
+ for (const snapshot of snapshots.slice().reverse()) {
481
+ restorePlatformSnapshot(snapshot);
482
+ }
483
+ throw err;
484
+ }
485
+ return summary;
486
+ }
487
+
488
+ module.exports = {
489
+ install,
490
+ installPlatform,
491
+ atomicWriteFile,
492
+ rootFor,
493
+ PACKAGE_NAME,
494
+ MARKER,
495
+ ALL_PLATFORMS,
496
+ };