superpowers-manager 0.1.4 → 0.2.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.
@@ -0,0 +1,284 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import re
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ ENVELOPE_KEYS = {"protocol", "operation", "ok", "messages", "result", "error"}
11
+ MESSAGE_KEYS = {"channel", "text"}
12
+ ERROR_KEYS = {"code", "message", "hints"}
13
+ OWNERSHIP_KEYS = {"plugin", "marketplace"}
14
+ VERIFICATION_HINT_KEYS = {"mismatch", "missing"}
15
+ OPERATIONS = {"build", "inspect", "install", "uninstall"}
16
+ FINGERPRINT_RE = re.compile(r"(?:[0-9a-fA-F]{7}|[0-9a-fA-F]{40})\Z")
17
+ MAX_NESTING = 64
18
+ MAX_RESPONSE_BYTES = 1_048_576
19
+
20
+
21
+ class ProtocolError(ValueError):
22
+ pass
23
+
24
+
25
+ def reject_constant(value: str) -> None:
26
+ raise ProtocolError(f"non-standard JSON constant: {value}")
27
+
28
+
29
+ def reject_duplicate_keys(
30
+ pairs: list[tuple[str, object]],
31
+ ) -> dict[str, object]:
32
+ obj: dict[str, object] = {}
33
+ for key, value in pairs:
34
+ if key in obj:
35
+ raise ProtocolError("duplicate object key")
36
+ obj[key] = value
37
+ return obj
38
+
39
+
40
+ def nesting_exceeds_limit(value: object) -> bool:
41
+ stack = [(value, 0)]
42
+ while stack:
43
+ current, depth = stack.pop()
44
+ if isinstance(current, dict):
45
+ next_depth = depth + 1
46
+ if next_depth > MAX_NESTING:
47
+ return True
48
+ stack.extend((child, next_depth) for child in current.values())
49
+ elif isinstance(current, list):
50
+ next_depth = depth + 1
51
+ if next_depth > MAX_NESTING:
52
+ return True
53
+ stack.extend((child, next_depth) for child in current)
54
+ return False
55
+
56
+
57
+ def require_object(value: object, label: str) -> dict[str, object]:
58
+ if not isinstance(value, dict):
59
+ raise ProtocolError(f"{label} must be an object")
60
+ return value
61
+
62
+
63
+ def require_exact_keys(value: dict[str, object], expected: set[str], label: str) -> None:
64
+ actual = set(value)
65
+ if actual != expected:
66
+ raise ProtocolError(
67
+ f"{label} keys must be {sorted(expected)}, got {sorted(actual)}"
68
+ )
69
+
70
+
71
+ def contains_terminal_control(value: str) -> bool:
72
+ return any(
73
+ ord(character) < 0x20
74
+ or 0x7F <= ord(character) <= 0x9F
75
+ or 0xD800 <= ord(character) <= 0xDFFF
76
+ for character in value
77
+ )
78
+
79
+
80
+ def require_non_empty_string(value: object, label: str) -> str:
81
+ if not isinstance(value, str) or not value or "\n" in value or "\r" in value:
82
+ raise ProtocolError(f"{label} must be a non-empty single-line string")
83
+ if contains_terminal_control(value):
84
+ raise ProtocolError(f"{label} must not contain terminal control characters")
85
+ return value
86
+
87
+
88
+ def validate_messages(value: object) -> list[dict[str, str]]:
89
+ if not isinstance(value, list):
90
+ raise ProtocolError("messages must be an array")
91
+ messages: list[dict[str, str]] = []
92
+ for index, item in enumerate(value):
93
+ message = require_object(item, f"messages[{index}]")
94
+ require_exact_keys(message, MESSAGE_KEYS, f"messages[{index}]")
95
+ channel = message.get("channel")
96
+ if channel not in {"stdout", "stderr"}:
97
+ raise ProtocolError(f"messages[{index}].channel is invalid")
98
+ text = require_non_empty_string(message.get("text"), f"messages[{index}].text")
99
+ messages.append({"channel": str(channel), "text": text})
100
+ return messages
101
+
102
+
103
+ def validate_error(value: object) -> dict[str, object]:
104
+ error = require_object(value, "error")
105
+ require_exact_keys(error, ERROR_KEYS, "error")
106
+ code = require_non_empty_string(error.get("code"), "error.code")
107
+ message = require_non_empty_string(error.get("message"), "error.message")
108
+ hints_value = error.get("hints")
109
+ if not isinstance(hints_value, list):
110
+ raise ProtocolError("error.hints must be an array")
111
+ hints = [
112
+ require_non_empty_string(hint, f"error.hints[{index}]")
113
+ for index, hint in enumerate(hints_value)
114
+ ]
115
+ return {"code": code, "message": message, "hints": hints}
116
+
117
+
118
+ def validate_result(
119
+ operation: str, value: object, inspect_view: str | None
120
+ ) -> dict[str, object]:
121
+ result = require_object(value, "result")
122
+ if operation in {"build", "uninstall"}:
123
+ require_exact_keys(result, set(), f"{operation} result")
124
+ return {}
125
+ if operation == "inspect":
126
+ if inspect_view == "fingerprint":
127
+ require_exact_keys(result, {"view", "fingerprint"}, "inspect result")
128
+ if result.get("view") != "fingerprint":
129
+ raise ProtocolError("inspect result view must be fingerprint")
130
+ fingerprint = result.get("fingerprint")
131
+ if fingerprint is not None and (
132
+ not isinstance(fingerprint, str)
133
+ or FINGERPRINT_RE.fullmatch(fingerprint) is None
134
+ ):
135
+ raise ProtocolError("fingerprint must be null, 7 hex, or 40 hex")
136
+ return {"view": "fingerprint", "fingerprint": fingerprint}
137
+ if inspect_view == "ownership":
138
+ require_exact_keys(
139
+ result,
140
+ {"view", "resources", "legacy_resources", "identity_state"},
141
+ "inspect result",
142
+ )
143
+ if result.get("view") != "ownership":
144
+ raise ProtocolError("inspect result view must be ownership")
145
+ resources = require_object(result.get("resources"), "resources")
146
+ legacy_resources = require_object(
147
+ result.get("legacy_resources"), "legacy_resources"
148
+ )
149
+ for label, group in (
150
+ ("resources", resources),
151
+ ("legacy_resources", legacy_resources),
152
+ ):
153
+ require_exact_keys(group, OWNERSHIP_KEYS, label)
154
+ if not all(isinstance(group[key], bool) for key in OWNERSHIP_KEYS):
155
+ raise ProtocolError(f"{label} values must be Boolean")
156
+ manager_present = any(resources.values())
157
+ legacy_present = any(legacy_resources.values())
158
+ expected_state = {
159
+ (False, False): "neither",
160
+ (True, False): "manager",
161
+ (False, True): "legacy",
162
+ (True, True): "both",
163
+ }[(manager_present, legacy_present)]
164
+ identity_state = result.get("identity_state")
165
+ if identity_state != expected_state:
166
+ raise ProtocolError(
167
+ f"identity_state must be {expected_state} for the reported resources"
168
+ )
169
+ return {
170
+ "view": "ownership",
171
+ "resources": resources,
172
+ "legacy_resources": legacy_resources,
173
+ "identity_state": identity_state,
174
+ }
175
+ if inspect_view == "update-control":
176
+ require_exact_keys(
177
+ result,
178
+ {"view", "update_control"},
179
+ "update-control inspect result",
180
+ )
181
+ if result.get("view") != "update-control":
182
+ raise ProtocolError("inspect result view must be update-control")
183
+ value = result.get("update_control")
184
+ if value not in {"managed", "unsupported"}:
185
+ raise ProtocolError(
186
+ "update-control value must be managed or unsupported"
187
+ )
188
+ return {"view": "update-control", "update_control": value}
189
+ raise ProtocolError(
190
+ "inspect view must be fingerprint or ownership, or update-control"
191
+ )
192
+ if operation == "install":
193
+ require_exact_keys(result, {"verification_hints"}, "install result")
194
+ hints = require_object(result.get("verification_hints"), "verification_hints")
195
+ unknown = set(hints) - VERIFICATION_HINT_KEYS
196
+ if unknown:
197
+ raise ProtocolError(f"unknown verification hint keys: {sorted(unknown)}")
198
+ normalized = {
199
+ key: require_non_empty_string(value, f"verification_hints.{key}")
200
+ for key, value in hints.items()
201
+ }
202
+ return {"verification_hints": normalized}
203
+ raise ProtocolError(f"unsupported operation: {operation}")
204
+
205
+
206
+ def validate_envelope(
207
+ raw: object, operation: str, adapter_exit: int, inspect_view: str | None
208
+ ) -> tuple[list[dict[str, str]], dict[str, object] | None, dict[str, object] | None]:
209
+ envelope = require_object(raw, "response")
210
+ require_exact_keys(envelope, ENVELOPE_KEYS, "response")
211
+ protocol = envelope.get("protocol")
212
+ if type(protocol) is not int or protocol != 1:
213
+ raise ProtocolError("protocol must equal integer 1")
214
+ if envelope.get("operation") != operation:
215
+ raise ProtocolError("response operation does not match invocation")
216
+ ok = envelope.get("ok")
217
+ if not isinstance(ok, bool):
218
+ raise ProtocolError("ok must be Boolean")
219
+ messages = validate_messages(envelope.get("messages"))
220
+ if ok:
221
+ if adapter_exit != 0:
222
+ raise ProtocolError("successful response requires adapter exit 0")
223
+ if envelope.get("error") is not None:
224
+ raise ProtocolError("successful response error must be null")
225
+ result = validate_result(operation, envelope.get("result"), inspect_view)
226
+ return messages, result, None
227
+ if adapter_exit == 0:
228
+ raise ProtocolError("failure response requires nonzero adapter exit")
229
+ if envelope.get("result") is not None:
230
+ raise ProtocolError("failure response result must be null")
231
+ error = validate_error(envelope.get("error"))
232
+ return messages, None, error
233
+
234
+
235
+ def replay(messages: list[dict[str, str]]) -> None:
236
+ for message in messages:
237
+ stream = sys.stdout if message["channel"] == "stdout" else sys.stderr
238
+ print(message["text"], file=stream)
239
+
240
+
241
+ def main() -> int:
242
+ parser = argparse.ArgumentParser()
243
+ parser.add_argument("--operation", choices=sorted(OPERATIONS), required=True)
244
+ parser.add_argument("--adapter-exit", type=int, required=True)
245
+ parser.add_argument("--response", type=Path, required=True)
246
+ parser.add_argument("--result", type=Path, required=True)
247
+ parser.add_argument(
248
+ "--inspect-view", choices=("fingerprint", "ownership", "update-control")
249
+ )
250
+ args = parser.parse_args()
251
+ try:
252
+ response_size = args.response.stat().st_size
253
+ if response_size > MAX_RESPONSE_BYTES:
254
+ raise ProtocolError(
255
+ f"response exceeds {MAX_RESPONSE_BYTES}-byte limit"
256
+ )
257
+ with args.response.open(encoding="utf-8") as handle:
258
+ raw = json.load(
259
+ handle,
260
+ parse_constant=reject_constant,
261
+ object_pairs_hook=reject_duplicate_keys,
262
+ )
263
+ if nesting_exceeds_limit(raw):
264
+ raise ProtocolError("response JSON nesting exceeds limit")
265
+ messages, result, error = validate_envelope(
266
+ raw, args.operation, args.adapter_exit, args.inspect_view
267
+ )
268
+ replay(messages)
269
+ if error is not None:
270
+ print(f"error: {error['message']}", file=sys.stderr)
271
+ for hint in error["hints"]:
272
+ print(f"hint: {hint}", file=sys.stderr)
273
+ return 1
274
+ with args.result.open("w", encoding="utf-8") as handle:
275
+ json.dump(result, handle, allow_nan=False, separators=(",", ":"))
276
+ handle.write("\n")
277
+ return 0
278
+ except (OSError, UnicodeError, json.JSONDecodeError, ProtocolError, RecursionError) as exc:
279
+ print(f"error: invalid adapter response: {exc}", file=sys.stderr)
280
+ return 2
281
+
282
+
283
+ if __name__ == "__main__":
284
+ raise SystemExit(main())
package/scripts/install CHANGED
@@ -2,29 +2,24 @@
2
2
  set -eu
