superpowers-manager 0.2.0 → 0.4.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 (68) hide show
  1. package/README.md +340 -66
  2. package/dist/adapter-result.js +204 -0
  3. package/dist/adapter.js +690 -0
  4. package/dist/atomic.js +142 -0
  5. package/dist/cli-arguments.js +32 -0
  6. package/dist/cli.js +313 -0
  7. package/dist/codex-harness.js +271 -0
  8. package/dist/codex-json.js +89 -0
  9. package/dist/codex-prepare.js +199 -0
  10. package/dist/codex-presentation.js +259 -0
  11. package/dist/codex-state.js +78 -0
  12. package/dist/commands/context.js +1 -0
  13. package/dist/commands/install.js +291 -0
  14. package/dist/commands/pin.js +79 -0
  15. package/dist/commands/prepare.js +420 -0
  16. package/dist/commands/probe.js +257 -0
  17. package/dist/commands/track-latest.js +44 -0
  18. package/dist/commands/uninstall.js +217 -0
  19. package/dist/commands/unpin.js +95 -0
  20. package/dist/commands/update.js +79 -0
  21. package/dist/domain/fingerprint.js +4 -0
  22. package/dist/domain/refs.js +37 -0
  23. package/dist/effective-selection.js +123 -0
  24. package/dist/generated-plugin.js +748 -0
  25. package/dist/git.js +38 -0
  26. package/dist/harness.js +1 -0
  27. package/dist/hooks.js +290 -0
  28. package/dist/lifecycle.js +135 -0
  29. package/dist/manifest-overlay.js +125 -0
  30. package/dist/provenance.js +111 -0
  31. package/dist/python-json-format.js +83 -0
  32. package/dist/python-json.js +29 -0
  33. package/dist/python-text.js +74 -0
  34. package/dist/safe-path.js +137 -0
  35. package/dist/safety-error.js +10 -0
  36. package/dist/selection-state-cli.js +85 -0
  37. package/dist/selection-store.js +172 -0
  38. package/dist/selection.js +200 -0
  39. package/dist/status.js +24 -0
  40. package/dist/strict-json.js +248 -0
  41. package/dist/upstream-cli.js +110 -0
  42. package/dist/upstream-version.js +40 -0
  43. package/dist/upstream.js +253 -0
  44. package/dist/validate-generated-plugin-cli.js +118 -0
  45. package/dist/validator.js +317 -0
  46. package/dist/workspace.js +139 -0
  47. package/package.json +25 -4
  48. package/bin/superpowers-manager.js +0 -290
  49. package/scripts/adapters/codex/adapter +0 -603
  50. package/scripts/adapters/codex/lib.sh +0 -449
  51. package/scripts/adapters/codex/validate-generated-plugin.py +0 -314
  52. package/scripts/core/adapter.sh +0 -103
  53. package/scripts/core/common.sh +0 -59
  54. package/scripts/core/lifecycle.sh +0 -141
  55. package/scripts/core/provenance.sh +0 -135
  56. package/scripts/core/selection-state.py +0 -414
  57. package/scripts/core/selection.sh +0 -168
  58. package/scripts/core/status.sh +0 -27
  59. package/scripts/core/upstream.sh +0 -280
  60. package/scripts/core/validate-adapter-response.py +0 -284
  61. package/scripts/install +0 -59
  62. package/scripts/pin +0 -51
  63. package/scripts/prepare +0 -117
  64. package/scripts/probe +0 -82
  65. package/scripts/track-latest +0 -30
  66. package/scripts/uninstall +0 -35
  67. package/scripts/unpin +0 -41
  68. package/scripts/update +0 -34
