pi-lean-dimension 0.3.3 → 0.4.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 +17 -24
- package/node_modules/pi-lean-portal/README.md +20 -45
- package/node_modules/pi-lean-portal/browser-toggle.ts +10 -101
- package/node_modules/pi-lean-portal/package.json +2 -2
- package/node_modules/pi-lean-search/__tests__/web-search.test.ts +2 -5
- package/node_modules/pi-lean-search/index.ts +1 -11
- package/node_modules/pi-lean-search/package.json +2 -2
- package/node_modules/pi-tool-masking/README.md +69 -10
- package/node_modules/pi-tool-masking/index.ts +730 -39
- package/node_modules/pi-tool-masking/package.json +1 -2
- package/package.json +3 -3
- package/node_modules/pi-lean-portal/__tests__/browser-toggle-migration.test.ts +0 -145
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
1
4
|
import type {
|
|
2
5
|
ExtensionAPI,
|
|
3
6
|
ExtensionContext,
|
|
7
|
+
SessionEntry,
|
|
4
8
|
} from "@earendil-works/pi-coding-agent";
|
|
5
9
|
|
|
6
10
|
// ---------------------------------------------------------------------------
|
|
7
|
-
//
|
|
11
|
+
// Public API — types
|
|
8
12
|
// ---------------------------------------------------------------------------
|
|
9
13
|
|
|
10
14
|
export interface ToolsetSpec {
|
|
@@ -40,10 +44,20 @@ export interface ToolsetChangedEvent {
|
|
|
40
44
|
member?: string;
|
|
41
45
|
}
|
|
42
46
|
|
|
43
|
-
|
|
47
|
+
/**
|
|
48
|
+
* How toolsets with no persisted branch entry resolve on restore.
|
|
49
|
+
*
|
|
50
|
+
* @deprecated The `"inclusion"` member is deprecated since 1.2.0 — use
|
|
51
|
+
* `"allowlist"` for focus-style suppression (a finite, branch-persisted set
|
|
52
|
+
* whose complement is computed at restore), or `"exclusion"` for the
|
|
53
|
+
* default-on floor. `"inclusion"` still works (with a one-time runtime
|
|
54
|
+
* warning) through the deprecation window; it is scheduled for removal in a
|
|
55
|
+
* near-term 1.x minor.
|
|
56
|
+
*/
|
|
57
|
+
export type DefaultResolutionMode = "exclusion" | "inclusion" | "allowlist";
|
|
44
58
|
|
|
45
59
|
// ---------------------------------------------------------------------------
|
|
46
|
-
//
|
|
60
|
+
// Change notification — event names
|
|
47
61
|
// ---------------------------------------------------------------------------
|
|
48
62
|
|
|
49
63
|
export const TOOLSET_EVENTS = {
|
|
@@ -52,7 +66,7 @@ export const TOOLSET_EVENTS = {
|
|
|
52
66
|
} as const;
|
|
53
67
|
|
|
54
68
|
// ---------------------------------------------------------------------------
|
|
55
|
-
// Registry on globalThis
|
|
69
|
+
// Registry on globalThis
|
|
56
70
|
// ---------------------------------------------------------------------------
|
|
57
71
|
|
|
58
72
|
const REGISTRY_KEY = "__piToolMaskingRegistry";
|
|
@@ -81,9 +95,35 @@ function getRegistry(): Registry {
|
|
|
81
95
|
|
|
82
96
|
const MODULE_KEY = "__piToolMaskingModuleState";
|
|
83
97
|
const MODE_PERSIST_KEY = "toolset-resolution-mode";
|
|
98
|
+
const DEPRECATION_WARNED_KEY = "__piToolMaskingDeprecationWarned";
|
|
99
|
+
|
|
100
|
+
const INCLUSION_DEPRECATED_MESSAGE =
|
|
101
|
+
'[pi-tool-masking] "inclusion" resolution mode is deprecated since 1.2.0 ' +
|
|
102
|
+
'and will be removed in a coming 1.x minor; use "allowlist" for focus suppression.';
|
|
103
|
+
|
|
104
|
+
// Once-per-process-per-site dedup for the inclusion deprecation warning.
|
|
105
|
+
// Keyed by trigger site (`"setDefaultResolutionMode"` / `"doRestore"`), not
|
|
106
|
+
// per call — "you're on the deprecated path" needs saying once per entry
|
|
107
|
+
// point. Lives on globalThis (like the registry and module state) so a
|
|
108
|
+
// /reload, which re-evals modules in the same process, does not re-warn.
|
|
109
|
+
function warnInclusionDeprecation(
|
|
110
|
+
site: "setDefaultResolutionMode" | "doRestore",
|
|
111
|
+
): void {
|
|
112
|
+
if (!(DEPRECATION_WARNED_KEY in globalThis)) {
|
|
113
|
+
(globalThis as any)[DEPRECATION_WARNED_KEY] = new Set<string>();
|
|
114
|
+
}
|
|
115
|
+
const warned = (globalThis as any)[DEPRECATION_WARNED_KEY] as Set<string>;
|
|
116
|
+
if (warned.has(site)) return;
|
|
117
|
+
warned.add(site);
|
|
118
|
+
console.warn(INCLUSION_DEPRECATED_MESSAGE);
|
|
119
|
+
}
|
|
84
120
|
|
|
85
121
|
interface ModuleState {
|
|
86
122
|
defaultResolutionMode: DefaultResolutionMode;
|
|
123
|
+
// explicit `| undefined`: exactOptionalPropertyTypes forbids assigning
|
|
124
|
+
// `undefined` to a bare optional property (TS2412), and both doRestore and
|
|
125
|
+
// setDefaultResolutionMode assign undefined for non-allowlist modes.
|
|
126
|
+
activeAllowlist?: string[] | undefined;
|
|
87
127
|
}
|
|
88
128
|
|
|
89
129
|
function getModuleState(): ModuleState {
|
|
@@ -96,7 +136,7 @@ function getModuleState(): ModuleState {
|
|
|
96
136
|
}
|
|
97
137
|
|
|
98
138
|
// ---------------------------------------------------------------------------
|
|
99
|
-
// deepEqual for spec comparison (
|
|
139
|
+
// deepEqual for spec comparison (idempotent re-registration)
|
|
100
140
|
// ---------------------------------------------------------------------------
|
|
101
141
|
|
|
102
142
|
function deepEqual(a: unknown, b: unknown): boolean {
|
|
@@ -132,7 +172,7 @@ function deepEqual(a: unknown, b: unknown): boolean {
|
|
|
132
172
|
// ---------------------------------------------------------------------------
|
|
133
173
|
|
|
134
174
|
function ensureRestoreHandler(pi: ExtensionAPI): void {
|
|
135
|
-
// Dedup by event-object identity
|
|
175
|
+
// Dedup by event-object identity. The runner passes the same event
|
|
136
176
|
// reference to every extension's handler in one emit() call, so the first
|
|
137
177
|
// handler wins and the rest skip. Each /reload constructs a fresh event
|
|
138
178
|
// object, so restore re-runs with the fresh pi.
|
|
@@ -142,57 +182,179 @@ function ensureRestoreHandler(pi: ExtensionAPI): void {
|
|
|
142
182
|
|
|
143
183
|
const registry = getRegistry();
|
|
144
184
|
|
|
145
|
-
// Re-read durable resolution mode before per-toolset fallback
|
|
185
|
+
// Re-read durable resolution mode before per-toolset fallback.
|
|
146
186
|
// setDefaultResolutionMode persists this bit; a fresh process defaults
|
|
147
187
|
// to "exclusion" until the persisted entry is replayed here. Mode
|
|
148
188
|
// entries are from a prior session (not written during this restore), so
|
|
149
|
-
// a single read here is sufficient.
|
|
189
|
+
// a single read here is sufficient. Null-tombstone-aware: read the LAST
|
|
190
|
+
// mode entry regardless of `data` (a `null` tombstone is the most recent
|
|
191
|
+
// mode fact and must beat a stale prior entry, falling through to
|
|
192
|
+
// "exclusion"); there is no settings fallback for mode — no mode
|
|
193
|
+
// settings tier exists, so mode resolution is `branchMode ?? "exclusion"`.
|
|
150
194
|
const modeEntries = ctx.sessionManager
|
|
151
195
|
.getBranch()
|
|
152
|
-
.filter((b: any) => b.customType === MODE_PERSIST_KEY
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
196
|
+
.filter((b: any) => b.customType === MODE_PERSIST_KEY);
|
|
197
|
+
const lastModeEntry = modeEntries[modeEntries.length - 1] as any;
|
|
198
|
+
const branchMode = lastModeEntry?.data?.mode;
|
|
199
|
+
const branchAllowlist = lastModeEntry?.data?.allowlist;
|
|
200
|
+
// Fail closed: a branch entry claiming "allowlist" with no usable array
|
|
201
|
+
// is corruption (write-time validation prevents it, but branch files are
|
|
202
|
+
// hand-editable). Recover to an EMPTY allowlist, not to "exclusion":
|
|
203
|
+
// (1) Respect the branch's mode claim. mode="allowlist" is a state
|
|
204
|
+
// someone chose; silently rewriting it to "exclusion" (which
|
|
205
|
+
// means "everything on" under default fallback) is a mode change
|
|
206
|
+
// nobody made and fails OPEN — the wrong default for a masking
|
|
207
|
+
// library. Empty allowlist = "nothing is on", the safe recovery.
|
|
208
|
+
// (2) Keep mode + array consistent. mode=allowlist with
|
|
209
|
+
// activeAllowlist=undefined is contradictory
|
|
210
|
+
// (getDefaultResolutionMode() === "allowlist" while
|
|
211
|
+
// getActiveAllowlist() === undefined). mode=allowlist with
|
|
212
|
+
// activeAllowlist=[] is consistent and means "no member is allowed
|
|
213
|
+
// on" — the same semantic as a populated allowlist whose members
|
|
214
|
+
// are all unregistered.
|
|
215
|
+
// Asymmetric with `setDefaultResolutionMode`: write-time rejects an
|
|
216
|
+
// empty array as a likely mistake (e.g. deleting the last member and
|
|
217
|
+
// forgetting to switch modes); restore-time recovers a missing/non-array
|
|
218
|
+
// to [] as the safe recovery. Write-time validates intent; restore-time
|
|
219
|
+
// picks the safe recovery.
|
|
220
|
+
const allowArr = Array.isArray(branchAllowlist) ? branchAllowlist : [];
|
|
221
|
+
const mode: DefaultResolutionMode =
|
|
222
|
+
branchMode === "inclusion" || branchMode === "exclusion"
|
|
223
|
+
? branchMode
|
|
224
|
+
: branchMode === "allowlist"
|
|
225
|
+
? "allowlist"
|
|
226
|
+
: "exclusion";
|
|
227
|
+
const ms = getModuleState();
|
|
228
|
+
ms.defaultResolutionMode = mode;
|
|
229
|
+
// Deprecation: resolving a branch mode entry to "inclusion" is the
|
|
230
|
+
// deprecated path (fires on /reload of a session that last set
|
|
231
|
+
// inclusion). warnInclusionDeprecation dedups once per process per
|
|
232
|
+
// trigger site.
|
|
233
|
+
if (mode === "inclusion") warnInclusionDeprecation("doRestore");
|
|
234
|
+
// Mirror the allowlist into module state so the parameterless
|
|
235
|
+
// `getActiveAllowlist()` can read it — branch is the source of truth,
|
|
236
|
+
// module state is the live mirror (same pattern as
|
|
237
|
+
// `defaultResolutionMode`). `setDefaultResolutionMode` writes this same
|
|
238
|
+
// field when it appends the mode entry. Non-allowlist modes → undefined.
|
|
239
|
+
ms.activeAllowlist = mode === "allowlist" ? allowArr : undefined;
|
|
240
|
+
|
|
241
|
+
// Allowlist short-circuit: the allowlist is a finite array of
|
|
242
|
+
// toolset ids stored in the branch mode entry; the suppression (the
|
|
243
|
+
// complement) is COMPUTED here over all registered toolsets, not stored.
|
|
244
|
+
// While the last mode entry is "allowlist", this set-level override is
|
|
245
|
+
// authoritative: per-toolset branch entries and settings pins are
|
|
246
|
+
// bypassed, and toolsets registered after the mode entry are off.
|
|
247
|
+
// Atomic two-phase restore: phase 1 computes the desired active-tools
|
|
248
|
+
// set and applies it in ONE `setActiveTools` call (no per-toolset emit
|
|
249
|
+
// during the loop — a companion mirror on `changed` cannot fire
|
|
250
|
+
// mid-restore and `appendEntry` against an in-progress state); phase 2
|
|
251
|
+
// emits `restored` for every registered toolset AFTER state is final.
|
|
252
|
+
if (mode === "allowlist") {
|
|
253
|
+
// `allowArr` computed above — fail-closed `[]` recovery already applied.
|
|
254
|
+
const allow = new Set<string>(allowArr);
|
|
255
|
+
const registered = new Set(pi.getAllTools().map((t) => t.name));
|
|
256
|
+
|
|
257
|
+
// Phase 1: desired set = current − (suppressed toolset members) +
|
|
258
|
+
// (allowlist members). The suppress set is the complement of the
|
|
259
|
+
// allowlist among registered toolset tools only; everything else in
|
|
260
|
+
// the current set (tools not owned by any registered toolset) is kept
|
|
261
|
+
// as-is — `setActiveTools` is a full replacement, so we must not
|
|
262
|
+
// rebuild the set from only allowlist members. This mirrors the
|
|
263
|
+
// per-toolset restore (`_applyRestoreToolset` only adds/removes
|
|
264
|
+
// `spec.names`), applied set-wide.
|
|
265
|
+
const current = new Set(pi.getActiveTools());
|
|
266
|
+
const suppress = new Set<string>();
|
|
267
|
+
for (const [, entry] of registry) {
|
|
268
|
+
if (!allow.has(entry.spec.id)) {
|
|
269
|
+
for (const n of entry.spec.names) suppress.add(n);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const desired = new Set<string>();
|
|
273
|
+
for (const n of current) {
|
|
274
|
+
if (!suppress.has(n)) desired.add(n);
|
|
275
|
+
}
|
|
276
|
+
for (const [, entry] of registry) {
|
|
277
|
+
if (allow.has(entry.spec.id)) {
|
|
278
|
+
for (const n of entry.spec.names) desired.add(n);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// The `registered` filter is partially redundant: names carried over
|
|
282
|
+
// from `current` are already active (hence registered); it only
|
|
283
|
+
// matters for spec names that aren't registered tools. Kept for
|
|
284
|
+
// parity with `_applyRestoreToolset`'s per-name filtering.
|
|
285
|
+
pi.setActiveTools([...desired].filter((n) => registered.has(n)));
|
|
286
|
+
|
|
287
|
+
// Phase 2: notify AFTER state is final. `restored` for every
|
|
288
|
+
// registered toolset — the whole pass is a branch replay of the
|
|
289
|
+
// authoritative allowlist entry, not a live toggle (see the JSDoc on
|
|
290
|
+
// `getActiveAllowlist` for the event-type divergence by mode).
|
|
291
|
+
for (const [, entry] of registry) {
|
|
292
|
+
_emitToolsetEvents(
|
|
293
|
+
entry.spec,
|
|
294
|
+
pi,
|
|
295
|
+
TOOLSET_EVENTS.restored,
|
|
296
|
+
allow.has(entry.spec.id),
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
return;
|
|
157
300
|
}
|
|
158
|
-
|
|
301
|
+
|
|
302
|
+
// Read settings.json toolset defaults once per restore pass.
|
|
303
|
+
// settings.json is stable mid-restore (unlike the branch, which
|
|
304
|
+
// companion mirroring can mutate), so a single read suffices.
|
|
305
|
+
const settingsDefaults = readMergedToolsetDefaults();
|
|
159
306
|
|
|
160
307
|
// ponytail: restore applies each toolset's entry independently and does
|
|
161
|
-
// NOT re-run the requires cascade. Safe because
|
|
162
|
-
//
|
|
308
|
+
// NOT re-run the requires cascade. Safe because persisted state is always
|
|
309
|
+
// consistent — the live-toggling cascade makes an
|
|
163
310
|
// incoherent persisted combo unreachable. Re-adding cascade here would
|
|
164
311
|
// double-toggle and break restore independence.
|
|
165
312
|
//
|
|
166
313
|
// Re-read the branch per toolset (not once before the loop): a companion
|
|
167
|
-
// mirror
|
|
314
|
+
// mirror fires synchronously inside `_applyRestoreToolset` and may
|
|
168
315
|
// `appendEntry` for a toolset later in iteration order (e.g. my-plugin.web's
|
|
169
316
|
// default-false restore makes search.web disable itself). Snapshotting the
|
|
170
317
|
// branch once would hide that write from the later toolset, so it would
|
|
171
318
|
// fall back to its packaged default and desync from the companion — the
|
|
172
|
-
//
|
|
319
|
+
// "search's own restore reads the branch and finds the entry the mirror
|
|
173
320
|
// just wrote" guarantee.
|
|
174
321
|
for (const [, entry] of registry) {
|
|
175
322
|
const { spec } = entry;
|
|
176
323
|
|
|
177
324
|
// Find persisted entry for this toolset (last-writer-wins).
|
|
178
325
|
// Fresh read per toolset so companion-mirror writes during this
|
|
179
|
-
// pass are visible to later toolsets.
|
|
326
|
+
// pass are visible to later toolsets. The `b.data != null` filter
|
|
327
|
+
// is dropped: a null (tombstoned) last entry means "cleared →
|
|
328
|
+
// fall through to settings → mode floor → packaged" and must beat
|
|
329
|
+
// a stale prior entry instead of being invisible.
|
|
180
330
|
const branchNow = ctx.sessionManager.getBranch();
|
|
181
331
|
const persistEntries = branchNow.filter(
|
|
182
|
-
(b: any) => b.customType === spec.persistKey
|
|
332
|
+
(b: any) => b.customType === spec.persistKey,
|
|
183
333
|
);
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (typeof enabled === "boolean") {
|
|
189
|
-
_applyRestoreToolset(spec, pi, enabled, true);
|
|
190
|
-
}
|
|
334
|
+
const lastEntry = persistEntries[persistEntries.length - 1];
|
|
335
|
+
const enabled = (lastEntry as any)?.data?.enabled;
|
|
336
|
+
if (typeof enabled === "boolean") {
|
|
337
|
+
_applyRestoreToolset(spec, pi, enabled, true);
|
|
191
338
|
} else {
|
|
192
|
-
// No entry —
|
|
339
|
+
// No usable branch entry — fall through to settings tier (2),
|
|
340
|
+
// then mode floor, then packaged `defaultEnabled` (3). A pinned
|
|
341
|
+
// settings entry is explicit user intent and participates in
|
|
342
|
+
// BOTH modes — mirroring how the chat-branch tier (also user
|
|
343
|
+
// intent) is honored in inclusion via the if-branch. Only
|
|
344
|
+
// unpinned toolsets consult mode for the floor (exclusion →
|
|
345
|
+
// `defaultEnabled ?? true`, inclusion → false).
|
|
346
|
+
// `readMergedToolsetDefaults()` returns the on-disk shape
|
|
347
|
+
// Record<persistKey, { enabled: boolean }>
|
|
348
|
+
// so the pin is the wrapped object; unwrap with `?.enabled`.
|
|
349
|
+
const settingsEnabled = settingsDefaults[spec.persistKey]?.enabled;
|
|
193
350
|
const fallback = spec.defaultEnabled ?? true;
|
|
194
|
-
const
|
|
195
|
-
|
|
351
|
+
const resolved =
|
|
352
|
+
typeof settingsEnabled === "boolean"
|
|
353
|
+
? settingsEnabled
|
|
354
|
+
: mode === "inclusion"
|
|
355
|
+
? false
|
|
356
|
+
: fallback;
|
|
357
|
+
_applyRestoreToolset(spec, pi, resolved, false);
|
|
196
358
|
}
|
|
197
359
|
}
|
|
198
360
|
};
|
|
@@ -215,7 +377,7 @@ function _emitToolsetEvents(
|
|
|
215
377
|
|
|
216
378
|
if (spec.emitMemberEvents) {
|
|
217
379
|
for (const name of spec.names) {
|
|
218
|
-
// Only emit for names that are actually registered tools
|
|
380
|
+
// Only emit for names that are actually registered tools
|
|
219
381
|
if (!pi.getAllTools().some((t) => t.name === name)) continue;
|
|
220
382
|
pi.events.emit(eventType, {
|
|
221
383
|
id: spec.id,
|
|
@@ -287,7 +449,7 @@ function _applyRestoreToolset(
|
|
|
287
449
|
pi.setActiveTools(filtered);
|
|
288
450
|
}
|
|
289
451
|
|
|
290
|
-
// Always emit regardless of state (always-emit invariant
|
|
452
|
+
// Always emit regardless of state (always-emit invariant)
|
|
291
453
|
const eventType = isPersistedEntry
|
|
292
454
|
? TOOLSET_EVENTS.restored
|
|
293
455
|
: TOOLSET_EVENTS.changed;
|
|
@@ -295,7 +457,7 @@ function _applyRestoreToolset(
|
|
|
295
457
|
}
|
|
296
458
|
|
|
297
459
|
// ---------------------------------------------------------------------------
|
|
298
|
-
// Enable cascade + cycle detection
|
|
460
|
+
// Enable cascade + cycle detection
|
|
299
461
|
// ---------------------------------------------------------------------------
|
|
300
462
|
|
|
301
463
|
function _enableToolset(
|
|
@@ -328,7 +490,7 @@ function _enableToolset(
|
|
|
328
490
|
}
|
|
329
491
|
|
|
330
492
|
// ---------------------------------------------------------------------------
|
|
331
|
-
// Disable reverse-cascade
|
|
493
|
+
// Disable reverse-cascade
|
|
332
494
|
// ---------------------------------------------------------------------------
|
|
333
495
|
|
|
334
496
|
function _disableDependents(
|
|
@@ -387,7 +549,7 @@ class ToolsetImpl implements Toolset {
|
|
|
387
549
|
}
|
|
388
550
|
|
|
389
551
|
// ---------------------------------------------------------------------------
|
|
390
|
-
//
|
|
552
|
+
// Public API — functions
|
|
391
553
|
// ---------------------------------------------------------------------------
|
|
392
554
|
|
|
393
555
|
export function defineToolset(pi: ExtensionAPI, spec: ToolsetSpec): Toolset {
|
|
@@ -467,23 +629,100 @@ export function defineToolset(pi: ExtensionAPI, spec: ToolsetSpec): Toolset {
|
|
|
467
629
|
return toolset;
|
|
468
630
|
}
|
|
469
631
|
|
|
632
|
+
/**
|
|
633
|
+
* Set how toolsets with no persisted entry resolve on restore.
|
|
634
|
+
*
|
|
635
|
+
* - `"exclusion"` (default): toolsets fall back to `defaultEnabled ?? true`.
|
|
636
|
+
* - `"allowlist"` (ids): only the listed toolset ids are on, everything else
|
|
637
|
+
* off — the finite, branch-persisted focus constraint, resilient to
|
|
638
|
+
* toolsets installed after the mode was set. Requires a non-empty array.
|
|
639
|
+
* - `"inclusion"`: all unknown toolsets default off.
|
|
640
|
+
*
|
|
641
|
+
* @deprecated The `"inclusion"` mode is deprecated since 1.2.0 — use
|
|
642
|
+
* `"allowlist"` for focus-style suppression (or `"exclusion"` for the
|
|
643
|
+
* default-on floor). Setting `"inclusion"` emits a one-time runtime warning;
|
|
644
|
+
* it is scheduled for removal in a near-term 1.x minor.
|
|
645
|
+
*/
|
|
470
646
|
export function setDefaultResolutionMode(
|
|
471
647
|
pi: ExtensionAPI,
|
|
472
648
|
mode: DefaultResolutionMode,
|
|
649
|
+
allowlist?: string[],
|
|
473
650
|
): void {
|
|
474
|
-
if (mode
|
|
651
|
+
if (mode === "inclusion")
|
|
652
|
+
warnInclusionDeprecation("setDefaultResolutionMode");
|
|
653
|
+
if (mode !== "exclusion" && mode !== "inclusion" && mode !== "allowlist") {
|
|
654
|
+
throw new Error(
|
|
655
|
+
`[pi-tool-masking] Invalid defaultResolutionMode: "${mode}". Must be "exclusion", "inclusion", or "allowlist".`,
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
// Write-time validation (asymmetric with restore): an allowlist mode with
|
|
659
|
+
// no/empty array is a likely mistake (deleting the last member and
|
|
660
|
+
// forgetting to switch modes); restore instead recovers a corrupt
|
|
661
|
+
// missing/non-array allowlist to `[]` (fail closed). Write-time validates
|
|
662
|
+
// intent; restore-time picks the safe recovery. Forward references are
|
|
663
|
+
// legal — ids need not be registered yet.
|
|
664
|
+
if (
|
|
665
|
+
mode === "allowlist" &&
|
|
666
|
+
(!allowlist || !Array.isArray(allowlist) || allowlist.length === 0)
|
|
667
|
+
) {
|
|
475
668
|
throw new Error(
|
|
476
|
-
`[pi-tool-masking]
|
|
669
|
+
`[pi-tool-masking] defaultResolutionMode "allowlist" requires a non-empty allowlist array of toolset ids.`,
|
|
477
670
|
);
|
|
478
671
|
}
|
|
479
|
-
getModuleState()
|
|
480
|
-
|
|
672
|
+
const ms = getModuleState();
|
|
673
|
+
ms.defaultResolutionMode = mode;
|
|
674
|
+
// Mirror into module state so `getActiveAllowlist()` stays consistent with
|
|
675
|
+
// the branch without a `sessionManager` dependency. Existing exclusion /
|
|
676
|
+
// inclusion entries persist `{ mode }` only (unchanged shape);
|
|
677
|
+
// `.activeAllowlist` is undefined for non-allowlist modes. Copy the
|
|
678
|
+
// caller's array — the branch snapshots on append, but module state holds
|
|
679
|
+
// the live reference; a caller mutating the array post-call would
|
|
680
|
+
// otherwise drift the mirror from the branch.
|
|
681
|
+
ms.activeAllowlist =
|
|
682
|
+
mode === "allowlist" && allowlist ? [...allowlist] : undefined;
|
|
683
|
+
pi.appendEntry(
|
|
684
|
+
MODE_PERSIST_KEY,
|
|
685
|
+
mode === "allowlist" ? { mode, allowlist } : { mode },
|
|
686
|
+
);
|
|
481
687
|
}
|
|
482
688
|
|
|
483
689
|
export function getDefaultResolutionMode(): DefaultResolutionMode {
|
|
484
690
|
return getModuleState().defaultResolutionMode;
|
|
485
691
|
}
|
|
486
692
|
|
|
693
|
+
/**
|
|
694
|
+
* Read the live allowlist array from module state (mirrored from the last
|
|
695
|
+
* mode branch entry by `doRestore`'s mode-resolution block and by
|
|
696
|
+
* `setDefaultResolutionMode` when it appends the entry). Returns the
|
|
697
|
+
* `allowlist` array when the active mode is `"allowlist"`, otherwise
|
|
698
|
+
* `undefined`.
|
|
699
|
+
*
|
|
700
|
+
* Parameterless (like `getDefaultResolutionMode`) — the consumer call site
|
|
701
|
+
* receives `pi: ExtensionAPI`, which does not expose `sessionManager`, so a
|
|
702
|
+
* `pi`-arg signature would not compile there. The branch remains the source
|
|
703
|
+
* of truth; module state is the live mirror.
|
|
704
|
+
*
|
|
705
|
+
* **Event-type divergence by mode (restore contract):** under
|
|
706
|
+
* exclusion/inclusion, the per-toolset restore loop emits `restored` for
|
|
707
|
+
* toolsets with a branch entry and `changed` for default-fallback toolsets
|
|
708
|
+
* (no branch entry). Under allowlist, restore emits `restored` for **every**
|
|
709
|
+
* registered toolset — the whole pass is a branch replay of the
|
|
710
|
+
* authoritative allowlist entry, not a live toggle. A consumer expecting
|
|
711
|
+
* `changed` for fallback toolsets during restore gets `restored` instead
|
|
712
|
+
* while allowlist is active.
|
|
713
|
+
*
|
|
714
|
+
* **`requires` cascade:** not re-run during allowlist restore (same
|
|
715
|
+
* independence invariant as per-toolset restore). A caller that passes an
|
|
716
|
+
* allowlist missing a dependency gets that dep off — pass the forward
|
|
717
|
+
* closure.
|
|
718
|
+
*/
|
|
719
|
+
export function getActiveAllowlist(): string[] | undefined {
|
|
720
|
+
// Copy on read: module state is the internal mirror; handing out the live
|
|
721
|
+
// reference would let a consumer mutate it and corrupt the mirror.
|
|
722
|
+
const allowlist = getModuleState().activeAllowlist;
|
|
723
|
+
return allowlist ? [...allowlist] : undefined;
|
|
724
|
+
}
|
|
725
|
+
|
|
487
726
|
/**
|
|
488
727
|
* Enumerate every registered toolset in the global registry.
|
|
489
728
|
* Returns a read-only snapshot — callers cannot mutate the live registry
|
|
@@ -495,3 +734,455 @@ export function getDefaultResolutionMode(): DefaultResolutionMode {
|
|
|
495
734
|
export function getRegisteredToolsets(): readonly RegistryEntry[] {
|
|
496
735
|
return [...getRegistry().values()];
|
|
497
736
|
}
|
|
737
|
+
|
|
738
|
+
// ---------------------------------------------------------------------------
|
|
739
|
+
// Tombstone helpers + apply-without-persist
|
|
740
|
+
// ---------------------------------------------------------------------------
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Tombstone a toolset's chat-branch entry: append `null` for `persistKey`
|
|
744
|
+
* so `doRestore` falls through to the settings tier. Owns the
|
|
745
|
+
* tombstone-write convention.
|
|
746
|
+
*
|
|
747
|
+
* Dedup: appends `null` only if the key has a prior entry whose last entry
|
|
748
|
+
* is not already cleared. A toolset that was never toggled has no branch
|
|
749
|
+
* entry — no tombstone is written. Consecutive restores with no intervening
|
|
750
|
+
* toggle write zero tombstones.
|
|
751
|
+
*
|
|
752
|
+
* `branch` is the caller's branch snapshot
|
|
753
|
+
* (`ctx.sessionManager.getBranch()`): `ExtensionAPI` exposes `appendEntry`
|
|
754
|
+
* but not `sessionManager`, so the dedup read comes from the caller.
|
|
755
|
+
*
|
|
756
|
+
* ponytail: dedup caps growth at one tombstone per toggle/restore cycle,
|
|
757
|
+
* but many cycles in one session still stack entries. Upgrade path: a
|
|
758
|
+
* pi-core "compact toolset entries" op, out of scope.
|
|
759
|
+
*/
|
|
760
|
+
export function clearToolsetEntry(
|
|
761
|
+
pi: ExtensionAPI,
|
|
762
|
+
persistKey: string,
|
|
763
|
+
branch: readonly SessionEntry[],
|
|
764
|
+
): void {
|
|
765
|
+
let last: SessionEntry | undefined;
|
|
766
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
767
|
+
if ((branch[i] as any).customType === persistKey) {
|
|
768
|
+
last = branch[i];
|
|
769
|
+
break;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
const data = (last as any)?.data;
|
|
773
|
+
// No prior entry, a tombstoned last entry (data null), or a last entry
|
|
774
|
+
// without an `enabled` field → already effectively cleared, skip.
|
|
775
|
+
if (data == null || data?.enabled == null) {
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
pi.appendEntry(persistKey, null);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Tombstone every registered toolset's chat-branch entry (dedup'd per
|
|
783
|
+
* toolset — never-toggled toolsets get no tombstone). Covers exactly the
|
|
784
|
+
* toolsets in the global registry. `branch` is the caller's
|
|
785
|
+
* `ctx.sessionManager.getBranch()` snapshot (see `clearToolsetEntry`).
|
|
786
|
+
*/
|
|
787
|
+
export function clearAllToolsetEntries(
|
|
788
|
+
pi: ExtensionAPI,
|
|
789
|
+
branch: readonly SessionEntry[],
|
|
790
|
+
): void {
|
|
791
|
+
for (const [, entry] of getRegistry()) {
|
|
792
|
+
clearToolsetEntry(pi, entry.spec.persistKey, branch);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Apply a toolset's enabled state via `setActiveTools` and emit
|
|
798
|
+
* `TOOLSET_EVENTS.changed` — **without** writing a branch entry. The
|
|
799
|
+
* live-apply half of a settings restore: pull the toolset to its
|
|
800
|
+
* settings/packaged default without persisting a chat-branch pin.
|
|
801
|
+
*/
|
|
802
|
+
export function applyToolsetEnabled(
|
|
803
|
+
pi: ExtensionAPI,
|
|
804
|
+
spec: ToolsetSpec,
|
|
805
|
+
enabled: boolean,
|
|
806
|
+
): void {
|
|
807
|
+
_applyRestoreToolset(spec, pi, enabled, false);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// ---------------------------------------------------------------------------
|
|
811
|
+
// Settings.json reader — toolsetDefaults tier
|
|
812
|
+
// ---------------------------------------------------------------------------
|
|
813
|
+
|
|
814
|
+
/** On-disk settings shape: `toolsetDefaults[persistKey] = { enabled }`. */
|
|
815
|
+
type ToolsetDefaultsMap = Record<string, { enabled: boolean }>;
|
|
816
|
+
|
|
817
|
+
function settingsPath(scope: "global" | "project"): string {
|
|
818
|
+
const agentDir =
|
|
819
|
+
process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
820
|
+
return scope === "global"
|
|
821
|
+
? join(agentDir, "settings.json")
|
|
822
|
+
: join(process.cwd(), ".pi", "settings.json");
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Read one scope's settings.json as a parsed object, or `{}` on any
|
|
827
|
+
* read/parse failure (never-throw policy — a malformed file contributes
|
|
828
|
+
* `{}` to the merge; only mutators throw `MalformedSettingsError`).
|
|
829
|
+
*/
|
|
830
|
+
function readSettingsJsonSafe(scope: "global" | "project"): unknown {
|
|
831
|
+
const path = settingsPath(scope);
|
|
832
|
+
try {
|
|
833
|
+
if (!existsSync(path)) return {};
|
|
834
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
835
|
+
} catch {
|
|
836
|
+
return {};
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
let _settingsOverride: ToolsetDefaultsMap | null = null;
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Inject a snapshot of toolset defaults for tests. Pass `null` to restore
|
|
844
|
+
* the disk-read path. Production code never calls this.
|
|
845
|
+
*
|
|
846
|
+
* @internal
|
|
847
|
+
*/
|
|
848
|
+
export function setSettingsOverrideForTests(
|
|
849
|
+
defaults: ToolsetDefaultsMap | null,
|
|
850
|
+
): void {
|
|
851
|
+
_settingsOverride = defaults;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Extract toolset defaults from a settings.json object.
|
|
856
|
+
*
|
|
857
|
+
* Reads `json.toolsetDefaults` — a map of
|
|
858
|
+
* `{ [persistKey]: { enabled: boolean } }`. Entries whose value isn't a
|
|
859
|
+
* `{ enabled }` shape are dropped silently. Returns the on-disk shape
|
|
860
|
+
* verbatim (no flattening — call sites unwrap via `?.enabled`).
|
|
861
|
+
*
|
|
862
|
+
* @internal
|
|
863
|
+
*/
|
|
864
|
+
export function parseToolsetDefaults(json: unknown): ToolsetDefaultsMap {
|
|
865
|
+
if (!json || typeof json !== "object" || Array.isArray(json)) return {};
|
|
866
|
+
const td = (json as Record<string, unknown>)["toolsetDefaults"];
|
|
867
|
+
if (!td || typeof td !== "object" || Array.isArray(td)) return {};
|
|
868
|
+
const result: ToolsetDefaultsMap = {};
|
|
869
|
+
for (const [key, val] of Object.entries(td as Record<string, unknown>)) {
|
|
870
|
+
// ponytail: only `enabled` is read; extra fields (`label`, etc.) ignored.
|
|
871
|
+
// Add a schema validator if downstreams depend on more fields.
|
|
872
|
+
const valObj = val as Record<string, unknown>;
|
|
873
|
+
if (valObj && typeof valObj["enabled"] === "boolean") {
|
|
874
|
+
result[key] = { enabled: valObj["enabled"] as boolean };
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return result;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Shallow-merge global and project toolset defaults.
|
|
882
|
+
*
|
|
883
|
+
* Project wins on key collision: `{ ...global_, ...project }`. Per-entry
|
|
884
|
+
* only — no deep merge of the `{ enabled }` values.
|
|
885
|
+
*
|
|
886
|
+
* @internal
|
|
887
|
+
*/
|
|
888
|
+
export function mergeToolsetDefaults(
|
|
889
|
+
global_: ToolsetDefaultsMap,
|
|
890
|
+
project: ToolsetDefaultsMap,
|
|
891
|
+
): ToolsetDefaultsMap {
|
|
892
|
+
return { ...global_, ...project };
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Read and merge toolset defaults from settings.json (global + project).
|
|
897
|
+
*
|
|
898
|
+
* Reads `json.toolsetDefaults` from:
|
|
899
|
+
* `<agentDir>/settings.json` (global)
|
|
900
|
+
* `<cwd>/.pi/settings.json` (project)
|
|
901
|
+
*
|
|
902
|
+
* Where `agentDir = PI_CODING_AGENT_DIR ?? ~/.pi/agent`.
|
|
903
|
+
* Missing/unreadable/malformed files contribute `{}`. Never throws.
|
|
904
|
+
*
|
|
905
|
+
* Returns the on-disk shape `Record<persistKey, { enabled: boolean }>`
|
|
906
|
+
* (not flattened) — call sites unwrap with `?.enabled`. Project overrides
|
|
907
|
+
* global per entry.
|
|
908
|
+
*
|
|
909
|
+
* When `setSettingsOverrideForTests` has set an override, returns that
|
|
910
|
+
* override verbatim instead of reading disk.
|
|
911
|
+
*
|
|
912
|
+
* @public — exported for snapshot-in usage (read once per loop
|
|
913
|
+
* and pass to `getEffectiveDefault`).
|
|
914
|
+
*
|
|
915
|
+
* ponytail: hardcodes the two pi-core settings paths (global
|
|
916
|
+
* `~/.pi/agent/settings.json`, project `<cwd>/.pi/settings.json`) with no
|
|
917
|
+
* configuration knob — if pi-core moves its settings paths or format this
|
|
918
|
+
* breaks. Upgrade path: a pi-core settings-path registry, if one ever appears.
|
|
919
|
+
*/
|
|
920
|
+
export function readMergedToolsetDefaults(): ToolsetDefaultsMap {
|
|
921
|
+
if (_settingsOverride !== null) return { ..._settingsOverride };
|
|
922
|
+
return mergeToolsetDefaults(
|
|
923
|
+
parseToolsetDefaults(readSettingsJsonSafe("global")),
|
|
924
|
+
parseToolsetDefaults(readSettingsJsonSafe("project")),
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Read toolset defaults from one settings.json scope (global or project).
|
|
930
|
+
*
|
|
931
|
+
* Returns the raw `toolsetDefaults` block parsed from that scope's file,
|
|
932
|
+
* without merging. Missing/unreadable/malformed files return `{}`.
|
|
933
|
+
*
|
|
934
|
+
* When `setSettingsOverrideForTests` has set an override, returns that
|
|
935
|
+
* override for both scopes (test mode approximation — attribution tests
|
|
936
|
+
* must use the writer seam + disk round-trip).
|
|
937
|
+
*
|
|
938
|
+
* @public — exported for a `defaults show`-style command that needs
|
|
939
|
+
* per-scope attribution.
|
|
940
|
+
*/
|
|
941
|
+
export function readToolsetDefaults(
|
|
942
|
+
scope: "global" | "project",
|
|
943
|
+
): ToolsetDefaultsMap {
|
|
944
|
+
if (_settingsOverride !== null) return { ..._settingsOverride };
|
|
945
|
+
return parseToolsetDefaults(readSettingsJsonSafe(scope));
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Resolve a toolset's effective fresh-session default: settings tier (2)
|
|
950
|
+
* then packaged `spec.defaultEnabled` (3). **Ignores resolution mode** —
|
|
951
|
+
* callers that need mode-aware behavior must consult
|
|
952
|
+
* `getDefaultResolutionMode()` themselves and act accordingly.
|
|
953
|
+
*
|
|
954
|
+
* Pass an explicit `snapshot` (the merged settings map from
|
|
955
|
+
* `readMergedToolsetDefaults()`) when calling from a loop over multiple
|
|
956
|
+
* toolsets — read the snapshot once before the loop and pass it in to
|
|
957
|
+
* avoid re-reading disk per toolset. When `snapshot` is omitted the
|
|
958
|
+
* function performs its own one-off `readMergedToolsetDefaults()` call.
|
|
959
|
+
*
|
|
960
|
+
* `snapshot` shares the on-disk shape
|
|
961
|
+
* `Record<persistKey, { enabled: boolean }>`; the pin is unwrapped via
|
|
962
|
+
* `?.enabled`. A missing or malformed entry falls through to
|
|
963
|
+
* `spec.defaultEnabled ?? true`.
|
|
964
|
+
*
|
|
965
|
+
* @public — exported for call sites that need the settings-aware default
|
|
966
|
+
* without re-implementing the reader (e.g. focus teardown and
|
|
967
|
+
* post-install actuation).
|
|
968
|
+
*/
|
|
969
|
+
export function getEffectiveDefault(
|
|
970
|
+
spec: ToolsetSpec,
|
|
971
|
+
snapshot?: ToolsetDefaultsMap,
|
|
972
|
+
): boolean {
|
|
973
|
+
const map = snapshot ?? readMergedToolsetDefaults();
|
|
974
|
+
const settingsEnabled = map[spec.persistKey]?.enabled;
|
|
975
|
+
return typeof settingsEnabled === "boolean"
|
|
976
|
+
? settingsEnabled
|
|
977
|
+
: (spec.defaultEnabled ?? true);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// ---------------------------------------------------------------------------
|
|
981
|
+
// Settings.json writer — toolsetDefaults tier
|
|
982
|
+
// ---------------------------------------------------------------------------
|
|
983
|
+
|
|
984
|
+
let _settingsWriterOverride: {
|
|
985
|
+
global: ToolsetDefaultsMap;
|
|
986
|
+
project: ToolsetDefaultsMap;
|
|
987
|
+
} | null = null;
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Capture toolset-defaults writes in-memory instead of hitting disk. Pass
|
|
991
|
+
* `null` to restore the disk-write path. Independent of
|
|
992
|
+
* `setSettingsOverrideForTests` — both seams must be cleared (`null`) for a
|
|
993
|
+
* true round-trip that hits disk on both read and write.
|
|
994
|
+
*
|
|
995
|
+
* @internal
|
|
996
|
+
*/
|
|
997
|
+
export function setSettingsWriterOverrideForTests(
|
|
998
|
+
state: {
|
|
999
|
+
global: ToolsetDefaultsMap;
|
|
1000
|
+
project: ToolsetDefaultsMap;
|
|
1001
|
+
} | null,
|
|
1002
|
+
): void {
|
|
1003
|
+
_settingsWriterOverride = state;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* Read one scope's settings.json (with the malformed-file guard), run
|
|
1008
|
+
* `mutator` against the parsed object, and write it back iff `mutator`
|
|
1009
|
+
* returns `true`. Returns the same boolean so callers whose result *is*
|
|
1010
|
+
* "did it write" (e.g. `clearToolsetDefaults`) can use it directly.
|
|
1011
|
+
*
|
|
1012
|
+
* `mutator` returns `false` to skip the write (used by clear when the
|
|
1013
|
+
* key is absent — no needless reformat of a hand-edited file). The
|
|
1014
|
+
* malformed-file guard throws before `mutator` runs, so a corrupt file is
|
|
1015
|
+
* never handed to a mutator and never overwritten.
|
|
1016
|
+
*
|
|
1017
|
+
* ponytail: read-modify-write is not atomic — concurrent Pi sessions
|
|
1018
|
+
* writing the same global settings.json can lose writes. An advisory
|
|
1019
|
+
* file lock or write-to-temp+rename would close this; revisit if
|
|
1020
|
+
* cross-session write contention becomes observable.
|
|
1021
|
+
*/
|
|
1022
|
+
function mutateSettingsJson(
|
|
1023
|
+
scope: "global" | "project",
|
|
1024
|
+
mutator: (existing: Record<string, unknown>) => boolean,
|
|
1025
|
+
): boolean {
|
|
1026
|
+
const path = settingsPath(scope);
|
|
1027
|
+
|
|
1028
|
+
let existing: Record<string, unknown>;
|
|
1029
|
+
try {
|
|
1030
|
+
if (existsSync(path)) {
|
|
1031
|
+
const raw = readFileSync(path, "utf-8");
|
|
1032
|
+
const parsed = JSON.parse(raw);
|
|
1033
|
+
if (
|
|
1034
|
+
typeof parsed !== "object" ||
|
|
1035
|
+
parsed === null ||
|
|
1036
|
+
Array.isArray(parsed)
|
|
1037
|
+
) {
|
|
1038
|
+
throw new MalformedSettingsError(
|
|
1039
|
+
`[pi-tool-masking] Refusing to overwrite non-object settings.json at ` +
|
|
1040
|
+
`${path}. The file contains ${
|
|
1041
|
+
Array.isArray(parsed)
|
|
1042
|
+
? "a JSON array"
|
|
1043
|
+
: typeof parsed === "object"
|
|
1044
|
+
? "null"
|
|
1045
|
+
: typeof parsed
|
|
1046
|
+
}. Fix or remove it before writing.`,
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
existing = parsed as Record<string, unknown>;
|
|
1050
|
+
} else {
|
|
1051
|
+
existing = {};
|
|
1052
|
+
}
|
|
1053
|
+
} catch (err: unknown) {
|
|
1054
|
+
if (err instanceof MalformedSettingsError) throw err;
|
|
1055
|
+
if (err instanceof SyntaxError) {
|
|
1056
|
+
throw new MalformedSettingsError(
|
|
1057
|
+
`[pi-tool-masking] Refusing to overwrite malformed settings.json at ` +
|
|
1058
|
+
`${path}. Fix or remove it before writing. Parse error: ${err.message}`,
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
throw err;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
const write = mutator(existing);
|
|
1065
|
+
|
|
1066
|
+
if (write) {
|
|
1067
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1068
|
+
writeFileSync(path, JSON.stringify(existing, null, 2) + "\n");
|
|
1069
|
+
}
|
|
1070
|
+
return write;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Write a batch of toolset default entries to one settings scope.
|
|
1075
|
+
*
|
|
1076
|
+
* `entries` is the on-disk shape `{ [persistKey]: { enabled: boolean } }`
|
|
1077
|
+
* — the same shape `readMergedToolsetDefaults()` returns. Each entry
|
|
1078
|
+
* becomes `toolsetDefaults[persistKey] = { enabled }` in the chosen
|
|
1079
|
+
* scope's settings file, preserving every other top-level key.
|
|
1080
|
+
*
|
|
1081
|
+
* Merge semantics: shallow per-entry within `toolsetDefaults`. Existing
|
|
1082
|
+
* entries for persistKeys NOT in `entries` are preserved; entries in
|
|
1083
|
+
* `entries` overwrite any same-key existing entry. A write where every
|
|
1084
|
+
* entry already matches its on-disk value is a no-op: the file is left
|
|
1085
|
+
* untouched (no reformat, no mtime bump).
|
|
1086
|
+
*
|
|
1087
|
+
* **Malformed-file guard:**
|
|
1088
|
+
* - File missing → write fresh (nothing to lose).
|
|
1089
|
+
* - File parses to a non-object (array, string, null) → **throw**
|
|
1090
|
+
* (data-loss guard — would destroy unparsable user config).
|
|
1091
|
+
* - `JSON.parse` throws → **throw** (same reason).
|
|
1092
|
+
*
|
|
1093
|
+
* Returns the path of the settings file the entries were merged into
|
|
1094
|
+
* (whether or not any entry actually changed — the file is the write
|
|
1095
|
+
* destination either way).
|
|
1096
|
+
*
|
|
1097
|
+
* @public
|
|
1098
|
+
*/
|
|
1099
|
+
export function writeToolsetDefaults(
|
|
1100
|
+
entries: ToolsetDefaultsMap,
|
|
1101
|
+
scope: "global" | "project",
|
|
1102
|
+
): string {
|
|
1103
|
+
// Seam path: merge into memory
|
|
1104
|
+
if (_settingsWriterOverride !== null) {
|
|
1105
|
+
for (const [key, val] of Object.entries(entries)) {
|
|
1106
|
+
_settingsWriterOverride[scope][key] = { enabled: val.enabled };
|
|
1107
|
+
}
|
|
1108
|
+
return settingsPath(scope);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
mutateSettingsJson(scope, (existing) => {
|
|
1112
|
+
// Non-object `toolsetDefaults` (array/string/null) recovers to `{}` —
|
|
1113
|
+
// same recovery as the reader (`parseToolsetDefaults`). A bare array
|
|
1114
|
+
// would swallow string-keyed writes (JSON.stringify drops them) and a
|
|
1115
|
+
// string would throw a confusing TypeError on `td[key] = ...`.
|
|
1116
|
+
const raw = existing.toolsetDefaults;
|
|
1117
|
+
const td: Record<string, unknown> =
|
|
1118
|
+
raw && typeof raw === "object" && !Array.isArray(raw)
|
|
1119
|
+
? (raw as Record<string, unknown>)
|
|
1120
|
+
: {};
|
|
1121
|
+
let changed = false;
|
|
1122
|
+
for (const [key, val] of Object.entries(entries)) {
|
|
1123
|
+
if (
|
|
1124
|
+
(td[key] as { enabled?: unknown } | undefined)?.enabled !== val.enabled
|
|
1125
|
+
) {
|
|
1126
|
+
td[key] = { enabled: val.enabled };
|
|
1127
|
+
changed = true;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
if (!changed) return false;
|
|
1131
|
+
existing.toolsetDefaults = td;
|
|
1132
|
+
return true;
|
|
1133
|
+
});
|
|
1134
|
+
return settingsPath(scope);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Remove the `toolsetDefaults` wrapper key entirely from one scope's
|
|
1139
|
+
* settings file, preserving every other top-level key. After this, every
|
|
1140
|
+
* toolset in that scope falls back to tier 3 (packaged default, or
|
|
1141
|
+
* `spec.defaultEnabled ?? true`).
|
|
1142
|
+
*
|
|
1143
|
+
* Returns the path of the settings file the key was removed from, or
|
|
1144
|
+
* `null` if the key was already absent (or the file was missing). No
|
|
1145
|
+
* per-entry clear path by design — callers who want that write an
|
|
1146
|
+
* `entries` map without the unwanted keys via `writeToolsetDefaults`.
|
|
1147
|
+
*
|
|
1148
|
+
* **Malformed-file guard:** same as `writeToolsetDefaults` — throws on
|
|
1149
|
+
* non-object or unparsable JSON rather than overwriting user config.
|
|
1150
|
+
*
|
|
1151
|
+
* @public
|
|
1152
|
+
*/
|
|
1153
|
+
export function clearToolsetDefaults(
|
|
1154
|
+
scope: "global" | "project",
|
|
1155
|
+
): string | null {
|
|
1156
|
+
// Seam path: clear all keys in memory
|
|
1157
|
+
if (_settingsWriterOverride !== null) {
|
|
1158
|
+
const state = _settingsWriterOverride[scope];
|
|
1159
|
+
const keys = Object.keys(state);
|
|
1160
|
+
for (const key of keys) {
|
|
1161
|
+
delete state[key];
|
|
1162
|
+
}
|
|
1163
|
+
return keys.length > 0 ? settingsPath(scope) : null;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
return mutateSettingsJson(scope, (existing) => {
|
|
1167
|
+
if (!("toolsetDefaults" in existing)) return false; // no write, no reformat
|
|
1168
|
+
delete existing.toolsetDefaults;
|
|
1169
|
+
return true;
|
|
1170
|
+
})
|
|
1171
|
+
? settingsPath(scope)
|
|
1172
|
+
: null;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Error thrown when refusing to overwrite a malformed or non-object
|
|
1177
|
+
* settings.json to prevent data-loss of user pi-core config.
|
|
1178
|
+
*
|
|
1179
|
+
* @public — exported for downstream consumers to distinguish malformed-file
|
|
1180
|
+
* errors from generic I/O errors without string-matching `message`.
|
|
1181
|
+
* Catch with `instanceof MalformedSettingsError`.
|
|
1182
|
+
*/
|
|
1183
|
+
export class MalformedSettingsError extends Error {
|
|
1184
|
+
constructor(message: string) {
|
|
1185
|
+
super(message);
|
|
1186
|
+
this.name = "MalformedSettingsError";
|
|
1187
|
+
}
|
|
1188
|
+
}
|