3
3
 
4
4
  root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
5
- . "$root/scripts/lib.sh"
5
+ . "$root/scripts/core/common.sh"
6
+ . "$root/scripts/core/provenance.sh"
7
+ . "$root/scripts/core/status.sh"
8
+ . "$root/scripts/core/lifecycle.sh"
9
+ . "$root/scripts/core/adapter.sh"
6
10
 
7
- codex_bin="${SUPERPOWERS_CODEX:-codex}"
8
- refresh_mode="${SUPERPOWERS_INSTALL_REFRESH_MODE:-add-only}"
9
-
10
- spw_require_command "$codex_bin"
11
11
  spw_require_command python3
12
12
 
13
- case "$refresh_mode" in
14
- add-only|remove-add) ;;
15
- *) spw_die "unsupported SUPERPOWERS_INSTALL_REFRESH_MODE: $refresh_mode" ;;
16
- esac
17
-
18
- echo "Note: remove or disable conflicting Superpowers providers yourself before relying on manager-provided skills."
13
+ echo "Note: remove or disable conflicting Superpowers providers yourself before relying on manager skills."
19
14
 
20
15
  # Prepare-before-mutate: probe, and (re)generate the plugin tree if it is
21
16
  # missing or stale — including validation — entirely before any Codex
22
17
  # marketplace or plugin mutation. A failed prepare leaves Codex untouched.
