superpowers-manager 0.1.6 → 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.
package/README.md CHANGED
@@ -41,15 +41,19 @@ It never removes the legacy provider automatically.
41
41
 
42
42
  Superpowers Manager requires Node 24+, `git`, Python 3, and a POSIX `sh`.
43
43
  Codex CLI is required for `probe`, `install`, `update`, and `uninstall`;
44
- `prepare` does not require it.
44
+ `prepare`, `pin`, `track-latest`, and `unpin` do not require it. Codex is the
45
+ only supported integration today.
45
46
 
46
47
  macOS and Linux are tested. WSL2 is supported. The native Windows path is
47
48
  untested; the launcher looks for Git Bash, `git`, and `python3`, but path
48
49
  handling between MSYS and Codex remains a known risk area.
49
50
 
50
- Prepare, install, probe, and update resolve the requested upstream ref over the
51
- network. Updates are user-triggered and need upstream network access; the
52
- manager does not run automatic or background updates.
51
+ Network needs depend on the saved policy. `pin` verifies its target;
52
+ `track-latest` and invocation overrides resolve from upstream when applied; and
53
+ `prepare`, `install`, and `update` fetch or verify the effective source. A probe
54
+ of a saved exact pin can reuse its recorded identity without upstream access.
55
+ Updates remain user-triggered; the manager does not run automatic or background
56
+ updates.
53
57
 
54
58
  ## What it does
55
59
 
@@ -76,7 +80,8 @@ compatibility evidence.
76
80
 
77
81
  ## Runtime architecture
78
82
 