@@ -0,0 +1,748 @@
1
+ import { lstat, readdir, readFile, readlink, stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { posix } from "node:path";
4
+ import { COMMIT_RE } from "./domain/refs.js";
5
+ import { pythonSplitlines, pythonStrip } from "./python-text.js";
6
+ import { parseStrictJson } from "./strict-json.js";
7
+ export const DEFAULT_FS_DEPS = {
8
+ lstat,
9
+ stat,
10
+ readdir,
11
+ readlink,
12
+ readFile,
13
+ };
14
+ // The Python's own SEMVER_RE (:15-22). Deliberately not SEMVER_BASE_SOURCE
15
+ // from src/domain/refs.ts, which omits the `+build` component; widening the
16
+ // shared constant would change TAG_RE and is out of scope.
17
+ const SEMVER_RE = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
18
+ const MANIFEST_JSON_PROFILE = {
19
+ duplicateKeys: "last-wins",
20
+ nonStandardConstants: "reject",
21
+ maxDepth: 256,
22
+ };
23
+ /** Resolution failed. The caller supplies its own site diagnostic. */
24
+ class ResolutionFailure extends Error {
25
+ }
26
+ /** A non-absence error from an existence/type probe. */
27
+ class InspectionFailure extends Error {
28
+ }
29
+ /** Any error from a directory enumeration, absence-like included. */
30
+ class EnumerationFailure extends Error {
31
+ }
32
+ /**
33
+ * `ENOENT`/`ENOTDIR` mean *missing*; every other error rejects. This is the
34
+ * only place the absence set is defined.
35
+ */
36
+ function isAbsenceError(cause) {
37
+ const code = cause?.code;
38
+ return code === "ENOENT" || code === "ENOTDIR";
39
+ }
40
+ const STRICT_DECODER = new TextDecoder("utf-8", {
41
+ fatal: true,
42
+ ignoreBOM: true,
43
+ });
44
+ /** Decode a raw pathname. Throws when the bytes are not valid UTF-8. */
45
+ function decodePathBytes(bytes) {
46
+ return STRICT_DECODER.decode(bytes);
47
+ }
48
+ /**
49
+ * Node silently maps an unpaired surrogate to U+FFFD on the way to the
50
+ * syscall, so a string carrying one would open a different file than the
51
+ * caller named. CPython raises `UnicodeEncodeError` instead.
52
+ */
53
+ export function hasUnpairedSurrogate(value) {
54
+ for (let index = 0; index < value.length; index += 1) {
55
+ const unit = value.charCodeAt(index);
56
+ if (unit >= 0xd800 && unit <= 0xdbff) {
57
+ const next = value.charCodeAt(index + 1);
58
+ if (!(next >= 0xdc00 && next <= 0xdfff))
59
+ return true;
60
+ index += 1;
61
+ }
62
+ else if (unit >= 0xdc00 && unit <= 0xdfff) {
63
+ return true;
64
+ }
65
+ }
66
+ return false;
67
+ }
68
+ /**
69
+ * CPython `Path.expanduser`, minus `~user`, which stays unexpanded.
70
+ * Concatenates rather than joining: `join` normalizes, and normalization
71
+ * anywhere upstream of `resolvePath` defeats component-wise resolution just as
72
+ * thoroughly as normalization inside it.
73
+ */
74
+ function expandUser(value) {
75
+ if (value === "~")
76
+ return homedir();
77
+ if (value.startsWith("~/"))
78
+ return `${homedir()}/${value.slice(2)}`;
79
+ return value;
80
+ }
81
+ /**
82
+ * Makes `value` absolute **without collapsing `..`**. `node:path`'s `resolve`
83
+ * and `join` both normalize, which would erase a `..` before its preceding
84
+ * component is ever resolved.
85
+ */
86
+ function absolutize(value) {
87
+ return posix.isAbsolute(value) ? value : `${process.cwd()}/${value}`;
88
+ }
89
+ /**
90
+ * A component-wise port of `posixpath._joinrealpath`, with this PR's frozen
91
+ * fail-closed policy substituted for CPython's version-divergent suppression.
92
+ */
93
+ async function resolvePath(target, deps, { strict }) {
94
+ if (hasUnpairedSurrogate(target))
95
+ throw new ResolutionFailure(target);
96
+ const start = absolutize(target);
97
+ const seen = new Map();
98
+ /**
99
+ * Paths left unresolved because resolving them re-entered a link already in
100
+ * progress. Membership is a property of the path, not of the walk, so a
101
+ * later `..` cancels a cycle simply by moving `current` off the marked path
102
+ * — which is the 3.13+ behavior this port freezes. A boolean latch would
103
+ * instead mean "a cycle occurred somewhere" and would reject
104
+ * `outer -> loop/../real`, where the cycle is cancelled before the end.
105
+ */
106
+ const liveComponents = new Set();
107
+ const walk = async (base, rest) => {
108
+ let current = base;
109
+ for (const name of rest.split("/")) {
110
+ if (name === "" || name === ".")
111
+ continue;
112
+ if (name === "..") {
113
+ // Pops the already-resolved prefix textually, before any lstat.
114
+ current = posix.dirname(current);
115
+ continue;
116
+ }
117
+ const child = posix.join(current, name);
118
+ let link = false;
119
+ try {
120
+ link = (await deps.lstat(child)).isSymbolicLink();
121
+ }
122
+ catch (cause) {
123
+ if (!isAbsenceError(cause))
124
+ throw new ResolutionFailure(child);
125
+ if (strict)
126
+ throw new ResolutionFailure(child);
127
+ current = child;
128
+ continue;
129
+ }
130
+ if (!link) {
131
+ current = child;
132
+ continue;
133
+ }
134
+ if (seen.has(child)) {
135
+ const memo = seen.get(child);
136
+ if (memo != null) {
137
+ current = memo;
138
+ continue;
139
+ }
140
+ // A live cycle: leave this component unresolved and keep resolving the
141
+ // remainder. Marking the path is what makes it rejectable if it turns
142
+ // out to be where resolution ends.
143
+ if (strict)
144
+ throw new ResolutionFailure(child);
145
+ liveComponents.add(child);
146
+ current = child;
147
+ continue;
148
+ }
149
+ seen.set(child, null);
150
+ let raw;
151
+ try {
152
+ raw = (await deps.readlink(child, { encoding: "buffer" }));
153
+ }
154
+ catch {
155
+ // Every readlink error rejects, absence-like errnos included.
156
+ throw new ResolutionFailure(child);
157
+ }
158
+ let linkTarget;
159
+ try {
160
+ linkTarget = decodePathBytes(raw);
161
+ }
162
+ catch {
163
+ throw new ResolutionFailure(child);
164
+ }
165
+ const resolvedLink = await walk(posix.isAbsolute(linkTarget) ? "/" : current, linkTarget);
166
+ seen.set(child, resolvedLink);
167
+ current = resolvedLink;
168
+ }
169
+ return current;
170
+ };
171
+ const resolved = await walk("/", start);
172
+ // Reject only when resolution *ends* on an unresolved cycle. A cycle that a
173
+ // later component or `..` moved away from is not a live cycle.
174
+ if (liveComponents.has(resolved))
175
+ throw new ResolutionFailure(resolved);
176
+ return resolved;
177
+ }
178
+ /**
179
+ * An existence/type probe under the inspection-failure rule. `follow` picks
180
+ * `stat` (symlink-following, as `exists`/`is_dir`/`is_file` do) or `lstat`
181
+ * (`os.path.lexists`).
182
+ */
183
+ async function inspectPath(path, deps, follow) {
184
+ if (hasUnpairedSurrogate(path))
185
+ throw new InspectionFailure(path);
186
+ try {
187
+ const info = follow ? await deps.stat(path) : await deps.lstat(path);
188
+ if (info.isDirectory())
189
+ return "directory";
190
+ if (info.isFile())
191
+ return "file";
192
+ return "other";
193
+ }
194
+ catch (cause) {
195
+ if (isAbsenceError(cause))
196
+ return "missing";
197
+ throw new InspectionFailure(path);
198
+ }
199
+ }
200
+ /** `Path.is_symlink()` under the inspection-failure rule. */
201
+ async function inspectLink(path, deps) {
202
+ if (hasUnpairedSurrogate(path))
203
+ throw new InspectionFailure(path);
204
+ try {
205
+ return (await deps.lstat(path)).isSymbolicLink() ? "symlink" : "other";
206
+ }
207
+ catch (cause) {
208
+ if (isAbsenceError(cause))
209
+ return "missing";
210
+ throw new InspectionFailure(path);
211
+ }
212
+ }
213
+ /**
214
+ * Buffer-mode enumeration. Every error rejects — absence included — and an
215
+ * entry whose bytes are not valid UTF-8 rejects rather than being silently
216
+ * mapped to U+FFFD and then vanishing behind a later `ENOENT`.
217
+ */
218
+ async function listDirectory(path, deps) {
219
+ let entries;
220
+ try {
221
+ entries = (await deps.readdir(path, { encoding: "buffer" }));
222
+ }
223
+ catch {
224
+ throw new EnumerationFailure(path);
225
+ }
226
+ return entries.map((entry) => {
227
+ try {
228
+ return decodePathBytes(entry);
229
+ }
230
+ catch {
231
+ throw new EnumerationFailure(path);
232
+ }
233
+ });
234
+ }
235
+ async function loadJsonObject(path, label, errors, deps) {
236
+ let text;
237
+ try {
238
+ // Read bytes and decode explicitly: `ignoreBOM: true` keeps a BOM in the
239
+ // text so the parser rejects it, matching Python. A default decoder would
240
+ // strip it and accept a manifest Python rejects.
241
+ text = STRICT_DECODER.decode(await deps.readFile(path));
242
+ }
243
+ catch {
244
+ // `read_text` failures and decode failures share one branch at :74.
245
+ errors.push(`${label} is unreadable UTF-8`);
246
+ return null;
247
+ }
248
+ let value;
249
+ try {
250
+ value = parseStrictJson(text, MANIFEST_JSON_PROFILE);
251
+ }
252
+ catch (cause) {
253
+ const message = cause instanceof Error ? cause.message : String(cause);
254
+ errors.push(message.startsWith("container depth exceeds ")
255
+ ? `${label} exceeds maximum JSON nesting`
256
+ : `${label} must contain valid JSON`);
257
+ return null;
258
+ }
259
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
260
+ errors.push(`${label} must contain a JSON object`);
261
+ return null;
262
+ }
263
+ return value;
264
+ }
265
+ async function validateLocalPath(pluginRoot, rawValue, label, errors, deps, { requireDirectory = false, requireFile = false } = {}) {
266
+ if (typeof rawValue !== "string" || pythonStrip(rawValue) === "") {
267
+ errors.push(`${label} must be a non-empty relative path`);
268
+ return;
269
+ }
270
+ if (posix.isAbsolute(rawValue)) {
271
+ errors.push(`${label} must be a relative path`);
272
+ return;
273
+ }
274
+ let root;
275
+ let target;
276
+ try {
277
+ root = await resolvePath(pluginRoot, deps, { strict: false });
278
+ // Concatenate — `posix.join` would collapse a `..` inside `rawValue`
279
+ // before `resolvePath` ever sees it, which is the same accept-side
280
+ // containment bypass the hand-written resolver exists to avoid.
281
+ target = await resolvePath(`${root}/${rawValue}`, deps, {
282
+ strict: false,
283
+ });
284
+ }
285
+ catch {
286
+ errors.push(`${label} could not be resolved`);
287
+ return;
288
+ }
289
+ // If `root` were `/`, `${root}/` is `//`, which no child ever starts with —
290
+ // everything would read as an escape. Fail-closed and unreachable in
291
+ // production, since the plugin root is never the filesystem root.
292
+ if (target !== root && !target.startsWith(`${root}/`)) {
293
+ errors.push(`${label} escapes the plugin root`);
294
+ return;
295
+ }
296
+ let presence;
297
+ try {
298
+ presence = await inspectPath(target, deps, true);
299
+ }
300
+ catch {
301
+ errors.push(`${label} target \`${rawValue}\` could not be inspected`);
302
+ return;
303
+ }
304
+ if (presence === "missing") {
305
+ errors.push(`${label} target \`${rawValue}\` does not exist`);
306
+ }
307
+ else if (requireDirectory && presence !== "directory") {
308
+ errors.push(`${label} target \`${rawValue}\` must be a directory`);
309
+ }
310
+ else if (requireFile && presence !== "file") {
311
+ errors.push(`${label} target \`${rawValue}\` must be a file`);
312
+ }
313
+ }
314
+ async function validateHookPath(pluginRoot, value, label, errors, deps) {
315
+ if (typeof value !== "string" || !value.startsWith("./")) {
316
+ errors.push(`${label} must start with \`./\``);
317
+ return;
318
+ }
319
+ await validateLocalPath(pluginRoot, value, label, errors, deps, {
320
+ requireFile: true,
321
+ });
322
+ }
323
+ async function validateHooks(pluginRoot, manifest, manifestSource, errors, deps) {
324
+ if (manifestSource === "fallback") {
325
+ if (Object.hasOwn(manifest, "hooks")) {
326
+ errors.push("fallback plugin manifest field `hooks` must be absent");
327
+ }
328
+ return "forbid";
329
+ }
330
+ if (!Object.hasOwn(manifest, "hooks"))
331
+ return "default";
332
+ const hooks = manifest.hooks;
333
+ if (hooks !== null &&
334
+ typeof hooks === "object" &&
335
+ !Array.isArray(hooks) &&
336
+ Object.keys(hooks).length === 0) {
337
+ return "forbid";
338
+ }
339
+ if (typeof hooks === "string") {
340
+ await validateHookPath(pluginRoot, hooks, "plugin manifest field `hooks`", errors, deps);
341
+ return "allow";
342
+ }
343
+ if (hooks !== null && typeof hooks === "object" && !Array.isArray(hooks)) {
344
+ return "allow";
345
+ }
346
+ if (!Array.isArray(hooks)) {
347
+ errors.push("plugin manifest field `hooks` has an unsupported type");
348
+ return "allow";
349
+ }
350
+ if (hooks.length === 0)
351
+ return "default";
352
+ if (hooks.every((value) => typeof value === "string")) {
353
+ for (const [index, value] of hooks.entries()) {
354
+ await validateHookPath(pluginRoot, value, `plugin manifest field \`hooks[${index}]\``, errors, deps);
355
+ }
356
+ return "allow";
357
+ }
358
+ if (hooks.every((value) => value !== null && typeof value === "object" && !Array.isArray(value))) {
359
+ return "allow";
360
+ }
361
+ errors.push("plugin manifest field `hooks` array must contain only paths or only objects");
362
+ return "allow";
363
+ }
364
+ async function validateManifest(pluginRoot, expectedVersion, manifestSource, errors, deps) {
365
+ const path = posix.join(pluginRoot, ".codex-plugin", "plugin.json");
366
+ let presence;
367
+ try {
368
+ presence = await inspectPath(path, deps, true);
369
+ }
370
+ catch {
371
+ errors.push("required file `.codex-plugin/plugin.json` could not be inspected");
372
+ return "forbid";
373
+ }
374
+ if (presence !== "file") {
375
+ errors.push("missing required file `.codex-plugin/plugin.json`");
376
+ return "forbid";
377
+ }
378
+ const manifest = await loadJsonObject(path, "plugin manifest", errors, deps);
379
+ if (manifest === null)
380
+ return "forbid";
381
+ const hookPolicy = await validateHooks(pluginRoot, manifest, manifestSource, errors, deps);
382
+ if (manifest.name !== "superpowers") {
383
+ errors.push("plugin manifest field `name` must equal `superpowers`");
384
+ }
385
+ const version = manifest.version;
386
+ if (version !== expectedVersion) {
387
+ errors.push("plugin manifest field `version` must equal expected version");
388
+ }
389
+ if (typeof version !== "string" || !SEMVER_RE.test(version)) {
390
+ errors.push("plugin manifest field `version` must be SemVer 2.0.0");
391
+ }
392
+ const description = manifest.description;
393
+ if (typeof description !== "string" || pythonStrip(description) === "") {
394
+ errors.push("plugin manifest field `description` must be non-empty");
395
+ }
396
+ if (manifest.skills !== "./skills/") {
397
+ errors.push("plugin manifest field `skills` must equal `./skills/`");
398
+ }
399
+ await validateLocalPath(pluginRoot, manifest.skills, "plugin manifest field `skills`", errors, deps, { requireDirectory: true });
400
+ if (Object.hasOwn(manifest, "apps")) {
401
+ await validateLocalPath(pluginRoot, manifest.apps, "plugin manifest field `apps`", errors, deps);
402
+ }
403
+ if (Object.hasOwn(manifest, "mcpServers")) {
404
+ const mcpServers = manifest.mcpServers;
405
+ if (typeof mcpServers === "string") {
406
+ await validateLocalPath(pluginRoot, mcpServers, "plugin manifest field `mcpServers`", errors, deps);
407
+ }
408
+ else if (mcpServers === null ||
409
+ typeof mcpServers !== "object" ||
410
+ Array.isArray(mcpServers)) {
411
+ errors.push("plugin manifest field `mcpServers` must be a string or object");
412
+ }
413
+ }
414
+ if (!Object.hasOwn(manifest, "interface"))
415
+ return hookPolicy;
416
+ const iface = manifest.interface;
417
+ if (iface === null || typeof iface !== "object" || Array.isArray(iface)) {
418
+ errors.push("plugin manifest field `interface` must be an object");
419
+ return hookPolicy;
420
+ }
421
+ for (const field of ["composerIcon", "logo", "logoDark"]) {
422
+ if (Object.hasOwn(iface, field)) {
423
+ await validateLocalPath(pluginRoot, iface[field], `plugin manifest field \`interface.${field}\``, errors, deps);
424
+ }
425
+ }
426
+ if (Object.hasOwn(iface, "screenshots")) {
427
+ const screenshots = iface.screenshots;
428
+ if (!Array.isArray(screenshots)) {
429
+ errors.push("plugin manifest field `interface.screenshots` must be an array");
430
+ }
431
+ else {
432
+ for (const [index, value] of screenshots.entries()) {
433
+ await validateLocalPath(pluginRoot, value, `plugin manifest field \`interface.screenshots[${index}]\``, errors, deps);
434
+ }
435
+ }
436
+ }
437
+ return hookPolicy;
438
+ }
439
+ const REQUIRED_FILES = [
440
+ ".codex-plugin/plugin.template.json",
441
+ ".superpowers-upstream.json",
442
+ "LICENSE",
443
+ "README.md",
444
+ "CODE_OF_CONDUCT.md",
445
+ ];
446
+ /** Python `sorted()` orders by code point; JavaScript's default sort does not. */
447
+ function compareByCodePoint(left, right) {
448
+ // `Array.from` splits by code point, exactly as spreading would; oxlint's
449
+ // `no-misused-spread` rejects the spread form, and grapheme segmentation is
450
+ // the wrong unit here — Python compares code points.
451
+ const leftPoints = Array.from(left);
452
+ const rightPoints = Array.from(right);
453
+ const shared = Math.min(leftPoints.length, rightPoints.length);
454
+ for (let index = 0; index < shared; index += 1) {
455
+ const a = leftPoints[index].codePointAt(0);
456
+ const b = rightPoints[index].codePointAt(0);
457
+ if (a !== b)
458
+ return a - b;
459
+ }
460
+ return leftPoints.length - rightPoints.length;
461
+ }
462
+ async function validateSkillFrontmatter(skillMd, skillName, errors, deps) {
463
+ let contents;
464
+ try {
465
+ contents = STRICT_DECODER.decode(await deps.readFile(skillMd));
466
+ }
467
+ catch {
468
+ errors.push(`skill \`${skillName}\` has unreadable UTF-8 \`SKILL.md\``);
469
+ return;
470
+ }
471
+ if (contents === "") {
472
+ errors.push(`skill \`${skillName}\` has empty \`SKILL.md\``);
473
+ return;
474
+ }
475
+ const lines = pythonSplitlines(contents);
476
+ if (lines.length === 0 || lines[0] !== "---") {
477
+ errors.push(`skill \`${skillName}\` must start with \`---\``);
478
+ return;
479
+ }
480
+ const closingIndex = lines.indexOf("---", 1);
481
+ if (closingIndex === -1) {
482
+ errors.push(`skill \`${skillName}\` frontmatter is not closed`);
483
+ return;
484
+ }
485
+ const frontmatter = lines.slice(1, closingIndex);
486
+ for (const key of ["name", "description"]) {
487
+ const matches = frontmatter.filter((line) => line.startsWith(`${key}:`));
488
+ if (matches.length !== 1) {
489
+ errors.push(`skill \`${skillName}\` frontmatter must contain exactly one top-level \`${key}:\``);
490
+ continue;
491
+ }
492
+ const value = pythonStrip(matches[0].slice(matches[0].indexOf(":") + 1));
493
+ if (value === "" ||
494
+ value === "''" ||
495
+ value === '""' ||
496
+ value.startsWith("#")) {
497
+ errors.push(`skill \`${skillName}\` frontmatter field \`${key}\` must be non-empty`);
498
+ }
499
+ }
500
+ }
501
+ async function validateHookSubtree(pluginRoot, hooksRoot, errors, deps) {
502
+ let resolvedRoot;
503
+ try {
504
+ resolvedRoot = await resolvePath(pluginRoot, deps, { strict: true });
505
+ }
506
+ catch {
507
+ errors.push("generated plugin root could not be resolved");
508
+ return;
509
+ }
510
+ const validateSymlink = async (path) => {
511
+ let isLink;
512
+ try {
513
+ isLink = (await inspectLink(path, deps)) === "symlink";
514
+ }
515
+ catch {
516
+ // The `src/generated-plugin.ts:667::isLink =` probe reaches the first catch bounded by `src/generated-plugin.ts:672-676::let rawTarget`; the `src/generated-plugin.ts:678::rawTarget = decodePathBytes` readlink reaches the same site text.
517
+ // The three probes sharing the *subtree* string are `src/generated-plugin.ts:711::resolvedDirectory =`, `src/generated-plugin.ts:721::children =`, and
518
+ // `src/generated-plugin.ts:730::deps, true)) === "directory"`, not this one.
519
+ errors.push(`generated hook symlink could not be inspected: ${path}`);
520
+ return false;
521
+ }
522
+ if (!isLink)
523
+ return true;
524
+ let rawTarget;
525
+ try {
526
+ rawTarget = decodePathBytes((await deps.readlink(path, { encoding: "buffer" })));
527
+ }
528
+ catch {
529
+ errors.push(`generated hook symlink could not be inspected: ${path}`);
530
+ return false;
531
+ }
532
+ if (posix.isAbsolute(rawTarget)) {
533
+ errors.push(`generated hook symlink must be relative: ${path}`);
534
+ return false;
535
+ }
536
+ try {
537
+ const resolved = await resolvePath(path, deps, { strict: true });
538
+ if (resolved !== resolvedRoot &&
539
+ !resolved.startsWith(`${resolvedRoot}/`)) {
540
+ throw new ResolutionFailure(resolved);
541
+ }
542
+ }
543
+ catch {
544
+ errors.push(`generated hook symlink escapes or is broken: ${path}`);
545
+ return false;
546
+ }
547
+ return true;
548
+ };
549
+ if (!(await validateSymlink(hooksRoot)))
550
+ return;
551
+ const pending = [hooksRoot];
552
+ const visited = new Set();
553
+ while (pending.length > 0) {
554
+ const directory = pending.pop();
555
+ let resolvedDirectory;
556
+ try {
557
+ resolvedDirectory = await resolvePath(directory, deps, { strict: true });
558
+ }
559
+ catch {
560
+ errors.push("generated hook subtree could not be inspected");
561
+ continue;
562
+ }
563
+ if (visited.has(resolvedDirectory))
564
+ continue;
565
+ visited.add(resolvedDirectory);
566
+ let children;
567
+ try {
568
+ // Unsorted on purpose: error order here is filesystem order.
569
+ children = await listDirectory(directory, deps);
570
+ }
571
+ catch {
572
+ errors.push("generated hook subtree could not be inspected");
573
+ continue;
574
+ }
575
+ for (const name of children) {
576
+ const child = posix.join(directory, name);
577
+ if (!(await validateSymlink(child)))
578
+ continue;
579
+ try {
580
+ if ((await inspectPath(child, deps, true)) === "directory") {
581
+ pending.push(child);
582
+ }
583
+ }
584
+ catch {
585
+ errors.push("generated hook subtree could not be inspected");
586
+ }
587
+ }
588
+ }
589
+ }
590
+ async function validateTree(pluginRoot, hookPolicy, errors, deps) {
591
+ for (const relative of REQUIRED_FILES) {
592
+ let presence;
593
+ try {
594
+ presence = await inspectPath(posix.join(pluginRoot, relative), deps, true);
595
+ }
596
+ catch {
597
+ errors.push(`required file \`${relative}\` could not be inspected`);
598
+ continue;
599
+ }
600
+ if (presence !== "file")
601
+ errors.push(`missing required file \`${relative}\``);
602
+ }
603
+ const hooksRoot = posix.join(pluginRoot, "hooks");
604
+ let hooksExists;
605
+ try {
606
+ // `os.path.lexists`: a broken `hooks` symlink counts as present.
607
+ hooksExists = (await inspectPath(hooksRoot, deps, false)) !== "missing";
608
+ }
609
+ catch {
610
+ errors.push("generated plugin path `hooks/` could not be inspected");
611
+ hooksExists = false;
612
+ }
613
+ if (hookPolicy === "forbid" && hooksExists) {
614
+ errors.push("generated plugin must not contain `hooks/` for this manifest source");
615
+ }
616
+ else if (hooksExists) {
617
+ await validateLocalPath(pluginRoot, "./hooks", "generated plugin path `hooks/`", errors, deps, { requireDirectory: true });
618
+ await validateHookSubtree(pluginRoot, hooksRoot, errors, deps);
619
+ if (hookPolicy === "default") {
620
+ let presence;
621
+ try {
622
+ presence = await inspectPath(posix.join(hooksRoot, "hooks.json"), deps, true);
623
+ }
624
+ catch {
625
+ errors.push("`hooks/hooks.json` could not be inspected");
626
+ presence = "file";
627
+ }
628
+ if (presence !== "file") {
629
+ errors.push("default-discovered `hooks/` must contain `hooks/hooks.json`");
630
+ }
631
+ }
632
+ }
633
+ const skillsRoot = posix.join(pluginRoot, "skills");
634
+ let skillsPresence;
635
+ try {
636
+ skillsPresence = await inspectPath(skillsRoot, deps, true);
637
+ }
638
+ catch {
639
+ errors.push("required directory `skills/` could not be inspected");
640
+ return;
641
+ }
642
+ if (skillsPresence !== "directory") {
643
+ errors.push("missing required directory `skills/`");
644
+ return;
645
+ }
646
+ let skillDirs;
647
+ try {
648
+ const entries = await listDirectory(skillsRoot, deps);
649
+ skillDirs = [];
650
+ for (const name of entries) {
651
+ if (name.startsWith("."))
652
+ continue;
653
+ if ((await inspectPath(posix.join(skillsRoot, name), deps, true)) ===
654
+ "directory") {
655
+ skillDirs.push(name);
656
+ }
657
+ }
658
+ skillDirs.sort(compareByCodePoint);
659
+ }
660
+ catch {
661
+ // The Python wraps sorted(), iterdir() and is_dir() in one try/except.
662
+ errors.push("skills directory could not be enumerated");
663
+ return;
664
+ }
665
+ if (skillDirs.length === 0) {
666
+ errors.push("`skills/` must contain at least one skill directory");
667
+ return;
668
+ }
669
+ for (const name of skillDirs) {
670
+ const skillMd = posix.join(skillsRoot, name, "SKILL.md");
671
+ let presence;
672
+ try {
673
+ presence = await inspectPath(skillMd, deps, true);
674
+ }
675
+ catch {
676
+ errors.push(`skill \`${name}\` \`SKILL.md\` could not be inspected`);
677
+ continue;
678
+ }
679
+ if (presence !== "file") {
680
+ errors.push(`skill \`${name}\` is missing \`SKILL.md\``);
681
+ continue;
682
+ }
683
+ await validateSkillFrontmatter(skillMd, name, errors, deps);
684
+ }
685
+ }
686
+ const PROVENANCE_KEYS = [
687
+ "source",
688
+ "requested_ref",
689
+ "resolved_ref",
690
+ "commit",
691
+ "upstream_manifest_version",
692
+ ];
693
+ const SORTED_PROVENANCE_KEYS = [...PROVENANCE_KEYS].sort(compareByCodePoint);
694
+ async function validateProvenance(options, pluginRoot, errors, deps) {
695
+ const path = posix.join(pluginRoot, ".superpowers-upstream.json");
696
+ let presence;
697
+ try {
698
+ presence = await inspectPath(path, deps, true);
699
+ }
700
+ catch {
701
+ errors.push("provenance file `.superpowers-upstream.json` could not be inspected");
702
+ return;
703
+ }
704
+ if (presence !== "file")
705
+ return;
706
+ const provenance = await loadJsonObject(path, "provenance", errors, deps);
707
+ if (provenance === null)
708
+ return;
709
+ const keys = Object.keys(provenance).sort(compareByCodePoint);
710
+ const expectedKeys = SORTED_PROVENANCE_KEYS;
711
+ if (keys.length !== expectedKeys.length ||
712
+ keys.some((key, index) => key !== expectedKeys[index])) {
713
+ errors.push("provenance keys do not match the manager-owned contract");
714
+ }
715
+ const expected = {
716
+ source: options.source,
717
+ requested_ref: options.requestedRef,
718
+ resolved_ref: options.resolvedRef,
719
+ commit: options.commit,
720
+ upstream_manifest_version: options.upstreamManifestVersion,
721
+ };
722
+ // Python iterates the literal's insertion order; PROVENANCE_KEYS matches it.
723
+ for (const key of PROVENANCE_KEYS) {
724
+ if (provenance[key] !== expected[key]) {
725
+ errors.push(`provenance field \`${key}\` does not match expected value`);
726
+ }
727
+ }
728
+ if (!COMMIT_RE.test(options.commit)) {
729
+ errors.push("commit must be 40 lowercase hexadecimal characters");
730
+ }
731
+ }
732
+ export async function validateGeneratedPlugin(options, deps = DEFAULT_FS_DEPS) {
733
+ const errors = [];
734
+ let pluginRoot;
735
+ try {
736
+ pluginRoot = await resolvePath(expandUser(options.pluginRoot), deps, {
737
+ strict: false,
738
+ });
739
+ }
740
+ catch {
741
+ errors.push("plugin root could not be resolved");
742
+ return errors;
743
+ }
744
+ const hookPolicy = await validateManifest(pluginRoot, options.manifestVersion, options.manifestSource, errors, deps);
745
+ await validateTree(pluginRoot, hookPolicy, errors, deps);
746
+ await validateProvenance(options, pluginRoot, errors, deps);
747
+ return errors;
748
+ }