@huseynsnmz/pi-suite 0.2.1 → 0.2.3
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/CHANGELOG.md +23 -0
- package/README.md +8 -1
- package/package.json +6 -1
- package/packages/board/README.md +1 -1
- package/packages/commands/README.md +1 -1
- package/packages/commands/src/index.ts +22 -0
- package/packages/frame/README.md +8 -2
- package/packages/frame/docs/reference.md +1 -1
- package/packages/frame/src/index.ts +8 -3
- package/packages/frame/src/kit/settings/files.ts +8 -8
- package/packages/frame/src/kit/settings/store.ts +3 -1
- package/packages/frame/src/ui/editor.ts +64 -7
- package/packages/frame/src/ui/history-search.ts +21 -0
- package/packages/frame/src/ui/pi-entries.ts +2 -0
- package/packages/frame/src/ui/thinking.ts +34 -0
- package/packages/memory/README.md +1 -1
- package/packages/modes/README.md +6 -2
- package/packages/modes/src/index.ts +9 -7
- package/packages/modes/src/modes.ts +18 -1
- package/packages/router/README.md +29 -9
- package/packages/router/src/config.ts +28 -27
- package/packages/router/src/index.ts +61 -13
- package/packages/router/src/route.ts +16 -10
- package/packages/subscription/LICENSE +21 -0
- package/packages/subscription/README.md +60 -0
- package/packages/subscription/package.json +23 -0
- package/packages/subscription/src/anchors.ts +75 -0
- package/packages/subscription/src/billing.ts +39 -0
- package/packages/subscription/src/catalog.ts +310 -0
- package/packages/subscription/src/cc-sessions.ts +122 -0
- package/packages/subscription/src/config.ts +34 -0
- package/packages/subscription/src/convert.ts +161 -0
- package/packages/subscription/src/index.ts +69 -0
- package/packages/subscription/src/mcp.ts +78 -0
- package/packages/subscription/src/models.ts +65 -0
- package/packages/subscription/src/prompt.ts +57 -0
- package/packages/subscription/src/provider.ts +617 -0
- package/packages/subscription/src/session.ts +294 -0
- package/packages/subscription/src/util.ts +73 -0
- package/packages/web/README.md +5 -5
- package/packages/web/src/config.ts +13 -23
- package/packages/web/src/index.ts +11 -7
- package/packages/web/src/reader.ts +15 -4
- package/packages/web/src/validate.ts +2 -2
- package/src/index.ts +92 -10
- package/src/settings-ui.ts +63 -0
- package/src/settings.ts +116 -15
package/src/index.ts
CHANGED
|
@@ -5,7 +5,8 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
|
|
6
6
|
import { type ExtensionAPI, type ExtensionCommandContext, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { checkSection, type Field, FIELDS, shownValue, typedValue } from "./settings-ui.ts";
|
|
9
|
+
import { isPart, loadSettings, moveOldSettings, type Part, PART_NAMES, PARTS, readSection, saveSettings, separateInstalls, writeSection } from "./settings.ts";
|
|
9
10
|
|
|
10
11
|
function loadPart(part: Part): Promise<{ default: (pi: ExtensionAPI) => void | Promise<void> }> {
|
|
11
12
|
return import(new URL(`../packages/${part}/src/index.ts`, import.meta.url).href);
|
|
@@ -20,8 +21,9 @@ const ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
|
20
21
|
|
|
21
22
|
export default async function piSuite(pi: ExtensionAPI): Promise<void> {
|
|
22
23
|
const agentDir = getAgentDir();
|
|
24
|
+
const moveProblems = moveOldSettings(agentDir);
|
|
23
25
|
const settings = loadSettings(agentDir);
|
|
24
|
-
const notices: string[] = settings.problem === undefined ? [] : [`pi-suite: ${settings.problem}`];
|
|
26
|
+
const notices: string[] = [...moveProblems, ...(settings.problem === undefined ? [] : [`pi-suite: ${settings.problem}`])];
|
|
25
27
|
const loaded = new Set<Part>();
|
|
26
28
|
const failed = new Set<Part>();
|
|
27
29
|
if (settings.off.has("modes")) notices.push(`pi-suite: modes is off, so ${MODES_OFF}`);
|
|
@@ -77,20 +79,26 @@ export default async function piSuite(pi: ExtensionAPI): Promise<void> {
|
|
|
77
79
|
}
|
|
78
80
|
if (on) off.delete(part);
|
|
79
81
|
else off.add(part);
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
try {
|
|
83
|
+
saveSettings(agentDir, off);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
ctx.ui.notify(`Nothing changed: ${(error as Error).message}`, "error");
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
if (!on && part === "router") await leaveModels(ctx, "pi-virtual");
|
|
89
|
+
if (!on && part === "subscription") await leaveModels(ctx, "claude-agent-sdk");
|
|
82
90
|
return true;
|
|
83
91
|
}
|
|
84
92
|
|
|
85
93
|
/** The session would otherwise stay on a model that's gone after the reload, and every prompt would fail. */
|
|
86
|
-
async function
|
|
87
|
-
if (ctx.model?.api !==
|
|
94
|
+
async function leaveModels(ctx: ExtensionCommandContext, api: string): Promise<void> {
|
|
95
|
+
if (ctx.model?.api !== api) return;
|
|
88
96
|
const lastAnswer = ctx.sessionManager
|
|
89
97
|
.getBranch()
|
|
90
98
|
.flatMap((entry) => (entry.type === "message" && entry.message.role === "assistant" && entry.message.stopReason !== "error" && entry.message.stopReason !== "aborted" ? [entry.message] : []))
|
|
91
99
|
.at(-1);
|
|
92
100
|
const answered = lastAnswer && ctx.modelRegistry.find(lastAnswer.provider, lastAnswer.model);
|
|
93
|
-
const next = answered ?? ctx.modelRegistry.getAvailable().find((model) => model.api !== "pi-virtual");
|
|
101
|
+
const next = (answered?.api === api ? undefined : answered) ?? ctx.modelRegistry.getAvailable().find((model) => model.api !== "pi-virtual" && model.api !== api);
|
|
94
102
|
if (next === undefined) return;
|
|
95
103
|
await pi.setModel(next);
|
|
96
104
|
if (ctx.hasUI) ctx.ui.notify(`Now on ${next.provider}/${next.id}.`, "info");
|
|
@@ -102,6 +110,80 @@ export default async function piSuite(pi: ExtensionAPI): Promise<void> {
|
|
|
102
110
|
else if (ctx.hasUI) ctx.ui.notify("Saved. Run /reload to apply it.", "info");
|
|
103
111
|
}
|
|
104
112
|
|
|
113
|
+
/** Asks for a new value. True if one was saved. */
|
|
114
|
+
async function editField(part: Part, field: Field, ctx: ExtensionCommandContext): Promise<boolean> {
|
|
115
|
+
const section = readSection(agentDir, part);
|
|
116
|
+
const current = section[field.key];
|
|
117
|
+
let value: unknown;
|
|
118
|
+
const prompt = `${part}: ${field.label}`;
|
|
119
|
+
if (field.kind === "boolean" || field.kind === "choice" || field.kind === "model") {
|
|
120
|
+
const options =
|
|
121
|
+
field.kind === "boolean"
|
|
122
|
+
? ["on", "off"]
|
|
123
|
+
: field.kind === "choice"
|
|
124
|
+
? [...field.options, "default"]
|
|
125
|
+
: ["default", ...ctx.modelRegistry.getAvailable().filter((model) => model.api !== "pi-virtual").map((model) => `${model.provider}/${model.id}`)];
|
|
126
|
+
const picked = await ctx.ui.select(prompt, options);
|
|
127
|
+
if (picked === undefined) return false;
|
|
128
|
+
value = field.kind === "boolean" ? (picked === "on" ? true : undefined) : picked === "default" ? undefined : picked;
|
|
129
|
+
} else {
|
|
130
|
+
const shown = Array.isArray(current) ? current.join(", ") : current === undefined ? "" : String(current);
|
|
131
|
+
const typed = await ctx.ui.input(`${prompt}${field.kind === "list" ? " (separate with commas, empty clears)" : " (empty clears)"}`, shown);
|
|
132
|
+
if (typed === undefined) return false;
|
|
133
|
+
value = typedValue(field, typed);
|
|
134
|
+
}
|
|
135
|
+
const next = { ...section };
|
|
136
|
+
if (value === undefined) delete next[field.key];
|
|
137
|
+
else next[field.key] = value;
|
|
138
|
+
checkSection(part, next);
|
|
139
|
+
writeSection(agentDir, part, next);
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** One part: on or off, then its settings. True if anything was saved. */
|
|
144
|
+
async function partMenu(part: Part, ctx: ExtensionCommandContext): Promise<boolean> {
|
|
145
|
+
let changed = false;
|
|
146
|
+
const fields = FIELDS[part] ?? [];
|
|
147
|
+
for (;;) {
|
|
148
|
+
let section: Record<string, unknown> = {};
|
|
149
|
+
try {
|
|
150
|
+
section = readSection(agentDir, part);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
ctx.ui.notify((error as Error).message, "error");
|
|
153
|
+
}
|
|
154
|
+
const off = currentOff();
|
|
155
|
+
const toggle = off.has(part) ? "Turned off. Enter turns it on" : "Turned on. Enter turns it off";
|
|
156
|
+
const extra = part === "router" ? ["Models and reasoning"] : part === "frame" ? ["Icons, frame style and more"] : [];
|
|
157
|
+
const rows = [toggle, ...extra, ...fields.map((field) => `${field.label}: ${shownValue(part, field, section)}`)];
|
|
158
|
+
const row = await ctx.ui.select(`${part}: ${PARTS[part]} (Esc to go back)`, rows);
|
|
159
|
+
if (row === undefined) return changed;
|
|
160
|
+
const index = rows.indexOf(row);
|
|
161
|
+
if (index === 0) {
|
|
162
|
+
if (await setPart(part, off.has(part), ctx)) changed = true;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (extra.includes(row)) {
|
|
166
|
+
if (part === "frame") {
|
|
167
|
+
ctx.ui.notify("Press Ctrl+S in the editor for the frame's settings, or use /harness with the frame off.", "info");
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const probe: { open?: (ctx: ExtensionCommandContext) => Promise<void> } = {};
|
|
171
|
+
pi.events.emit("pi-suite:router-settings", probe);
|
|
172
|
+
if (probe.open === undefined) ctx.ui.notify("Turn router on and reload first.", "warning");
|
|
173
|
+
else {
|
|
174
|
+
await probe.open(ctx);
|
|
175
|
+
}
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const field = fields[index - 1 - extra.length] as Field;
|
|
179
|
+
try {
|
|
180
|
+
if (await editField(part, field, ctx)) changed = true;
|
|
181
|
+
} catch (error) {
|
|
182
|
+
ctx.ui.notify(`Not saved: ${(error as Error).message}`, "error");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
105
187
|
function status(): string {
|
|
106
188
|
const off = currentOff();
|
|
107
189
|
return PART_NAMES.map((part) => {
|
|
@@ -114,7 +196,7 @@ export default async function piSuite(pi: ExtensionAPI): Promise<void> {
|
|
|
114
196
|
}
|
|
115
197
|
|
|
116
198
|
pi.registerCommand("suite", {
|
|
117
|
-
description: "
|
|
199
|
+
description: "Settings for pi-suite, and turning its parts on or off: /suite, /suite on web, /suite off board, /suite list",
|
|
118
200
|
getArgumentCompletions: (prefix) => {
|
|
119
201
|
const typed = prefix.trimStart().toLowerCase();
|
|
120
202
|
const options = ["list", ...PART_NAMES.flatMap((part) => [`on ${part}`, `off ${part}`])];
|
|
@@ -145,10 +227,10 @@ export default async function piSuite(pi: ExtensionAPI): Promise<void> {
|
|
|
145
227
|
for (;;) {
|
|
146
228
|
const off = currentOff();
|
|
147
229
|
const rows = PART_NAMES.map((part) => `${off.has(part) ? "[ ]" : "[x]"} ${part} ${PARTS[part]}`);
|
|
148
|
-
const row = await ctx.ui.select("pi-suite:
|
|
230
|
+
const row = await ctx.ui.select("pi-suite: pick a part (Esc when done)", rows);
|
|
149
231
|
if (row === undefined) break;
|
|
150
232
|
const part = PART_NAMES[rows.indexOf(row)] as Part;
|
|
151
|
-
if (await
|
|
233
|
+
if (await partMenu(part, ctx)) changed = true;
|
|
152
234
|
}
|
|
153
235
|
await finish(changed, ctx);
|
|
154
236
|
},
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// What /suite can change in each part's section of suite.json. Each part still checks its own values when it loads.
|
|
2
|
+
import { parseConfig as parseWeb } from "../packages/web/src/config.ts";
|
|
3
|
+
import type { Part } from "./settings.ts";
|
|
4
|
+
|
|
5
|
+
export type Field =
|
|
6
|
+
| { key: string; label: string; kind: "boolean" | "text" | "list" | "model" }
|
|
7
|
+
| { key: string; label: string; kind: "choice"; options: readonly string[] }
|
|
8
|
+
| { key: string; label: string; kind: "number"; min: number; max: number };
|
|
9
|
+
|
|
10
|
+
const MODES_ALLOWED = ["normal", "auto", "plan", "read-only"];
|
|
11
|
+
/** Yolo has to be asked for each time, so it can't be a default. */
|
|
12
|
+
export const FIELDS: Partial<Record<Part, readonly Field[]>> = {
|
|
13
|
+
modes: [{ key: "defaultMode", label: "Mode for new sessions", kind: "choice", options: MODES_ALLOWED }],
|
|
14
|
+
web: [
|
|
15
|
+
{ key: "companySearch", label: "Allow searching through a company's service", kind: "boolean" },
|
|
16
|
+
{ key: "provider", label: "Search with", kind: "choice", options: ["searxng", "markdown.new"] },
|
|
17
|
+
{ key: "searxngUrl", label: "Your SearXNG server", kind: "text" },
|
|
18
|
+
{ key: "readerModel", label: "Model that reads pages", kind: "model" },
|
|
19
|
+
{ key: "allowDomains", label: "Only fetch from these sites", kind: "list" },
|
|
20
|
+
{ key: "denyDomains", label: "Never fetch from these sites", kind: "list" },
|
|
21
|
+
{ key: "trustedDomains", label: "Sites it can write its own addresses on", kind: "list" },
|
|
22
|
+
{ key: "privateWords", label: "Words a search can never hold", kind: "list" },
|
|
23
|
+
{ key: "askNewDomains", label: "Ask before the first fetch from each site", kind: "boolean" },
|
|
24
|
+
{ key: "strictUrls", label: "Ask about every address nobody gave it", kind: "boolean" },
|
|
25
|
+
{ key: "minConfidence", label: "Withhold answers the reader scores below", kind: "number", min: 1, max: 10 },
|
|
26
|
+
{ key: "dnsFilter", label: "Block sites using a DNS filter", kind: "choice", options: ["cloudflare-family", "cloudflare-security", "off"] },
|
|
27
|
+
],
|
|
28
|
+
subscription: [
|
|
29
|
+
{ key: "promptMode", label: "System prompt", kind: "choice", options: ["claude", "pi"] },
|
|
30
|
+
{ key: "pathToClaudeCodeExecutable", label: "Claude Code program", kind: "text" },
|
|
31
|
+
{ key: "maxSessions", label: "Claude Code processes to keep", kind: "number", min: 1, max: 16 },
|
|
32
|
+
{ key: "idleMinutes", label: "Close an idle process after, in minutes", kind: "number", min: 1, max: 1440 },
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** The value /suite shows when nothing's been chosen. */
|
|
37
|
+
const UNSET_WEB: Readonly<Record<string, string>> = { minConfidence: "4", dnsFilter: "cloudflare-family", provider: "automatic" };
|
|
38
|
+
|
|
39
|
+
export function shownValue(part: Part, field: Field, section: Record<string, unknown>): string {
|
|
40
|
+
const value = section[field.key];
|
|
41
|
+
if (value === undefined) return part === "web" ? (UNSET_WEB[field.key] ?? (field.kind === "boolean" ? "off" : "not set")) : field.kind === "boolean" ? "off" : "default";
|
|
42
|
+
if (typeof value === "boolean") return value ? "on" : "off";
|
|
43
|
+
if (Array.isArray(value)) return value.length === 0 ? "not set" : value.join(", ");
|
|
44
|
+
return String(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What was typed, as the value to save. Undefined clears the setting. Throws if it can't be used. */
|
|
48
|
+
export function typedValue(field: Field, typed: string): unknown {
|
|
49
|
+
const text = typed.trim();
|
|
50
|
+
if (text === "") return undefined;
|
|
51
|
+
if (field.kind === "list") return text.split(",").map((item) => item.trim()).filter(Boolean);
|
|
52
|
+
if (field.kind === "number") {
|
|
53
|
+
const number = Number(text);
|
|
54
|
+
if (!Number.isInteger(number) || number < field.min || number > field.max) throw new Error(`${field.label} must be a whole number from ${field.min} to ${field.max}`);
|
|
55
|
+
return number;
|
|
56
|
+
}
|
|
57
|
+
return text;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Throws rather than save what the part would refuse when it loads. */
|
|
61
|
+
export function checkSection(part: Part, section: Record<string, unknown>): void {
|
|
62
|
+
if (part === "web") parseWeb(section);
|
|
63
|
+
}
|
package/src/settings.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// ~/.pi/agent/suite.json: which parts are off
|
|
1
|
+
// ~/.pi/agent/suite.json: which parts are off, and each part's settings under its own name.
|
|
2
|
+
// Global only: a repository must not choose where your code or searches go, or which program runs.
|
|
2
3
|
import { existsSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
|
|
@@ -10,6 +11,7 @@ export const PARTS = {
|
|
|
10
11
|
frame: "The start card, framed editor, tool boxes, footer and todo list",
|
|
11
12
|
board: "The board of agent sessions",
|
|
12
13
|
commands: "/clear and /security-review",
|
|
14
|
+
subscription: "Claude models on your Claude login, asking before anything is billed as extra usage",
|
|
13
15
|
} as const;
|
|
14
16
|
|
|
15
17
|
export type Part = keyof typeof PARTS;
|
|
@@ -20,33 +22,44 @@ export function settingsPath(agentDir: string): string {
|
|
|
20
22
|
return join(agentDir, "suite.json");
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
/** Off until asked for: it adds models that count as logged in, and its README says what using them risks. */
|
|
26
|
+
const OFF_BY_DEFAULT: readonly Part[] = ["subscription"];
|
|
27
|
+
|
|
23
28
|
export function isPart(name: string): name is Part {
|
|
24
29
|
return (PART_NAMES as string[]).includes(name);
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
export interface Loaded {
|
|
28
33
|
off: ReadonlySet<Part>;
|
|
29
|
-
/**
|
|
34
|
+
/** Every part keeps its default when the file can't be understood, so a typo can't turn off plan mode. */
|
|
30
35
|
problem?: string;
|
|
31
36
|
}
|
|
32
37
|
|
|
38
|
+
function defaults(problem?: string): Loaded {
|
|
39
|
+
return { off: new Set(OFF_BY_DEFAULT), ...(problem === undefined ? {} : { problem }) };
|
|
40
|
+
}
|
|
41
|
+
|
|
33
42
|
export function parseSettings(text: string): Loaded {
|
|
34
43
|
let raw: unknown;
|
|
35
44
|
try {
|
|
36
45
|
raw = JSON.parse(text);
|
|
37
46
|
} catch {
|
|
38
|
-
return
|
|
47
|
+
return defaults("suite.json isn't valid JSON, so every part is at its default");
|
|
39
48
|
}
|
|
40
|
-
const off = (raw as { off?: unknown
|
|
41
|
-
if (raw === null || typeof raw !== "object" || Array.isArray(raw) || (off !== undefined && !Array.isArray(off))) {
|
|
42
|
-
return
|
|
49
|
+
const { off, on } = (raw ?? {}) as { off?: unknown; on?: unknown };
|
|
50
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw) || (off !== undefined && !Array.isArray(off)) || (on !== undefined && !Array.isArray(on))) {
|
|
51
|
+
return defaults('suite.json should look like { "off": ["board"], "on": ["subscription"] }, so every part is at its default');
|
|
43
52
|
}
|
|
44
|
-
const
|
|
45
|
-
const
|
|
53
|
+
const offNames = (off ?? []) as unknown[];
|
|
54
|
+
const onNames = (on ?? []) as unknown[];
|
|
55
|
+
const unknown = [...offNames, ...onNames].filter((name) => typeof name !== "string" || !isPart(name));
|
|
46
56
|
if (unknown.length > 0) {
|
|
47
|
-
return
|
|
57
|
+
return defaults(`suite.json names ${unknown.join(", ")}, which isn't one of ${PART_NAMES.join(", ")}, so every part is at its default`);
|
|
48
58
|
}
|
|
49
|
-
|
|
59
|
+
// Saying off wins, in case a part is in both.
|
|
60
|
+
const result = new Set<Part>(OFF_BY_DEFAULT.filter((name) => !onNames.includes(name)));
|
|
61
|
+
for (const name of offNames as Part[]) result.add(name);
|
|
62
|
+
return { off: result };
|
|
50
63
|
}
|
|
51
64
|
|
|
52
65
|
export function loadSettings(agentDir: string): Loaded {
|
|
@@ -54,21 +67,109 @@ export function loadSettings(agentDir: string): Loaded {
|
|
|
54
67
|
try {
|
|
55
68
|
text = readFileSync(settingsPath(agentDir), "utf8");
|
|
56
69
|
} catch (error) {
|
|
57
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return
|
|
58
|
-
return
|
|
70
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return defaults();
|
|
71
|
+
return defaults(`suite.json can't be read, so every part is at its default: ${(error as Error).message}`);
|
|
59
72
|
}
|
|
60
73
|
return parseSettings(text);
|
|
61
74
|
}
|
|
62
75
|
|
|
63
76
|
export function saveSettings(agentDir: string, off: ReadonlySet<Part>): void {
|
|
64
|
-
|
|
77
|
+
const turnedOn = OFF_BY_DEFAULT.filter((name) => !off.has(name));
|
|
78
|
+
const { on: _on, ...raw } = readAll(agentDir);
|
|
79
|
+
writeAll(agentDir, { ...raw, off: PART_NAMES.filter((name) => off.has(name) && !OFF_BY_DEFAULT.includes(name)), ...(turnedOn.length > 0 ? { on: turnedOn } : {}) });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type Raw = Record<string, unknown>;
|
|
83
|
+
|
|
84
|
+
const isObject = (value: unknown): value is Raw => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
85
|
+
|
|
86
|
+
/** Throws on a file it can't understand, so a broken one can't quietly mean "no allow list". */
|
|
87
|
+
function readAll(agentDir: string): Raw {
|
|
88
|
+
let text: string;
|
|
89
|
+
try {
|
|
90
|
+
text = readFileSync(settingsPath(agentDir), "utf8");
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
let raw: unknown;
|
|
96
|
+
try {
|
|
97
|
+
raw = JSON.parse(text);
|
|
98
|
+
} catch {
|
|
99
|
+
throw new Error("suite.json isn't valid JSON");
|
|
100
|
+
}
|
|
101
|
+
if (!isObject(raw)) throw new Error("suite.json must hold a JSON object");
|
|
102
|
+
return raw;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Only you can read it, as it can hold private words. A linked file, as in a dotfiles setup, stays linked. */
|
|
106
|
+
function writeAll(agentDir: string, raw: Raw): void {
|
|
65
107
|
const named = settingsPath(agentDir);
|
|
66
108
|
const path = existsSync(named) ? realpathSync(named) : named;
|
|
67
|
-
const temp = `${path}.${process.pid}.tmp`;
|
|
68
|
-
writeFileSync(temp, `${JSON.stringify(
|
|
109
|
+
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
110
|
+
writeFileSync(temp, `${JSON.stringify(raw, null, 2)}\n`, { mode: 0o600 });
|
|
69
111
|
renameSync(temp, path);
|
|
70
112
|
}
|
|
71
113
|
|
|
114
|
+
/** Where each part kept its settings before they moved here. */
|
|
115
|
+
const OLD_FILES: Readonly<Record<string, string>> = {
|
|
116
|
+
router: "router.json",
|
|
117
|
+
web: "web.json",
|
|
118
|
+
subscription: "claude-sdk.json",
|
|
119
|
+
modes: join("extensions", "pi-modes", "config.json"),
|
|
120
|
+
frame: join("extensions", "pi-harness", "settings.json"),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** Moves one old file in, keeping it as .bak. What's already here wins. A broken file stays put and throws. */
|
|
124
|
+
function moveOldFile(agentDir: string, section: string): void {
|
|
125
|
+
const name = OLD_FILES[section];
|
|
126
|
+
if (name === undefined) return;
|
|
127
|
+
const path = join(agentDir, name);
|
|
128
|
+
if (!existsSync(path)) return;
|
|
129
|
+
let old: unknown;
|
|
130
|
+
try {
|
|
131
|
+
old = JSON.parse(readFileSync(path, "utf8"));
|
|
132
|
+
} catch (error) {
|
|
133
|
+
throw new Error(`${path} can't be read, so it wasn't moved to suite.json. Fix or delete it: ${(error as Error).message}`);
|
|
134
|
+
}
|
|
135
|
+
if (!isObject(old)) throw new Error(`${path} must hold a JSON object, so it wasn't moved to suite.json. Fix or delete it`);
|
|
136
|
+
const raw = readAll(agentDir);
|
|
137
|
+
// The frame's file already has its settings under their own names.
|
|
138
|
+
const moved = section === "frame" ? old : { [section]: old };
|
|
139
|
+
writeAll(agentDir, { ...moved, ...raw });
|
|
140
|
+
renameSync(path, `${path}.bak`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Before any part loads. A file that can't move is reported here and refused again by its part. */
|
|
144
|
+
export function moveOldSettings(agentDir: string): string[] {
|
|
145
|
+
const problems: string[] = [];
|
|
146
|
+
for (const section of Object.keys(OLD_FILES)) {
|
|
147
|
+
try {
|
|
148
|
+
moveOldFile(agentDir, section);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
problems.push(`pi-suite: ${(error as Error).message}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return problems;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** One part's settings. Throws if they can't be understood, for the part to refuse or report. */
|
|
157
|
+
export function readSection(agentDir: string, section: string): Raw {
|
|
158
|
+
moveOldFile(agentDir, section);
|
|
159
|
+
const value = readAll(agentDir)[section];
|
|
160
|
+
if (value === undefined) return {};
|
|
161
|
+
if (!isObject(value)) throw new Error(`${section} in suite.json must be an object`);
|
|
162
|
+
return value;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Reads the file again first, as another session may have changed another part. */
|
|
166
|
+
export function writeSection(agentDir: string, section: string, value: Raw): void {
|
|
167
|
+
const raw = readAll(agentDir);
|
|
168
|
+
if (Object.keys(value).length === 0) delete raw[section];
|
|
169
|
+
else raw[section] = value;
|
|
170
|
+
writeAll(agentDir, raw);
|
|
171
|
+
}
|
|
172
|
+
|
|
72
173
|
/** Separate installs of what's now in here would register everything twice. */
|
|
73
174
|
export function separateInstalls(packages: unknown): string[] {
|
|
74
175
|
if (!Array.isArray(packages)) return [];
|