@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
@@ -0,0 +1,288 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const crypto = require('node:crypto');
7
+
8
+ const { isSha256 } = require('./content-hash');
9
+
10
+ const SCHEMA_VERSION = 1;
11
+ const REQUIRED_FIELDS = [
12
+ 'schema_version',
13
+ 'platform',
14
+ 'version',
15
+ 'installed_at',
16
+ 'installed_paths',
17
+ 'backups',
18
+ ];
19
+ const ATOMIC_WRITE_ATTEMPTS = 10;
20
+
21
+ function defaultXskRoot() {
22
+ return path.join(os.homedir(), '.xsk');
23
+ }
24
+
25
+ function manifestPath(platform, options) {
26
+ const opts = options || {};
27
+ const root = opts.xskRoot || defaultXskRoot();
28
+ return path.join(root, 'manifests', `${platform}.manifest`);
29
+ }
30
+
31
+ function pathSegments(targetPath) {
32
+ const segments = [];
33
+ let current = path.resolve(targetPath);
34
+ while (true) {
35
+ segments.push(current);
36
+ const parent = path.dirname(current);
37
+ if (parent === current) break;
38
+ current = parent;
39
+ }
40
+ return segments.reverse();
41
+ }
42
+
43
+ function assertSafePath(targetPath, label, options) {
44
+ const opts = options || {};
45
+ const target = path.resolve(targetPath);
46
+ for (const segment of pathSegments(target)) {
47
+ let stat;
48
+ try {
49
+ stat = fs.lstatSync(segment);
50
+ } catch (e) {
51
+ if (e && e.code === 'ENOENT') continue;
52
+ throw e;
53
+ }
54
+ const parent = path.dirname(segment);
55
+ const isTopLevel = parent === path.dirname(parent);
56
+ if (stat.isSymbolicLink() && isTopLevel) {
57
+ continue;
58
+ }
59
+ if (stat.isSymbolicLink()) {
60
+ throw new Error(`refusing to operate through symlinked ${label}: ${segment}`);
61
+ }
62
+ const isTarget = segment === target;
63
+ if (!stat.isDirectory() && !(isTarget && opts.allowNonDirectoryTarget)) {
64
+ throw new Error(`refusing to operate through non-directory ${label} ancestor: ${segment}`);
65
+ }
66
+ }
67
+ }
68
+
69
+ function isSafePath(targetPath, label, options) {
70
+ try {
71
+ assertSafePath(targetPath, label, options);
72
+ return true;
73
+ } catch (e) {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ function isInsideDir(child, parent) {
79
+ const rel = path.relative(path.resolve(parent), path.resolve(child));
80
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
81
+ }
82
+
83
+ function validateOperationalSemantics(options) {
84
+ const opts = options || {};
85
+ const skillsRoot = opts.skillsRoot;
86
+ const paths = Array.isArray(opts.manifest && opts.manifest.installed_paths) ? opts.manifest.installed_paths : [];
87
+ for (const p of paths) {
88
+ if (!isInsideDir(p, skillsRoot)) {
89
+ return { valid: false, reason: `installed path escapes platform root: ${p}` };
90
+ }
91
+ }
92
+ return { valid: true };
93
+ }
94
+
95
+ function copyInstalledHashes(records) {
96
+ if (!Array.isArray(records)) {
97
+ return [];
98
+ }
99
+ return records.map((r) => ({ target: r.target, sha256: r.sha256 }));
100
+ }
101
+
102
+ function atomicWriteFile(targetPath, content, options) {
103
+ const opts = options || {};
104
+ const basename = path.basename(targetPath);
105
+ const dir = path.dirname(targetPath);
106
+ const dirLabel = opts.dirLabel || 'atomic write dir';
107
+ const tempLabel = opts.tempLabel || 'atomic temp file';
108
+ const targetLabel = opts.targetLabel || 'atomic target file';
109
+ const flags =
110
+ fs.constants.O_WRONLY |
111
+ fs.constants.O_CREAT |
112
+ fs.constants.O_EXCL |
113
+ (fs.constants.O_NOFOLLOW || 0);
114
+ let tmp = null;
115
+ let fd = null;
116
+
117
+ assertSafePath(dir, dirLabel);
118
+ fs.mkdirSync(dir, { recursive: true });
119
+ assertSafePath(dir, dirLabel);
120
+
121
+ try {
122
+ for (let attempt = 0; attempt < ATOMIC_WRITE_ATTEMPTS; attempt += 1) {
123
+ const suffix = crypto.randomBytes(8).toString('hex');
124
+ const candidate = path.join(dir, `.${basename}.tmp-${process.pid}-${suffix}`);
125
+ try {
126
+ assertSafePath(candidate, tempLabel, { allowNonDirectoryTarget: true });
127
+ fd = fs.openSync(candidate, flags, 0o666);
128
+ tmp = candidate;
129
+ break;
130
+ } catch (e) {
131
+ if (e && e.code === 'EEXIST') {
132
+ continue;
133
+ }
134
+ throw e;
135
+ }
136
+ }
137
+ if (fd === null || tmp === null) {
138
+ throw new Error(`unable to create exclusive temp file for ${targetPath}`);
139
+ }
140
+
141
+ fs.writeFileSync(fd, content);
142
+ fs.closeSync(fd);
143
+ fd = null;
144
+ assertSafePath(targetPath, targetLabel, { allowNonDirectoryTarget: true });
145
+ fs.renameSync(tmp, targetPath);
146
+ tmp = null;
147
+ } catch (e) {
148
+ if (fd !== null) {
149
+ try {
150
+ fs.closeSync(fd);
151
+ } catch (closeErr) {
152
+ /* best effort */
153
+ }
154
+ }
155
+ if (tmp !== null) {
156
+ try {
157
+ fs.rmSync(tmp, { force: true });
158
+ } catch (cleanupErr) {
159
+ /* best effort */
160
+ }
161
+ }
162
+ throw e;
163
+ }
164
+ }
165
+
166
+ function validate(manifest, options) {
167
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
168
+ return false;
169
+ }
170
+ for (const f of REQUIRED_FIELDS) {
171
+ if (!Object.prototype.hasOwnProperty.call(manifest, f)) {
172
+ return false;
173
+ }
174
+ }
175
+ if (manifest.schema_version !== SCHEMA_VERSION) {
176
+ return false;
177
+ }
178
+ if (typeof manifest.platform !== 'string' || manifest.platform.length === 0) {
179
+ return false;
180
+ }
181
+ const opts = options || {};
182
+ if (opts.expectedPlatform && manifest.platform !== opts.expectedPlatform) {
183
+ return false;
184
+ }
185
+ if (typeof manifest.version !== 'string') {
186
+ return false;
187
+ }
188
+ if (typeof manifest.installed_at !== 'string' || manifest.installed_at.length === 0) {
189
+ return false;
190
+ }
191
+ if (!Array.isArray(manifest.installed_paths)) {
192
+ return false;
193
+ }
194
+ for (const p of manifest.installed_paths) {
195
+ if (typeof p !== 'string' || p.length === 0) {
196
+ return false;
197
+ }
198
+ }
199
+ if (!Array.isArray(manifest.backups)) {
200
+ return false;
201
+ }
202
+ for (const b of manifest.backups) {
203
+ if (!b || typeof b !== 'object' || Array.isArray(b)) {
204
+ return false;
205
+ }
206
+ if (typeof b.target !== 'string' || typeof b.backup !== 'string') {
207
+ return false;
208
+ }
209
+ }
210
+ if (Object.prototype.hasOwnProperty.call(manifest, 'installed_hashes')) {
211
+ if (!Array.isArray(manifest.installed_hashes)) {
212
+ return false;
213
+ }
214
+ for (const h of manifest.installed_hashes) {
215
+ if (!h || typeof h !== 'object' || Array.isArray(h)) {
216
+ return false;
217
+ }
218
+ if (typeof h.target !== 'string' || !isSha256(h.sha256)) {
219
+ return false;
220
+ }
221
+ }
222
+ }
223
+ return true;
224
+ }
225
+
226
+ function create(platform, version, partial) {
227
+ const p = partial || {};
228
+ return {
229
+ schema_version: SCHEMA_VERSION,
230
+ platform,
231
+ version,
232
+ installed_at: p.installed_at || new Date().toISOString(),
233
+ installed_paths: Array.isArray(p.installed_paths) ? p.installed_paths.slice() : [],
234
+ backups: Array.isArray(p.backups) ? p.backups.map((b) => ({ target: b.target, backup: b.backup })) : [],
235
+ installed_hashes: copyInstalledHashes(p.installed_hashes),
236
+ };
237
+ }
238
+
239
+ function read(platform, options) {
240
+ const p = manifestPath(platform, options);
241
+ assertSafePath(path.dirname(p), 'manifest dir');
242
+ if (!fs.existsSync(p)) {
243
+ return null;
244
+ }
245
+ assertSafePath(p, 'manifest file', { allowNonDirectoryTarget: true });
246
+ const data = fs.readFileSync(p, 'utf8');
247
+ return JSON.parse(data);
248
+ }
249
+
250
+ function write(platform, manifest, options) {
251
+ const p = manifestPath(platform, options);
252
+ const dir = path.dirname(p);
253
+ assertSafePath(dir, 'manifest dir');
254
+ fs.mkdirSync(dir, { recursive: true });
255
+ assertSafePath(dir, 'manifest dir');
256
+ const payload = JSON.stringify(manifest, null, 2) + '\n';
257
+ atomicWriteFile(p, payload, {
258
+ dirLabel: 'manifest dir',
259
+ tempLabel: 'manifest temp file',
260
+ targetLabel: 'manifest file',
261
+ });
262
+ assertSafePath(p, 'manifest file', { allowNonDirectoryTarget: true });
263
+ return p;
264
+ }
265
+
266
+ function removeManifest(platform, options) {
267
+ const p = manifestPath(platform, options);
268
+ assertSafePath(p, 'manifest file', { allowNonDirectoryTarget: true });
269
+ fs.rmSync(p, { force: true });
270
+ return p;
271
+ }
272
+
273
+ module.exports = {
274
+ SCHEMA_VERSION,
275
+ REQUIRED_FIELDS,
276
+ validate,
277
+ create,
278
+ read,
279
+ write,
280
+ manifestPath,
281
+ defaultXskRoot,
282
+ assertSafePath,
283
+ isSafePath,
284
+ isInsideDir,
285
+ validateOperationalSemantics,
286
+ atomicWriteFile,
287
+ removeManifest,
288
+ };
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ // Ownership constants and per-skill safety predicates shared by install and
4
+ // uninstall. Lives in its own module so install.js can run an uninstall-first
5
+ // reset without requiring uninstall.js (which would create a require cycle).
6
+
7
+ const fs = require('node:fs');
8
+ const path = require('node:path');
9
+
10
+ const { isSafePath, isInsideDir } = require('./manifest');
11
+
12
+ const PACKAGE_NAME = '@xenonbyte/xsk';
13
+ const MARKER = '.xsk-owned';
14
+
15
+ function isSymlink(p) {
16
+ try {
17
+ return fs.lstatSync(p).isSymbolicLink();
18
+ } catch (e) {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ function isExistingNonRegularFile(p) {
24
+ try {
25
+ return !fs.lstatSync(p).isFile();
26
+ } catch (e) {
27
+ if (e && e.code === 'ENOENT') return false;
28
+ throw e;
29
+ }
30
+ }
31
+
32
+ function hasUnsafeSkillPath(skillDir, skillFile, markerFile) {
33
+ return (
34
+ !isSafePath(skillDir, 'skill dir') ||
35
+ !isSafePath(skillFile, 'skill file', { allowNonDirectoryTarget: true }) ||
36
+ !isSafePath(markerFile, 'marker file', { allowNonDirectoryTarget: true }) ||
37
+ isExistingNonRegularFile(skillFile) ||
38
+ isExistingNonRegularFile(markerFile)
39
+ );
40
+ }
41
+
42
+ function hasValidMarker(markerFile) {
43
+ try {
44
+ return fs.readFileSync(markerFile, 'utf8') === `${PACKAGE_NAME}\n`;
45
+ } catch (e) {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ function safeBackupForSkill(backups, skillFile, xskRoot, platform, skillsRoot) {
51
+ const matches = backups.filter((b) => b.target === skillFile);
52
+ const backup = matches[0];
53
+ if (!backup) return { backup: null, unsafe: false, missing: false };
54
+ if (matches.length > 1) return { backup, unsafe: true, missing: false };
55
+
56
+ const expectedDir = path.join(xskRoot, 'install', 'backups', platform);
57
+ const backupPath = path.resolve(backup.backup);
58
+ if (!isInsideDir(path.resolve(backup.target), skillsRoot)) {
59
+ return { backup, unsafe: true, missing: false };
60
+ }
61
+ if (!isInsideDir(backupPath, expectedDir)) {
62
+ return { backup, unsafe: true, missing: false };
63
+ }
64
+ if (!isSafePath(expectedDir, 'backup dir') || !isSafePath(backupPath, 'backup file', { allowNonDirectoryTarget: true })) {
65
+ return { backup, unsafe: true, missing: false };
66
+ }
67
+ if (!fs.existsSync(backupPath)) {
68
+ return { backup, unsafe: false, missing: true };
69
+ }
70
+ const stat = fs.lstatSync(backupPath);
71
+ if (!stat.isFile()) {
72
+ return { backup, unsafe: true, missing: false };
73
+ }
74
+ return { backup, unsafe: false, missing: false };
75
+ }
76
+
77
+ module.exports = {
78
+ PACKAGE_NAME,
79
+ MARKER,
80
+ isSymlink,
81
+ isExistingNonRegularFile,
82
+ hasUnsafeSkillPath,
83
+ hasValidMarker,
84
+ safeBackupForSkill,
85
+ };
package/lib/skills.js ADDED
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ const ALL_PLATFORMS = ['claude', 'codex', 'opencode', 'gemini'];
4
+
5
+ const skills = [
6
+ {
7
+ name: 'xsk-think',
8
+ description:
9
+ 'Plan before coding. Weigh options, surface ambiguities, and produce a decision-complete design, then stop for approval.',
10
+ platforms: ALL_PLATFORMS.slice(),
11
+ fragmentBase: 'think',
12
+ },
13
+ {
14
+ name: 'xsk-bypass-claude',
15
+ description:
16
+ 'Set the current project to Claude Code bypass-permissions mode by writing .claude/settings.local.json. Claude only.',
17
+ platforms: ['claude'],
18
+ fragmentBase: 'bypass-claude',
19
+ },
20
+ {
21
+ name: 'xsk-skill-scaffold',
22
+ description:
23
+ 'Bring an agent-skill project up to the xsk standard, or refuse if the target is not an agent-skill project.',
24
+ platforms: ALL_PLATFORMS.slice(),
25
+ fragmentBase: 'skill-scaffold',
26
+ },
27
+ {
28
+ name: 'xsk-write-req',
29
+ description:
30
+ 'Turn plain-language needs into a grounded requirement document in requirements/, with a self-audit gate before it is finalized.',
31
+ platforms: ALL_PLATFORMS.slice(),
32
+ fragmentBase: 'write-req',
33
+ },
34
+ {
35
+ name: 'xsk-archive-req',
36
+ description:
37
+ 'Archive the active requirement document into requirements/archive/ and leave zero active docs.',
38
+ platforms: ALL_PLATFORMS.slice(),
39
+ fragmentBase: 'archive-req',
40
+ },
41
+ {
42
+ name: 'xsk-check',
43
+ description:
44
+ 'Review a code change before it ships. Check scope drift, enforce hard stops, gate findings on evidence, then verify and sign off. Distilled from Waza /check.',
45
+ platforms: ALL_PLATFORMS.slice(),
46
+ fragmentBase: 'check',
47
+ },
48
+ ];
49
+
50
+ function forPlatform(platform) {
51
+ return skills.filter((s) => s.platforms.includes(platform));
52
+ }
53
+
54
+ function get(name) {
55
+ return skills.find((s) => s.name === name);
56
+ }
57
+
58
+ module.exports = { skills, forPlatform, get, ALL_PLATFORMS };
package/lib/status.js ADDED
@@ -0,0 +1,171 @@
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 { read, validate, defaultXskRoot, isSafePath, isInsideDir, validateOperationalSemantics } = require('./manifest');
8
+ const { ALL_PLATFORMS, MARKER, rootFor } = require('./install');
9
+
10
+ const STATES = ['not-installed', 'ok', 'drift', 'invalid'];
11
+
12
+ function expectedInstalledPathType(p) {
13
+ const base = path.basename(p);
14
+ if (base === 'SKILL.md' || base === MARKER) {
15
+ return 'file';
16
+ }
17
+ return 'directory';
18
+ }
19
+
20
+ function recordedPathEntries(manifest) {
21
+ const installed = Array.isArray(manifest.installed_paths)
22
+ ? manifest.installed_paths.map((p) => ({ path: p, expectedType: expectedInstalledPathType(p) }))
23
+ : [];
24
+ const backups = Array.isArray(manifest.backups)
25
+ ? manifest.backups.map((b) => ({ path: b.backup, target: b.target, expectedType: 'file', kind: 'backup' }))
26
+ : [];
27
+ return installed.concat(backups);
28
+ }
29
+
30
+ function recordedPaths(manifest) {
31
+ return recordedPathEntries(manifest).map((entry) => entry.path);
32
+ }
33
+
34
+ function pathType(p) {
35
+ try {
36
+ const stat = fs.lstatSync(p);
37
+ if (stat.isFile()) return 'file';
38
+ if (stat.isDirectory()) return 'directory';
39
+ return 'other';
40
+ } catch (e) {
41
+ if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) return 'missing';
42
+ throw e;
43
+ }
44
+ }
45
+
46
+ function recordedPathHasDrift(entry, options) {
47
+ const opts = options || {};
48
+ const exists = opts.exists || ((p) => fs.existsSync(p));
49
+ const safe = opts.safe || (() => true);
50
+ const typeOf = opts.typeOf || null;
51
+ if (!safe(entry.path, entry)) return true;
52
+ if (!exists(entry.path, entry)) return true;
53
+ if (typeOf && typeOf(entry.path, entry) !== entry.expectedType) return true;
54
+ return false;
55
+ }
56
+
57
+ function missingRecordedPaths(manifest, options) {
58
+ const opts = options || {};
59
+ return recordedPathEntries(manifest)
60
+ .filter((entry) => recordedPathHasDrift(entry, opts))
61
+ .map((entry) => entry.path);
62
+ }
63
+
64
+ function statusOf(manifest, options) {
65
+ const opts = options || {};
66
+ if (!manifest) return 'not-installed';
67
+ if (!validate(manifest, { expectedPlatform: opts.expectedPlatform })) return 'invalid';
68
+ const exists = opts.exists || ((p) => fs.existsSync(p));
69
+ const safe = opts.safe || (() => true);
70
+ const typeOf = opts.typeOf || null;
71
+ const paths = recordedPaths(manifest);
72
+ if (paths.length === 0) return 'ok';
73
+ return missingRecordedPaths(manifest, { exists, safe, typeOf }).length === 0 ? 'ok' : 'drift';
74
+ }
75
+
76
+ function computeStatus(options) {
77
+ const opts = options || {};
78
+ const platforms = opts.platforms || ALL_PLATFORMS;
79
+ const xskRoot = opts.xskRoot || defaultXskRoot();
80
+
81
+ const result = { platforms: {} };
82
+ for (const platform of platforms) {
83
+ let manifest = null;
84
+ let readError = null;
85
+ try {
86
+ manifest = read(platform, { xskRoot });
87
+ } catch (e) {
88
+ readError = e;
89
+ }
90
+
91
+ if (readError) {
92
+ result.platforms[platform] = {
93
+ state: 'invalid',
94
+ reason: `manifest is not valid JSON: ${readError.message}`,
95
+ };
96
+ continue;
97
+ }
98
+
99
+ if (!manifest) {
100
+ result.platforms[platform] = { state: 'not-installed' };
101
+ continue;
102
+ }
103
+
104
+ let skillsRoot = null;
105
+ if (validate(manifest, { expectedPlatform: platform })) {
106
+ skillsRoot = rootFor(platform, opts.platformRoots);
107
+ const semantics = validateOperationalSemantics({ platform, skillsRoot, manifest });
108
+ if (!semantics.valid) {
109
+ result.platforms[platform] = { state: 'invalid', reason: semantics.reason };
110
+ continue;
111
+ }
112
+ }
113
+
114
+ const exists = (p) => fs.existsSync(p);
115
+ const backupRoot = path.join(xskRoot, 'install', 'backups', platform);
116
+ const safe = (p, entry) => isSafePath(p, 'status recorded path', {
117
+ allowNonDirectoryTarget: entry.expectedType !== 'directory',
118
+ }) && (
119
+ entry.kind !== 'backup' ||
120
+ (
121
+ isInsideDir(entry.target, skillsRoot) &&
122
+ isInsideDir(p, backupRoot) &&
123
+ isSafePath(backupRoot, 'status backup dir') &&
124
+ isSafePath(p, 'status backup file', { allowNonDirectoryTarget: true })
125
+ )
126
+ );
127
+ const state = statusOf(manifest, { expectedPlatform: platform, exists, safe, typeOf: pathType });
128
+ const entry = { state };
129
+ if (state === 'drift') {
130
+ entry.missing = missingRecordedPaths(manifest, { exists, safe, typeOf: pathType });
131
+ }
132
+ if (state === 'ok' || state === 'drift') {
133
+ entry.installedCount = (manifest.installed_paths || []).filter(
134
+ (p) => p.endsWith('SKILL.md'),
135
+ ).length;
136
+ entry.version = manifest.version;
137
+ }
138
+ result.platforms[platform] = entry;
139
+ }
140
+ return result;
141
+ }
142
+
143
+ function render(result, options) {
144
+ const opts = options || {};
145
+ if (opts.json) {
146
+ return JSON.stringify(result, null, 2);
147
+ }
148
+ const lines = [];
149
+ const order = opts.platforms || Object.keys(result.platforms);
150
+ for (const platform of order) {
151
+ const entry = result.platforms[platform];
152
+ if (!entry) continue;
153
+ let line = `${platform}: ${entry.state}`;
154
+ if (entry.installedCount !== undefined) {
155
+ line += ` (${entry.installedCount} skill${entry.installedCount === 1 ? '' : 's'})`;
156
+ }
157
+ if (entry.version) {
158
+ line += ` v${entry.version}`;
159
+ }
160
+ if (entry.missing && entry.missing.length) {
161
+ line += `; ${entry.missing.length} recorded path(s) missing`;
162
+ }
163
+ if (entry.reason) {
164
+ line += ` - ${entry.reason}`;
165
+ }
166
+ lines.push(line);
167
+ }
168
+ return lines.join('\n');
169
+ }
170
+
171
+ module.exports = { statusOf, computeStatus, render, STATES };