superpowers-manager 0.7.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +36 -16
  2. package/dist/atomic.js +1 -3
  3. package/dist/cli-arguments.js +4 -1
  4. package/dist/cli.js +14 -43
  5. package/dist/commands/adapter-call.js +24 -0
  6. package/dist/commands/install.js +16 -63
  7. package/dist/commands/mutation.js +5 -5
  8. package/dist/commands/pin.js +10 -5
  9. package/dist/commands/prepare.js +42 -108
  10. package/dist/commands/probe.js +33 -19
  11. package/dist/commands/uninstall.js +18 -46
  12. package/dist/commands/unpin.js +2 -1
  13. package/dist/commands/update.js +8 -29
  14. package/dist/domain/refs.js +0 -3
  15. package/dist/effective-selection.js +11 -15
  16. package/dist/git-tree.js +4 -6
  17. package/dist/harness-command-result.js +36 -5
  18. package/dist/harnesses/claude-code/harness.js +39 -0
  19. package/dist/harnesses/claude-code/install.js +380 -0
  20. package/dist/harnesses/claude-code/native.js +79 -0
  21. package/dist/harnesses/claude-code/paths.js +35 -0
  22. package/dist/harnesses/claude-code/prepare.js +117 -0
  23. package/dist/harnesses/claude-code/presentation.js +25 -0
  24. package/dist/harnesses/claude-code/state.js +253 -0
  25. package/dist/harnesses/codex/adapter.js +27 -68
  26. package/dist/harnesses/codex/conflicts.js +3 -8
  27. package/dist/harnesses/codex/generated-plugin.js +4 -11
  28. package/dist/harnesses/codex/harness.js +4 -26
  29. package/dist/harnesses/codex/hooks.js +11 -27
  30. package/dist/harnesses/codex/lifecycle.js +22 -107
  31. package/dist/harnesses/codex/manifest-overlay.js +9 -27
  32. package/dist/harnesses/codex/paths.js +29 -0
  33. package/dist/harnesses/codex/prepare.js +9 -43
  34. package/dist/harnesses/codex/presentation.js +1 -1
  35. package/dist/harnesses/codex/publication.js +5 -40
  36. package/dist/harnesses/codex/state.js +1 -6
  37. package/dist/harnesses/codex/stored-state.js +1 -6
  38. package/dist/harnesses/opencode/config.js +105 -50
  39. package/dist/harnesses/opencode/discovery.js +11 -15
  40. package/dist/harnesses/opencode/install.js +57 -102
  41. package/dist/harnesses/opencode/native.js +28 -49
  42. package/dist/harnesses/opencode/package.js +45 -67
  43. package/dist/harnesses/opencode/prepare.js +2 -7
  44. package/dist/harnesses/opencode/presentation.js +3 -7
  45. package/dist/harnesses/opencode/state.js +1 -0
  46. package/dist/harnesses/pi/install.js +31 -120
  47. package/dist/harnesses/pi/native.js +9 -34
  48. package/dist/harnesses/pi/package.js +9 -50
  49. package/dist/harnesses/pi/prepare.js +3 -8
  50. package/dist/harnesses/pi/presentation.js +2 -7
  51. package/dist/harnesses/pi/settings.js +5 -9
  52. package/dist/harnesses/pi/shared-skills.js +23 -53
  53. package/dist/harnesses/pi/state.js +3 -10
  54. package/dist/provenance.js +3 -19
  55. package/dist/resource-lock.js +11 -16
  56. package/dist/safe-path.js +28 -1
  57. package/dist/selection.js +15 -19
  58. package/dist/snapshot-journal.js +101 -0
  59. package/dist/snapshot-package.js +62 -1
  60. package/dist/snapshot-prepare.js +9 -2
  61. package/dist/snapshot-presentation.js +9 -0
  62. package/dist/upstream.js +8 -12
  63. package/dist/validator.js +26 -23
  64. package/dist/workspace.js +29 -35
  65. package/package.json +10 -9
  66. package/dist/python-json.js +0 -4
  67. package/dist/upstream-workspace.js +0 -8
