superpowers-manager 0.1.6 → 0.3.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 (59) hide show
  1. package/README.md +184 -64
  2. package/bin/superpowers-manager.js +9 -237
  3. package/dist/adapter-result.js +204 -0
  4. package/dist/adapter.js +649 -0
  5. package/dist/atomic.js +142 -0
  6. package/dist/cli-arguments.js +32 -0
  7. package/dist/cli.js +293 -0
  8. package/dist/codex-json.js +89 -0
  9. package/dist/codex-state.js +78 -0
  10. package/dist/commands/context.js +1 -0
  11. package/dist/commands/install.js +376 -0
  12. package/dist/commands/pin.js +79 -0
  13. package/dist/commands/prepare.js +527 -0
  14. package/dist/commands/probe.js +392 -0
  15. package/dist/commands/track-latest.js +44 -0
  16. package/dist/commands/uninstall.js +308 -0
  17. package/dist/commands/unpin.js +95 -0
  18. package/dist/commands/update.js +138 -0
  19. package/dist/domain/fingerprint.js +4 -0
  20. package/dist/domain/refs.js +37 -0
  21. package/dist/effective-selection.js +123 -0
  22. package/dist/generated-plugin.js +748 -0
  23. package/dist/git.js +38 -0
  24. package/dist/hooks.js +290 -0
  25. package/dist/lifecycle.js +220 -0
  26. package/dist/manifest-overlay.js +125 -0
  27. package/dist/provenance.js +111 -0
  28. package/dist/python-json-format.js +83 -0
  29. package/dist/python-json.js +29 -0
  30. package/dist/python-text.js +74 -0
  31. package/dist/safe-path.js +137 -0
  32. package/dist/safety-error.js +10 -0
  33. package/dist/selection-state-cli.js +85 -0
  34. package/dist/selection-store.js +172 -0
  35. package/dist/selection.js +200 -0
  36. package/dist/status.js +24 -0
  37. package/dist/strict-json.js +248 -0
  38. package/dist/upstream-cli.js +110 -0
  39. package/dist/upstream-version.js +40 -0
  40. package/dist/upstream.js +253 -0
  41. package/dist/validate-generated-plugin-cli.js +118 -0
  42. package/dist/validator.js +317 -0
  43. package/dist/workspace.js +139 -0
  44. package/package.json +25 -2
  45. package/scripts/adapters/codex/adapter +0 -598
  46. package/scripts/adapters/codex/lib.sh +0 -391
  47. package/scripts/adapters/codex/validate-generated-plugin.py +0 -314
  48. package/scripts/core/adapter.sh +0 -92
  49. package/scripts/core/common.sh +0 -35
  50. package/scripts/core/lifecycle.sh +0 -127
  51. package/scripts/core/provenance.sh +0 -135
  52. package/scripts/core/status.sh +0 -27
  53. package/scripts/core/upstream.sh +0 -146
  54. package/scripts/core/validate-adapter-response.py +0 -245
  55. package/scripts/install +0 -60
  56. package/scripts/prepare +0 -126
  57. package/scripts/probe +0 -61
  58. package/scripts/uninstall +0 -44
  59. package/scripts/update +0 -31
