superpowers-manager 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,136 @@
1
+ #!/bin/sh
2
+ set -eu
3
+
4
+ root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
5
+ . "$root/scripts/lib.sh"
6
+
7
+ upstream_url="${SUPERPOWERS_UPSTREAM_URL:-$SPW_UPSTREAM_URL_DEFAULT}"
8
+ cache_parent="${SUPERPOWERS_CACHE_DIR:-$root/.cache/upstream}"
9
+ cache="$cache_parent/superpowers"
10
+ plugin_root="${SUPERPOWERS_PLUGIN_ROOT:-$root/plugins/superpowers}"
11
+ manifest_template="${SUPERPOWERS_MANIFEST_TEMPLATE:-$root/plugins/superpowers/.codex-plugin/plugin.template.json}"
12
+ builtin_validator="$root/scripts/validate-generated-plugin.py"
13
+ additional_validator="${SUPERPOWERS_VALIDATOR:-}"
14
+ tmp_parent=$(dirname "$plugin_root")
15
+ tmp_plugin="$tmp_parent/.superpowers.tmp.$$"
16
+
17
+ cleanup_tmp_plugin() {
18
+ status=$?
19
+ trap - 0 HUP INT TERM
20
+ rm -rf "$tmp_plugin" || :
21
+ exit "$status"
22
+ }
23
+
24
+ trap cleanup_tmp_plugin 0
25
+ trap 'exit 129' HUP
26
+ trap 'exit 130' INT
27
+ trap 'exit 143' TERM
28
+
29
+ requested_ref=$(spw_config_ref "$root")
30
+
31
+ spw_require_command git
32
+ spw_require_command python3
33
+ [ -f "$manifest_template" ] || spw_die "missing fallback manifest template: $manifest_template"
34
+ [ -f "$builtin_validator" ] || spw_die "missing built-in plugin validator: $builtin_validator"
35
+
36
+ resolved_record=$(spw_resolve_ref "$upstream_url" "$requested_ref")
37
+ resolution_kind=$(printf '%s\n' "$resolved_record" | awk '{ print $1 }')
38
+ resolved_ref=$(printf '%s\n' "$resolved_record" | awk '{ print $2 }')
39
+ commit=$(printf '%s\n' "$resolved_record" | awk '{ print $3 }')
40
+
41
+ mkdir -p "$cache_parent" "$tmp_parent"
42
+ if [ -d "$cache/.git" ]; then
43
+ if ! git -C "$cache" fetch --tags --prune "$upstream_url"; then
44
+ spw_die "cannot fetch upstream repo: $upstream_url"
45
+ fi
46
+ else
47
+ if ! git clone "$upstream_url" "$cache"; then
48
+ spw_die "cannot clone upstream repo: $upstream_url"
49
+ fi
50
+ fi
51
+ if ! git -C "$cache" checkout --detach "$commit" >/dev/null; then
52
+ spw_die "cannot check out upstream commit: $commit"
53
+ fi
54
+
55
+ spw_require_upstream_path "$cache/skills" "skills/"
56
+ spw_require_upstream_path "$cache/LICENSE" "LICENSE"
57
+ spw_require_upstream_path "$cache/README.md" "README.md"
58
+ spw_require_upstream_path "$cache/CODE_OF_CONDUCT.md" "CODE_OF_CONDUCT.md"
59
+
60
+ rm -rf "$tmp_plugin"
61
+ mkdir -p "$tmp_plugin/.codex-plugin"
62
+ upstream_manifest="$cache/.codex-plugin/plugin.json"
63
+ if [ -f "$upstream_manifest" ]; then
64
+ cp "$upstream_manifest" "$tmp_plugin/.codex-plugin/plugin.json"
65
+ else
66
+ cp "$manifest_template" "$tmp_plugin/.codex-plugin/plugin.json"
67
+ fi
68
+
69
+ spw_copy_path_if_present "$cache/skills" "$tmp_plugin/skills"
70
+ spw_copy_path_if_present "$cache/assets" "$tmp_plugin/assets"
71
+ spw_copy_path_if_present "$cache/LICENSE" "$tmp_plugin/LICENSE"
72
+ spw_copy_path_if_present "$cache/README.md" "$tmp_plugin/README.md"
73
+ spw_copy_path_if_present "$cache/CODE_OF_CONDUCT.md" "$tmp_plugin/CODE_OF_CONDUCT.md"
74
+
75
+ upstream_manifest_version=""
76
+ if [ -f "$upstream_manifest" ]; then
77
+ upstream_manifest_version=$(spw_json_get "$upstream_manifest" "version")
78
+ fi
79
+
80
+ spw_write_metadata_json \
81
+ "$tmp_plugin/.superpowers-upstream.json" \
82
+ "$upstream_url" \
83
+ "$requested_ref" \
84
+ "$resolved_ref" \
85
+ "$commit" \
86
+ "$upstream_manifest_version"
87
+
88
+ manager_version=$(
89
+ spw_manifest_version_for_ref "$requested_ref" "$resolution_kind" "$resolved_ref" "$commit"
90
+ )
91
+ spw_apply_manifest_overlay \
92
+ "$tmp_plugin/.codex-plugin/plugin.json" \
93
+ "$manager_version"
94
+
95
+ # The validator must inspect the exact complete tree that will be swapped.
96
+ cp "$manifest_template" "$tmp_plugin/.codex-plugin/plugin.template.json"
97
+
98
+ if ! python3 -S "$builtin_validator" \
99
+ --plugin-root "$tmp_plugin" \
100
+ --source "$upstream_url" \
101
+ --requested-ref "$requested_ref" \
102
+ --resolved-ref "$resolved_ref" \
103
+ --commit "$commit" \
104
+ --manifest-version "$manager_version" \
105
+ --upstream-manifest-version "$upstream_manifest_version"; then
106
+ spw_die "built-in generated plugin validation failed"
107
+ fi
108
+
109
+ if [ -n "$additional_validator" ]; then
110
+ [ -f "$additional_validator" ] || \
111
+ spw_die "additional plugin validator not found: $additional_validator"
112
+ if ! python3 "$additional_validator" "$tmp_plugin"; then
113
+ spw_die "additional plugin validation failed"
114
+ fi
115
+ fi
116
+
117
+ # Replace the live tree with a single atomic rename, keeping a backup so a
118
+ # failed swap restores the previous working generated tree (the spec requires
119
+ # that a failed prepare never destroy it). tmp_plugin is a same-filesystem
120
+ # sibling of plugin_root, so the renames cannot fail mid-copy.
121
+ backup="$tmp_parent/.superpowers.bak.$$"
122
+ rm -rf "$backup"
123
+ if [ -e "$plugin_root" ]; then
124
+ mv "$plugin_root" "$backup"
125
+ fi
126
+ if mv "$tmp_plugin" "$plugin_root"; then
127
+ rm -rf "$backup"
128
+ else
129
+ if [ -e "$backup" ]; then
130
+ mv "$backup" "$plugin_root"
131
+ fi
132
+ rm -rf "$tmp_plugin"
133
+ spw_die "failed to install generated tree into $plugin_root; previous tree restored"
134
+ fi
135
+
136
+ echo "prepared $resolved_ref at $commit"
package/scripts/probe ADDED
@@ -0,0 +1,50 @@
1
+ #!/bin/sh
2
+ set -eu
3
+
4
+ root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
5
+ . "$root/scripts/lib.sh"
6
+
7
+ codex_bin="${SUPERPOWERS_CODEX:-codex}"
8
+ spw_require_command "$codex_bin"
9
+ spw_require_command python3
10
+ identity_snapshot=$(spw_codex_identity_snapshot "$codex_bin")
11
+ identity_state=$(spw_snapshot_get "$identity_snapshot" identity_state)
12
+
13
+ upstream_url="${SUPERPOWERS_UPSTREAM_URL:-$SPW_UPSTREAM_URL_DEFAULT}"
14
+ requested_ref=$(spw_config_ref "$root")
15
+ resolved_record=$(spw_resolve_ref "$upstream_url" "$requested_ref")
16
+ resolved_ref=$(printf '%s\n' "$resolved_record" | awk '{ print $2 }')
17
+ desired_commit=$(printf '%s\n' "$resolved_record" | awk '{ print $3 }')
18
+
19
+ generated_metadata="$root/plugins/superpowers/.superpowers-upstream.json"
20
+ generated_commit=$(spw_metadata_commit_or_empty "$generated_metadata" || true)
21
+
22
+ installed_metadata=$(spw_find_installed_metadata || true)
23
+ installed_manifest=$(spw_find_installed_manifest || true)
24
+ installed_commit=""
25
+ if [ -n "$installed_metadata" ]; then
26
+ installed_commit=$(spw_metadata_commit_or_empty "$installed_metadata" || true)
27
+ fi
28
+ if [ -z "$installed_commit" ] && [ -n "$installed_manifest" ]; then
29
+ installed_commit=$(spw_manifest_short_sha_or_empty "$installed_manifest" || true)
30
+ fi
31
+
32
+ status=$(spw_status_for_commits "$desired_commit" "$generated_commit" "$installed_commit")
33
+
34
+ if [ "${1:-}" = "--porcelain" ]; then
35
+ printf 'requested_ref=%s\n' "$requested_ref"
36
+ printf 'resolved_ref=%s\n' "$resolved_ref"
37
+ printf 'desired_commit=%s\n' "$desired_commit"
38
+ printf 'generated_commit=%s\n' "$generated_commit"
39
+ printf 'installed_commit=%s\n' "$installed_commit"
40
+ printf 'identity_state=%s\n' "$identity_state"
41
+ printf 'status=%s\n' "$status"
42
+ else
43
+ printf 'requested ref: %s\n' "$requested_ref"
44
+ printf 'resolved ref: %s\n' "$resolved_ref"
45
+ printf 'desired commit: %s\n' "$desired_commit"
46
+ printf 'generated plugin commit: %s\n' "${generated_commit:-not present}"
47
+ printf 'installed manager commit or fingerprint: %s\n' "${installed_commit:-not detected}"
48
+ printf 'Codex identity state: %s\n' "$identity_state"
49
+ printf 'status: %s\n' "$status"
50
+ fi
@@ -0,0 +1,61 @@
1
+ #!/bin/sh
2
+ set -eu
3
+
4
+ root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
5
+ . "$root/scripts/lib.sh"
6
+
7
+ codex_bin="${SUPERPOWERS_CODEX:-codex}"
8
+
9
+ spw_require_command "$codex_bin"
10
+ spw_require_command python3
11
+
12
+ # Preflight: read and parse BOTH Codex listings before removing anything, so a
13
+ # read/parse failure aborts before any removal (fail closed).
14
+ ownership=$(spw_codex_identity_snapshot "$codex_bin")
15
+ plugin_present=$(spw_snapshot_get "$ownership" manager_plugin)
16
+ marketplace_present=$(spw_snapshot_get "$ownership" manager_marketplace)
17
+
18
+ # Remove the plugin first, then the marketplace, so an installed plugin is
19
+ # never orphaned from a just-removed marketplace source.
20
+ if [ "$plugin_present" = true ]; then
21
+ if ! "$codex_bin" plugin remove "$SPW_PLUGIN_ID"; then
22
+ spw_die "codex plugin remove failed for $SPW_PLUGIN_ID"
23
+ fi
24
+ echo "removed plugin $SPW_PLUGIN_ID"
25
+ else
26
+ echo "plugin not installed; skipping"
27
+ fi
28
+
29
+ if [ "$marketplace_present" = true ]; then
30
+ if ! "$codex_bin" plugin marketplace remove "$SPW_MARKETPLACE_NAME"; then
31
+ spw_die "codex plugin marketplace remove failed for $SPW_MARKETPLACE_NAME"
32
+ fi
33
+ echo "removed marketplace $SPW_MARKETPLACE_NAME"
34
+ else
35
+ echo "marketplace not registered; skipping"
36
+ fi
37
+
38
+ # Verify-after: re-query and refuse to claim success while either manager
39
+ # target survives. The strict snapshot also records whether legacy state is
40
+ # still present without ever mutating that state.
41
+ ownership=$(spw_codex_identity_snapshot "$codex_bin")
42
+ plugin_present=$(spw_snapshot_get "$ownership" manager_plugin)
43
+ marketplace_present=$(spw_snapshot_get "$ownership" manager_marketplace)
44
+ identity_state=$(spw_snapshot_get "$ownership" identity_state)
45
+ if [ "$plugin_present" = true ]; then
46
+ spw_die "plugin $SPW_PLUGIN_ID is still installed after removal"
47
+ fi
48
+ if [ "$marketplace_present" = true ]; then
49
+ spw_die "marketplace $SPW_MARKETPLACE_NAME is still registered after removal"
50
+ fi
51
+
52
+ case "$identity_state" in
53
+ legacy|both)
54
+ printf '%s\n' \
55
+ 'Legacy superpowers-wrapper Codex state remains installed.' \
56
+ 'Run: npx superpowers-wrapper@0.1.1 uninstall'
57
+ ;;
58
+ esac
59
+
60
+ echo "uninstall complete"
61
+ echo "note: local generated artifacts under plugins/superpowers/ and .cache/upstream/ were left in place; remove them manually or regenerate with scripts/prepare."
package/scripts/update ADDED
@@ -0,0 +1,36 @@
1
+ #!/bin/sh
2
+ set -eu
3
+
4
+ root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
5
+ . "$root/scripts/lib.sh"
6
+
7
+ probe_output=$(sh "$root/scripts/probe" --porcelain)
8
+ identity_state=$(printf '%s\n' "$probe_output" | awk -F= '$1 == "identity_state" { print $2 }')
9
+ [ -n "$identity_state" ] || spw_die "probe did not report Codex identity state"
10
+ spw_require_no_legacy_state "$identity_state"
11
+ status=$(printf '%s\n' "$probe_output" | awk -F= '$1 == "status" { print $2 }')
12
+
13
+ case "$status" in
14
+ current)
15
+ printf '%s\n' "$probe_output"
16
+ echo "manager is current"
17
+ ;;
18
+ needs\ prepare)
19
+ sh "$root/scripts/prepare"
20
+ sh "$root/scripts/install"
21
+ desired_commit=$(spw_metadata_commit_or_empty "$root/plugins/superpowers/.superpowers-upstream.json" || true)
22
+ [ -n "$desired_commit" ] || spw_die "generated metadata missing desired commit after install"
23
+ spw_verify_refresh "$desired_commit"
24
+ ;;
25
+ needs\ install)
26
+ sh "$root/scripts/install"
27
+ desired_commit=$(spw_metadata_commit_or_empty "$root/plugins/superpowers/.superpowers-upstream.json" || true)
28
+ [ -n "$desired_commit" ] || spw_die "generated metadata missing desired commit after install"
29
+ spw_verify_refresh "$desired_commit"
30
+ ;;
31
+ *)
32
+ echo "$probe_output"
33
+ echo "error: unknown probe status: $status" >&2
34
+ exit 1
35
+ ;;
36
+ esac
@@ -0,0 +1,314 @@
1
+ #!/usr/bin/env python3
2
+ """Validate the wrapper-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 wrapper 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 wrapper-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())