@@ -1,8 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { lstat } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
- import { addArtifactHashField, digestArtifactTree, readArtifactFile, } from "./artifact-tree.js";
4
+ import { ARTIFACT_DIGEST_RE, ARTIFACT_RECEIPT, addArtifactHashField, digestArtifactTree, readArtifactFile, readArtifactObject, } from "./artifact-tree.js";
5
+ import { COMMIT_RE } from "./domain/refs.js";
5
6
  import { classifyPathNoFollow } from "./safe-path.js";
7
+ import { SafetyError } from "./safety-error.js";
6
8
  import { validateSource } from "./selection.js";
7
9
  const OFFICIAL_SOURCES = new Set([
8
10
  "https://github.com/obra/superpowers",
@@ -76,3 +78,62 @@ export async function requireSnapshotBootstrap(root, relativePath, expectedSize,
76
78
  createHash("sha256").update(bytes).digest("hex") !== expectedDigest)
77
79
  throw new Error("bootstrap implementation");
78
80
  }
81
+ // strictGeneration=false admits a generation on unknown/unsupported
82
+ // compatibility. Only Pi passes false: its readers keep that recorded policy
83
+ // (tests/unit/harnesses/pi/package.test.ts, "receipt readers retain their
84
+ // distinct unsupported-generation policy").
85
+ function isReceiptCompatibility(value, strictGeneration) {
86
+ if (!value || typeof value !== "object" || Array.isArray(value))
87
+ return false;
88
+ const compatibility = value;
89
+ if (typeof compatibility.reason !== "string")
90
+ return false;
91
+ if (compatibility.kind === "supported" ||
92
+ compatibility.kind === "experimental")
93
+ return typeof compatibility.generation === "string";
94
+ return ((compatibility.kind === "unknown" ||
95
+ compatibility.kind === "unsupported") &&
96
+ (!strictGeneration || compatibility.generation === undefined));
97
+ }
98
+ export function createSnapshotReceipts(rules) {
99
+ const module = `${rules.harness}-package`;
100
+ async function readReceipt(root) {
101
+ const path = join(root, ARTIFACT_RECEIPT);
102
+ try {
103
+ const value = await readArtifactObject(root, path);
104
+ if (value.schema !== 1 ||
105
+ value.manager !== "superpowers-manager" ||
106
+ value.harness !== rules.harness ||
107
+ typeof value.source !== "string" ||
108
+ typeof value.commit !== "string" ||
109
+ !COMMIT_RE.test(value.commit) ||
110
+ typeof value.digest !== "string" ||
111
+ !ARTIFACT_DIGEST_RE.test(value.digest) ||
112
+ typeof value.binding !== "string" ||
113
+ !ARTIFACT_DIGEST_RE.test(value.binding))
114
+ throw new Error("receipt fields");
115
+ validateSource(value.source);
116
+ if (snapshotReceiptBinding(value) !==
117
+ value.binding)
118
+ throw new Error("receipt binding");
119
+ if (!isReceiptCompatibility(value.compatibility, rules.strictGeneration))
120
+ throw new Error("receipt compatibility");
121
+ return value;
122
+ }
123
+ catch (cause) {
124
+ throw new SafetyError(module, `invalid ${rules.label} artifact receipt: ${path}`, { cause });
125
+ }
126
+ }
127
+ async function readAssessment(root) {
128
+ const receipt = await readReceipt(root);
129
+ if ((await digestArtifactTree(root)) !== receipt.digest)
130
+ throw new SafetyError(module, `${rules.label} artifact digest mismatch: ${root}`);
131
+ return {
132
+ receipt,
133
+ compatibility: await rules.assessCompatibility(root, {
134
+ effectiveSource: receipt.source,
135
+ }),
136
+ };
137
+ }
138
+ return { readReceipt, readAssessment };
139
+ }
@@ -6,6 +6,10 @@ import { materializeGitTree } from "./git-tree.js";
6
6
  import { classifyPathNoFollow } from "./safe-path.js";
7
7
  import { sameSnapshotSource, snapshotReceiptBinding, } from "./snapshot-package.js";
8
8
  export function createSnapshotPreparation(b) {
9
+ const preparationLocation = (ctx) => ({
10
+ destinationRoot: b.paths(ctx.env ?? {}, process.cwd()).preparedRoot,
11
+ stagingLeaf: "superpowers",
12
+ });
9
13
  const artifactFrom = (root, a) => ({
10
14
  root,
11
15
  commit: a.receipt.commit,
@@ -13,6 +17,7 @@ export function createSnapshotPreparation(b) {
13
17
  compatibility: a.compatibility,
14
18
  });
15
19
  return {
20
+ preparationLocation,
16
21
  async prepareCandidate(input) {
17
22
  try {
18
23
  await materializeGitTree(input.upstreamRoot, input.selection.desiredCommit, input.candidateRoot);
@@ -20,6 +25,8 @@ export function createSnapshotPreparation(b) {
20
25
  if (compatibility.kind === "unsupported" ||
21
26
  compatibility.kind === "unknown")
22
27
  return failureResult("prepare", "unsupported", compatibility.reason, [], []);
28
+ if (b.rewrite !== undefined)
29
+ await b.rewrite(input.candidateRoot, input.selection);
23
30
  const digest = await digestArtifactTree(input.candidateRoot);
24
31
  const identity = {
25
32
  schema: 1,
@@ -42,7 +49,7 @@ export function createSnapshotPreparation(b) {
42
49
  }
43
50
  },
44
51
  async inspectPrepared(selection, ctx) {
45
- const root = b.preparationLocation(ctx).destinationRoot;
52
+ const root = preparationLocation(ctx).destinationRoot;
46
53
  const unknown = {
47
54
  kind: "unknown",
48
55
  reason: `${b.label} prepared compatibility evidence is missing`,
@@ -83,7 +90,7 @@ export function createSnapshotPreparation(b) {
83
90
  }
84
91
  },
85
92
  async readPrepared(ctx) {
86
- const root = b.preparationLocation(ctx).destinationRoot;
93
+ const root = preparationLocation(ctx).destinationRoot;
87
94
  try {
88
95
  const assessment = await b.readAssessment(root);
89
96
  if (assessment.compatibility.kind !== "supported" &&
@@ -42,3 +42,12 @@ export function createSnapshotPresentation(label, renderProbe, wasAbsent) {
42
42
  },
43
43
  };
44
44
  }
45
+ // Each harness chooses its fields and applies its own escaping before calling.
46
+ export function probeOutput(fields) {
47
+ return {
48
+ human: fields
49
+ .map(([key, value]) => `${key.replaceAll("_", " ")}: ${value || "not present"}\n`)
50
+ .join(""),
51
+ porcelain: fields.map(([key, value]) => `${key}=${value}\n`).join(""),
52
+ };
53
+ }
package/dist/upstream.js CHANGED
@@ -1,8 +1,9 @@
1
- import { readFile, stat } from "node:fs/promises";
2
- import { join } from "node:path";
1
+ import { readFile } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
3
  import { COMMIT_INPUT_RE, compareStable, parseStableTag, } from "./domain/refs.js";
4
4
  import { runGit } from "./git.js";
5
5
  import { SafetyError } from "./safety-error.js";
6
+ import { isDirectory } from "./safe-path.js";
6
7
  import { displaySource } from "./selection.js";
7
8
  import { withWorkspace } from "./workspace.js";
8
9
  const UNAVAILABLE_OBJECT_RE = /not our ref|unadvertised object|couldn't find remote ref/;
@@ -35,16 +36,6 @@ function failed(result) {
35
36
  function combined(result) {
36
37
  return `${result.stdout}${result.stderr}`.replace(/\n+$/, "");
37
38
  }
38
- // Mirrors `[ -d "$repository/.git" ]`: a regular file, a broken symlink, or
39
- // an unreadable path is *not* a directory, so the cache is re-initialized.
40
- async function isDirectory(path) {
41
- try {
42
- return (await stat(path)).isDirectory();
43
- }
44
- catch {
45
- return false;
46
- }
47
- }
48
39
  export function parseLsRemote(output) {
49
40
  const entries = [];
50
41
  for (const line of output.split("\n")) {
@@ -251,3 +242,8 @@ export async function fetchExactCommit(source, commit, repository, workspacePare
251
242
  }
252
243
  });
253
244
  }
245
+ // An explicit relative SUPERPOWERS_CACHE_DIR resolves against the
246
+ // invocation's working directory.
247
+ export function upstreamCacheRoot(root, env) {
248
+ return resolve(env.SUPERPOWERS_CACHE_DIR || join(root, ".cache", "upstream"), "superpowers");
249
+ }
package/dist/validator.js CHANGED
@@ -198,14 +198,21 @@ export async function resolveValidator(configured) {
198
198
  // runs whatever PATH selects -- the same divergence that makes realpath unsafe
199
199
  // here.
200
200
  const pathLike = configured.includes(sep);
201
+ if (!pathLike) {
202
+ return {
203
+ configured,
204
+ resolved: null,
205
+ isSymlink: false,
206
+ isDirectory: false,
207
+ exists: false,
208
+ };
209
+ }
201
210
  let isSymlink = false;
202
- if (pathLike) {
203
- try {
204
- isSymlink = (await lstat(configured)).isSymbolicLink();
205
- }
206
- catch {
207
- /* spawn reports the real reason */
208
- }
211
+ try {
212
+ isSymlink = (await lstat(configured)).isSymbolicLink();
213
+ }
214
+ catch {
215
+ /* spawn reports the real reason */
209
216
  }
210
217
  // Resolve ONLY when the configured value contains a path separator -- which is
211
218
  // exactly when spawn bypasses PATH and the two agree about the target. For a
@@ -214,25 +221,21 @@ export async function resolveValidator(configured) {
214
221
  // interpreter and spawn("node") ran another. Resolving a bare name would let the
215
222
  // manager confidently disclose a file it is not about to run.
216
223
  let resolved = null;
217
- if (configured.includes(sep)) {
218
- try {
219
- resolved = await realpath(configured);
220
- }
221
- catch {
222
- /* unresolvable; stays null and is never claimed */
223
- }
224
+ try {
225
+ resolved = await realpath(configured);
226
+ }
227
+ catch {
228
+ /* unresolvable; stays null and is never claimed */
224
229
  }
225
230
  let isDirectory = false;
226
231
  let exists = false;
227
- if (pathLike) {
228
- try {
229
- const s = await stat(configured);
230
- isDirectory = s.isDirectory();
231
- exists = true;
232
- }
233
- catch {
234
- /* spawn reports the real reason */
235
- }
232
+ try {
233
+ const s = await stat(configured);
234
+ isDirectory = s.isDirectory();
235
+ exists = true;
236
+ }
237
+ catch {
238
+ /* spawn reports the real reason */
236
239
  }
237
240
  return { configured, resolved, isSymlink, isDirectory, exists };
238
241
  }
package/dist/workspace.js CHANGED
@@ -3,17 +3,11 @@ import { mkdtemp, rm } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { SafetyError } from "./safety-error.js";
5
5
  const MANAGED_SIGNALS = ["SIGHUP", "SIGINT", "SIGTERM"];
6
- // Each active workspace carries its caller's failure reporter. The signal
7
- // path still invokes it, for parity with the normal path, but a signal death
8
- // never builds a result outcome (see src/adapter-result.ts), so a report
9
- // that only lands in the adapter's buffered log would never become
10
- // observable — the signal path therefore also writes the hand-written
11
- // diagnostic straight to process.stderr, unconditionally; that raw write is
12
- // the only thing actually visible to a caller. Previously the signal path
13
- // called the module-private cleanup and swallowed the rejection in
14
- // Promise.allSettled, so a signal-time failure was silent entirely — this is
15
- // the state PR 11.4 removed from the normal path.
16
- const active = new Map();
6
+ // Workspaces the signal path must remove. A signal death never builds a
7
+ // result outcome (see src/adapter-result.ts), so the signal path writes its
8
+ // hand-written diagnostic straight to process.stderr; that write is the only
9
+ // report a caller can observe.
10
+ const active = new Set();
17
11
  let exiting = false;
18
12
  const handlers = new Map();
19
13
  // One phrasing for one condition. The adapter re-emits this for a suppressed
@@ -40,29 +34,18 @@ function cleanupForSignal(signal) {
40
34
  if (exiting)
41
35
  return;
42
36
  exiting = true;
43
- for (const [path, report] of active) {
37
+ for (const path of active) {
44
38
  try {
45
39
  rmSync(path, { recursive: true, force: true });
46
40
  }
47
41
  catch {
48
- // The reporter is invoked for parity with the normal path, but nothing
49
- // ever reads it back here — no outcome is built before the process
50
- // dies — so the diagnostic is also written straight to stderr,
51
- // unconditionally; that write is the only thing actually observable.
52
42
  // The diagnostic is hand-written and names the workspace; the caught
53
- // cause is not interpolated.
54
- //
55
- // Reporting is guarded: if either call throws — plausibly EPIPE after
56
- // SIGHUP, when the process on the other end of the pipe is already
57
- // gone — the exception must not escape this loop, or the remaining
58
- // workspaces' cleanup, deregistration, and the re-raise below never run.
43
+ // cause is not interpolated. Guarded: if the write throws — plausibly
44
+ // EPIPE after SIGHUP — the remaining workspaces' cleanup,
45
+ // deregistration, and the re-raise below must still run. Node documents
46
+ // pipe writes as asynchronous on some platforms (notably macOS); small
47
+ // buffers like this one are written inline.
59
48
  try {
60
- if (report)
61
- report(path);
62
- // Node documents pipe writes as asynchronous on some platforms
63
- // (notably macOS); a saturated pipe could drop this write. Small
64
- // buffers like this one are written inline, which is why the
65
- // covering test observes it reliably.
66
49
  process.stderr.write(`${workspaceRemovalFailure(path)}\n`);
67
50
  }
68
51
  catch {
@@ -96,7 +79,7 @@ function deregisterCoordinator() {
96
79
  process.off(signal, handler);
97
80
  handlers.clear();
98
81
  }
99
- export async function withWorkspace(parent, prefix, fn, options = {}) {
82
+ async function runInWorkspace(parent, prefix, fn, options, reportCleanupFailure) {
100
83
  const remove = options.cleanup ?? cleanup;
101
84
  let workspace;
102
85
  try {
@@ -105,35 +88,46 @@ export async function withWorkspace(parent, prefix, fn, options = {}) {
105
88
  catch (cause) {
106
89
  throw new SafetyError("workspace", "cannot create workspace", { cause });
107
90
  }
108
- active.set(workspace, options.onCleanupFailure);
91
+ active.add(workspace);
109
92
  registerCoordinator();
110
93
  try {
111
- let result;
94
+ let value;
112
95
  let failed = false;
113
96
  let callbackError;
114
97
  try {
115
- result = await fn(workspace);
98
+ value = await fn(workspace);
116
99
  }
117
100
  catch (error) {
118
101
  failed = true;
119
102
  callbackError = error;
120
103
  }
104
+ let cleanupWarning = null;
121
105
  try {
122
106
  await remove(workspace);
123
107
  }
124
108
  catch (cleanupError) {
125
109
  if (failed)
126
110
  throw callbackError;
127
- if (options.onCleanupFailure === undefined)
111
+ if (!reportCleanupFailure)
128
112
  throw cleanupError;
129
- options.onCleanupFailure(workspace);
113
+ cleanupWarning = workspaceRemovalFailure(workspace);
130
114
  }
131
115
  if (failed)
132
116
  throw callbackError;
133
- return result;
117
+ return { value, cleanupWarning };
134
118
  }
135
119
  finally {
136
120
  active.delete(workspace);
137
121
  deregisterCoordinator();
138
122
  }
139
123
  }
124
+ export async function withWorkspace(parent, prefix, fn, options = {}) {
125
+ return (await runInWorkspace(parent, prefix, fn, options, false)).value;
126
+ }
127
+ // Like withWorkspace, except a cleanup failure after a SUCCESSFUL callback is
128
+ // returned as `cleanupWarning` instead of thrown, so the caller keeps the value
129
+ // it already computed and reports the leak itself. A callback throw still
130
+ // propagates, and a cleanup failure never masks it.
131
+ export function withWorkspaceReporting(parent, prefix, fn, options = {}) {
132
+ return runInWorkspace(parent, prefix, fn, options, true);
133
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "superpowers-manager",
3
- "version": "0.7.3",
4
- "description": "Install obra/superpowers directly from upstream for Codex, Pi, and OpenCode. Stay on a known-good release or commit, try a branch, and upgrade when you choose.",
3
+ "version": "0.9.0",
4
+ "description": "Install obra/superpowers directly from upstream for Codex, Pi, OpenCode, and Claude Code. Stay on a known-good release or commit, try a branch, and upgrade when you choose.",
5
5
  "keywords": [
6
6
  "superpowers",
7
7
  "obra-superpowers",
@@ -18,21 +18,22 @@
18
18
  "pi",
19
19
  "pi-coding-agent",
20
20
  "opencode",
21
+ "claude-code",
22
+ "claude-code-plugin",
21
23
  "version-pinning"
22
24
  ],
23
25
  "license": "MIT",
24
26
  "type": "module",
25
- "packageManager": "pnpm@12.4.0",
27
+ "packageManager": "pnpm@12.4.2",
26
28
  "scripts": {
27
- "lint": "oxlint src/ tests/bin/ tests/unit/ tests/baseline/ tests/lib/ tests/tools/ tests/container/ tests/run-node-suites.ts tests/assert-matcher-gate.ts",
28
- "format": "prettier --write src/ tests/bin/ tests/unit/ tests/baseline/ tests/lib/ tests/tools/ tests/container/ tests/run-node-suites.ts tests/assert-matcher-gate.ts",
29
- "format:check": "prettier --check src/ tests/bin/ tests/unit/ tests/baseline/ tests/lib/ tests/tools/ tests/container/ tests/run-node-suites.ts tests/assert-matcher-gate.ts",
30
- "test": "node tests/run-node-suites.ts",
31
- "test:unit": "node tests/run-node-suites.ts --group unit",
32
- "test:integration": "node tests/run-node-suites.ts --group integration",
29
+ "lint": "oxlint src/ tests/",
30
+ "format": "prettier --write src/ tests/",
31
+ "format:check": "prettier --check src/ tests/",
32
+ "test": "sh tests/run.sh",
33
33
  "test:harness:codex": "sh tests/container.sh harness-codex",
34
34
  "test:harness:pi": "sh tests/container.sh harness-pi",
35
35
  "test:harness:opencode": "sh tests/container.sh harness-opencode",
36
+ "test:harness:claude-code": "sh tests/container.sh harness-claude-code",
36
37
  "test:acceptance": "sh tests/acceptance.sh",
37
38
  "check:static": "pnpm run format:check && pnpm run lint && pnpm run typecheck && pnpm run knip",
38
39
  "check": "pnpm run check:static && pnpm run test",
@@ -1,4 +0,0 @@
1
- import { escapeNonAscii } from "./python-json-format.js";
2
- export function escapePythonJsonString(value) {
3
- return escapeNonAscii(JSON.stringify(value).slice(1, -1));
4
- }
@@ -1,8 +0,0 @@
1
- import { isAbsolute, join, resolve } from "node:path";
2
- export function upstreamCacheRoot(root, env, cwd) {
3
- const configured = env.SUPERPOWERS_CACHE_DIR || join(root, ".cache", "upstream");
4
- const cacheParent = isAbsolute(configured)
5
- ? configured
6
- : resolve(cwd, configured);
7
- return join(cacheParent, "superpowers");
8
- }