79
- - `scripts/core/` owns the shared lifecycle, status, and protocol validation.
83
+ - `scripts/core/` owns the shared lifecycle, status, and protocol validation;
84
+ see the [version-1 adapter response protocol](https://github.com/j7an/superpowers-manager/blob/main/docs/adapter-protocol.md).
80
85
  - `scripts/adapters/codex/` owns build, inspection, reconciliation, and Codex
81
86
  mutation.
82
87
  - Codex is the only supported adapter today; no public harness selector ships
@@ -121,6 +126,9 @@ manager will not remove it for you.
121
126
 
122
127
  | Command | Codex side effects | Purpose |
123
128
  |---|---|---|
129
+ | `npx superpowers-manager pin REF` | None | Resolve and save an exact upstream release tag or full commit |
130
+ | `npx superpowers-manager track-latest` | None | Save a policy that resolves the latest stable release when applied |
131
+ | `npx superpowers-manager unpin` | None | Remove saved selection and return to the packaged fallback policy |
124
132
  | `npx superpowers-manager prepare` | None | Resolve upstream, build a staged plugin tree, validate it, and replace the generated tree only on success |
125
133
  | `npx superpowers-manager probe` | None | Report requested and resolved refs, desired/generated/installed commits, identity state, and status |
126
134
  | `npx superpowers-manager install` | Marketplace and plugin state | Prepare and validate, register the marketplace, install the plugin, and verify installed state |
@@ -136,29 +144,75 @@ when resulting manager state cannot be verified.
136
144
 
137
145
  ## Choosing the upstream version
138
146
 
139
- `SUPERPOWERS_REF` selects a stable release tag, full commit SHA, branch, or
140
- other resolvable upstream ref for that invocation. The stateless npx package
141
- records the requested ref, resolved ref, and exact commit, but does not persist
142
- the selection for a later invocation that omits `SUPERPOWERS_REF`.
147
+ Save a tag pin, commit pin, or explicit latest-stable policy for future
148
+ invocations:
143
149
 
144
- Without `SUPERPOWERS_REF`, the manager reads `config/upstream-ref`, which ships
145
- as `latest-release`. Accepted values include:
150
+ ```sh
151
+ npx superpowers-manager pin v6.1.1
152
+ npx superpowers-manager pin 0123456789abcdef0123456789abcdef01234567 # replace with a real upstream commit
153
+ npx superpowers-manager track-latest
154
+ npx superpowers-manager unpin
155
+ ```
156
+
157
+ The selection commands save intent only; they do not prepare generated content
158
+ or change Codex state. Run `npx superpowers-manager install` for a first
159
+ installation or `npx superpowers-manager update` to apply the saved policy to
160
+ an existing installation. There is no background updater: selection changes
161
+ take effect only through a user-triggered `prepare`, `install`, or `update`.
162
+
163
+ `pin` accepts only an exact `vMAJOR.MINOR.PATCH` tag (optionally with a SemVer
164
+ prerelease suffix) or a full 40-character commit. It resolves and verifies the
165
+ target before saving both the exact commit identity and its source. The saved
166
+ identity is the contract, not the contents of a clone cache: clearing or moving
167
+ `SUPERPOWERS_CACHE_DIR` does not change the selection. `track-latest` saves the
168
+ source and resolves the highest stable `vX.Y.Z` tag each time the policy is
169
+ applied. `unpin` removes only the saved selection and restores the packaged
170
+ `config/upstream-ref` fallback, currently `latest-release`.
171
+
172
+ The user-wide selection file is `selection.json` under the first configured
173
+ location:
174
+
175
+ 1. `$SUPERPOWERS_CONFIG_DIR`
176
+ 2. `$XDG_CONFIG_HOME/superpowers-manager`
177
+ 3. `$HOME/.config/superpowers-manager`
178
+
179
+ Each location must be absolute. The ref and source precedence chains are
180
+ independent:
181
+
182
+ - Ref: `SUPERPOWERS_REF`, then saved selection, then `config/upstream-ref`.
183
+ - Source: `SUPERPOWERS_UPSTREAM_URL`, then saved source, then the official
184
+ `https://github.com/obra/superpowers` source.
146
185
 
147
- - `latest-release` — highest stable `vX.Y.Z` tag (prereleases are excluded).
148
- - A specific tag, e.g. `v6.0.3`.
149
- - A full 40-character commit SHA.
150
- - Any other ref upstream resolves (e.g. a branch name).
186
+ `SUPERPOWERS_REF` is an invocation-only override and is not persisted. Unlike
187
+ an exact saved pin, it accepts stable tags, full commit SHAs, branches, and
188
+ other resolvable upstream refs for that invocation:
151
189
 
152
190
  ```sh
153
- SUPERPOWERS_REF=v6.0.3 npx superpowers-manager prepare
154
- SUPERPOWERS_REF=main npx superpowers-manager probe
155
- SUPERPOWERS_REF=feature/foo npx superpowers-manager install
156
- SUPERPOWERS_REF=latest-release npx superpowers-manager update
191
+ SUPERPOWERS_REF=feature/foo npx superpowers-manager probe
157
192
  ```
158
193
 
159
- `SUPERPOWERS_CACHE_DIR` may point to a persistent upstream clone cache to avoid
160
- re-cloning between package materializations. It caches Git objects; it does not
161
- persist ref selection or trigger updates.
194
+ `pin` and `track-latest` bind the current `SUPERPOWERS_UPSTREAM_URL`, or the
195
+ official source when that variable is unset, to the saved intent. An invocation
196
+ may override only the ref or only the source; `probe` exposes
197
+ `selection_origin`, `selection_mode`, `upstream_source_origin`,
198
+ `effective_source`, the saved-selection fields, and a mixed-origin warning in
199
+ human output so that combination remains visible.
200
+
201
+ A saved exact pin lets `probe` determine the desired upstream identity without
202
+ contacting Git, even when the saved source is temporarily unavailable. This is
203
+ not a general offline installation promise: `pin` verifies the requested
204
+ target, `track-latest` must resolve when applied, and `prepare`/`install`/`update`
205
+ must fetch or verify content from the effective source. Codex inspection is
206
+ still required for lifecycle status.
207
+
208
+ HTTP(S) upstream URLs containing userinfo are now rejected, including
209
+ token-only userinfo. This is an intentional compatibility break: use a Git
210
+ credential helper for HTTP(S) authentication or an SSH source instead of
211
+ embedding credentials in the URL.
212
+
213
+ Saved selection does not claim or transfer provider ownership. The manager
214
+ continues to mutate only `superpowers@superpowers-manager` and never removes or
215
+ updates the official provider or any other provider automatically.
162
216
 
163
217
  ## How versioning works
164
218
 
@@ -236,8 +290,9 @@ plugins/superpowers/
236
290
  .superpowers-upstream.json # generated provenance (gitignored)
237
291
  scripts/
238
292
  adapters/codex/ # Codex adapter entrypoint + validator helpers
239
- core/ # shared lifecycle/provenance/status modules
240
- prepare probe install update uninstall # user-facing shell entrypoints
293
+ core/ # shared lifecycle, selection, provenance, and status modules
294
+ pin track-latest unpin # persistent-selection entrypoints
295
+ prepare probe install update uninstall # lifecycle entrypoints
241
296
  tests/ # hermetic suite + manual Codex probe
242
297
  .cache/upstream/ # upstream clone cache (gitignored)
243
298
  ```
@@ -6,7 +6,7 @@ import * as path from 'node:path';
6
6
  import { spawnSync } from 'node:child_process';
7
7
 
8
8
  /**
9
- * @typedef {'prepare' | 'probe' | 'install' | 'update' | 'uninstall'} Subcommand
9
+ * @typedef {'pin' | 'track-latest' | 'unpin' | 'prepare' | 'probe' | 'install' | 'update' | 'uninstall'} Subcommand
10
10
  */
11
11
 
12
12
  /**
@@ -27,8 +27,22 @@ import { spawnSync } from 'node:child_process';
27
27
  * @typedef {{ file: string, argv: string[] }} SpawnDescriptor
28
28
  */
29
29
 
30
- const SUBCOMMANDS = ['prepare', 'probe', 'install', 'update', 'uninstall'];
31
- const CODEX_SUBCOMMANDS = ['probe', 'install', 'update', 'uninstall'];
30
+ const SUBCOMMANDS = [
31
+ 'pin', 'track-latest', 'unpin', 'prepare', 'probe', 'install', 'update', 'uninstall',
32
+ ];
33
+ const PIN_TAG_RE = /^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-(?:(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$/;
34
+ const PIN_COMMIT_RE = /^[0-9A-Fa-f]{40}$/;
35
+ /** @type {Record<Subcommand, string[]>} */
36
+ const COMMAND_REQUIREMENTS = {
37
+ pin: ['git', 'python3'],
38
+ 'track-latest': ['python3'],
39
+ unpin: [],
40
+ prepare: ['git', 'python3'],
41
+ probe: ['git', 'python3', 'codex'],
42
+ install: ['git', 'python3', 'codex'],
43
+ update: ['git', 'python3', 'codex'],
44
+ uninstall: ['python3', 'codex'],
45
+ };
32
46
  // Mirrors upstream Superpowers' hooks/run-hook.cmd discovery order.
33
47
  const GIT_BASH_CANDIDATES = [
34
48
  'C:\\Program Files\\Git\\bin\\bash.exe',
@@ -78,7 +92,20 @@ function parseArgs(argv) {
78
92
  if (first === '--help' || first === '-h') return { kind: 'help' };
79
93
  if (first === '--version') return { kind: 'version' };
80
94
  if (first && SUBCOMMANDS.includes(first)) {
81
- return { kind: 'run', cmd: /** @type {Subcommand} */ (first), args: argv.slice(1) };
95
+ const args = argv.slice(1);
96
+ if (first === 'pin' && args.length !== 1) {
97
+ return { kind: 'usage-error', message: 'usage: superpowers-manager pin REF' };
98
+ }
99
+ if (first === 'pin' && !PIN_TAG_RE.test(args[0]) && !PIN_COMMIT_RE.test(args[0])) {
100
+ return {
101
+ kind: 'usage-error',
102
+ message: 'pin REF must be an exact v-prefixed SemVer tag or full 40-hex commit',
103
+ };
104
+ }
105
+ if ((first === 'track-latest' || first === 'unpin') && args.length !== 0) {
106
+ return { kind: 'usage-error', message: `usage: superpowers-manager ${first}` };
107
+ }
108
+ return { kind: 'run', cmd: /** @type {Subcommand} */ (first), args };
82
109
  }
83
110
  return { kind: 'usage-error', message: `unknown subcommand: ${first}` };
84
111
  }
@@ -127,8 +154,15 @@ function discoverShell(env, platform) {
127
154
  return findTool('bash', env, platform);
128
155
  }
129
156
 
130
- // Tool preflight; never touches Codex state. codex is required only for the
131
- // subcommands that mutate or read Codex.
157
+ /**
158
+ * @returns {Record<Subcommand, string[]>}
159
+ */
160
+ function commandRequirements() {
161
+ return COMMAND_REQUIREMENTS;
162
+ }
163
+
164
+ // Tool preflight; never touches Codex state. Requirements are specific to the
165
+ // selected command, while a POSIX shell remains mandatory for every command.
132
166
  /**
133
167
  * @param {Subcommand} cmd
134
168
  * @param {NodeJS.ProcessEnv} env
@@ -137,8 +171,17 @@ function discoverShell(env, platform) {
137
171
  */
138
172
  function preflight(cmd, env, platform) {
139
173
  const errors = [];
140
- for (const tool of ['git', 'python3']) {
141
- if (!findTool(tool, env, platform)) {
174
+ for (const tool of COMMAND_REQUIREMENTS[cmd]) {
175
+ if (tool === 'codex') {
176
+ const codexBin = env.SUPERPOWERS_CODEX || 'codex';
177
+ // An explicit override may be a path rather than a PATH-resolvable name.
178
+ const found = codexBin.includes(path.sep)
179
+ ? fs.existsSync(codexBin)
180
+ : Boolean(findTool(codexBin, env, platform));
181
+ if (!found) {
182
+ errors.push(`required command not found: ${codexBin} — install the Codex CLI or set SUPERPOWERS_CODEX`);
183
+ }
184
+ } else if (!findTool(tool, env, platform)) {
142
185
  errors.push(`required command not found: ${tool} — install ${tool} and re-run`);
143
186
  }
144
187
  }
@@ -148,16 +191,6 @@ function preflight(cmd, env, platform) {
148
191
  ? 'no POSIX shell found — install Git for Windows (provides bash) or use WSL2'
149
192
  : 'required command not found: sh');
150
193
  }
151
- if (CODEX_SUBCOMMANDS.includes(cmd)) {
152
- const codexBin = env.SUPERPOWERS_CODEX || 'codex';
153
- // An explicit override may be a path rather than a PATH-resolvable name.
154
- const found = codexBin.includes(path.sep)
155
- ? fs.existsSync(codexBin)
156
- : Boolean(findTool(codexBin, env, platform));
157
- if (!found) {
158
- errors.push(`required command not found: ${codexBin} — install the Codex CLI or set SUPERPOWERS_CODEX`);
159
- }
160
- }
161
194
  if (errors.length) return { ok: false, errors };
162
195
  return { ok: true, shell: /** @type {string} */ (shell) };
163
196
  }
@@ -183,8 +216,14 @@ function buildSpawn(cmd, args, root, shell, platform) {
183
216
 
184
217
  function usage() {
185
218
  return [
186
- 'usage: superpowers-manager [prepare|probe|install|update|uninstall] [args...]',
219
+ 'usage: superpowers-manager [command] [args...]',
220
+ '',
221
+ 'Selection commands (save intent only; they do not prepare or install it):',
222
+ ' pin REF save an exact upstream release tag or commit',
223
+ ' track-latest save selection of the latest stable upstream release',
224
+ ' unpin remove the saved selection and return to the packaged fallback',
187
225
  '',
226
+ 'Apply and lifecycle commands:',
188
227
  ' prepare fetch the pinned upstream ref and generate the plugin tree',
189
228
  ' probe report upstream/generated/installed status (accepts --porcelain)',
190
229
  ' install register this package root as a Codex marketplace and install the plugin',
@@ -193,9 +232,13 @@ function usage() {
193
232
  '',
194
233
  'Environment overrides (passed through to the scripts): SUPERPOWERS_REF,',
195
234
  'SUPERPOWERS_UPSTREAM_URL, SUPERPOWERS_CODEX, SUPERPOWERS_CACHE_DIR,',
235
+ 'SUPERPOWERS_CONFIG_DIR, XDG_CONFIG_HOME,',
196
236
  'SUPERPOWERS_PLUGIN_ROOT, SUPERPOWERS_MANIFEST_TEMPLATE,',
197
237
  'SUPERPOWERS_VALIDATOR,',
198
238
  'SUPERPOWERS_INSTALLED_SEARCH_ROOT, SUPERPOWERS_INSTALL_REFRESH_MODE',
239
+ '',
240
+ 'Selection state uses SUPERPOWERS_CONFIG_DIR when set; otherwise it uses',
241
+ '$XDG_CONFIG_HOME/superpowers-manager, then $HOME/.config/superpowers-manager.',
199
242
  ].join('\n');
200
243
  }
201
244
 
@@ -239,6 +282,9 @@ function main() {
239
282
  process.exit(res.status === null ? 1 : res.status);
240
283
  }
241
284
 
242
- export { resolvePackageRoot, isMain, parseArgs, findTool, discoverShell, preflight, buildSpawn, usage };
285
+ export {
286
+ resolvePackageRoot, isMain, parseArgs, findTool, discoverShell,
287
+ commandRequirements, preflight, buildSpawn, usage,
288
+ };
243
289
 
244
290
  if (isMain(import.meta.filename, process.argv[1])) main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superpowers-manager",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "description": "Install and update the latest stable obra/superpowers release directly from upstream, without waiting for agent marketplaces to catch up. Codex supported today.",
5
5
  "keywords": [
6
6
  "superpowers",
@@ -203,24 +203,15 @@ spw_codex_build() {
203
203
  [ -f "$validator" ] || spw_codex_failure build "build-failed" "missing built-in plugin validator: $validator" "" ""
204
204
 
205
205
  tmp_parent="${TMPDIR:-/tmp}"
206
- if ! scratch=$(mktemp -d "$tmp_parent/superpowers-manager.adapter-build.XXXXXX"); then
206
+ if ! scratch=$(spw_make_workspace "$tmp_parent" "superpowers-manager.adapter-build"); then
207
207
  spw_codex_failure build "build-failed" "cannot create adapter build workspace under $tmp_parent" "" ""
208
208
  fi
209
+ spw_install_workspace_trap "$scratch"
209
210
  stdout_file="$scratch/stdout"
210
211
  stderr_file="$scratch/stderr"
211
212
  messages_file="$scratch/messages"
212
213
  candidate_manifest="$candidate_root/.codex-plugin/plugin.json"
213
214
  provenance_path="$candidate_root/.superpowers-upstream.json"
214
- cleanup() {
215
- status=$?
216
- trap - 0 HUP INT TERM
217
- rm -rf "$scratch" || :
218
- exit "$status"
219
- }
220
- trap cleanup 0
221
- trap 'exit 129' HUP
222
- trap 'exit 130' INT
223
- trap 'exit 143' TERM
224
215
 
225
216
  mkdir -p "$candidate_root/.codex-plugin"
226
217
  : > "$messages_file"
@@ -250,7 +241,7 @@ spw_codex_build() {
250
241
 
251
242
  if python3 -S "$validator" \
252
243
  --plugin-root "$candidate_root" \
253
- --source "$upstream_source" \
244
+ --source="$upstream_source" \
254
245
  --requested-ref "$requested_ref" \
255
246
  --resolved-ref "$resolved_ref" \
256
247
  --commit "$commit" \
@@ -302,23 +293,14 @@ spw_codex_install() {
302
293
  esac
303
294
 
304
295
  tmp_parent="${TMPDIR:-/tmp}"
305
- if ! scratch=$(mktemp -d "$tmp_parent/superpowers-manager.adapter-install.XXXXXX"); then
296
+ if ! scratch=$(spw_make_workspace "$tmp_parent" "superpowers-manager.adapter-install"); then
306
297
  spw_codex_failure install "install-failed" "cannot create adapter install workspace under $tmp_parent" "" ""
307
298
  fi
299
+ spw_install_workspace_trap "$scratch"
308
300
  stdout_file="$scratch/stdout"
309
301
  stderr_file="$scratch/stderr"
310
302
  messages_file="$scratch/messages"
311
303
  hints_file="$scratch/hints"
312
- cleanup() {
313
- status=$?
314
- trap - 0 HUP INT TERM
315
- rm -rf "$scratch" || :
316
- exit "$status"
317
- }
318
- trap cleanup 0
319
- trap 'exit 129' HUP
320
- trap 'exit 130' INT
321
- trap 'exit 143' TERM
322
304
 
323
305
  : > "$messages_file"
324
306
  : > "$hints_file"
@@ -426,22 +408,13 @@ spw_codex_uninstall() {
426
408
  spw_codex_require_command uninstall "$codex_bin"
427
409
 
428
410
  tmp_parent="${TMPDIR:-/tmp}"
429
- if ! scratch=$(mktemp -d "$tmp_parent/superpowers-manager.adapter-uninstall.XXXXXX"); then
411
+ if ! scratch=$(spw_make_workspace "$tmp_parent" "superpowers-manager.adapter-uninstall"); then
430
412
  spw_codex_failure uninstall "uninstall-failed" "cannot create adapter uninstall workspace under $tmp_parent" "" ""
431
413
  fi
414
+ spw_install_workspace_trap "$scratch"
432
415
  stdout_file="$scratch/stdout"
433
416
  stderr_file="$scratch/stderr"
434
417
  messages_file="$scratch/messages"
435
- cleanup() {
436
- status=$?
437
- trap - 0 HUP INT TERM
438
- rm -rf "$scratch" || :
439
- exit "$status"
440
- }
441
- trap cleanup 0
442
- trap 'exit 129' HUP
443
- trap 'exit 130' INT
444
- trap 'exit 143' TERM
445
418
 
446
419
  : > "$messages_file"
447
420
 
@@ -491,12 +464,53 @@ spw_codex_inspect() {
491
464
 
492
465
  case "$view" in
493
466
  fingerprint)
494
- fingerprint=$(spw_installed_commit_or_empty || true)
495
- if [ -n "$fingerprint" ]; then
496
- spw_codex_success inspect "{\"view\":\"fingerprint\",\"fingerprint\":\"$fingerprint\"}" ""
497
- else
498
- spw_codex_success inspect '{"view":"fingerprint","fingerprint":null}' ""
467
+ codex_bin="${SUPERPOWERS_CODEX:-codex}"
468
+ spw_require_command python3
469
+ spw_codex_require_command inspect "$codex_bin"
470
+
471
+ tmp_parent="${TMPDIR:-/tmp}"
472
+ if ! scratch=$(spw_make_workspace "$tmp_parent" "superpowers-manager.adapter-fingerprint"); then
473
+ spw_codex_failure inspect "inspect-failed" "cannot create adapter fingerprint workspace under $tmp_parent" "" ""
474
+ fi
475
+ spw_install_workspace_trap "$scratch"
476
+ stderr_file="$scratch/stderr"
477
+ messages_file="$scratch/messages"
478
+
479
+ : > "$messages_file"
480
+ : > "$stderr_file"
481
+ if ! plugin_listing=$("$codex_bin" plugin list --json 2>"$stderr_file"); then
482
+ spw_codex_append_messages "$messages_file" stderr "$stderr_file"
483
+ spw_codex_failure inspect "inspect-failed" "cannot list Codex plugins via '$codex_bin plugin list --json'" "" "$messages_file"
484
+ fi
485
+ spw_codex_append_messages "$messages_file" stderr "$stderr_file"
486
+ if ! active_version=$(spw_active_plugin_version_from_json \
487
+ "$plugin_listing" "$SPW_PLUGIN_ID"); then
488
+ spw_codex_failure inspect "inspect-failed" "cannot parse output of '$codex_bin plugin list --json'" "" "$messages_file"
489
+ fi
490
+ if [ -z "$active_version" ]; then
491
+ spw_codex_success inspect \
492
+ '{"view":"fingerprint","fingerprint":null}' "$messages_file"
493
+ return 0
499
494
  fi
495
+
496
+ search_root="${SUPERPOWERS_INSTALLED_SEARCH_ROOT:-$HOME/.codex}"
497
+ active_root=$(spw_installed_root_for_version \
498
+ "$search_root" "$SPW_MARKETPLACE_NAME" "superpowers" "$active_version")
499
+ fingerprint=$(spw_installed_commit_from_root_or_empty "$active_root")
500
+ if [ -z "$fingerprint" ]; then
501
+ spw_codex_failure inspect "inspect-failed" \
502
+ "cannot inspect active Codex plugin fingerprint under $active_root" "" "$messages_file"
503
+ fi
504
+ spw_codex_success inspect \
505
+ "{\"view\":\"fingerprint\",\"fingerprint\":\"$fingerprint\"}" \
506
+ "$messages_file"
507
+ ;;
508
+ update-control)
509
+ # Capability declaration for the fixed Codex local-package topology,
510
+ # backed by Layer-4 A/B evidence; revisit if topology or observed update
511
+ # behavior changes.
512
+ spw_codex_success inspect \
513
+ '{"view":"update-control","update_control":"managed"}' ""
500
514
  ;;
501
515
  ownership)
502
516
  codex_bin="${SUPERPOWERS_CODEX:-codex}"
@@ -504,21 +518,12 @@ spw_codex_inspect() {
504
518
  spw_codex_require_command inspect "$codex_bin"
505
519
 
506
520
  tmp_parent="${TMPDIR:-/tmp}"
507
- if ! scratch=$(mktemp -d "$tmp_parent/superpowers-manager.adapter-inspect.XXXXXX"); then
521
+ if ! scratch=$(spw_make_workspace "$tmp_parent" "superpowers-manager.adapter-inspect"); then
508
522
  spw_codex_failure inspect "inspect-failed" "cannot create adapter inspect workspace under $tmp_parent" "" ""
509
523
  fi
524
+ spw_install_workspace_trap "$scratch"
510
525
  stderr_file="$scratch/stderr"
511
526
  messages_file="$scratch/messages"
512
- cleanup() {
513
- status=$?
514
- trap - 0 HUP INT TERM
515
- rm -rf "$scratch" || :
516
- exit "$status"
517
- }
518
- trap cleanup 0
519
- trap 'exit 129' HUP
520
- trap 'exit 130' INT
521
- trap 'exit 143' TERM
522
527
 
523
528
  : > "$messages_file"
524
529
 
@@ -99,20 +99,77 @@ print("same" if na == nb else "different")
99
99
  PY
100
100
  }
101
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
102
+ spw_active_plugin_version_from_json() {
103
+ json="$1"
104
+ plugin_id="$2"
105
+ python3 - "$json" "$plugin_id" <<'PY'
106
+ import json
107
+ import sys
108
+
109
+ raw, plugin_id = sys.argv[1:]
110
+
111
+ def reject_constant(value):
112
+ raise ValueError(f"non-standard JSON constant: {value}")
113
+
114
+ def has_terminal_control(value):
115
+ return any(
116
+ ord(character) < 0x20
117
+ or 0x7F <= ord(character) <= 0x9F
118
+ or 0xD800 <= ord(character) <= 0xDFFF
119
+ for character in value
120
+ )
121
+
122
+ try:
123
+ data = json.loads(raw, parse_constant=reject_constant)
124
+ except (json.JSONDecodeError, RecursionError, ValueError):
125
+ sys.exit(2)
126
+
127
+ if not isinstance(data, dict):
128
+ sys.exit(2)
129
+ items = data.get("installed")
130
+ if not isinstance(items, list):
131
+ sys.exit(2)
132
+
133
+ matches = []
134
+ for item in items:
135
+ if not isinstance(item, dict):
136
+ sys.exit(2)
137
+ item_plugin_id = item.get("pluginId")
138
+ if not isinstance(item_plugin_id, str) or not item_plugin_id:
139
+ sys.exit(2)
140
+ if item_plugin_id == plugin_id:
141
+ matches.append(item)
142
+
143
+ if len(matches) > 1:
144
+ sys.exit(2)
145
+ if not matches:
146
+ sys.exit(0)
147
+
148
+ version = matches[0].get("version")
149
+ if (
150
+ not isinstance(version, str)
151
+ or not version
152
+ or version in {".", ".."}
153
+ or "/" in version
154
+ or "\\" in version
155
+ or has_terminal_control(version)
156
+ ):
157
+ sys.exit(2)
158
+ print(version)
159
+ PY
108
160
  }
109
161
 
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
162
+ spw_installed_root_for_version() {
163
+ search_root="$1"
164
+ marketplace="$2"
165
+ plugin="$3"
166
+ version="$4"
167
+ case "$search_root" in
168
+ /) search_root= ;;
169
+ */) search_root=${search_root%/} ;;
170
+ esac
171
+ printf '%s/plugins/cache/%s/%s/%s\n' \
172
+ "$search_root" "$marketplace" "$plugin" "$version"
116
173
  }
117
174
 
118
175
  spw_codex_json_get_or_empty() {
@@ -265,14 +322,15 @@ spw_manifest_short_sha_or_empty() {
265
322
  esac
266
323
  }
267
324
 
268
- spw_installed_commit_or_empty() {
269
- installed_metadata=$(spw_find_installed_metadata || true)
270
- installed_manifest=$(spw_find_installed_manifest || true)
325
+ spw_installed_commit_from_root_or_empty() {
326
+ active_root="$1"
327
+ installed_metadata="$active_root/.superpowers-upstream.json"
328
+ installed_manifest="$active_root/.codex-plugin/plugin.json"
271
329
  installed_commit=""
272
- if [ -n "$installed_metadata" ]; then
330
+ if [ -f "$installed_metadata" ]; then
273
331
  installed_commit=$(spw_codex_metadata_commit_or_empty "$installed_metadata" || true)
274
332
  fi
275
- if [ -z "$installed_commit" ] && [ -n "$installed_manifest" ]; then
333
+ if [ -z "$installed_commit" ] && [ -f "$installed_manifest" ]; then
276
334
  installed_commit=$(spw_manifest_short_sha_or_empty "$installed_manifest" || true)
277
335
  fi
278
336
  printf '%s\n' "$installed_commit"
@@ -24,6 +24,7 @@ spw_invoke_adapter() {
24
24
  shift
25
25
 
26
26
  response_file="${result_file}.response"
27
+ rm -f "$result_file"
27
28
  adapter_exit=0
28
29
  "$SPW_ADAPTER" "$operation" "$@" > "$response_file" || adapter_exit=$?
29
30
 
@@ -31,15 +32,20 @@ spw_invoke_adapter() {
31
32
  if [ -n "$inspect_view" ]; then
32
33
  args="$inspect_view"
33
34
  fi
35
+ validator_exit=0
34
36
  if [ -n "$args" ]; then
35
37
  python3 -S "$SPW_ADAPTER_RESPONSE_VALIDATOR" \
36
38
  --operation "$operation" --adapter-exit "$adapter_exit" \
37
39
  --response "$response_file" --result "$result_file" \
38
- --inspect-view "$inspect_view"
40
+ --inspect-view "$inspect_view" || validator_exit=$?
39
41
  else
40
42
  python3 -S "$SPW_ADAPTER_RESPONSE_VALIDATOR" \
41
43
  --operation "$operation" --adapter-exit "$adapter_exit" \
42
- --response "$response_file" --result "$result_file"
44
+ --response "$response_file" --result "$result_file" || validator_exit=$?
45
+ fi
46
+ if [ "$validator_exit" -ne 0 ]; then
47
+ rm -f "$result_file"
48
+ return 1
43
49
  fi
44
50
  }
45
51
 
@@ -76,6 +82,11 @@ spw_inspect_ownership() {
76
82
  spw_invoke_adapter inspect "$result_file" ownership -- --view ownership
77
83
  }
78
84
 
85
+ spw_inspect_update_control() {
86
+ result_file="$1"
87
+ spw_invoke_adapter inspect "$result_file" update-control -- --view update-control
88
+ }
89
+
79
90
  spw_adapter_install() {
80
91
  result_file="$1"
81
92
  package_root="$2"
@@ -6,6 +6,11 @@ spw_die() {
6
6
  exit 1
7
7
  }
8
8
 
9
+ spw_usage_error() {
10
+ echo "error: $*" >&2
11
+ exit 2
12
+ }
13
+
9
14
  spw_require_command() {
10
15
  command_name="$1"
11
16
  if ! command -v "$command_name" >/dev/null 2>&1; then
@@ -13,6 +18,25 @@ spw_require_command() {
13
18
  fi
14
19
  }
15
20
 
21
+ spw_make_workspace() {
22
+ mktemp -d "$1/$2.XXXXXX"
23
+ }
24
+
25
+ spw_cleanup_workspace_trap() {
26
+ _spw_workspace_trap_status=$?
27
+ trap - 0 HUP INT TERM
28
+ rm -rf "$_spw_workspace_trap_path" || :
29
+ exit "$_spw_workspace_trap_status"
30
+ }
31
+
32
+ spw_install_workspace_trap() {
33
+ _spw_workspace_trap_path="$1"
34
+ trap spw_cleanup_workspace_trap 0
35
+ trap 'exit 129' HUP
36
+ trap 'exit 130' INT
37
+ trap 'exit 143' TERM
38
+ }
39
+
16
40
  spw_root() {
17
41
  CDPATH= cd -- "$(dirname "$0")/.." && pwd
18
42
  }