pi-bro 0.20.0 → 0.22.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/settings.ts ADDED
@@ -0,0 +1,505 @@
1
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
+ import { randomUUID } from "node:crypto";
3
+ import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import {
6
+ type BackendSelection,
7
+ agySelection,
8
+ CLAUDE_EFFORTS,
9
+ GROK_EFFORTS,
10
+ CODEX_EFFORTS,
11
+ MUSE_EFFORTS,
12
+ } from "./backend.ts";
13
+ import { type BroMode, DEFAULT_BRO_MODE, parseBroMode } from "./prompt.ts";
14
+ import { isRecord } from "./util.ts";
15
+
16
+ export const EFFORTS = ["default", "low", "medium", "high"] as const;
17
+ export const BACKENDS = ["agy", "claude", "grok", "codex", "muse"] as const;
18
+ export type BackendName = (typeof BACKENDS)[number];
19
+ export type BroEffort = "default" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
20
+ export type AgyEffort = Exclude<BroEffort, "default" | "minimal" | "xhigh" | "max">;
21
+ export type ClaudeEffort = (typeof CLAUDE_EFFORTS)[number];
22
+ export { GROK_EFFORTS, CODEX_EFFORTS, MUSE_EFFORTS };
23
+ export type GrokEffort = (typeof GROK_EFFORTS)[number];
24
+ export type CodexEffort = (typeof CODEX_EFFORTS)[number];
25
+ export type MuseEffort = (typeof MUSE_EFFORTS)[number];
26
+
27
+ export const CAPABILITIES = ["explain", "show", "btw", "advisor", "review"] as const;
28
+ export type Capability = (typeof CAPABILITIES)[number];
29
+ export const CAPABILITY_LABELS: Record<Capability, string> = {
30
+ explain: "Explain",
31
+ show: "Show",
32
+ btw: "Btw",
33
+ advisor: "Advisor",
34
+ review: "Guided review",
35
+ };
36
+
37
+ export type ModelEffortPair = { backend?: BackendName; model: string; effort: BroEffort };
38
+ export type BroSettings = {
39
+ backend?: BackendName;
40
+ model: string;
41
+ effort: BroEffort;
42
+ mode: BroMode;
43
+ showTurns: number;
44
+ overrides: Partial<Record<Capability, ModelEffortPair>>;
45
+ };
46
+
47
+ export const CLAUDE_MODELS = [
48
+ { id: "sonnet", label: "Claude Sonnet" },
49
+ { id: "opus", label: "Claude Opus" },
50
+ ] as const;
51
+
52
+ export const GROK_MODELS = [
53
+ { id: "grok-4.7", label: "Grok 4.7" },
54
+ { id: "grok-4.7-build-fast", label: "Grok 4.7 Build Fast" },
55
+ ] as const;
56
+
57
+ export const CODEX_MODELS = [
58
+ { id: "gpt-5.5", label: "GPT-5.5" },
59
+ { id: "gpt-5.4", label: "GPT-5.4" },
60
+ ] as const;
61
+
62
+ export const MUSE_MODELS = [
63
+ { id: "muse-spark-1.3-contributor", label: "Muse Spark 1.3 Contributor" },
64
+ { id: "muse-spark-1.3", label: "Muse Spark 1.3" },
65
+ ] as const;
66
+
67
+ export type AgyModelFamily = {
68
+ id: string;
69
+ label: string;
70
+ efforts: AgyEffort[];
71
+ variants: Array<{ id: string; effort?: AgyEffort }>;
72
+ };
73
+
74
+ export const DEFAULT_SHOW_TURNS = 1;
75
+
76
+ export function parseBackend(value: unknown, context: string): BackendName {
77
+ if (typeof value !== "string" || !BACKENDS.some((backend) => backend === value)) {
78
+ throw new Error(`${context} backend must be "agy", "claude", "grok", "codex", or "muse".`);
79
+ }
80
+ return value as BackendName;
81
+ }
82
+
83
+ export function isAgyEffort(effort: unknown): effort is AgyEffort | "default" {
84
+ return EFFORTS.some((item) => item === effort);
85
+ }
86
+
87
+ export function isClaudeEffort(effort: unknown): effort is ClaudeEffort | "default" {
88
+ return effort === "default" || CLAUDE_EFFORTS.some((item) => item === effort);
89
+ }
90
+
91
+ export function isGrokEffort(effort: unknown): effort is GrokEffort | "default" {
92
+ return effort === "default" || GROK_EFFORTS.some((item) => item === effort);
93
+ }
94
+
95
+ export function isCodexEffort(effort: unknown): effort is CodexEffort | "default" {
96
+ return effort === "default" || CODEX_EFFORTS.some((item) => item === effort);
97
+ }
98
+
99
+ export function isMuseEffort(effort: unknown): effort is MuseEffort | "default" {
100
+ return effort === "default" || MUSE_EFFORTS.some((item) => item === effort);
101
+ }
102
+
103
+ // Sonnet/opus aliases resolve case-insensitively; any other non-empty string passes
104
+ // through untouched as an explicit user-entered model ID.
105
+ export function resolveClaudeModel(input: string): string {
106
+ const trimmed = input.trim();
107
+ if (!trimmed) throw new Error("Claude model must be a non-empty model ID (sonnet, opus, or an explicit model ID).");
108
+ const alias = CLAUDE_MODELS.find((model) => model.id === trimmed.toLowerCase());
109
+ return alias ? alias.id : trimmed;
110
+ }
111
+
112
+ // Grok seeds resolve to themselves; any other non-empty string passes through
113
+ // untouched as an explicit user-entered model ID (installed `grok models` confirms).
114
+ export function resolveGrokModel(input: string): string {
115
+ const trimmed = input.trim();
116
+ if (!trimmed) throw new Error("Grok model must be a non-empty model ID (grok-4.7, grok-4.7-build-fast, or an explicit model ID).");
117
+ return trimmed;
118
+ }
119
+
120
+ // Codex seeds resolve to themselves; any other non-empty string passes through
121
+ // untouched as an explicit user-entered model ID.
122
+ export function resolveCodexModel(input: string): string {
123
+ const trimmed = input.trim();
124
+ if (!trimmed) throw new Error("Codex model must be a non-empty model ID (gpt-5.5, gpt-5.4, or an explicit model ID).");
125
+ return trimmed;
126
+ }
127
+
128
+ // Muse seeds resolve to themselves; any other non-empty string passes through
129
+ // untouched as an explicit user-entered model ID.
130
+ export function resolveMuseModel(input: string): string {
131
+ const trimmed = input.trim();
132
+ if (!trimmed) throw new Error("Muse model must be a non-empty model ID (muse-spark-1.3-contributor, muse-spark-1.3, or an explicit model ID).");
133
+ return trimmed;
134
+ }
135
+
136
+ export type ExternalBackend = "claude" | "grok" | "codex" | "muse";
137
+
138
+ export interface ExternalBackendMeta {
139
+ readonly name: ExternalBackend;
140
+ readonly label: string;
141
+ readonly prefix: string;
142
+ readonly efforts: readonly BroEffort[];
143
+ readonly models: readonly { id: string; label: string }[];
144
+ readonly effortHelp: string;
145
+ isEffort(effort: unknown): effort is BroEffort;
146
+ resolveModel(input: string): string;
147
+ }
148
+
149
+ export const EXTERNAL_BACKENDS: Record<ExternalBackend, ExternalBackendMeta> = {
150
+ claude: {
151
+ name: "claude",
152
+ label: "Claude",
153
+ prefix: "claude:",
154
+ efforts: CLAUDE_EFFORTS,
155
+ models: CLAUDE_MODELS,
156
+ effortHelp: '"default", "low", "medium", "high", "xhigh", or "max"',
157
+ isEffort: isClaudeEffort,
158
+ resolveModel: resolveClaudeModel,
159
+ },
160
+ grok: {
161
+ name: "grok",
162
+ label: "Grok",
163
+ prefix: "grok:",
164
+ efforts: GROK_EFFORTS,
165
+ models: GROK_MODELS,
166
+ effortHelp: '"default", "low", "medium", "high", or "xhigh"',
167
+ isEffort: isGrokEffort,
168
+ resolveModel: resolveGrokModel,
169
+ },
170
+ codex: {
171
+ name: "codex",
172
+ label: "Codex",
173
+ prefix: "codex:",
174
+ efforts: CODEX_EFFORTS,
175
+ models: CODEX_MODELS,
176
+ effortHelp: '"default", "low", "medium", "high", or "xhigh"',
177
+ isEffort: isCodexEffort,
178
+ resolveModel: resolveCodexModel,
179
+ },
180
+ muse: {
181
+ name: "muse",
182
+ label: "Muse",
183
+ prefix: "muse:",
184
+ efforts: MUSE_EFFORTS,
185
+ models: MUSE_MODELS,
186
+ effortHelp: '"default", "minimal", "low", "medium", "high", "xhigh", or "max"',
187
+ isEffort: isMuseEffort,
188
+ resolveModel: resolveMuseModel,
189
+ },
190
+ };
191
+
192
+ export function parseBackendOption(value: string): { backend: ExternalBackend; id: string } | undefined {
193
+ for (const meta of Object.values(EXTERNAL_BACKENDS)) {
194
+ if (value.startsWith(meta.prefix) && value.length > meta.prefix.length) {
195
+ return { backend: meta.name, id: value.slice(meta.prefix.length) };
196
+ }
197
+ }
198
+ return undefined;
199
+ }
200
+
201
+ export function parseModelEffortPair(value: unknown, context: string): ModelEffortPair {
202
+ if (!isRecord(value) || typeof value.model !== "string" || !value.model.trim()) {
203
+ throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", or "high".`);
204
+ }
205
+ const backend = value.backend === undefined ? undefined : parseBackend(value.backend, context);
206
+ if (backend && backend in EXTERNAL_BACKENDS) {
207
+ const meta = EXTERNAL_BACKENDS[backend as ExternalBackend];
208
+ if (!meta.isEffort(value.effort)) {
209
+ throw new Error(`${context} must contain a model and effort set to ${meta.effortHelp}.`);
210
+ }
211
+ return { backend, model: value.model.trim(), effort: value.effort };
212
+ }
213
+ if (!isAgyEffort(value.effort)) {
214
+ throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", or "high".`);
215
+ }
216
+ const pair: ModelEffortPair = { model: value.model.trim(), effort: value.effort };
217
+ if (backend !== undefined) pair.backend = backend;
218
+ return pair;
219
+ }
220
+
221
+ export function parseOverrides(value: unknown): Partial<Record<Capability, ModelEffortPair>> {
222
+ if (value === undefined) return {};
223
+ if (!isRecord(value)) throw new Error("Settings overrides must be an object.");
224
+ const overrides: Partial<Record<Capability, ModelEffortPair>> = {};
225
+ for (const capability of CAPABILITIES) {
226
+ if (value[capability] === undefined) continue;
227
+ overrides[capability] = parseModelEffortPair(value[capability], `Settings overrides.${capability}`);
228
+ }
229
+ return overrides;
230
+ }
231
+
232
+ export function parseBroSettings(value: unknown): BroSettings {
233
+ if (!isRecord(value)) {
234
+ throw new Error('Settings must contain a model and effort set to "default", "low", "medium", or "high".');
235
+ }
236
+ // v2 disk shape: { version: 2, default: { backend, model, effort }, mode, showTurns, overrides }.
237
+ // Reads never rewrite: a legacy flat file keeps parsing into backend-less (Agy) settings.
238
+ if (value.version !== undefined && value.version !== 2) throw new Error(`Unsupported settings version ${JSON.stringify(value.version)}.`);
239
+ if (value.version === 2 && value.default === undefined) throw new Error("Settings version 2 requires default.");
240
+
241
+ const isV2 = value.default !== undefined;
242
+ if (isV2 && !isRecord(value.default)) {
243
+ throw new Error("Settings default must contain a model and effort.");
244
+ }
245
+ const pair = parseModelEffortPair(isV2 ? value.default : value, isV2 ? "Settings default" : "Settings");
246
+
247
+ const mode = value.mode === undefined ? DEFAULT_BRO_MODE : parseBroMode(value.mode);
248
+ if (!mode) throw new Error('Settings mode must be "brief", "balanced", or "faithful".');
249
+
250
+ const showTurns = value.showTurns === undefined ? DEFAULT_SHOW_TURNS : value.showTurns;
251
+ if (typeof showTurns !== "number" || !Number.isInteger(showTurns) || showTurns < 1) {
252
+ throw new Error("Settings showTurns must be a positive whole number of turns.");
253
+ }
254
+
255
+ const overrides = parseOverrides(value.overrides);
256
+ const settings: BroSettings = { model: pair.model, effort: pair.effort, mode, showTurns, overrides };
257
+ if (pair.backend !== undefined) settings.backend = pair.backend;
258
+ return settings;
259
+ }
260
+
261
+ export function applyModelChange(
262
+ currentPair: ModelEffortPair,
263
+ newValue: string,
264
+ families: readonly AgyModelFamily[],
265
+ ): ModelEffortPair | undefined {
266
+ const switched = parseBackendOption(newValue);
267
+ if (switched !== undefined) {
268
+ const meta = EXTERNAL_BACKENDS[switched.backend];
269
+ const isSameBackend = currentPair.backend === switched.backend;
270
+ const effort = isSameBackend && meta.isEffort(currentPair.effort) ? currentPair.effort : "default";
271
+ return { backend: switched.backend, model: meta.resolveModel(switched.id), effort };
272
+ }
273
+
274
+ const family = families.find((item) => item.id === newValue || item.variants.some((variant) => variant.id === newValue));
275
+ if (!family) return undefined;
276
+
277
+ // Resolve current pair first so variant suffixes (e.g. gemini-a-high) resolve to their canonical effort
278
+ const resolved = resolveModelEffort(currentPair, families as AgyModelFamily[]);
279
+ const isCurrentAgy = (currentPair.backend ?? "agy") === "agy";
280
+ const effectiveEffort = (isCurrentAgy && resolved.family) ? resolved.pair.effort : currentPair.effort;
281
+
282
+ const keepCurrent = isCurrentAgy && (
283
+ effectiveEffort === "default"
284
+ ? !family.efforts.length
285
+ : family.efforts.includes(effectiveEffort as AgyEffort)
286
+ );
287
+
288
+ return {
289
+ model: family.id,
290
+ effort: keepCurrent ? effectiveEffort : preferredEffort(family),
291
+ };
292
+ }
293
+
294
+ export function applyEffortChange(
295
+ currentPair: ModelEffortPair,
296
+ newValue: string,
297
+ families: readonly AgyModelFamily[],
298
+ ): ModelEffortPair | undefined {
299
+ const backend = currentPair.backend ?? "agy";
300
+ if (backend in EXTERNAL_BACKENDS) {
301
+ const meta = EXTERNAL_BACKENDS[backend as ExternalBackend];
302
+ if (!meta.isEffort(newValue)) return undefined;
303
+ return { backend: meta.name, model: currentPair.model, effort: newValue };
304
+ }
305
+
306
+ // Canonicalize Agy model to family ID to prevent detached variant suffixes
307
+ const resolved = resolveModelEffort(currentPair, families as AgyModelFamily[]);
308
+ return {
309
+ model: resolved.family?.id ?? currentPair.model,
310
+ effort: newValue as BroEffort,
311
+ };
312
+ }
313
+
314
+ export function capabilityOverride(settings: BroSettings, capability: Capability): ModelEffortPair | undefined {
315
+ return settings.overrides[capability];
316
+ }
317
+
318
+ export function capabilityPair(settings: BroSettings, capability: Capability): ModelEffortPair {
319
+ return capabilityOverride(settings, capability) ?? { ...(settings.backend ? { backend: settings.backend } : {}), model: settings.model, effort: settings.effort };
320
+ }
321
+
322
+ // An override always pins both model and effort together (never just one), so a capability's
323
+ // setting is either fully inherited or fully its own — no partial-inheritance edge cases.
324
+ //
325
+ // An override is cleared ONLY by an explicit "Default" selection (pair === undefined), never
326
+ // automatically because it happens to match the shared default: a user who deliberately pins a
327
+ // capability to the same model as the shared default wants it to STAY on that model even if the
328
+ // shared default changes later.
329
+ export function withCapabilityOverride(
330
+ settings: BroSettings,
331
+ capability: Capability,
332
+ override: ModelEffortPair | undefined,
333
+ ): BroSettings {
334
+ const nextOverrides = { ...settings.overrides };
335
+ if (override === undefined) {
336
+ delete nextOverrides[capability];
337
+ } else {
338
+ nextOverrides[capability] = override;
339
+ }
340
+ return { ...settings, overrides: nextOverrides };
341
+ }
342
+
343
+ export function resolveModelEffort(
344
+ pair: ModelEffortPair,
345
+ families: AgyModelFamily[],
346
+ ): { pair: ModelEffortPair; family?: AgyModelFamily } {
347
+ // Claude/Grok/Codex/Muse selections never resolve through the Agy catalog; they pass through untouched.
348
+ if (pair.backend && pair.backend !== "agy") return { pair };
349
+ const family = families.find((item) => item.id === pair.model || item.variants.some((variant) => variant.id === pair.model));
350
+ if (!family) return { pair };
351
+ const variant = family.variants.find((item) => item.id === pair.model);
352
+ const resolved: ModelEffortPair = {
353
+ model: family.id,
354
+ effort: pair.effort === "default" && variant?.effort ? variant.effort : pair.effort,
355
+ };
356
+ if (pair.backend !== undefined) resolved.backend = pair.backend;
357
+ return { family, pair: resolved };
358
+ }
359
+
360
+ // A capability's effective backend: an explicit override is a complete selection, so a
361
+ // backend-less override still means Agy (all legacy stays Agy) and never inherits the
362
+ // shared default's backend. Only a capability without an override inherits the default.
363
+ export function capabilityBackend(settings: BroSettings, capability: Capability): BackendName {
364
+ const override = settings.overrides[capability];
365
+ if (override) return override.backend ?? "agy";
366
+ return settings.backend ?? "agy";
367
+ }
368
+
369
+ // Routes one capability's whole pair to its backend execution selection. Agy keeps the
370
+ // existing model/effort split; Claude/Grok/Codex/Muse carry the model plus an optional effort
371
+ // ("default" means the CLI's own default and is omitted). Throws for an effort the
372
+ // resolved backend does not support instead of silently sending a mismatched pair.
373
+ export function selectionForCapability(settings: BroSettings, capability: Capability): BackendSelection {
374
+ const pair = capabilityPair(settings, capability);
375
+ const backend = capabilityBackend(settings, capability);
376
+ if (backend in EXTERNAL_BACKENDS) {
377
+ const meta = EXTERNAL_BACKENDS[backend as ExternalBackend];
378
+ if (!meta.isEffort(pair.effort)) {
379
+ throw new Error(`\`${pair.effort}\` is not supported on the ${meta.label} backend. Run \`/bro config\` to fix this.`);
380
+ }
381
+ return (
382
+ pair.effort === "default"
383
+ ? { backend, model: pair.model }
384
+ : { backend, model: pair.model, effort: pair.effort }
385
+ ) as BackendSelection;
386
+ }
387
+ if (!isAgyEffort(pair.effort)) {
388
+ throw new Error(`\`${pair.effort}\` is not supported on the Agy backend. Run \`/bro config\` to fix this.`);
389
+ }
390
+ return agySelection({ model: pair.model, effort: pair.effort });
391
+ }
392
+
393
+ // The modal label for the exact selection a request runs with: model plus effort, where an
394
+ // omitted effort (the model's own default) reads simply "default".
395
+ export function selectionLabel(selection: BackendSelection): string {
396
+ return `${selection.model} · ${selection.effort ?? "default"}`;
397
+ }
398
+
399
+ export function resolveCapabilitySettings(
400
+ settings: BroSettings,
401
+ capability: Capability,
402
+ families: AgyModelFamily[],
403
+ ): { pair: ModelEffortPair; family?: AgyModelFamily } {
404
+ return resolveModelEffort(capabilityPair(settings, capability), families);
405
+ }
406
+
407
+ export function preferredEffort(family: AgyModelFamily): BroEffort {
408
+ return family.efforts.includes("low") ? "low" : (family.efforts[0] ?? "default");
409
+ }
410
+
411
+ export function effortDisplay(resolved: { pair: ModelEffortPair; family?: AgyModelFamily }, backend: BackendName): string {
412
+ if (backend in EXTERNAL_BACKENDS) {
413
+ const meta = EXTERNAL_BACKENDS[backend as ExternalBackend];
414
+ return meta.isEffort(resolved.pair.effort) ? resolved.pair.effort : `${resolved.pair.effort} (unsupported)`;
415
+ }
416
+ if (!resolved.family) return "unavailable";
417
+ const fixed = !resolved.family.efforts.length;
418
+ const valid = fixed ? resolved.pair.effort === "default" : resolved.family.efforts.includes(resolved.pair.effort as AgyEffort);
419
+ if (!valid) return `${resolved.pair.effort} (unsupported)`;
420
+ return fixed ? "fixed" : resolved.pair.effort;
421
+ }
422
+
423
+ export function settingsFile(): string {
424
+ return join(getAgentDir(), "bro-settings.json");
425
+ }
426
+
427
+ export async function ensureSettingsFile(): Promise<void> {
428
+ await mkdir(getAgentDir(), { recursive: true });
429
+ try {
430
+ const envModel = process.env.PI_BRO_MODEL?.trim();
431
+ const defaultModel = envModel || "gemini-3.7-flash";
432
+ const initial: BroSettings = {
433
+ model: defaultModel,
434
+ effort: envModel ? "default" : "low",
435
+ mode: DEFAULT_BRO_MODE,
436
+ showTurns: DEFAULT_SHOW_TURNS,
437
+ overrides: {},
438
+ };
439
+ await writeFile(
440
+ settingsFile(),
441
+ `${JSON.stringify(settingsPayload(initial), null, 2)}\n`,
442
+ { encoding: "utf8", flag: "wx", mode: 0o600 },
443
+ );
444
+ } catch (error) {
445
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
446
+ }
447
+ }
448
+
449
+ export async function readSettings(): Promise<BroSettings> {
450
+ await ensureSettingsFile();
451
+ try {
452
+ return parseBroSettings(JSON.parse(await readFile(settingsFile(), "utf8")));
453
+ } catch (error) {
454
+ if (error instanceof SyntaxError) throw new Error(`${settingsFile()} is not valid JSON.`);
455
+ if (error instanceof Error) throw new Error(`${settingsFile()}: ${error.message}`);
456
+ throw error;
457
+ }
458
+ }
459
+
460
+ // Exported so persistence stays testable without touching the filesystem. This only ever omits
461
+ // the `overrides` key itself when there are no overrides at all — it does NOT deduplicate or drop
462
+ // any individual override that happens to match the shared default; see withCapabilityOverride
463
+ // for why an explicit override is always kept until the user clears it back to "Default".
464
+ export function settingsPayload(settings: BroSettings): Record<string, unknown> {
465
+ const overrides: Record<string, unknown> = {};
466
+ for (const [capability, pair] of Object.entries(settings.overrides)) {
467
+ if (pair) overrides[capability] = { backend: pair.backend ?? "agy", model: pair.model, effort: pair.effort };
468
+ }
469
+ return {
470
+ version: 2,
471
+ default: { backend: settings.backend ?? "agy", model: settings.model, effort: settings.effort },
472
+ mode: settings.mode,
473
+ showTurns: settings.showTurns,
474
+ ...(Object.keys(overrides).length ? { overrides } : {}),
475
+ };
476
+ }
477
+
478
+ export async function writeSettings(settings: BroSettings): Promise<void> {
479
+ // ponytail: last writer wins across concurrent Pi processes; add locking only if that becomes a common workflow.
480
+ await mkdir(getAgentDir(), { recursive: true });
481
+ const file = settingsFile();
482
+ let target = file;
483
+ let exists = false;
484
+ try {
485
+ await lstat(file);
486
+ exists = true;
487
+ } catch (error) {
488
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
489
+ }
490
+ if (exists) {
491
+ target = await realpath(file);
492
+ }
493
+ const temporary = `${target}.${randomUUID()}.tmp`;
494
+ try {
495
+ await writeFile(temporary, `${JSON.stringify(settingsPayload(settings), null, 2)}\n`, {
496
+ encoding: "utf8",
497
+ mode: 0o600,
498
+ flush: true,
499
+ });
500
+ await rename(temporary, target);
501
+ } catch (error) {
502
+ await rm(temporary, { force: true }).catch(() => {});
503
+ throw error;
504
+ }
505
+ }