23
18
  probe_output=$(sh "$root/scripts/probe" --porcelain)
24
- identity_state=$(printf '%s\n' "$probe_output" | awk -F= '$1 == "identity_state" { print $2 }')
25
- [ -n "$identity_state" ] || spw_die "probe did not report Codex identity state"
19
+ identity_state=$(spw_probe_field "$probe_output" identity_state)
20
+ [ -n "$identity_state" ] || spw_die "probe did not report adapter identity state"
26
21
  spw_require_no_legacy_state "$identity_state"
27
- status=$(printf '%s\n' "$probe_output" | awk -F= '$1 == "status" { print $2 }')
22
+ status=$(spw_probe_field "$probe_output" status)
28
23
  case "$status" in
29
24
  needs\ prepare)
30
25
  sh "$root/scripts/prepare"
@@ -40,19 +35,25 @@ esac
40
35
  desired_commit=$(spw_metadata_commit_or_empty "$root/plugins/superpowers/.superpowers-upstream.json" || true)
41
36
  [ -n "$desired_commit" ] || spw_die "generated metadata missing desired commit after prepare"
42
37
 
43
- # Reconcile the manager marketplace pointer to this package root, then always
44
- # run the Codex install step: `install` means "make Codex use this root" and
45
- # never skips it because the generated tree looks current.
46
- spw_reconcile_marketplace "$codex_bin" "$root"
47
-
48
- case "$refresh_mode" in
49
- remove-add)
50
- "$codex_bin" plugin remove "$SPW_PLUGIN_ID" || true
51
- ;;
52
- esac
53
-
54
- if ! "$codex_bin" plugin add "$SPW_PLUGIN_ID"; then
55
- spw_die "codex plugin add failed for $SPW_PLUGIN_ID"
38
+ tmp_parent="${TMPDIR:-/tmp}"
39
+ if ! install_workspace=$(spw_make_workspace "$tmp_parent" "superpowers-manager.install"); then
40
+ spw_die "cannot create install workspace under $tmp_parent"
41
+ fi
42
+ spw_install_workspace_trap "$install_workspace"
43
+
44
+ TMPDIR="$install_workspace"
45
+ export TMPDIR
46
+ install_result="$install_workspace/install.json"
47
+ inspect_result="$install_workspace/inspect.json"
48
+ ownership_result="$install_workspace/ownership.json"
49
+ update_control_result="$install_workspace/update-control.json"
50
+ spw_inspect_ownership "$ownership_result"
51
+ spw_inspect_update_control "$update_control_result"
52
+ identity_state=$(spw_adapter_result_get "$ownership_result" "identity_state")
53
+ update_control=$(spw_adapter_result_get "$update_control_result" "update_control")
54
+ spw_require_no_legacy_state "$identity_state"
55
+ spw_require_managed_update_control "$update_control"
56
+ spw_adapter_install "$install_result" "$root"
57
+ if ! spw_verify_installed_fingerprint "$desired_commit" "$install_result" "$inspect_result"; then
58
+ exit 1
56
59
  fi
