superpowers-manager 0.4.0 → 0.5.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 +62 -533
- package/dist/adapter.js +8 -4
- package/dist/artifact-tree.js +110 -0
- package/dist/atomic.js +103 -7
- package/dist/cli-arguments.js +37 -0
- package/dist/cli.js +48 -11
- package/dist/codex-compatibility.js +126 -0
- package/dist/codex-conflicts.js +99 -0
- package/dist/codex-harness.js +51 -1
- package/dist/codex-json.js +7 -0
- package/dist/codex-prepare.js +69 -13
- package/dist/codex-presentation.js +24 -1
- package/dist/commands/install.js +115 -16
- package/dist/commands/mutation.js +71 -0
- package/dist/commands/prepare.js +19 -13
- package/dist/commands/probe.js +160 -10
- package/dist/commands/uninstall.js +6 -2
- package/dist/commands/update.js +5 -1
- package/dist/generated-plugin.js +3 -3
- package/dist/harness-compatibility.js +13 -0
- package/dist/manifest-overlay.js +1 -1
- package/dist/pi-compatibility.js +73 -0
- package/dist/pi-harness.js +63 -0
- package/dist/pi-install.js +495 -0
- package/dist/pi-native.js +81 -0
- package/dist/pi-package.js +153 -0
- package/dist/pi-paths.js +30 -0
- package/dist/pi-prepare.js +111 -0
- package/dist/pi-presentation.js +67 -0
- package/dist/pi-settings.js +171 -0
- package/dist/pi-shared-skills.js +413 -0
- package/dist/pi-state.js +311 -0
- package/dist/resource-lock.js +265 -0
- package/dist/safe-path.js +21 -5
- package/dist/upstream-workspace.js +8 -0
- package/dist/validator.js +2 -1
- package/package.json +11 -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 "./pi-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
|
+
}
|
package/dist/pi-paths.js
ADDED
|
@@ -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 "./pi-paths.js";
|
|
6
|
+
import { assessPiCompatibility, samePiSource } from "./pi-compatibility.js";
|
|
7
|
+
import { digestPiTree, materializePiTree, readPiPackageAssessment, piReceiptBinding, } from "./pi-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
|
+
}
|