@@ -1,391 +0,0 @@
1
- #!/bin/sh
2
- # Sourced module; callers own set -eu.
3
-
4
- SPW_PLUGIN_ID="superpowers@superpowers-manager"
5
- SPW_MARKETPLACE_NAME="superpowers-manager"
6
- SPW_LEGACY_PLUGIN_ID="superpowers@superpowers-wrapper"
7
- SPW_LEGACY_MARKETPLACE_NAME="superpowers-wrapper"
8
-
9
- # Given a JSON document as the FIRST ARGUMENT (a string), print "present" if any
10
- # element of the top-level array <array_key> is an object whose <field> equals
11
- # <value>, else print "absent" (exit 0). On unparseable JSON or invalid schema,
12
- # print nothing and exit 2 so callers can fail closed rather than treat an
13
- # unreadable listing as "absent". Every item in the inspected array must be an
14
- # object with a non-empty string <field>; otherwise ownership is unprovable and
15
- # the helper exits 2 instead of treating the malformed entry as unrelated.
16
- # The JSON is passed as an argument (exactly as spw_json_get takes a file path),
17
- # NOT on stdin: the here-doc below is Python's stdin (its program source), so a
18
- # json.load(sys.stdin) here would read the program, not the caller's JSON.
19
- spw_json_array_has() {
20
- json="$1"
21
- array_key="$2"
22
- field="$3"
23
- value="$4"
24
- python3 - "$json" "$array_key" "$field" "$value" <<'PY'
25
- import json, sys
26
- raw, array_key, field, value = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
27
- try:
28
- data = json.loads(raw)
29
- except (json.JSONDecodeError, RecursionError):
30
- sys.exit(2)
31
- if not isinstance(data, dict):
32
- sys.exit(2)
33
- items = data.get(array_key)
34
- if not isinstance(items, list):
35
- sys.exit(2)
36
- for item in items:
37
- if not isinstance(item, dict):
38
- sys.exit(2)
39
- field_value = item.get(field)
40
- if not isinstance(field_value, str) or not field_value:
41
- sys.exit(2)
42
- found = any(item[field] == value for item in items)
43
- print("present" if found else "absent")
44
- PY
45
- }
46
-
47
- # Print the registered root of <marketplace_name> from a marketplace-list JSON
48
- # document (as a string argument, like spw_json_array_has), or nothing if the
49
- # marketplace is absent. Exit 2 on unparseable JSON, a non-object item, or an
50
- # item without a non-empty string name: such an item cannot be proven unrelated
51
- # and must not be mistaken for an absent manager marketplace. Validate root only
52
- # on the matching item; unrelated marketplace roots are never read. A matching
53
- # item without a non-empty string root also exits 2. Empty output is unambiguous:
54
- # a valid registered root is always a non-empty path.
55
- spw_marketplace_root_from_json() {
56
- json="$1"
57
- marketplace_name="$2"
58
- python3 - "$json" "$marketplace_name" <<'PY'
59
- import json, sys
60
- raw, name = sys.argv[1], sys.argv[2]
61
- try:
62
- data = json.loads(raw)
63
- except (json.JSONDecodeError, RecursionError):
64
- sys.exit(2)
65
- if not isinstance(data, dict):
66
- sys.exit(2)
67
- items = data.get("marketplaces")
68
- if not isinstance(items, list):
69
- sys.exit(2)
70
- for item in items:
71
- if not isinstance(item, dict):
72
- sys.exit(2)
73
- item_name = item.get("name")
74
- if not isinstance(item_name, str) or not item_name:
75
- sys.exit(2)
76
- for item in items:
77
- if item["name"] == name:
78
- item_root = item.get("root")
79
- if not isinstance(item_root, str) or not item_root:
80
- sys.exit(2)
81
- print(item_root)
82
- sys.exit(0)
83
- PY
84
- }
85
-
86
- # Print "same" if the two paths refer to the same physical location, else
87
- # "different". Physical-path normalization via os.path.realpath on both
88
- # sides (handles /var vs /private/var and other symlinked roots); if
89
- # realpath fails, fall back to comparing the original strings.
90
- spw_paths_equal() {
91
- python3 - "$1" "$2" <<'PY'
92
- import os, sys
93
- a, b = sys.argv[1], sys.argv[2]
94
- try:
95
- na, nb = os.path.realpath(a), os.path.realpath(b)
96
- except OSError:
97
- na, nb = a, b
98
- print("same" if na == nb else "different")
99
- PY
100
- }
101
-
102
- spw_find_installed_metadata() {
103
- search_root="${SUPERPOWERS_INSTALLED_SEARCH_ROOT:-$HOME/.codex}"
104
- find "$search_root" \
105
- \( -path "*/$SPW_MARKETPLACE_NAME/superpowers/.superpowers-upstream.json" \
106
- -o -path "*/$SPW_MARKETPLACE_NAME/superpowers/*/.superpowers-upstream.json" \) \
107
- -type f 2>/dev/null | head -n 1
108
- }
109
-
110
- spw_find_installed_manifest() {
111
- search_root="${SUPERPOWERS_INSTALLED_SEARCH_ROOT:-$HOME/.codex}"
112
- find "$search_root" \
113
- \( -path "*/$SPW_MARKETPLACE_NAME/superpowers/.codex-plugin/plugin.json" \
114
- -o -path "*/$SPW_MARKETPLACE_NAME/superpowers/*/.codex-plugin/plugin.json" \) \
115
- -type f 2>/dev/null | head -n 1
116
- }
117
-
118
- spw_codex_json_get_or_empty() {
119
- file="$1"
120
- key="$2"
121
- [ -f "$file" ] || return 1
122
- python3 - "$file" "$key" <<'PY'
123
- import json
124
- import sys
125
-
126
- path, dotted_key = sys.argv[1:]
127
- MAX_JSON_NESTING = 256
128
-
129
- def reject_constant(constant):
130
- raise ValueError(f"non-standard numeric constant: {constant}")
131
-
132
- def nesting_exceeds_limit(value):
133
- stack = [(value, 0)]
134
- while stack:
135
- current, depth = stack.pop()
136
- if isinstance(current, dict):
137
- next_depth = depth + 1
138
- if next_depth > MAX_JSON_NESTING:
139
- return True
140
- stack.extend((child, next_depth) for child in current.values())
141
- elif isinstance(current, list):
142
- next_depth = depth + 1
143
- if next_depth > MAX_JSON_NESTING:
144
- return True
145
- stack.extend((child, next_depth) for child in current)
146
- return False
147
-
148
- try:
149
- with open(path, "r", encoding="utf-8") as handle:
150
- data = json.load(handle, parse_constant=reject_constant)
151
- except (OSError, UnicodeError, json.JSONDecodeError, RecursionError, ValueError):
152
- sys.exit(1)
153
-
154
- if nesting_exceeds_limit(data) or not isinstance(data, dict):
155
- sys.exit(1)
156
-
157
- value = data
158
- for part in dotted_key.split("."):
159
- if not isinstance(value, dict):
160
- value = ""
161
- break
162
- value = value.get(part, "")
163
-
164
- if value is None:
165
- value = ""
166
- if not isinstance(value, str):
167
- sys.exit(1)
168
- print(value)
169
- PY
170
- }
171
-
172
- spw_codex_metadata_commit_or_empty() {
173
- file="$1"
174
- if ! commit=$(spw_codex_json_get_or_empty "$file" "commit"); then
175
- return 1
176
- fi
177
- case "$commit" in
178
- ""|*[!0-9a-fA-F]*) return 1 ;;
179
- esac
180
- case "${#commit}" in
181
- 7|40) printf '%s\n' "$commit" ;;
182
- *) return 1 ;;
183
- esac
184
- }
185
-
186
- spw_apply_manifest_overlay() {
187
- manifest="$1"
188
- version="$2"
189
- python3 - "$manifest" "$version" <<'PY'
190
- import json
191
- import sys
192
-
193
- path, version = sys.argv[1:]
194
- MAX_JSON_NESTING = 256
195
-
196
- def reject_constant(constant):
197
- raise ValueError(f"non-standard numeric constant: {constant}")
198
-
199
- def nesting_exceeds_limit(value):
200
- stack = [(value, 0)]
201
- while stack:
202
- current, depth = stack.pop()
203
- if isinstance(current, dict):
204
- next_depth = depth + 1
205
- if next_depth > MAX_JSON_NESTING:
206
- return True
207
- stack.extend((child, next_depth) for child in current.values())
208
- elif isinstance(current, list):
209
- next_depth = depth + 1
210
- if next_depth > MAX_JSON_NESTING:
211
- return True
212
- stack.extend((child, next_depth) for child in current)
213
- return False
214
-
215
- try:
216
- with open(path, "r", encoding="utf-8") as f:
217
- data = json.load(f, parse_constant=reject_constant)
218
- except RecursionError:
219
- sys.exit(f"JSON nesting exceeds limit in {path}")
220
- except json.JSONDecodeError as exc:
221
- sys.exit(
222
- f"invalid manifest JSON in {path}: "
223
- f"line {exc.lineno} column {exc.colno}: {exc.msg}"
224
- )
225
- except (OSError, UnicodeError) as exc:
226
- sys.exit(f"cannot read manifest JSON in {path}: {exc}")
227
- except ValueError as exc:
228
- sys.exit(f"invalid manifest JSON in {path}: {exc}")
229
-
230
- if nesting_exceeds_limit(data):
231
- sys.exit(f"JSON nesting exceeds limit in {path}")
232
-
233
- if not isinstance(data, dict):
234
- sys.exit(f"manifest must be a JSON object: {path}")
235
-
236
- data["version"] = version
237
- data["skills"] = "./skills/"
238
- data.pop("hooks", None)
239
-
240
- try:
241
- with open(path, "w", encoding="utf-8") as f:
242
- json.dump(data, f, indent=2, allow_nan=False)
243
- f.write("\n")
244
- except RecursionError as exc:
245
- sys.exit(f"manifest JSON nesting exceeds limit while writing {path}: {exc}")
246
- except (OSError, UnicodeError, ValueError) as exc:
247
- sys.exit(f"cannot write manifest JSON in {path}: {exc}")
248
- PY
249
- }
250
-
251
- spw_manifest_short_sha_or_empty() {
252
- file="$1"
253
- if ! version=$(spw_codex_json_get_or_empty "$file" "version"); then
254
- return 1
255
- fi
256
- case "$version" in
257
- *+manager.*)
258
- short="${version##*.}"
259
- case "$short" in
260
- [0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])
261
- printf '%s\n' "$short"
262
- ;;
263
- esac
264
- ;;
265
- esac
266
- }
267
-
268
- spw_installed_commit_or_empty() {
269
- installed_metadata=$(spw_find_installed_metadata || true)
270
- installed_manifest=$(spw_find_installed_manifest || true)
271
- installed_commit=""
272
- if [ -n "$installed_metadata" ]; then
273
- installed_commit=$(spw_codex_metadata_commit_or_empty "$installed_metadata" || true)
274
- fi
275
- if [ -z "$installed_commit" ] && [ -n "$installed_manifest" ]; then
276
- installed_commit=$(spw_manifest_short_sha_or_empty "$installed_manifest" || true)
277
- fi
278
- printf '%s\n' "$installed_commit"
279
- }
280
-
281
- spw_codex_emit() {
282
- operation="$1"
283
- ok="$2"
284
- result_json="$3"
285
- code="$4"
286
- error_message="$5"
287
- hints_file="$6"
288
- messages_file="$7"
289
- python3 - "$operation" "$ok" "$result_json" "$code" "$error_message" "$hints_file" "$messages_file" <<'PY'
290
- import json
291
- import sys
292
-
293
- operation, ok_text, result_text, code, error_message, hints_path, messages_path = sys.argv[1:]
294
-
295
- def has_terminal_control(value):
296
- return any(
297
- ord(char) < 0x20
298
- or 0x7f <= ord(char) <= 0x9f
299
- or 0xd800 <= ord(char) <= 0xdfff
300
- for char in value
301
- )
302
-
303
- if any(has_terminal_control(value) for value in (operation, code, error_message)):
304
- raise SystemExit("protocol strings must not contain terminal control characters")
305
-
306
- messages = []
307
- if messages_path:
308
- with open(messages_path, encoding="utf-8") as handle:
309
- for number, raw in enumerate(handle, 1):
310
- line = raw.rstrip("\n")
311
- if "\t" not in line:
312
- raise SystemExit(f"invalid message record at line {number}")
313
- channel, text = line.split("\t", 1)
314
- if (
315
- channel not in {"stdout", "stderr"}
316
- or not text
317
- or "\t" in text
318
- or "\r" in text
319
- or has_terminal_control(text)
320
- ):
321
- raise SystemExit(f"invalid message record at line {number}")
322
- messages.append({"channel": channel, "text": text})
323
-
324
- ok = ok_text == "true"
325
- if ok:
326
- envelope = {
327
- "protocol": 1,
328
- "operation": operation,
329
- "ok": True,
330
- "messages": messages,
331
- "result": json.loads(result_text),
332
- "error": None,
333
- }
334
- else:
335
- hints = []
336
- if hints_path:
337
- with open(hints_path, encoding="utf-8") as handle:
338
- hints = [line.rstrip("\n") for line in handle if line.rstrip("\n")]
339
- if any(has_terminal_control(hint) for hint in hints):
340
- raise SystemExit("protocol hints must not contain terminal control characters")
341
- envelope = {
342
- "protocol": 1,
343
- "operation": operation,
344
- "ok": False,
345
- "messages": messages,
346
- "result": None,
347
- "error": {"code": code, "message": error_message, "hints": hints},
348
- }
349
- json.dump(envelope, sys.stdout, allow_nan=False, separators=(",", ":"))
350
- sys.stdout.write("\n")
351
- PY
352
- }
353
-
354
- spw_codex_success() {
355
- spw_codex_emit "$1" true "$2" "" "" "" "$3"
356
- }
357
-
358
- spw_codex_failure() {
359
- spw_codex_emit "$1" false '{}' "$2" "$3" "$4" "$5"
360
- exit 1
361
- }
362
-
363
- # Append command output as structured message records while keeping the
364
- # tab-delimited spool format unambiguous. Each physical command-output line is
365
- # escaped with Python's unicode_escape codec so literal backslashes remain
366
- # distinguishable from rendered control characters like \t and \r.
367
- spw_codex_append_messages() {
368
- messages_file="$1"
369
- channel="$2"
370
- input_file="$3"
371
- [ -f "$input_file" ] || return 0
372
- python3 - "$messages_file" "$channel" "$input_file" <<'PY'
373
- from pathlib import Path
374
- import sys
375
-
376
- messages_path = Path(sys.argv[1])
377
- channel = sys.argv[2]
378
- input_path = Path(sys.argv[3])
379
-
380
- chunks = input_path.read_bytes().split(b"\n")
381
- with messages_path.open("a", encoding="utf-8") as handle:
382
- for chunk in chunks:
383
- if not chunk:
384
- continue
385
- text = chunk.decode("utf-8", errors="backslashreplace")
386
- escaped = text.encode("unicode_escape").decode("ascii")
387
- if not escaped:
388
- continue
389
- handle.write(f"{channel}\t{escaped}\n")
390
- PY
391
- }
@@ -1,314 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Validate the manager-owned generated Superpowers plugin contract."""
3
-
4
- from __future__ import annotations
5
-
6
- import argparse
7
- import json
8
- import os
9
- import re
10
- import sys
11
- from pathlib import Path
12
- from typing import Any, Sequence
13
-
14
-
15
- SEMVER_RE = re.compile(
16
- r"^(0|[1-9][0-9]*)\."
17
- r"(0|[1-9][0-9]*)\."
18
- r"(0|[1-9][0-9]*)"
19
- r"(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)"
20
- r"(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?"
21
- r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
22
- )
23
- MAX_JSON_NESTING = 256
24
- PROVENANCE_KEYS = {
25
- "source",
26
- "requested_ref",
27
- "resolved_ref",
28
- "commit",
29
- "upstream_manifest_version",
30
- }
31
-
32
-
33
- def reject_json_constant(constant: str) -> None:
34
- raise ValueError(f"non-standard numeric constant: {constant}")
35
-
36
-
37
- def json_nesting_exceeds_limit(value: Any) -> bool:
38
- stack = [(value, 0)]
39
- while stack:
40
- current, depth = stack.pop()
41
- if isinstance(current, dict):
42
- next_depth = depth + 1
43
- if next_depth > MAX_JSON_NESTING:
44
- return True
45
- stack.extend((child, next_depth) for child in current.values())
46
- elif isinstance(current, list):
47
- next_depth = depth + 1
48
- if next_depth > MAX_JSON_NESTING:
49
- return True
50
- stack.extend((child, next_depth) for child in current)
51
- return False
52
-
53
-
54
- def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
55
- parser = argparse.ArgumentParser(
56
- description="Validate a generated Superpowers manager candidate."
57
- )
58
- parser.add_argument("--plugin-root", required=True)
59
- parser.add_argument("--source", required=True)
60
- parser.add_argument("--requested-ref", required=True)
61
- parser.add_argument("--resolved-ref", required=True)
62
- parser.add_argument("--commit", required=True)
63
- parser.add_argument("--manifest-version", required=True)
64
- parser.add_argument("--upstream-manifest-version", required=True)
65
- return parser.parse_args(argv)
66
-
67
-
68
- def load_json_object(path: Path, label: str, errors: list[str]) -> dict[str, Any] | None:
69
- try:
70
- text = path.read_text(encoding="utf-8")
71
- except (OSError, UnicodeError):
72
- errors.append(f"{label} is unreadable UTF-8")
73
- return None
74
- try:
75
- value = json.loads(text, parse_constant=reject_json_constant)
76
- except RecursionError:
77
- errors.append(f"{label} exceeds maximum JSON nesting")
78
- return None
79
- except (json.JSONDecodeError, ValueError):
80
- errors.append(f"{label} must contain valid JSON")
81
- return None
82
- if json_nesting_exceeds_limit(value):
83
- errors.append(f"{label} exceeds maximum JSON nesting")
84
- return None
85
- if not isinstance(value, dict):
86
- errors.append(f"{label} must contain a JSON object")
87
- return None
88
- return value
89
-
90
-
91
- def validate_local_path(
92
- plugin_root: Path,
93
- raw_value: Any,
94
- label: str,
95
- errors: list[str],
96
- *,
97
- require_directory: bool = False,
98
- ) -> None:
99
- if not isinstance(raw_value, str) or not raw_value.strip():
100
- errors.append(f"{label} must be a non-empty relative path")
101
- return
102
- raw_path = Path(raw_value)
103
- if raw_path.is_absolute():
104
- errors.append(f"{label} must be a relative path")
105
- return
106
- try:
107
- root = plugin_root.resolve()
108
- target = (root / raw_path).resolve(strict=False)
109
- except (OSError, RuntimeError, ValueError):
110
- errors.append(f"{label} could not be resolved")
111
- return
112
- try:
113
- target.relative_to(root)
114
- except ValueError:
115
- errors.append(f"{label} escapes the plugin root")
116
- return
117
- try:
118
- if not target.exists():
119
- errors.append(f"{label} target `{raw_value}` does not exist")
120
- elif require_directory and not target.is_dir():
121
- errors.append(f"{label} target `{raw_value}` must be a directory")
122
- except OSError:
123
- errors.append(f"{label} target `{raw_value}` could not be inspected")
124
-
125
-
126
- def validate_manifest(plugin_root: Path, expected_version: str, errors: list[str]) -> None:
127
- path = plugin_root / ".codex-plugin" / "plugin.json"
128
- if not path.is_file():
129
- errors.append("missing required file `.codex-plugin/plugin.json`")
130
- return
131
- manifest = load_json_object(path, "plugin manifest", errors)
132
- if manifest is None:
133
- return
134
-
135
- if manifest.get("name") != "superpowers":
136
- errors.append("plugin manifest field `name` must equal `superpowers`")
137
- version = manifest.get("version")
138
- if version != expected_version:
139
- errors.append("plugin manifest field `version` must equal expected version")
140
- if not isinstance(version, str) or SEMVER_RE.fullmatch(version) is None:
141
- errors.append("plugin manifest field `version` must be SemVer 2.0.0")
142
- description = manifest.get("description")
143
- if not isinstance(description, str) or not description.strip():
144
- errors.append("plugin manifest field `description` must be non-empty")
145
- if manifest.get("skills") != "./skills/":
146
- errors.append("plugin manifest field `skills` must equal `./skills/`")
147
- if "hooks" in manifest:
148
- errors.append("plugin manifest field `hooks` must be absent")
149
-
150
- validate_local_path(
151
- plugin_root,
152
- manifest.get("skills"),
153
- "plugin manifest field `skills`",
154
- errors,
155
- require_directory=True,
156
- )
157
- if "apps" in manifest:
158
- validate_local_path(plugin_root, manifest["apps"], "plugin manifest field `apps`", errors)
159
- if "mcpServers" in manifest:
160
- mcp_servers = manifest["mcpServers"]
161
- if isinstance(mcp_servers, str):
162
- validate_local_path(
163
- plugin_root, mcp_servers, "plugin manifest field `mcpServers`", errors
164
- )
165
- elif not isinstance(mcp_servers, dict):
166
- errors.append("plugin manifest field `mcpServers` must be a string or object")
167
-
168
- if "interface" not in manifest:
169
- return
170
- interface = manifest["interface"]
171
- if not isinstance(interface, dict):
172
- errors.append("plugin manifest field `interface` must be an object")
173
- return
174
- for field in ("composerIcon", "logo", "logoDark"):
175
- if field in interface:
176
- validate_local_path(
177
- plugin_root,
178
- interface[field],
179
- f"plugin manifest field `interface.{field}`",
180
- errors,
181
- )
182
- if "screenshots" in interface:
183
- screenshots = interface["screenshots"]
184
- if not isinstance(screenshots, list):
185
- errors.append("plugin manifest field `interface.screenshots` must be an array")
186
- else:
187
- for index, value in enumerate(screenshots):
188
- validate_local_path(
189
- plugin_root,
190
- value,
191
- f"plugin manifest field `interface.screenshots[{index}]`",
192
- errors,
193
- )
194
-
195
-
196
- def validate_skill_frontmatter(skill_md: Path, skill_name: str, errors: list[str]) -> None:
197
- try:
198
- contents = skill_md.read_text(encoding="utf-8")
199
- except (OSError, UnicodeError):
200
- errors.append(f"skill `{skill_name}` has unreadable UTF-8 `SKILL.md`")
201
- return
202
- if not contents:
203
- errors.append(f"skill `{skill_name}` has empty `SKILL.md`")
204
- return
205
- lines = contents.splitlines()
206
- if not lines or lines[0] != "---":
207
- errors.append(f"skill `{skill_name}` must start with `---`")
208
- return
209
- try:
210
- closing_index = lines.index("---", 1)
211
- except ValueError:
212
- errors.append(f"skill `{skill_name}` frontmatter is not closed")
213
- return
214
- frontmatter = lines[1:closing_index]
215
- for key in ("name", "description"):
216
- matches = [line for line in frontmatter if line.startswith(f"{key}:")]
217
- if len(matches) != 1:
218
- errors.append(
219
- f"skill `{skill_name}` frontmatter must contain exactly one top-level `{key}:`"
220
- )
221
- continue
222
- value = matches[0].split(":", 1)[1].strip()
223
- if value in {"", "''", '\"\"'} or value.startswith("#"):
224
- errors.append(f"skill `{skill_name}` frontmatter field `{key}` must be non-empty")
225
-
226
-
227
- def validate_tree(plugin_root: Path, errors: list[str]) -> None:
228
- required_files = (
229
- ".codex-plugin/plugin.template.json",
230
- ".superpowers-upstream.json",
231
- "LICENSE",
232
- "README.md",
233
- "CODE_OF_CONDUCT.md",
234
- )
235
- for relative in required_files:
236
- if not (plugin_root / relative).is_file():
237
- errors.append(f"missing required file `{relative}`")
238
- if os.path.lexists(plugin_root / "hooks"):
239
- errors.append("generated plugin must not contain `hooks/`")
240
-
241
- skills_root = plugin_root / "skills"
242
- if not skills_root.is_dir():
243
- errors.append("missing required directory `skills/`")
244
- return
245
- try:
246
- skill_dirs = sorted(
247
- path
248
- for path in skills_root.iterdir()
249
- if not path.name.startswith(".") and path.is_dir()
250
- )
251
- except OSError:
252
- errors.append("skills directory could not be enumerated")
253
- return
254
- if not skill_dirs:
255
- errors.append("`skills/` must contain at least one skill directory")
256
- return
257
- for skill_dir in skill_dirs:
258
- skill_md = skill_dir / "SKILL.md"
259
- if not skill_md.is_file():
260
- errors.append(f"skill `{skill_dir.name}` is missing `SKILL.md`")
261
- continue
262
- validate_skill_frontmatter(skill_md, skill_dir.name, errors)
263
-
264
-
265
- def validate_provenance(args: argparse.Namespace, plugin_root: Path, errors: list[str]) -> None:
266
- path = plugin_root / ".superpowers-upstream.json"
267
- if not path.is_file():
268
- return
269
- provenance = load_json_object(path, "provenance", errors)
270
- if provenance is None:
271
- return
272
- if set(provenance) != PROVENANCE_KEYS:
273
- errors.append("provenance keys do not match the manager-owned contract")
274
- expected = {
275
- "source": args.source,
276
- "requested_ref": args.requested_ref,
277
- "resolved_ref": args.resolved_ref,
278
- "commit": args.commit,
279
- "upstream_manifest_version": args.upstream_manifest_version,
280
- }
281
- for key, value in expected.items():
282
- if provenance.get(key) != value:
283
- errors.append(f"provenance field `{key}` does not match expected value")
284
- if re.fullmatch(r"[0-9a-f]{40}", args.commit) is None:
285
- errors.append("commit must be 40 lowercase hexadecimal characters")
286
-
287
-
288
- def validate(args: argparse.Namespace) -> list[str]:
289
- errors: list[str] = []
290
- try:
291
- plugin_root = Path(args.plugin_root).expanduser().resolve()
292
- except (OSError, RuntimeError, ValueError):
293
- errors.append("plugin root could not be resolved")
294
- return errors
295
- validate_manifest(plugin_root, args.manifest_version, errors)
296
- validate_tree(plugin_root, errors)
297
- validate_provenance(args, plugin_root, errors)
298
- return errors
299
-
300
-
301
- def main(argv: Sequence[str] | None = None) -> int:
302
- args = parse_args(argv)
303
- errors = validate(args)
304
- if errors:
305
- print("Generated plugin validation failed:", file=sys.stderr)
306
- for error in errors:
307
- print(f"- {error}", file=sys.stderr)
308
- return 1
309
- print(f"generated plugin validation passed: {args.plugin_root}")
310
- return 0
311
-
312
-
313
- if __name__ == "__main__":
314
- raise SystemExit(main())