57
-
58
- spw_verify_refresh "$desired_commit"
package/scripts/pin ADDED
@@ -0,0 +1,51 @@
1
+ #!/bin/sh
2
+ set -eu
3
+
4
+ root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
5
+ . "$root/scripts/core/common.sh"
6
+ . "$root/scripts/core/upstream.sh"
7
+ . "$root/scripts/core/selection.sh"
8
+
9
+ [ "$#" -eq 1 ] || spw_usage_error 'usage: superpowers-manager pin REF'
10
+ requested_ref="$1"
11
+
12
+ if spw_is_pinnable_tag "$requested_ref"; then
13
+ requested_kind=tag
14
+ elif spw_is_full_commit "$requested_ref"; then
15
+ requested_kind=raw-commit
16
+ else
17
+ spw_usage_error 'pin REF must be an exact v-prefixed SemVer tag or full 40-hex commit'
18
+ fi
19
+
20
+ spw_require_command python3
21
+ state_path=$(spw_selection_state_path)
22
+ tmp_parent="${TMPDIR:-/tmp}"
23
+ if ! pin_workspace=$(spw_make_workspace "$tmp_parent" "superpowers-manager.pin"); then
24
+ spw_die "cannot create pin workspace under $tmp_parent"
25
+ fi
26
+ spw_install_workspace_trap "$pin_workspace"
27
+
28
+ normalized="$pin_workspace/selection.json"
29
+ spw_selection_state "$root" read \
30
+ --path="$state_path" --output="$normalized"
31
+
32
+ source="${SUPERPOWERS_UPSTREAM_URL:-$SPW_UPSTREAM_URL_DEFAULT}"
33
+ spw_selection_state "$root" \
34
+ validate-source --source="$source"
35
+
36
+ if [ "$requested_kind" = tag ]; then
37
+ spw_require_command git
38
+ commit=$(spw_resolve_exact_tag "$source" "$requested_ref")
39
+ resolved_ref="$requested_ref"
40
+ else
41
+ spw_require_command git
42
+ requested_ref=$(printf '%s' "$requested_ref" | tr '[:upper:]' '[:lower:]')
43
+ resolved_ref="$requested_ref"
44
+ commit=$(spw_verify_raw_commit "$source" "$requested_ref" "$pin_workspace")
45
+ fi
46
+
47
+ spw_selection_state "$root" write-pinned \
48
+ --path="$state_path" --source="$source" --requested-ref="$requested_ref" \
49
+ --resolved-ref="$resolved_ref" --commit="$commit"
50
+
51
+ printf 'pinned upstream selection to %s at %s\n' "$requested_ref" "$commit"
package/scripts/prepare CHANGED
@@ -2,54 +2,63 @@
2
2
  set -eu
