@sherif-fanous/pi-presets-plus 0.1.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/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/package.json +74 -0
- package/src/activation/active-state.ts +25 -0
- package/src/activation/apply.ts +236 -0
- package/src/activation/baseline.ts +32 -0
- package/src/activation/clear.ts +434 -0
- package/src/activation/dirty.ts +69 -0
- package/src/activation/drift-handlers.ts +71 -0
- package/src/activation/drift.ts +77 -0
- package/src/activation/same-set.ts +32 -0
- package/src/activation/state-matches.ts +29 -0
- package/src/activation/thinking.ts +54 -0
- package/src/commands/presets/clear.ts +18 -0
- package/src/commands/presets/index.ts +9 -0
- package/src/commands/presets/notify.ts +22 -0
- package/src/commands/presets/reload.ts +28 -0
- package/src/commands/presets/router.ts +139 -0
- package/src/commands/presets/status.ts +262 -0
- package/src/flag.ts +88 -0
- package/src/hotkey-conflicts.ts +136 -0
- package/src/hotkey-reload-baseline.ts +112 -0
- package/src/hotkeys.ts +104 -0
- package/src/index.ts +171 -0
- package/src/messages.ts +34 -0
- package/src/store/api.ts +262 -0
- package/src/store/load.ts +175 -0
- package/src/store/merge.ts +69 -0
- package/src/store/paths.ts +38 -0
- package/src/store/save.ts +75 -0
- package/src/store/validate.ts +195 -0
- package/src/types.ts +169 -0
- package/src/ui/confirm.ts +126 -0
- package/src/ui/editor.ts +1617 -0
- package/src/ui/filter.ts +79 -0
- package/src/ui/frame.ts +109 -0
- package/src/ui/hotkey-input.ts +242 -0
- package/src/ui/info-dialog.ts +118 -0
- package/src/ui/labels.ts +51 -0
- package/src/ui/picker-state.ts +151 -0
- package/src/ui/picker.ts +982 -0
- package/src/ui/reload-prompt.ts +59 -0
- package/src/ui/status.ts +55 -0
- package/src/ui/widgets.ts +274 -0
package/src/store/api.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High-level storage API for presets.
|
|
3
|
+
*
|
|
4
|
+
* Owns the operations the rest of the extension calls to read and mutate
|
|
5
|
+
* presets across both scopes: `loadAll`, `saveScope`, and the CRUD
|
|
6
|
+
* primitives (`addPreset`, `updatePreset`, `removePreset`,
|
|
7
|
+
* `reorderWithinScope`). Storage is cache-free — every call re-reads
|
|
8
|
+
* from disk — and mutations that would violate file invariants return an
|
|
9
|
+
* `Err` result rather than throwing.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
annotateAndAnalyzeHotkeys,
|
|
13
|
+
type HotkeyAnalysis,
|
|
14
|
+
} from "../hotkey-conflicts.js";
|
|
15
|
+
import type {
|
|
16
|
+
LoadedPreset,
|
|
17
|
+
Preset,
|
|
18
|
+
PresetScope,
|
|
19
|
+
PresetsFile,
|
|
20
|
+
} from "../types.js";
|
|
21
|
+
import { loadFile } from "./load.js";
|
|
22
|
+
import { mergeScopes } from "./merge.js";
|
|
23
|
+
import { getGlobalPresetsPath, getProjectPresetsPath } from "./paths.js";
|
|
24
|
+
import { atomicWrite } from "./save.js";
|
|
25
|
+
import { computeClampWarning } from "./validate.js";
|
|
26
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
27
|
+
|
|
28
|
+
/** Result of {@link loadAll}. */
|
|
29
|
+
interface LoadAllResult {
|
|
30
|
+
hotkeyAnalysis: HotkeyAnalysis;
|
|
31
|
+
presets: LoadedPreset[];
|
|
32
|
+
warnings: string[];
|
|
33
|
+
}
|
|
34
|
+
/** Result type for mutating operations: success carries no payload. */
|
|
35
|
+
type SaveResult = { ok: true } | { ok: false; reason: string };
|
|
36
|
+
/** Subset of `ExtensionContext` the storage API actually needs. */
|
|
37
|
+
type StorageContext = Pick<ExtensionContext, "cwd" | "modelRegistry">;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Append a preset to the named scope.
|
|
41
|
+
*
|
|
42
|
+
* Returns an `Err` result when the new name collides with an existing
|
|
43
|
+
* preset in the same scope. Callers in later UI changes can map this to
|
|
44
|
+
* a friendly "name already exists" notification.
|
|
45
|
+
*/
|
|
46
|
+
export async function addPreset(
|
|
47
|
+
preset: Preset,
|
|
48
|
+
presetScope: PresetScope,
|
|
49
|
+
ctx: StorageContext,
|
|
50
|
+
): Promise<SaveResult> {
|
|
51
|
+
const current = await readScope(presetScope, ctx);
|
|
52
|
+
|
|
53
|
+
if (current.some((existing) => existing.name === preset.name)) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
reason: `a preset named "${preset.name}" already exists in scope "${presetScope}".`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const next = [...current, preset];
|
|
61
|
+
|
|
62
|
+
await saveScope(presetScope, next, ctx);
|
|
63
|
+
|
|
64
|
+
return { ok: true };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read both scope files and return the merged, ordered, scope-tagged
|
|
69
|
+
* preset list with availability computed.
|
|
70
|
+
*/
|
|
71
|
+
export async function loadAll(ctx: StorageContext): Promise<LoadAllResult> {
|
|
72
|
+
const [user, project] = await Promise.all([
|
|
73
|
+
loadFile(getGlobalPresetsPath()),
|
|
74
|
+
loadFile(getProjectPresetsPath(ctx.cwd)),
|
|
75
|
+
]);
|
|
76
|
+
const presets = mergeScopes(
|
|
77
|
+
{ user: user.presets, project: project.presets },
|
|
78
|
+
ctx,
|
|
79
|
+
).map((preset) => ({
|
|
80
|
+
...preset,
|
|
81
|
+
...(computeClampWarning(preset, ctx)
|
|
82
|
+
? { clampWarning: true as const }
|
|
83
|
+
: {}),
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
const hotkeyAnalysis = annotateAndAnalyzeHotkeys(presets);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
hotkeyAnalysis,
|
|
90
|
+
presets,
|
|
91
|
+
warnings: [...user.warnings, ...project.warnings],
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Remove a preset by name. No-op (returns `{ ok: true }`) when the named
|
|
97
|
+
* preset does not exist; this matches the "idempotent delete" expectation
|
|
98
|
+
* the spec calls out.
|
|
99
|
+
*/
|
|
100
|
+
export async function removePreset(
|
|
101
|
+
name: string,
|
|
102
|
+
scope: PresetScope,
|
|
103
|
+
ctx: StorageContext,
|
|
104
|
+
): Promise<SaveResult> {
|
|
105
|
+
const current = await readScope(scope, ctx);
|
|
106
|
+
const next = current.filter((existing) => existing.name !== name);
|
|
107
|
+
|
|
108
|
+
if (next.length === current.length) return { ok: true };
|
|
109
|
+
await saveScope(scope, next, ctx);
|
|
110
|
+
|
|
111
|
+
return { ok: true };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Reorder presets within a scope according to the supplied name list.
|
|
116
|
+
*
|
|
117
|
+
* Defensive behavior: any names not present in `orderedNames` keep their
|
|
118
|
+
* relative file order and are appended after the explicitly-ordered
|
|
119
|
+
* entries. Names in `orderedNames` that don't match any existing preset
|
|
120
|
+
* are silently ignored — this matters when the caller's UI snapshot is
|
|
121
|
+
* slightly stale (e.g. a delete happened between picker render and reorder
|
|
122
|
+
* commit).
|
|
123
|
+
*/
|
|
124
|
+
export async function reorderWithinScope(
|
|
125
|
+
scope: PresetScope,
|
|
126
|
+
orderedNames: readonly string[],
|
|
127
|
+
ctx: StorageContext,
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
const current = await readScope(scope, ctx);
|
|
130
|
+
const byName = new Map(
|
|
131
|
+
current.map((preset) => [preset.name, preset] as const),
|
|
132
|
+
);
|
|
133
|
+
const seen = new Set<string>();
|
|
134
|
+
const ordered: Preset[] = [];
|
|
135
|
+
|
|
136
|
+
for (const name of orderedNames) {
|
|
137
|
+
const preset = byName.get(name);
|
|
138
|
+
|
|
139
|
+
if (!preset || seen.has(name)) continue;
|
|
140
|
+
ordered.push(preset);
|
|
141
|
+
seen.add(name);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const preset of current) {
|
|
145
|
+
if (!seen.has(preset.name)) {
|
|
146
|
+
ordered.push(preset);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
await saveScope(scope, ordered, ctx);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Atomically rewrite a single scope's file with the given preset list.
|
|
155
|
+
*
|
|
156
|
+
* The serialized shape is always `{ version: 1, presets }`; only typed
|
|
157
|
+
* fields on `Preset` are emitted. Callers are responsible for ordering
|
|
158
|
+
* and uniqueness; this function just persists.
|
|
159
|
+
*/
|
|
160
|
+
export async function saveScope(
|
|
161
|
+
scope: PresetScope,
|
|
162
|
+
presets: readonly Preset[],
|
|
163
|
+
ctx: StorageContext,
|
|
164
|
+
): Promise<void> {
|
|
165
|
+
const file: PresetsFile = {
|
|
166
|
+
version: 1,
|
|
167
|
+
presets: presets.map(serializePreset),
|
|
168
|
+
};
|
|
169
|
+
const path = pathForScope(scope, ctx);
|
|
170
|
+
|
|
171
|
+
await atomicWrite(path, `${JSON.stringify(file, null, 2)}\n`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Replace an existing preset by name.
|
|
176
|
+
*
|
|
177
|
+
* Supports renaming: `next.name` may differ from `oldName`. Position in
|
|
178
|
+
* the file is preserved. Returns `Err` when:
|
|
179
|
+
* - no preset with `oldName` exists in `scope`
|
|
180
|
+
* - the rename would collide with another preset's name
|
|
181
|
+
*/
|
|
182
|
+
export async function updatePreset(
|
|
183
|
+
oldName: string,
|
|
184
|
+
scope: PresetScope,
|
|
185
|
+
next: Preset,
|
|
186
|
+
ctx: StorageContext,
|
|
187
|
+
): Promise<SaveResult> {
|
|
188
|
+
const current = await readScope(scope, ctx);
|
|
189
|
+
const index = current.findIndex((existing) => existing.name === oldName);
|
|
190
|
+
|
|
191
|
+
if (index === -1) {
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
reason: `no preset named "${oldName}" in scope "${scope}".`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (
|
|
199
|
+
next.name !== oldName &&
|
|
200
|
+
current.some(
|
|
201
|
+
(existing, existingIndex) =>
|
|
202
|
+
existingIndex !== index && existing.name === next.name,
|
|
203
|
+
)
|
|
204
|
+
) {
|
|
205
|
+
return {
|
|
206
|
+
ok: false,
|
|
207
|
+
reason: `a preset named "${next.name}" already exists in scope "${scope}".`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const updated = [...current];
|
|
212
|
+
|
|
213
|
+
updated[index] = next;
|
|
214
|
+
await saveScope(scope, updated, ctx);
|
|
215
|
+
|
|
216
|
+
return { ok: true };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function pathForScope(presetScope: PresetScope, ctx: StorageContext): string {
|
|
220
|
+
return presetScope === "user"
|
|
221
|
+
? getGlobalPresetsPath()
|
|
222
|
+
: getProjectPresetsPath(ctx.cwd);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Read a single scope file, ignoring warnings. Used by the CRUD helpers
|
|
227
|
+
* which don't have a UI to surface warnings to. The next `loadAll` call
|
|
228
|
+
* (and therefore the next `/presets list` / `/presets reload`) will see
|
|
229
|
+
* any persistent warnings.
|
|
230
|
+
*/
|
|
231
|
+
async function readScope(
|
|
232
|
+
presetScope: PresetScope,
|
|
233
|
+
ctx: StorageContext,
|
|
234
|
+
): Promise<Preset[]> {
|
|
235
|
+
const path = pathForScope(presetScope, ctx);
|
|
236
|
+
const result = await loadFile(path);
|
|
237
|
+
|
|
238
|
+
return result.presets;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Serialize a `Preset` into the on-disk shape, dropping `undefined`
|
|
243
|
+
* fields so the JSON stays clean. Round-tripping `LoadedPreset`-derived
|
|
244
|
+
* values (which carry merge metadata) strips `scope`, `shadowed`, and
|
|
245
|
+
* `unavailable` automatically.
|
|
246
|
+
*/
|
|
247
|
+
function serializePreset(preset: Preset): Preset {
|
|
248
|
+
const out: Preset = {
|
|
249
|
+
name: preset.name,
|
|
250
|
+
provider: preset.provider,
|
|
251
|
+
model: preset.model,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
if (preset.thinkingLevel !== undefined)
|
|
255
|
+
out.thinkingLevel = preset.thinkingLevel;
|
|
256
|
+
if (preset.tools !== undefined) out.tools = [...preset.tools];
|
|
257
|
+
if (preset.instructions !== undefined) out.instructions = preset.instructions;
|
|
258
|
+
if (preset.hotkey !== undefined) out.hotkey = preset.hotkey;
|
|
259
|
+
if (preset.order !== undefined) out.order = preset.order;
|
|
260
|
+
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-file loader for preset storage.
|
|
3
|
+
*
|
|
4
|
+
* Owns reading one scope file from disk and turning it into a list of
|
|
5
|
+
* valid presets plus human-readable warnings; it does NOT own merging
|
|
6
|
+
* across scopes, availability computation, or any mutation.
|
|
7
|
+
*/
|
|
8
|
+
import { readFile } from "node:fs/promises";
|
|
9
|
+
|
|
10
|
+
import type { Preset } from "../types.js";
|
|
11
|
+
import { findDuplicatePresetNames, validatePresetShape } from "./validate.js";
|
|
12
|
+
|
|
13
|
+
/** Output of {@link loadFile}. */
|
|
14
|
+
interface LoadFileResult {
|
|
15
|
+
/** Presets that passed shape and uniqueness checks, in file order. */
|
|
16
|
+
presets: Preset[];
|
|
17
|
+
/** Human-readable warnings; safe to surface verbatim via `ctx.ui.notify`. */
|
|
18
|
+
warnings: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Read and parse a single preset file.
|
|
23
|
+
*
|
|
24
|
+
* Behavior is fully described by the storage spec:
|
|
25
|
+
*
|
|
26
|
+
* | Condition | Result |
|
|
27
|
+
* | ---------------------------- | --------------------------------------- |
|
|
28
|
+
* | file does not exist | `{ presets: [], warnings: [] }` |
|
|
29
|
+
* | other read error | `{ presets: [], warnings: [...] }` |
|
|
30
|
+
* | invalid JSON | `{ presets: [], warnings: [...] }` |
|
|
31
|
+
* | top-level not an object | `{ presets: [], warnings: [...] }` |
|
|
32
|
+
* | unsupported `version` | `{ presets: [], warnings: [...] }` |
|
|
33
|
+
* | missing `presets` array | `{ presets: [], warnings: [...] }` |
|
|
34
|
+
* | per-preset shape error | skip preset, warn, keep the rest |
|
|
35
|
+
* | duplicate name within file | skip later occurrences, warn, keep first|
|
|
36
|
+
*/
|
|
37
|
+
export async function loadFile(path: string): Promise<LoadFileResult> {
|
|
38
|
+
let rawData: string;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
rawData = await readFile(path, "utf-8");
|
|
42
|
+
} catch (err) {
|
|
43
|
+
// `ENOENT` is the only error that does not warrant a warning: a
|
|
44
|
+
// missing file is the normal "no presets configured yet" state.
|
|
45
|
+
if (isNotFoundError(err)) return emptyResult();
|
|
46
|
+
|
|
47
|
+
return emptyResult(
|
|
48
|
+
`Failed to read preset file ${path}: ${describeError(err)}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let parsedData: unknown;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
parsedData = JSON.parse(rawData);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
return emptyResult(
|
|
58
|
+
`The preset file ${path} contains invalid JSON: ${describeError(err)}`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (
|
|
63
|
+
typeof parsedData !== "object" ||
|
|
64
|
+
parsedData === null ||
|
|
65
|
+
Array.isArray(parsedData)
|
|
66
|
+
) {
|
|
67
|
+
return emptyResult(
|
|
68
|
+
`The preset file ${path} top-level must be an object with a "version" and "presets" field.`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const obj = parsedData as Record<string, unknown>;
|
|
73
|
+
|
|
74
|
+
if (obj.version !== 1) {
|
|
75
|
+
return emptyResult(
|
|
76
|
+
`The preset file ${path} declares unsupported version ${JSON.stringify(obj.version)}; expected 1. File ignored and left untouched.`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!Array.isArray(obj.presets)) {
|
|
81
|
+
return emptyResult(
|
|
82
|
+
`The preset file ${path} is missing a top-level "presets" array.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const warnings: string[] = [];
|
|
87
|
+
const validatedPresets: Preset[] = [];
|
|
88
|
+
const rawPresets: unknown[] = obj.presets;
|
|
89
|
+
|
|
90
|
+
// First pass: shape validation. Skip-and-warn on individual offenders so
|
|
91
|
+
// one broken preset never disables the whole file.
|
|
92
|
+
for (let i = 0; i < rawPresets.length; i++) {
|
|
93
|
+
const candidatePreset = rawPresets[i];
|
|
94
|
+
const result = validatePresetShape(candidatePreset);
|
|
95
|
+
|
|
96
|
+
if (!result.ok) {
|
|
97
|
+
const label = describeInvalidPreset(candidatePreset, i);
|
|
98
|
+
|
|
99
|
+
warnings.push(
|
|
100
|
+
`Preset ${label} in ${path} skipped: ${result.reason ?? "Invalid shape"}.`,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// validatePresetShape narrows to "object with required fields"; cast is safe.
|
|
107
|
+
validatedPresets.push(candidatePreset as Preset);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Second pass: duplicate name detection. The first occurrence wins.
|
|
111
|
+
const duplicatePresetNames = findDuplicatePresetNames(validatedPresets);
|
|
112
|
+
|
|
113
|
+
if (duplicatePresetNames.length > 0) {
|
|
114
|
+
const dropIndices = new Set(
|
|
115
|
+
duplicatePresetNames.map((duplicate) => duplicate.index),
|
|
116
|
+
);
|
|
117
|
+
const uniquePresets: Preset[] = [];
|
|
118
|
+
|
|
119
|
+
for (let i = 0; i < validatedPresets.length; i++) {
|
|
120
|
+
if (dropIndices.has(i)) {
|
|
121
|
+
const dropped = validatedPresets[i];
|
|
122
|
+
|
|
123
|
+
if (dropped) {
|
|
124
|
+
warnings.push(
|
|
125
|
+
`Preset "${dropped.name}" in ${path} skipped: duplicate name (first occurrence kept).`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const keep = validatedPresets[i];
|
|
133
|
+
|
|
134
|
+
if (keep) uniquePresets.push(keep);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { presets: uniquePresets, warnings };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { presets: validatedPresets, warnings };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function describeError(err: unknown): string {
|
|
144
|
+
if (err instanceof Error) return err.message;
|
|
145
|
+
|
|
146
|
+
return String(err);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Best-effort label for an invalid preset entry in warning text. */
|
|
150
|
+
function describeInvalidPreset(preset: unknown, index: number): string {
|
|
151
|
+
if (
|
|
152
|
+
typeof preset === "object" &&
|
|
153
|
+
preset !== null &&
|
|
154
|
+
!Array.isArray(preset) &&
|
|
155
|
+
typeof (preset as { name?: unknown }).name === "string" &&
|
|
156
|
+
(preset as { name: string }).name.length > 0
|
|
157
|
+
) {
|
|
158
|
+
return `"${(preset as { name: string }).name}"`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return `at index ${index}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Empty result used whenever the file cannot be read or is malformed. */
|
|
165
|
+
function emptyResult(warning?: string): LoadFileResult {
|
|
166
|
+
return { presets: [], warnings: warning ? [warning] : [] };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isNotFoundError(err: unknown): boolean {
|
|
170
|
+
if (typeof err !== "object" || err === null || !("code" in err)) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return err.code === "ENOENT";
|
|
175
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope merge for preset storage.
|
|
3
|
+
*
|
|
4
|
+
* Owns combining the per-scope loader outputs into the single ordered
|
|
5
|
+
* `LoadedPreset[]` exposed by `loadAll`, including scope tagging,
|
|
6
|
+
* shadowing, and availability tagging. Pure: no I/O, no logging.
|
|
7
|
+
*/
|
|
8
|
+
import type { LoadedPreset, Preset } from "../types.js";
|
|
9
|
+
import { computeAvailability } from "./validate.js";
|
|
10
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
/** Per-scope inputs to {@link mergeScopes}. */
|
|
13
|
+
interface MergeScopesInput {
|
|
14
|
+
/** Presets from the global / user-scope file, in file order. */
|
|
15
|
+
user: readonly Preset[];
|
|
16
|
+
/** Presets from the project-scope file, in file order. */
|
|
17
|
+
project: readonly Preset[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Merge two scopes into a single ordered list.
|
|
22
|
+
*
|
|
23
|
+
* Globals are emitted first, then projects, each preserving file order.
|
|
24
|
+
* Globals that share a name with a project entry are tagged
|
|
25
|
+
* `shadowed: true` (still emitted, never dropped). Availability is
|
|
26
|
+
* computed for every entry.
|
|
27
|
+
*/
|
|
28
|
+
export function mergeScopes(
|
|
29
|
+
input: MergeScopesInput,
|
|
30
|
+
ctx: Pick<ExtensionContext, "modelRegistry">,
|
|
31
|
+
): LoadedPreset[] {
|
|
32
|
+
const projectNames = new Set(input.project.map((preset) => preset.name));
|
|
33
|
+
const out: LoadedPreset[] = [];
|
|
34
|
+
|
|
35
|
+
for (const userPreset of input.user) {
|
|
36
|
+
out.push({
|
|
37
|
+
...userPreset,
|
|
38
|
+
scope: "user",
|
|
39
|
+
...(projectNames.has(userPreset.name) ? { shadowed: true } : {}),
|
|
40
|
+
...availabilityField(userPreset, ctx),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const projectPreset of input.project) {
|
|
45
|
+
out.push({
|
|
46
|
+
...projectPreset,
|
|
47
|
+
scope: "project",
|
|
48
|
+
...availabilityField(projectPreset, ctx),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Spread-helper that returns either `{}` or `{ unavailable: <reason> }`.
|
|
57
|
+
*
|
|
58
|
+
* Keeps `LoadedPreset.unavailable` cleanly absent for available presets
|
|
59
|
+
* (rather than serializing `unavailable: undefined`) and avoids the
|
|
60
|
+
* caller having to do conditional assignment at every call site.
|
|
61
|
+
*/
|
|
62
|
+
function availabilityField(
|
|
63
|
+
preset: Pick<Preset, "provider" | "model">,
|
|
64
|
+
ctx: Pick<ExtensionContext, "modelRegistry">,
|
|
65
|
+
): { unavailable?: "no-key" | "no-model" } {
|
|
66
|
+
const reason = computeAvailability(preset, ctx);
|
|
67
|
+
|
|
68
|
+
return reason ? { unavailable: reason } : {};
|
|
69
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path resolution for preset storage files.
|
|
3
|
+
*
|
|
4
|
+
* Owns resolving the absolute on-disk location of each scope's presets
|
|
5
|
+
* file (global under the agent dir, project under `<cwd>/.pi/`); it does
|
|
6
|
+
* NOT perform any I/O.
|
|
7
|
+
*/
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
/** File name for the preset list within `PRESETS_PLUS_SUBDIR`. */
|
|
13
|
+
const PRESETS_FILE_NAME = "presets.json";
|
|
14
|
+
/** Subdirectory under both scopes that contains preset-related files. */
|
|
15
|
+
const PRESETS_PLUS_SUBDIR = "presets-plus";
|
|
16
|
+
/** Project-scope parent directory under the project root. */
|
|
17
|
+
const PROJECT_PI_DIR = ".pi";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Absolute path to the global / user-scope preset file.
|
|
21
|
+
*
|
|
22
|
+
* Uses pi's `getAgentDir()` by default (typically `~/.pi/agent`); pass an
|
|
23
|
+
* override only from tests that want to point at a tmp dir without
|
|
24
|
+
* patching environment variables.
|
|
25
|
+
*/
|
|
26
|
+
export function getGlobalPresetsPath(agentDir: string = getAgentDir()): string {
|
|
27
|
+
return join(agentDir, PRESETS_PLUS_SUBDIR, PRESETS_FILE_NAME);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Absolute path to the project-scope preset file for the given working dir.
|
|
32
|
+
*
|
|
33
|
+
* Mirrors pi's convention of placing project-local config under `<cwd>/.pi/`.
|
|
34
|
+
* The caller is expected to pass `ctx.cwd` from the extension context.
|
|
35
|
+
*/
|
|
36
|
+
export function getProjectPresetsPath(cwd: string): string {
|
|
37
|
+
return join(cwd, PROJECT_PI_DIR, PRESETS_PLUS_SUBDIR, PRESETS_FILE_NAME);
|
|
38
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic file writes for preset storage.
|
|
3
|
+
*
|
|
4
|
+
* Owns the durable write primitive (`mkdir -p` → tmp file → `fsync` →
|
|
5
|
+
* `rename`) used everywhere presets-plus persists user-visible state, so
|
|
6
|
+
* the destination is never observed in a partially-written form.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdir, open, rename, unlink } from "node:fs/promises";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Subset of `node:fs/promises` we depend on. Exposed so tests can inject
|
|
13
|
+
* a stub that simulates rename failures (Node's ESM exports of native
|
|
14
|
+
* modules are not spy-able via vitest).
|
|
15
|
+
*/
|
|
16
|
+
interface AtomicWriteFs {
|
|
17
|
+
mkdir: typeof mkdir;
|
|
18
|
+
open: typeof open;
|
|
19
|
+
rename: typeof rename;
|
|
20
|
+
unlink: typeof unlink;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const defaultFs: AtomicWriteFs = { mkdir, open, rename, unlink };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Atomically write `contents` to `target`.
|
|
27
|
+
*
|
|
28
|
+
* Throws on I/O failure; the destination is never partially written. The
|
|
29
|
+
* caller is responsible for serializing concurrent writes within the
|
|
30
|
+
* same process if it wants stricter ordering than last-write-wins.
|
|
31
|
+
*
|
|
32
|
+
* @param fs Override the underlying `node:fs/promises` calls (for tests).
|
|
33
|
+
*/
|
|
34
|
+
export async function atomicWrite(
|
|
35
|
+
target: string,
|
|
36
|
+
contents: string,
|
|
37
|
+
fs: AtomicWriteFs = defaultFs,
|
|
38
|
+
): Promise<void> {
|
|
39
|
+
const dir = dirname(target);
|
|
40
|
+
|
|
41
|
+
await fs.mkdir(dir, { recursive: true });
|
|
42
|
+
|
|
43
|
+
const temporaryFilePath = makeTmpPath(target);
|
|
44
|
+
let renamed = false;
|
|
45
|
+
const fileHandle = await fs.open(temporaryFilePath, "w");
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
try {
|
|
49
|
+
await fileHandle.writeFile(contents);
|
|
50
|
+
await fileHandle.sync();
|
|
51
|
+
} finally {
|
|
52
|
+
await fileHandle.close();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await fs.rename(temporaryFilePath, target);
|
|
56
|
+
renamed = true;
|
|
57
|
+
} finally {
|
|
58
|
+
if (!renamed) {
|
|
59
|
+
// Best effort: don't mask the original error if cleanup fails.
|
|
60
|
+
await fs.unlink(temporaryFilePath).catch(() => undefined);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build a tmp file path co-located with `target` so the rename is on the
|
|
67
|
+
* same filesystem and therefore guaranteed atomic.
|
|
68
|
+
*
|
|
69
|
+
* Uses `process.pid` and a high-resolution timestamp to avoid collisions
|
|
70
|
+
* between concurrent writers; `process.hrtime.bigint()` is monotonic
|
|
71
|
+
* within a single process so the suffix never repeats per call.
|
|
72
|
+
*/
|
|
73
|
+
export function makeTmpPath(target: string): string {
|
|
74
|
+
return `${target}.tmp.${process.pid}.${process.hrtime.bigint().toString(36)}`;
|
|
75
|
+
}
|