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,141 @@
1
+ #!/bin/sh
2
+ # Sourced module; callers own set -eu.
3
+
4
+ # Replace the live tree with a single atomic rename, keeping a backup so a
5
+ # failed swap restores the previous working generated tree. candidate and
6
+ # live_root must be same-filesystem siblings for atomic renames.
7
+ spw_replace_generated_tree() {
8
+ candidate="$1"
9
+ live_root="$2"
10
+ parent=$(dirname "$live_root")
11
+ backup="$parent/.superpowers.bak.$$"
12
+
13
+ rm -rf "$backup"
14
+ if [ -e "$live_root" ]; then
15
+ mv "$live_root" "$backup"
16
+ fi
17
+ if mv "$candidate" "$live_root"; then
18
+ rm -rf "$backup"
19
+ else
20
+ if [ -e "$backup" ]; then
21
+ mv "$backup" "$live_root"
22
+ fi
23
+ rm -rf "$candidate"
24
+ spw_die "failed to install generated tree into $live_root; previous tree restored"
25
+ fi
26
+ }
27
+
28
+ spw_generated_metadata_path() (
29
+ generated_root="$1"
30
+ printf '%s\n' "$generated_root/plugins/superpowers/.superpowers-upstream.json"
31
+ )
32
+
33
+ spw_generated_commit_or_empty() (
34
+ generated_root="$1"
35
+ generated_metadata=$(spw_generated_metadata_path "$generated_root")
36
+ spw_metadata_commit_lenient_or_empty "$generated_metadata"
37
+ )
38
+
39
+ spw_probe_field() {
40
+ printf '%s\n' "$1" | awk -F= -v key="$2" '$1 == key { print $2 }'
41
+ }
42
+
43
+ spw_require_no_legacy_state() {
44
+ identity_state="$1"
45
+ case "$identity_state" in
46
+ neither|manager)
47
+ return 0
48
+ ;;
49
+ legacy|both)
50
+ printf '%s\n' \
51
+ 'Legacy superpowers-wrapper Codex state is installed.' \
52
+ 'Run: npx superpowers-wrapper@0.1.1 uninstall' \
53
+ 'Then run: npx superpowers-manager install' >&2
54
+ return 1
55
+ ;;
56
+ *)
57
+ spw_die "unknown adapter identity state: $identity_state"
58
+ ;;
59
+ esac
60
+ }
61
+
62
+ spw_require_managed_update_control() {
63
+ case "$1" in
64
+ managed) return 0 ;;
65
+ unsupported)
66
+ spw_die "adapter cannot guarantee manager-controlled updates"
67
+ ;;
68
+ *) spw_die "unknown adapter update-control capability: $1" ;;
69
+ esac
70
+ }
71
+
72
+ spw_report_legacy_state() {
73
+ case "$1" in
74
+ legacy|both)
75
+ printf '%s\n' \
76
+ 'Legacy superpowers-wrapper Codex state remains installed.' \
77
+ 'Run: npx superpowers-wrapper@0.1.1 uninstall'
78
+ ;;
79
+ neither|manager)
80
+ ;;
81
+ *)
82
+ spw_die "unknown adapter identity state: $1"
83
+ ;;
84
+ esac
85
+ }
86
+
87
+ spw_verify_installed_fingerprint() {
88
+ desired_commit="$1"
89
+ install_result="$2"
90
+ inspect_result="$3"
91
+ if ! spw_inspect_fingerprint "$inspect_result"; then
92
+ echo "error: installed manager fingerprint inspection failed after install." >&2
93
+ return 1
94
+ fi
95
+ if ! installed_commit=$(spw_adapter_result_get "$inspect_result" "fingerprint"); then
96
+ echo "error: cannot parse installed manager fingerprint inspection result after install." >&2
97
+ return 1
98
+ fi
99
+ printf 'desired_commit=%s\n' "$desired_commit"
100
+ printf 'installed_commit=%s\n' "$installed_commit"
101
+ if [ -n "$installed_commit" ] && spw_commit_matches "$desired_commit" "$installed_commit"; then
102
+ echo "manager updated"
103
+ return 0
104
+ fi
105
+
106
+ hint=""
107
+ if [ -f "$install_result" ]; then
108
+ if [ -n "$installed_commit" ]; then
109
+ hint=$(spw_adapter_result_get "$install_result" "verification_hints.mismatch" || true)
110
+ else
111
+ hint=$(spw_adapter_result_get "$install_result" "verification_hints.missing" || true)
112
+ fi
113
+ fi
114
+
115
+ if [ -n "$installed_commit" ]; then
116
+ echo "error: installed manager fingerprint does not match the prepared plugin after install." >&2
117
+ else
118
+ echo "error: installed manager fingerprint is not detectable after install." >&2
119
+ fi
120
+ if [ -n "$hint" ]; then
121
+ echo "hint: $hint" >&2
122
+ fi
123
+ return 1
124
+ }
125
+
126
+ spw_verify_uninstalled_resources() {
127
+ inspect_result="$1"
128
+
129
+ if ! plugin_present=$(spw_adapter_result_boolean "$inspect_result" "resources.plugin"); then
130
+ return 1
131
+ fi
132
+ if ! marketplace_present=$(spw_adapter_result_boolean "$inspect_result" "resources.marketplace"); then
133
+ return 1
134
+ fi
135
+ if [ "$plugin_present" = true ]; then
136
+ spw_die "owned plugin resource is still installed after removal"
137
+ fi
138
+ if [ "$marketplace_present" = true ]; then
139
+ spw_die "owned marketplace resource is still registered after removal"
140
+ fi
141
+ }
@@ -0,0 +1,135 @@
1
+ #!/bin/sh
2
+ # Sourced module; callers own set -eu.
3
+
4
+ spw_json_get() {
5
+ file="$1"
6
+ key="$2"
7
+ if ! value=$(
8
+ python3 - "$file" "$key" 2>&1 <<'PY'
9
+ import json
10
+ import sys
11
+
12
+ path, dotted_key = sys.argv[1:]
13
+ MAX_JSON_NESTING = 256
14
+
15
+ def reject_constant(constant):
16
+ raise ValueError(f"non-standard numeric constant: {constant}")
17
+
18
+ def nesting_exceeds_limit(value):
19
+ stack = [(value, 0)]
20
+ while stack:
21
+ current, depth = stack.pop()
22
+ if isinstance(current, dict):
23
+ next_depth = depth + 1
24
+ if next_depth > MAX_JSON_NESTING:
25
+ return True
26
+ stack.extend((child, next_depth) for child in current.values())
27
+ elif isinstance(current, list):
28
+ next_depth = depth + 1
29
+ if next_depth > MAX_JSON_NESTING:
30
+ return True
31
+ stack.extend((child, next_depth) for child in current)
32
+ return False
33
+
34
+ try:
35
+ with open(path, "r", encoding="utf-8") as f:
36
+ data = json.load(f, parse_constant=reject_constant)
37
+ except RecursionError:
38
+ sys.exit(f"JSON nesting exceeds limit in {path}")
39
+ except json.JSONDecodeError as exc:
40
+ sys.exit(
41
+ f"invalid JSON in {path}: "
42
+ f"line {exc.lineno} column {exc.colno}: {exc.msg}"
43
+ )
44
+ except (OSError, UnicodeError) as exc:
45
+ sys.exit(f"cannot read JSON in {path}: {exc}")
46
+ except ValueError as exc:
47
+ sys.exit(f"invalid JSON in {path}: {exc}")
48
+
49
+ if nesting_exceeds_limit(data):
50
+ sys.exit(f"JSON nesting exceeds limit in {path}")
51
+
52
+ if not isinstance(data, dict):
53
+ sys.exit(f"JSON value must be an object in {path}")
54
+
55
+ value = data
56
+ for part in dotted_key.split("."):
57
+ if not isinstance(value, dict):
58
+ value = ""
59
+ break
60
+ value = value.get(part, "")
61
+ try:
62
+ print(value if value is not None else "")
63
+ except (OSError, UnicodeError) as exc:
64
+ sys.exit(f"cannot output JSON value from {path}: {exc}")
65
+ PY
66
+ ); then
67
+ spw_die "$value"
68
+ fi
69
+ printf '%s\n' "$value"
70
+ }
71
+
72
+ spw_write_metadata_json() {
73
+ file="$1"
74
+ source="$2"
75
+ requested_ref="$3"
76
+ resolved_ref="$4"
77
+ commit="$5"
78
+ upstream_manifest_version="$6"
79
+ python3 - "$file" "$source" "$requested_ref" "$resolved_ref" "$commit" "$upstream_manifest_version" <<'PY'
80
+ import json, sys
81
+ path, source, requested, resolved, commit, version = sys.argv[1:]
82
+ data = {
83
+ "source": source,
84
+ "requested_ref": requested,
85
+ "resolved_ref": resolved,
86
+ "commit": commit,
87
+ "upstream_manifest_version": version,
88
+ }
89
+ try:
90
+ with open(path, "w", encoding="utf-8") as f:
91
+ json.dump(data, f, indent=2, allow_nan=False)
92
+ f.write("\n")
93
+ except RecursionError as exc:
94
+ sys.exit(f"JSON nesting exceeds limit while writing {path}: {exc}")
95
+ except (OSError, UnicodeError, ValueError) as exc:
96
+ sys.exit(f"cannot write JSON to {path}: {exc}")
97
+ PY
98
+ }
99
+
100
+ spw_metadata_commit_or_empty() {
101
+ file="$1"
102
+ if [ -f "$file" ]; then
103
+ spw_json_get "$file" "commit"
104
+ fi
105
+ }
106
+
107
+ spw_metadata_commit_lenient_or_empty() {
108
+ file="$1"
109
+ [ -f "$file" ] || return 0
110
+
111
+ if value=$(
112
+ python3 -S - "$file" 2>/dev/null <<'PY'
113
+ import json
114
+ import re
115
+ import sys
116
+
117
+ path = sys.argv[1]
118
+
119
+ def reject_constant(constant):
120
+ raise ValueError(f"non-standard numeric constant: {constant}")
121
+
122
+ try:
123
+ with open(path, "r", encoding="utf-8") as handle:
124
+ data = json.load(handle, parse_constant=reject_constant)
125
+ except (OSError, UnicodeError, ValueError, RecursionError):
126
+ raise SystemExit(1)
127
+
128
+ commit = data.get("commit") if isinstance(data, dict) else None
129
+ if isinstance(commit, str) and re.fullmatch(r"[0-9a-fA-F]{40}", commit):
130
+ print(commit)
131
+ PY
132
+ ); then
133
+ printf '%s\n' "$value"
134
+ fi
135
+ }
@@ -0,0 +1,414 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import errno
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import re
10
+ import secrets
11
+ import stat
12
+ import sys
13
+ from urllib.parse import urlsplit
14
+
15
+
16
+ PINNED_KEYS = {
17
+ "schema_version", "mode", "source", "requested_ref", "resolved_ref", "commit"
18
+ }
19
+ TRACK_LATEST_KEYS = {"schema_version", "mode", "source"}
20
+ TAG_RE = re.compile(
21
+ r"v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)"
22
+ r"(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)"
23
+ r"(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?\Z"
24
+ )
25
+ COMMIT_RE = re.compile(r"[0-9a-f]{40}\Z")
26
+ COMMIT_INPUT_RE = re.compile(r"[0-9a-fA-F]{40}\Z")
27
+ MAX_JSON_NESTING = 256
28
+
29
+
30
+ class SelectionError(Exception):
31
+ """A controlled selection-state validation or persistence failure."""
32
+
33
+
34
+ def require_object(raw: object, label: str) -> dict[str, object]:
35
+ if not isinstance(raw, dict):
36
+ raise SelectionError(f"{label} must be a JSON object")
37
+ return raw
38
+
39
+
40
+ def require_exact_keys(record: dict[str, object], expected: set[str]) -> None:
41
+ actual = set(record)
42
+ if actual != expected:
43
+ missing = sorted(expected - actual)
44
+ unknown = sorted(actual - expected)
45
+ details = []
46
+ if missing:
47
+ details.append("missing " + ", ".join(missing))
48
+ if unknown:
49
+ details.append("unknown " + ", ".join(unknown))
50
+ raise SelectionError("selection state keys are invalid: " + "; ".join(details))
51
+
52
+
53
+ def require_single_line_string(value: object, label: str) -> str:
54
+ if not isinstance(value, str) or not value or any(char in value for char in "\r\n\0"):
55
+ raise SelectionError(f"{label} must be a non-empty single-line string")
56
+ return value
57
+
58
+
59
+ def validate_source(raw: object) -> str:
60
+ source = require_single_line_string(raw, "source")
61
+ try:
62
+ parsed = urlsplit(source)
63
+ except (UnicodeError, ValueError) as exc:
64
+ raise SelectionError("source URL is malformed") from exc
65
+ if parsed.scheme.lower() in ("http", "https") and parsed.username is not None:
66
+ raise SelectionError("HTTP(S) source must not include userinfo")
67
+ return source
68
+
69
+
70
+ def validate_pinned_record(
71
+ record: dict[str, object], source: str
72
+ ) -> dict[str, object]:
73
+ requested_ref = require_single_line_string(record["requested_ref"], "requested_ref")
74
+ resolved_ref = require_single_line_string(record["resolved_ref"], "resolved_ref")
75
+ commit = require_single_line_string(record["commit"], "commit")
76
+ if not COMMIT_RE.fullmatch(commit):
77
+ raise SelectionError("commit must be a lowercase 40-hex value")
78
+ if TAG_RE.fullmatch(requested_ref):
79
+ if resolved_ref != requested_ref:
80
+ raise SelectionError("tag resolved_ref must equal requested_ref")
81
+ elif COMMIT_RE.fullmatch(requested_ref):
82
+ if resolved_ref != requested_ref or commit != requested_ref:
83
+ raise SelectionError(
84
+ "raw commit requested_ref, resolved_ref, and commit must be equal"
85
+ )
86
+ else:
87
+ raise SelectionError("requested_ref must be an exact tag or full commit")
88
+ return {
89
+ "schema_version": 1,
90
+ "mode": "pinned",
91
+ "source": source,
92
+ "requested_ref": requested_ref,
93
+ "resolved_ref": resolved_ref,
94
+ "commit": commit,
95
+ }
96
+
97
+
98
+ def validate_record(raw: object) -> dict[str, object]:
99
+ record = require_object(raw, "selection state")
100
+ version = record.get("schema_version")
101
+ if type(version) is not int or version != 1:
102
+ raise SelectionError("schema_version must equal integer 1")
103
+ mode = record.get("mode")
104
+ expected = (
105
+ PINNED_KEYS if mode == "pinned"
106
+ else TRACK_LATEST_KEYS if mode == "track-latest"
107
+ else None
108
+ )
109
+ if expected is None:
110
+ raise SelectionError("mode must be pinned or track-latest")
111
+ require_exact_keys(record, expected)
112
+ source = validate_source(record["source"])
113
+ if mode == "track-latest":
114
+ return {"schema_version": 1, "mode": mode, "source": source}
115
+ return validate_pinned_record(record, source)
116
+
117
+
118
+ def normalize(record: dict[str, object] | None) -> dict[str, str]:
119
+ if record is None:
120
+ return {
121
+ "saved_mode": "none",
122
+ "saved_source": "",
123
+ "saved_requested_ref": "",
124
+ "saved_resolved_ref": "",
125
+ "saved_commit": "",
126
+ }
127
+ return {
128
+ "saved_mode": str(record["mode"]),
129
+ "saved_source": str(record["source"]),
130
+ "saved_requested_ref": str(record.get("requested_ref", "")),
131
+ "saved_resolved_ref": str(record.get("resolved_ref", "")),
132
+ "saved_commit": str(record.get("commit", "")),
133
+ }
134
+
135
+
136
+ def reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
137
+ result: dict[str, object] = {}
138
+ for key, value in pairs:
139
+ if key in result:
140
+ raise SelectionError(f"duplicate JSON key: {key}")
141
+ result[key] = value
142
+ return result
143
+
144
+
145
+ def reject_constant(constant: str) -> None:
146
+ raise SelectionError(f"non-standard numeric constant: {constant}")
147
+
148
+
149
+ def nesting_exceeds_limit(value: object) -> bool:
150
+ stack = [(value, 0)]
151
+ while stack:
152
+ current, depth = stack.pop()
153
+ if isinstance(current, dict):
154
+ next_depth = depth + 1
155
+ if next_depth > MAX_JSON_NESTING:
156
+ return True
157
+ stack.extend((child, next_depth) for child in current.values())
158
+ elif isinstance(current, list):
159
+ next_depth = depth + 1
160
+ if next_depth > MAX_JSON_NESTING:
161
+ return True
162
+ stack.extend((child, next_depth) for child in current)
163
+ return False
164
+
165
+
166
+ def describe_path_type(path: Path, label: str) -> os.stat_result | None:
167
+ try:
168
+ info = path.lstat()
169
+ except FileNotFoundError:
170
+ return None
171
+ except OSError as exc:
172
+ raise SelectionError(f"cannot inspect {label} {path}: {exc}") from exc
173
+ if stat.S_ISLNK(info.st_mode):
174
+ raise SelectionError(f"{label} must not be a symlink: {path}")
175
+ return info
176
+
177
+
178
+ def load_json(path: Path) -> object:
179
+ flags = os.O_RDONLY
180
+ if hasattr(os, "O_NOFOLLOW"):
181
+ flags |= os.O_NOFOLLOW
182
+ try:
183
+ fd = os.open(path, flags)
184
+ except OSError as exc:
185
+ raise SelectionError(f"cannot read selection state {path}: {exc}") from exc
186
+ try:
187
+ info = os.fstat(fd)
188
+ if not stat.S_ISREG(info.st_mode):
189
+ raise SelectionError(f"selection state must be a regular file: {path}")
190
+ with os.fdopen(fd, "r", encoding="utf-8") as handle:
191
+ fd = -1
192
+ raw = json.load(
193
+ handle,
194
+ object_pairs_hook=reject_duplicate_keys,
195
+ parse_constant=reject_constant,
196
+ )
197
+ except RecursionError as exc:
198
+ raise SelectionError(f"JSON nesting exceeds limit in {path}") from exc
199
+ except json.JSONDecodeError as exc:
200
+ raise SelectionError(
201
+ f"invalid JSON in {path}: line {exc.lineno} column {exc.colno}: {exc.msg}"
202
+ ) from exc
203
+ except ValueError as exc:
204
+ raise SelectionError(f"invalid JSON in {path}: {exc}") from exc
205
+ except (OSError, UnicodeError) as exc:
206
+ raise SelectionError(f"cannot read selection state {path}: {exc}") from exc
207
+ finally:
208
+ if fd >= 0:
209
+ os.close(fd)
210
+ if nesting_exceeds_limit(raw):
211
+ raise SelectionError(f"JSON nesting exceeds limit in {path}")
212
+ return raw
213
+
214
+
215
+ def load_record(path: Path) -> dict[str, object] | None:
216
+ parent_info = describe_path_type(path.parent, "selection state directory")
217
+ if parent_info is not None and not stat.S_ISDIR(parent_info.st_mode):
218
+ raise SelectionError(
219
+ f"selection state directory must be a directory: {path.parent}"
220
+ )
221
+ info = describe_path_type(path, "selection state")
222
+ if info is None:
223
+ return None
224
+ if not stat.S_ISREG(info.st_mode):
225
+ raise SelectionError(f"selection state must be a regular file: {path}")
226
+ return validate_record(load_json(path))
227
+
228
+
229
+ def write_json_output(path: Path, value: object) -> None:
230
+ try:
231
+ with path.open("w", encoding="utf-8") as handle:
232
+ json.dump(value, handle, indent=2, allow_nan=False)
233
+ handle.write("\n")
234
+ except (OSError, UnicodeError, ValueError) as exc:
235
+ raise SelectionError(f"cannot write normalized selection output {path}: {exc}") from exc
236
+
237
+
238
+ def ensure_state_directory(path: Path) -> None:
239
+ info = describe_path_type(path, "selection state directory")
240
+ if info is not None:
241
+ if not stat.S_ISDIR(info.st_mode):
242
+ raise SelectionError(f"selection state directory must be a directory: {path}")
243
+ return
244
+ old_umask = os.umask(0o077)
245
+ try:
246
+ path.mkdir(mode=0o700, parents=True, exist_ok=True)
247
+ except OSError as exc:
248
+ raise SelectionError(f"cannot create selection state directory {path}: {exc}") from exc
249
+ finally:
250
+ os.umask(old_umask)
251
+ info = describe_path_type(path, "selection state directory")
252
+ if info is None or not stat.S_ISDIR(info.st_mode):
253
+ raise SelectionError(f"selection state directory is not usable: {path}")
254
+
255
+
256
+ def create_temp_file(path: Path) -> tuple[int, Path]:
257
+ for _ in range(100):
258
+ candidate = path.parent / f".{path.name}.tmp.{os.getpid()}.{secrets.token_hex(8)}"
259
+ try:
260
+ fd = os.open(candidate, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
261
+ os.fchmod(fd, 0o600)
262
+ return fd, candidate
263
+ except FileExistsError:
264
+ continue
265
+ except OSError as exc:
266
+ raise SelectionError(f"cannot create selection state temporary file: {exc}") from exc
267
+ raise SelectionError("cannot create unique selection state temporary file")
268
+
269
+
270
+ def fsync_directory_best_effort(directory: Path) -> None:
271
+ flags = os.O_RDONLY
272
+ if hasattr(os, "O_DIRECTORY"):
273
+ flags |= os.O_DIRECTORY
274
+ try:
275
+ fd = os.open(directory, flags)
276
+ except OSError:
277
+ return
278
+ try:
279
+ try:
280
+ os.fsync(fd)
281
+ except OSError as exc:
282
+ if exc.errno not in (errno.EINVAL, errno.ENOTSUP, errno.EBADF):
283
+ return
284
+ finally:
285
+ os.close(fd)
286
+
287
+
288
+ def final_state_diagnostic(path: Path) -> str:
289
+ try:
290
+ record = load_record(path)
291
+ except SelectionError as exc:
292
+ return f"final selection state cannot be validated: {exc}"
293
+ if record is None:
294
+ return "selection state is now absent"
295
+ return f"selection state is now {record['mode']}"
296
+
297
+
298
+ def write_record(path: Path, proposed: dict[str, object]) -> None:
299
+ ensure_state_directory(path.parent)
300
+ load_record(path)
301
+ record = validate_record(proposed)
302
+ fd = -1
303
+ temporary: Path | None = None
304
+ replacement_started = False
305
+ try:
306
+ fd, temporary = create_temp_file(path)
307
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
308
+ fd = -1
309
+ json.dump(record, handle, indent=2, allow_nan=False)
310
+ handle.write("\n")
311
+ handle.flush()
312
+ os.fsync(handle.fileno())
313
+ validate_record(load_json(temporary))
314
+ replacement_started = True
315
+ os.replace(temporary, path)
316
+ temporary = None
317
+ fsync_directory_best_effort(path.parent)
318
+ except SelectionError:
319
+ raise
320
+ except (OSError, UnicodeError, ValueError) as exc:
321
+ if replacement_started:
322
+ raise SelectionError(
323
+ f"cannot complete selection state write: {exc}; {final_state_diagnostic(path)}"
324
+ ) from exc
325
+ raise SelectionError(f"cannot write selection state: {exc}") from exc
326
+ finally:
327
+ if fd >= 0:
328
+ os.close(fd)
329
+ if temporary is not None:
330
+ try:
331
+ temporary.unlink()
332
+ except FileNotFoundError:
333
+ pass
334
+ except OSError:
335
+ pass
336
+
337
+
338
+ def normalized_pinned_arguments(arguments: argparse.Namespace) -> dict[str, object]:
339
+ requested_ref = arguments.requested_ref
340
+ resolved_ref = arguments.resolved_ref
341
+ commit = arguments.commit.lower()
342
+ if COMMIT_INPUT_RE.fullmatch(requested_ref):
343
+ requested_ref = requested_ref.lower()
344
+ if COMMIT_INPUT_RE.fullmatch(resolved_ref):
345
+ resolved_ref = resolved_ref.lower()
346
+ return {
347
+ "schema_version": 1,
348
+ "mode": "pinned",
349
+ "source": arguments.source,
350
+ "requested_ref": requested_ref,
351
+ "resolved_ref": resolved_ref,
352
+ "commit": commit,
353
+ }
354
+
355
+
356
+ def parser() -> argparse.ArgumentParser:
357
+ result = argparse.ArgumentParser(description="Strict persistent selection-state helper")
358
+ subparsers = result.add_subparsers(dest="command", required=True)
359
+
360
+ read = subparsers.add_parser("read")
361
+ read.add_argument("--path", required=True)
362
+ read.add_argument("--output", required=True)
363
+
364
+ pinned = subparsers.add_parser("write-pinned")
365
+ pinned.add_argument("--path", required=True)
366
+ pinned.add_argument("--source", required=True)
367
+ pinned.add_argument("--requested-ref", required=True)
368
+ pinned.add_argument("--resolved-ref", required=True)
369
+ pinned.add_argument("--commit", required=True)
370
+
371
+ latest = subparsers.add_parser("write-track-latest")
372
+ latest.add_argument("--path", required=True)
373
+ latest.add_argument("--source", required=True)
374
+
375
+ validate = subparsers.add_parser("validate-source")
376
+ validate.add_argument("--source", required=True)
377
+
378
+ display = subparsers.add_parser("display-source")
379
+ display.add_argument("--source", required=True)
380
+ return result
381
+
382
+
383
+ def dispatch(arguments: argparse.Namespace) -> None:
384
+ if arguments.command == "read":
385
+ write_json_output(Path(arguments.output), normalize(load_record(Path(arguments.path))))
386
+ elif arguments.command == "write-pinned":
387
+ write_record(Path(arguments.path), normalized_pinned_arguments(arguments))
388
+ elif arguments.command == "write-track-latest":
389
+ write_record(Path(arguments.path), {
390
+ "schema_version": 1,
391
+ "mode": "track-latest",
392
+ "source": arguments.source,
393
+ })
394
+ elif arguments.command == "validate-source":
395
+ validate_source(arguments.source)
396
+ elif arguments.command == "display-source":
397
+ try:
398
+ print(validate_source(arguments.source))
399
+ except SelectionError:
400
+ print("<redacted-source>")
401
+
402
+
403
+ def main() -> int:
404
+ arguments = parser().parse_args()
405
+ try:
406
+ dispatch(arguments)
407
+ except SelectionError as exc:
408
+ print(f"error: {exc}", file=sys.stderr)
409
+ return 1
410
+ return 0
411
+
412
+
413
+ if __name__ == "__main__":
414
+ raise SystemExit(main())