3
3
 
4
4
  root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
5
- . "$root/scripts/lib.sh"
5
+ . "$root/scripts/core/common.sh"
6
+ . "$root/scripts/core/upstream.sh"
7
+ . "$root/scripts/core/selection.sh"
8
+ . "$root/scripts/core/provenance.sh"
9
+ . "$root/scripts/core/lifecycle.sh"
10
+ . "$root/scripts/core/adapter.sh"
6
11
 
7
- upstream_url="${SUPERPOWERS_UPSTREAM_URL:-$SPW_UPSTREAM_URL_DEFAULT}"
8
12
  cache_parent="${SUPERPOWERS_CACHE_DIR:-$root/.cache/upstream}"
9
- cache="$cache_parent/superpowers"
10
13
  plugin_root="${SUPERPOWERS_PLUGIN_ROOT:-$root/plugins/superpowers}"
11
14
  manifest_template="${SUPERPOWERS_MANIFEST_TEMPLATE:-$root/plugins/superpowers/.codex-plugin/plugin.template.json}"
12
- builtin_validator="$root/scripts/validate-generated-plugin.py"
13
15
  additional_validator="${SUPERPOWERS_VALIDATOR:-}"
16
+ invocation_root=$(pwd -P)
17
+ case "$cache_parent" in
18
+ /*) ;;
19
+ *) cache_parent="$invocation_root/$cache_parent" ;;
20
+ esac
21
+ case "$plugin_root" in
22
+ /*) ;;
23
+ *) plugin_root="$invocation_root/$plugin_root" ;;
24
+ esac
25
+ cache="$cache_parent/superpowers"
14
26
  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
27
+ mkdir -p "$tmp_parent"
28
+ if ! prepare_workspace=$(spw_make_workspace "$tmp_parent" ".superpowers.prepare"); then
29
+ spw_die "cannot create prepare workspace under $tmp_parent"
30
+ fi
31
+ spw_install_workspace_trap "$prepare_workspace"
32
+ tmp_plugin="$prepare_workspace/superpowers"
33
+ adapter_result="$prepare_workspace/adapter-build.json"
28
34
 
29
- requested_ref=$(spw_config_ref "$root")
35
+ TMPDIR="$prepare_workspace"
36
+ export TMPDIR
30
37
 
31
- spw_require_command git
32
38
  spw_require_command python3
39
+ spw_compute_effective_selection "$root" "$prepare_workspace"
40
+ spw_require_command git
41
+ spw_require_command "$SPW_ADAPTER"
33
42
  [ -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
43
 
41
44
  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
45
+ if [ "$SPW_SELECTION_MODE" = "pinned" ]; then
46
+ spw_fetch_exact_commit \
47
+ "$SPW_EFFECTIVE_SOURCE" "$SPW_DESIRED_COMMIT" "$cache" "$prepare_workspace"
46
48
  else
47
- if ! git clone "$upstream_url" "$cache"; then
48
- spw_die "cannot clone upstream repo: $upstream_url"
49
+ git_source=$(spw_git_safe_source "$SPW_EFFECTIVE_SOURCE")
50
+ if [ -d "$cache/.git" ]; then
51
+ if ! git -C "$cache" fetch --tags --prune -- "$git_source"; then
52
+ spw_die "cannot fetch upstream repo: $SPW_EFFECTIVE_SOURCE"
53
+ fi
54
+ else
55
+ if ! git clone -- "$git_source" "$cache"; then
56
+ spw_die "cannot clone upstream repo: $SPW_EFFECTIVE_SOURCE"
57
+ fi
49
58
  fi
50
59
  fi
51
- if ! git -C "$cache" checkout --detach "$commit" >/dev/null; then
52
- spw_die "cannot check out upstream commit: $commit"
60
+ if ! git -C "$cache" checkout --detach "$SPW_DESIRED_COMMIT" >/dev/null; then
61
+ spw_die "cannot check out upstream commit: $SPW_DESIRED_COMMIT"
53
62
  fi
54
63
 
55
64
  spw_require_upstream_path "$cache/skills" "skills/"
@@ -60,11 +69,6 @@ spw_require_upstream_path "$cache/CODE_OF_CONDUCT.md" "CODE_OF_CONDUCT.md"
60
69
  rm -rf "$tmp_plugin"
61
70
  mkdir -p "$tmp_plugin/.codex-plugin"
62
71
  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
72
 
69
73
  spw_copy_path_if_present "$cache/skills" "$tmp_plugin/skills"
70
74
  spw_copy_path_if_present "$cache/assets" "$tmp_plugin/assets"
@@ -79,32 +83,26 @@ fi
79
83
 
80
84
  spw_write_metadata_json \
81
85
  "$tmp_plugin/.superpowers-upstream.json" \
82
- "$upstream_url" \
83
- "$requested_ref" \
84
- "$resolved_ref" \
85
- "$commit" \
86
+ "$SPW_EFFECTIVE_SOURCE" \
87
+ "$SPW_REQUESTED_REF" \
88
+ "$SPW_RESOLVED_REF" \
89
+ "$SPW_DESIRED_COMMIT" \
86
90
  "$upstream_manifest_version"
87
91
 
88
92
  manager_version=$(
89
- spw_manifest_version_for_ref "$requested_ref" "$resolution_kind" "$resolved_ref" "$commit"
93
+ spw_manifest_version_for_ref \
94
+ "$SPW_REQUESTED_REF" "$SPW_RESOLUTION_KIND" \
95
+ "$SPW_RESOLVED_REF" "$SPW_DESIRED_COMMIT"
90
96
  )
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
97
+ spw_invoke_adapter build "$adapter_result" "" -- \
98
+ --upstream-root "$cache" \
99
+ --candidate-root "$tmp_plugin" \
100
+ --requested-ref "$SPW_REQUESTED_REF" \
101
+ --resolved-ref "$SPW_RESOLVED_REF" \
102
+ --commit "$SPW_DESIRED_COMMIT" \
103
+ --manager-version "$manager_version" \
104
+ --upstream-manifest-version "$upstream_manifest_version" \
105
+ --fallback-manifest "$manifest_template"
108
106
 
109
107
  if [ -n "$additional_validator" ]; then
110
108
  [ -f "$additional_validator" ] || \
@@ -114,23 +112,6 @@ if [ -n "$additional_validator" ]; then
114
112
  fi
115
113
  fi
116
114
 
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
115
+ spw_replace_generated_tree "$tmp_plugin" "$plugin_root"
135
116
 
136
- echo "prepared $resolved_ref at $commit"
117
+ echo "prepared $SPW_RESOLVED_REF at $SPW_DESIRED_COMMIT"