superpowers-manager 0.4.0 → 0.5.1

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 (44) hide show
  1. package/README.md +76 -517
  2. package/dist/artifact-tree.js +111 -0
  3. package/dist/atomic.js +103 -7
  4. package/dist/cli-arguments.js +37 -0
  5. package/dist/cli.js +49 -12
  6. package/dist/commands/install.js +116 -17
  7. package/dist/commands/mutation.js +71 -0
  8. package/dist/commands/prepare.js +23 -17
  9. package/dist/commands/probe.js +164 -14
  10. package/dist/commands/uninstall.js +9 -5
  11. package/dist/commands/update.js +6 -2
  12. package/dist/domain/refs.js +4 -0
  13. package/dist/harness-compatibility.js +13 -0
  14. package/dist/{adapter.js → harnesses/codex/adapter.js} +17 -13
  15. package/dist/harnesses/codex/compatibility.js +126 -0
  16. package/dist/harnesses/codex/conflicts.js +99 -0
  17. package/dist/{generated-plugin.js → harnesses/codex/generated-plugin.js} +7 -73
  18. package/dist/{codex-harness.js → harnesses/codex/harness.js} +55 -5
  19. package/dist/{hooks.js → harnesses/codex/hooks.js} +6 -6
  20. package/dist/{codex-json.js → harnesses/codex/json.js} +9 -2
  21. package/dist/{lifecycle.js → harnesses/codex/lifecycle.js} +2 -2
  22. package/dist/{manifest-overlay.js → harnesses/codex/manifest-overlay.js} +5 -5
  23. package/dist/{codex-prepare.js → harnesses/codex/prepare.js} +72 -16
  24. package/dist/{codex-presentation.js → harnesses/codex/presentation.js} +27 -4
  25. package/dist/{codex-state.js → harnesses/codex/state.js} +3 -3
  26. package/dist/harnesses/pi/compatibility.js +73 -0
  27. package/dist/harnesses/pi/harness.js +63 -0
  28. package/dist/harnesses/pi/install.js +495 -0
  29. package/dist/harnesses/pi/native.js +81 -0
  30. package/dist/harnesses/pi/package.js +153 -0
  31. package/dist/harnesses/pi/paths.js +30 -0
  32. package/dist/harnesses/pi/prepare.js +111 -0
  33. package/dist/harnesses/pi/presentation.js +67 -0
  34. package/dist/harnesses/pi/settings.js +171 -0
  35. package/dist/harnesses/pi/shared-skills.js +413 -0
  36. package/dist/harnesses/pi/state.js +311 -0
  37. package/dist/python-text.js +16 -0
  38. package/dist/resource-lock.js +265 -0
  39. package/dist/safe-path.js +21 -5
  40. package/dist/skill-validation.js +52 -0
  41. package/dist/upstream-workspace.js +8 -0
  42. package/dist/validate-generated-plugin-cli.js +1 -1
  43. package/dist/validator.js +2 -1
  44. package/package.json +12 -8
@@ -0,0 +1,153 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { chmod, mkdir, symlink, writeFile } from "node:fs/promises";
4
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { ARTIFACT_DIGEST_RE, ARTIFACT_RECEIPT, addArtifactHashField, digestArtifactTree, readArtifactObject, } from "../../artifact-tree.js";
7
+ import { COMMIT_RE } from "../../domain/refs.js";
8
+ import { assertNoFollowType, assertSymlinkTargetContained, } from "../../safe-path.js";
9
+ import { SafetyError } from "../../safety-error.js";
10
+ import { validateSource } from "../../selection.js";
11
+ import { assessPiCompatibility } from "./compatibility.js";
12
+ const exec = promisify(execFile);
13
+ const DECODER = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
14
+ // A consistency checksum, not authentication. A coherent manual rewrite of
15
+ // both receipt and bytes remains possible; installed comparison must still use
16
+ // an independently validated candidate.
17
+ export function piReceiptBinding(receipt) {
18
+ const hash = createHash("sha256");
19
+ for (const field of [
20
+ String(receipt.schema),
21
+ receipt.manager,
22
+ receipt.harness,
23
+ receipt.source,
24
+ receipt.commit,
25
+ receipt.digest,
26
+ ])
27
+ addArtifactHashField(hash, Buffer.from(field));
28
+ return hash.digest("hex");
29
+ }
30
+ async function gitBytes(repository, args) {
31
+ try {
32
+ return (await exec("git", ["--no-replace-objects", "-C", repository, ...args], {
33
+ encoding: null,
34
+ maxBuffer: 256 * 1024 * 1024,
35
+ })).stdout;
36
+ }
37
+ catch (cause) {
38
+ throw new SafetyError("pi-package", `cannot read Pi tree objects from repository: ${repository}`, { cause });
39
+ }
40
+ }
41
+ export async function materializePiTree(repository, commit, destination) {
42
+ try {
43
+ if (!COMMIT_RE.test(commit))
44
+ throw new Error("commit");
45
+ if (!(await gitBytes(repository, ["cat-file", "-t", commit])).equals(Buffer.from("commit\n")))
46
+ throw new Error("not a commit");
47
+ await assertNoFollowType(destination, ["missing"]);
48
+ const bytes = await gitBytes(repository, [
49
+ "ls-tree",
50
+ "-rz",
51
+ "--full-tree",
52
+ commit,
53
+ ]);
54
+ const entries = [];
55
+ let offset = 0;
56
+ while (offset < bytes.length) {
57
+ const end = bytes.indexOf(0, offset);
58
+ if (end < 0)
59
+ throw new Error("tree framing");
60
+ const record = DECODER.decode(bytes.subarray(offset, end));
61
+ offset = end + 1;
62
+ const match = /^(100644|100755|120000) blob ([a-f0-9]{40})\t([\s\S]+)$/.exec(record);
63
+ if (!match)
64
+ throw new Error("tree entry");
65
+ const path = match[3];
66
+ if (path
67
+ .split("/")
68
+ .some((part) => !part ||
69
+ part === "." ||
70
+ part === ".." ||
71
+ part.toLowerCase() === ".git") ||
72
+ isAbsolute(path) ||
73
+ path.includes("\\") ||
74
+ path.split("/")[0] === ARTIFACT_RECEIPT)
75
+ throw new Error("tree path");
76
+ entries.push({ mode: match[1], oid: match[2], path });
77
+ }
78
+ await mkdir(destination, { recursive: true });
79
+ for (const entry of entries) {
80
+ await mkdir(dirname(join(destination, entry.path)), { recursive: true });
81
+ }
82
+ for (const entry of entries.filter((entry) => entry.mode !== "120000")) {
83
+ const path = join(destination, entry.path);
84
+ await writeFile(path, await gitBytes(repository, ["cat-file", "blob", entry.oid]), { flag: "wx", mode: entry.mode === "100755" ? 0o755 : 0o644 });
85
+ await chmod(path, entry.mode === "100755" ? 0o755 : 0o644);
86
+ }
87
+ for (const entry of entries.filter((entry) => entry.mode === "120000")) {
88
+ const target = DECODER.decode(await gitBytes(repository, ["cat-file", "blob", entry.oid]));
89
+ const path = join(destination, entry.path);
90
+ const suffix = relative(resolve(destination), resolve(dirname(path), target));
91
+ if (!target ||
92
+ target.includes("\0") ||
93
+ isAbsolute(target) ||
94
+ suffix === ".." ||
95
+ suffix.startsWith("../") ||
96
+ isAbsolute(suffix))
97
+ throw new Error("link escape");
98
+ await symlink(target, path);
99
+ }
100
+ for (const entry of entries.filter((entry) => entry.mode === "120000"))
101
+ await assertSymlinkTargetContained(destination, join(destination, entry.path));
102
+ }
103
+ catch (cause) {
104
+ throw new SafetyError("pi-package", `cannot materialize Pi tree from ${repository} into ${destination}`, { cause });
105
+ }
106
+ }
107
+ export async function digestPiTree(root) {
108
+ return await digestArtifactTree(root);
109
+ }
110
+ export async function readPiReceipt(root) {
111
+ const path = join(root, ARTIFACT_RECEIPT);
112
+ try {
113
+ const value = await readArtifactObject(root, path);
114
+ if (value.schema !== 1 ||
115
+ value.manager !== "superpowers-manager" ||
116
+ value.harness !== "pi" ||
117
+ typeof value.source !== "string" ||
118
+ typeof value.commit !== "string" ||
119
+ !COMMIT_RE.test(value.commit) ||
120
+ typeof value.digest !== "string" ||
121
+ !ARTIFACT_DIGEST_RE.test(value.digest) ||
122
+ typeof value.binding !== "string" ||
123
+ !ARTIFACT_DIGEST_RE.test(value.binding))
124
+ throw new Error("receipt fields");
125
+ validateSource(value.source);
126
+ if (piReceiptBinding(value) !== value.binding)
127
+ throw new Error("receipt binding");
128
+ const compatibility = value.compatibility;
129
+ if (!compatibility ||
130
+ typeof compatibility !== "object" ||
131
+ Array.isArray(compatibility) ||
132
+ typeof compatibility.reason !== "string" ||
133
+ typeof compatibility.kind !== "string" ||
134
+ !["unknown", "unsupported", "supported", "experimental"].includes(compatibility.kind) ||
135
+ ((compatibility.kind === "supported" ||
136
+ compatibility.kind === "experimental") &&
137
+ typeof compatibility.generation !== "string"))
138
+ throw new Error("receipt compatibility");
139
+ return value;
140
+ }
141
+ catch (cause) {
142
+ throw new SafetyError("pi-package", `invalid Pi artifact receipt: ${path}`, { cause });
143
+ }
144
+ }
145
+ export async function readPiPackageAssessment(root) {
146
+ const receipt = await readPiReceipt(root);
147
+ if ((await digestPiTree(root)) !== receipt.digest)
148
+ throw new SafetyError("pi-package", `Pi artifact digest mismatch: ${root}`);
149
+ const compatibility = await assessPiCompatibility(root, {
150
+ effectiveSource: receipt.source,
151
+ });
152
+ return { receipt, compatibility };
153
+ }
@@ -0,0 +1,30 @@
1
+ import { homedir } from "node:os";
2
+ import { join, resolve } from "node:path";
3
+ function homeDirectory(env) {
4
+ return env.HOME && env.HOME.length > 0 ? env.HOME : homedir();
5
+ }
6
+ function expandTilde(path, home) {
7
+ if (path === "~")
8
+ return home;
9
+ if (path.startsWith("~/"))
10
+ return join(home, path.slice(2));
11
+ return path;
12
+ }
13
+ export function piPaths(env, cwd) {
14
+ const homeDir = resolve(cwd, homeDirectory(env));
15
+ const configured = env.PI_CODING_AGENT_DIR;
16
+ const selected = configured && configured.length > 0
17
+ ? expandTilde(configured, homeDir)
18
+ : join(homeDir, ".pi", "agent");
19
+ const agentDir = resolve(cwd, selected);
20
+ const managerRoot = join(agentDir, "superpowers-manager");
21
+ return {
22
+ homeDir,
23
+ agentDir,
24
+ settingsFile: join(agentDir, "settings.json"),
25
+ managerRoot,
26
+ preparedRoot: join(managerRoot, "prepared"),
27
+ installedRoot: join(managerRoot, "installed"),
28
+ recoveryRoot: join(managerRoot, "recovery"),
29
+ };
30
+ }
@@ -0,0 +1,111 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { failureResult, successResult, } from "../../adapter-result.js";
4
+ import { ARTIFACT_RECEIPT } from "../../artifact-tree.js";
5
+ import { piPaths } from "./paths.js";
6
+ import { assessPiCompatibility, samePiSource } from "./compatibility.js";
7
+ import { digestPiTree, materializePiTree, readPiPackageAssessment, piReceiptBinding, } from "./package.js";
8
+ import { classifyPathNoFollow } from "../../safe-path.js";
9
+ export function piPreparationLocation(ctx) {
10
+ return {
11
+ destinationRoot: piPaths(ctx.env ?? {}, process.cwd()).preparedRoot,
12
+ stagingLeaf: "superpowers",
13
+ };
14
+ }
15
+ export async function preparePiCandidate(input, _ctx) {
16
+ try {
17
+ await materializePiTree(input.upstreamRoot, input.selection.desiredCommit, input.candidateRoot);
18
+ const compatibility = await assessPiCompatibility(input.candidateRoot, input.selection);
19
+ if (compatibility.kind === "unsupported" ||
20
+ compatibility.kind === "unknown")
21
+ return failureResult("prepare", "unsupported", compatibility.reason, [], []);
22
+ const digest = await digestPiTree(input.candidateRoot);
23
+ const identity = {
24
+ schema: 1,
25
+ manager: "superpowers-manager",
26
+ harness: "pi",
27
+ source: input.selection.effectiveSource,
28
+ commit: input.selection.desiredCommit,
29
+ digest,
30
+ };
31
+ const receipt = {
32
+ ...identity,
33
+ binding: piReceiptBinding(identity),
34
+ compatibility,
35
+ };
36
+ await writeFile(join(input.candidateRoot, ARTIFACT_RECEIPT), JSON.stringify(receipt) + "\n", { flag: "wx" });
37
+ return successResult("prepare", {
38
+ root: input.candidateRoot,
39
+ commit: receipt.commit,
40
+ identity: digest,
41
+ compatibility,
42
+ }, []);
43
+ }
44
+ catch {
45
+ return failureResult("prepare", "invalid-package", `cannot prepare Pi artifact: ${input.candidateRoot}`, [], []);
46
+ }
47
+ }
48
+ async function readArtifactAssessment(root) {
49
+ const { receipt, compatibility } = await readPiPackageAssessment(root);
50
+ return {
51
+ receipt,
52
+ artifact: {
53
+ root,
54
+ commit: receipt.commit,
55
+ identity: receipt.digest,
56
+ compatibility,
57
+ },
58
+ };
59
+ }
60
+ async function readArtifact(root) {
61
+ const { artifact } = await readArtifactAssessment(root);
62
+ const { compatibility } = artifact;
63
+ if (compatibility.kind !== "supported" &&
64
+ compatibility.kind !== "experimental")
65
+ throw new Error("unsupported profile");
66
+ return artifact;
67
+ }
68
+ export async function inspectPiPrepared(selection, ctx) {
69
+ const root = piPreparationLocation(ctx).destinationRoot;
70
+ const unknown = {
71
+ kind: "unknown",
72
+ reason: "Pi prepared compatibility evidence is missing",
73
+ };
74
+ try {
75
+ if ((await classifyPathNoFollow(join(root, ARTIFACT_RECEIPT))) === "missing")
76
+ return successResult("inspect-prepared", { kind: "needs-prepare", observedIdentity: "", compatibility: unknown }, []);
77
+ const { artifact, receipt } = await readArtifactAssessment(root);
78
+ if (artifact.commit !== selection.desiredCommit ||
79
+ !samePiSource(receipt.source, selection.effectiveSource))
80
+ return successResult("inspect-prepared", {
81
+ kind: "needs-prepare",
82
+ observedIdentity: artifact.identity,
83
+ compatibility: unknown,
84
+ }, []);
85
+ if (artifact.compatibility.kind !== "supported" &&
86
+ artifact.compatibility.kind !== "experimental")
87
+ return successResult("inspect-prepared", {
88
+ kind: "needs-prepare",
89
+ observedIdentity: artifact.identity,
90
+ compatibility: artifact.compatibility,
91
+ }, []);
92
+ return successResult("inspect-prepared", {
93
+ kind: "current",
94
+ artifact,
95
+ observedIdentity: artifact.identity,
96
+ compatibility: artifact.compatibility,
97
+ }, []);
98
+ }
99
+ catch {
100
+ return failureResult("inspect-prepared", "invalid-package", `cannot inspect Pi prepared artifact: ${root}`, [], []);
101
+ }
102
+ }
103
+ export async function readPiPrepared(ctx) {
104
+ const root = piPreparationLocation(ctx).destinationRoot;
105
+ try {
106
+ return successResult("read-prepared", await readArtifact(root), []);
107
+ }
108
+ catch {
109
+ return failureResult("read-prepared", "invalid-package", `cannot read Pi prepared artifact: ${root}`, [], []);
110
+ }
111
+ }
@@ -0,0 +1,67 @@
1
+ export const piPresentation = {
2
+ installNotice: "",
3
+ currentNotice: "The Superpowers Pi snapshot is current.",
4
+ renderProbe(facts) {
5
+ const fields = [
6
+ ["harness", "pi"],
7
+ ["desired_commit", facts.selection.desiredCommit],
8
+ ["prepared_identity", facts.prepared.observedIdentity],
9
+ ["installed_identity", facts.installed.observedIdentity],
10
+ ["installation_state", facts.installed.kind],
11
+ ["resource_state", facts.resourceState ?? "idle"],
12
+ ["ownership", facts.ownership.presentationValue],
13
+ ["conflicts", (facts.ownership.presentationConflicts ?? []).join("; ")],
14
+ ["update_control", facts.control.presentationValue],
15
+ ["compatibility", facts.compatibility.kind],
16
+ ["compatibility_reason", facts.compatibility.reason],
17
+ ["status", facts.status],
18
+ ];
19
+ return {
20
+ human: fields
21
+ .map(([key, value]) => `${key.replaceAll("_", " ")}: ${value || "not present"}\n`)
22
+ .join(""),
23
+ porcelain: fields.map(([key, value]) => `${key}=${value}\n`).join(""),
24
+ };
25
+ },
26
+ renderInstallVerification(_desiredCommit, receipt, inspection) {
27
+ if (receipt.status !== 0 || !receipt.outcome.ok)
28
+ return {
29
+ stdout: [],
30
+ stderr: [
31
+ "error: Pi activation did not return a verified installation receipt",
32
+ ],
33
+ };
34
+ if (inspection.status !== 0 || !inspection.outcome.ok)
35
+ return receipt.outcome.result.missingVerificationOutput;
36
+ if (inspection.outcome.result.kind === "current")
37
+ return {
38
+ stdout: [
39
+ "Installed the frozen Superpowers Pi snapshot. Restart Pi to load it.",
40
+ ],
41
+ stderr: [],
42
+ };
43
+ return inspection.outcome.result.kind === "absent"
44
+ ? receipt.outcome.result.missingVerificationOutput
45
+ : receipt.outcome.result.mismatchVerificationOutput;
46
+ },
47
+ renderRemovalCompletion(_ownership, removalInput) {
48
+ return removalInput.receiptDigest === null &&
49
+ removalInput.registrationIdentity === null
50
+ ? {
51
+ stdout: ["No managed Superpowers Pi installation is present."],
52
+ stderr: [],
53
+ }
54
+ : {
55
+ stdout: [
56
+ "Removed the managed Superpowers Pi installation. Restart Pi to load the resulting state.",
57
+ ],
58
+ stderr: [],
59
+ };
60
+ },
61
+ callFailure(site) {
62
+ return {
63
+ unexpected: `Pi ${site} did not complete`,
64
+ invalidStatus: `Pi ${site} returned an invalid status`,
65
+ };
66
+ },
67
+ };
@@ -0,0 +1,171 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { canonicalizeProspectivePath } from "../../safe-path.js";
5
+ import { SafetyError } from "../../safety-error.js";
6
+ import { parseStrictJson, } from "../../strict-json.js";
7
+ const SETTINGS_PROFILE = {
8
+ duplicateKeys: "last-wins",
9
+ nonStandardConstants: "reject",
10
+ maxDepth: 256,
11
+ };
12
+ // Pi 0.85.1's preliminary isLocalPath check excludes github:, but parseGitUrl
13
+ // does not recognize that shorthand and parseSource falls back to local. Keep
14
+ // that verified case locally inspectable; the remaining exact, case-sensitive
15
+ // prefix policy stays bounded because widening it changes which settings
16
+ // identities compare as paths.
17
+ // Pi 0.85.1 source (isLocalPath, parseGitUrl, then parseSource's local fallback):
18
+ // https://github.com/earendil-works/pi/blob/d981de1229ef899957bbe968bc8dcda02a21f477/packages/coding-agent/src/utils/paths.ts#L50-L64
19
+ // https://github.com/earendil-works/pi/blob/d981de1229ef899957bbe968bc8dcda02a21f477/packages/coding-agent/src/utils/git.ts#L172-L179
20
+ // https://github.com/earendil-works/pi/blob/d981de1229ef899957bbe968bc8dcda02a21f477/packages/coding-agent/src/core/package-manager.ts#L1460-L1470
21
+ const REMOTE_SOURCE_PREFIXES = [
22
+ "npm:",
23
+ "git:",
24
+ "http:",
25
+ "https:",
26
+ "ssh:",
27
+ ];
28
+ function invalid(path, detail, cause) {
29
+ throw new SafetyError("pi-settings", `invalid Pi settings ${path}: ${detail}`, {
30
+ cause,
31
+ });
32
+ }
33
+ function object(value) {
34
+ return value !== null && typeof value === "object" && !Array.isArray(value)
35
+ ? value
36
+ : undefined;
37
+ }
38
+ function stringArray(value) {
39
+ return (Array.isArray(value) && value.every((item) => typeof item === "string"));
40
+ }
41
+ function requiredResourceState(entry) {
42
+ if (entry.autoload !== false &&
43
+ entry.skills === undefined &&
44
+ entry.extensions === undefined) {
45
+ return "enabled";
46
+ }
47
+ if (entry.skills?.length === 0 && entry.extensions?.length === 0) {
48
+ return "disabled";
49
+ }
50
+ if (entry.autoload === false &&
51
+ entry.skills === undefined &&
52
+ entry.extensions === undefined) {
53
+ return "disabled";
54
+ }
55
+ return "indeterminate";
56
+ }
57
+ function parsePackage(value, index, path) {
58
+ if (typeof value === "string") {
59
+ if (value.trim().length === 0) {
60
+ invalid(path, `package ${index} source must be a nonempty string`);
61
+ }
62
+ return { source: value, resourceState: "enabled" };
63
+ }
64
+ const entry = object(value);
65
+ if (entry === undefined) {
66
+ invalid(path, `package ${index} must be a string or object`);
67
+ }
68
+ if (typeof entry.source !== "string" || entry.source.trim().length === 0) {
69
+ invalid(path, `package ${index} source must be a nonempty string`);
70
+ }
71
+ if (entry.autoload !== undefined && typeof entry.autoload !== "boolean") {
72
+ invalid(path, `package ${index} autoload must be a boolean`);
73
+ }
74
+ if (entry.skills !== undefined && !stringArray(entry.skills)) {
75
+ invalid(path, `package ${index} skills must be an array of strings`);
76
+ }
77
+ if (entry.extensions !== undefined && !stringArray(entry.extensions)) {
78
+ invalid(path, `package ${index} extensions must be an array of strings`);
79
+ }
80
+ return {
81
+ source: entry.source,
82
+ resourceState: requiredResourceState({
83
+ ...(entry.autoload === undefined ? {} : { autoload: entry.autoload }),
84
+ ...(entry.skills === undefined ? {} : { skills: entry.skills }),
85
+ ...(entry.extensions === undefined
86
+ ? {}
87
+ : { extensions: entry.extensions }),
88
+ }),
89
+ };
90
+ }
91
+ function isMissing(cause) {
92
+ return (typeof cause === "object" &&
93
+ cause !== null &&
94
+ "code" in cause &&
95
+ cause.code === "ENOENT");
96
+ }
97
+ export function resolvePiLocalSource(source, agentDir, homeDir) {
98
+ let normalized = source.trim();
99
+ if (normalized.length === 0 ||
100
+ REMOTE_SOURCE_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
101
+ return null;
102
+ }
103
+ if (normalized === "~" || normalized.startsWith("~/")) {
104
+ if (homeDir === undefined || homeDir.length === 0) {
105
+ throw new SafetyError("pi-settings", "cannot resolve Pi local source containing ~ without HOME");
106
+ }
107
+ normalized =
108
+ normalized === "~" ? homeDir : join(homeDir, normalized.slice(2));
109
+ }
110
+ else if (normalized.startsWith("file://")) {
111
+ try {
112
+ normalized = fileURLToPath(normalized);
113
+ }
114
+ catch (cause) {
115
+ throw new SafetyError("pi-settings", "cannot resolve Pi local source from invalid file URL", { cause });
116
+ }
117
+ }
118
+ return resolve(agentDir, normalized);
119
+ }
120
+ export async function resolveCanonicalPiLocalSource(source, agentDir, homeDir) {
121
+ const resolved = resolvePiLocalSource(source, agentDir, homeDir);
122
+ return resolved === null ? null : canonicalizeProspectivePath(resolved);
123
+ }
124
+ export async function readPiSettings(path, homeDir) {
125
+ let bytes;
126
+ try {
127
+ bytes = await readFile(path);
128
+ }
129
+ catch (cause) {
130
+ if (isMissing(cause))
131
+ return { packages: [] };
132
+ throw new SafetyError("pi-settings", `cannot read Pi settings ${path}`, {
133
+ cause,
134
+ });
135
+ }
136
+ let parsed;
137
+ try {
138
+ parsed = parseStrictJson(bytes, SETTINGS_PROFILE);
139
+ }
140
+ catch (cause) {
141
+ throw new SafetyError("pi-settings", `cannot parse Pi settings ${path}`, {
142
+ cause,
143
+ });
144
+ }
145
+ const settings = object(parsed);
146
+ if (settings === undefined)
147
+ invalid(path, "settings must be an object");
148
+ if (settings.skills !== undefined && !stringArray(settings.skills)) {
149
+ invalid(path, "skills must be an array of strings");
150
+ }
151
+ if (settings.packages !== undefined && !Array.isArray(settings.packages)) {
152
+ invalid(path, "packages must be an array");
153
+ }
154
+ const packages = (settings.packages ?? []).map((entry, index) => parsePackage(entry, index, path));
155
+ const agentDir = dirname(resolve(path));
156
+ const managerInstallation = await canonicalizeProspectivePath(resolve(agentDir, join("superpowers-manager", "installed")));
157
+ const managerRegistrations = [];
158
+ for (const entry of packages) {
159
+ if ((await resolveCanonicalPiLocalSource(entry.source, agentDir, homeDir)) ===
160
+ managerInstallation) {
161
+ managerRegistrations.push(entry);
162
+ }
163
+ }
164
+ if (managerRegistrations.length > 1) {
165
+ invalid(path, "duplicate Manager package registrations");
166
+ }
167
+ return {
168
+ packages,
169
+ ...(settings.skills === undefined ? {} : { skills: settings.skills }),
170
+ };
171
+ }