@narumitw/pi-statusline 0.20.0 → 0.23.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/src/settings.ts CHANGED
@@ -1,15 +1,46 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { existsSync, linkSync, lstatSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import {
3
+ linkSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ renameSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from "node:fs";
3
11
  import { dirname, join } from "node:path";
4
- import process from "node:process";
5
12
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
- import type { SegmentName, StatuslineConfig, StatuslinePresetName } from "../presets/types.js";
13
+ import {
14
+ type ConfigSegmentName,
15
+ DENSITIES,
16
+ LINE_BREAK_SEGMENT_NAME,
17
+ PALETTE_NAMES,
18
+ SEGMENT_NAMES,
19
+ SEPARATOR_NAMES,
20
+ type SegmentName,
21
+ type StatuslineConfig,
22
+ } from "./types.js";
23
+
24
+ export const SETTINGS_FILE_NAME = "pi-statusline.json";
25
+ const LEGACY_SETTINGS_FILE_NAME = "pi-statusline-settings.json";
26
+
27
+ export const DEFAULT_EXTENSION_STATUS_ICONS: Record<string, string> = {
28
+ "chrome-devtools": "🌐",
29
+ "codex-usage": "📊",
30
+ caffeinate: "💊",
31
+ firecrawl: "🔥",
32
+ "github-pr": "🔎",
33
+ goal: "🎯",
34
+ lsp: "🧰",
35
+ "plan-mode": "📝",
36
+ pisync: "🔄",
37
+ subagents: "🧑‍🤝‍🧑",
38
+ "unknown-error-retry": "🔁",
39
+ };
7
40
 
8
- const SETTINGS_FILE = "pi-statusline.json";
9
- const LEGACY_SETTINGS_FILE = "pi-statusline-settings.json";
10
- const DEFAULT_PRESET: StatuslinePresetName = "tokyo-night";
11
41
  const DEFAULT_SEGMENTS: SegmentName[] = [
12
42
  "brand",
43
+ "provider",
13
44
  "model",
14
45
  "thinking",
15
46
  "cwd",
@@ -20,94 +51,482 @@ const DEFAULT_SEGMENTS: SegmentName[] = [
20
51
  "cost",
21
52
  "time",
22
53
  ];
54
+
55
+ export const DEFAULT_STATUSLINE_CONFIG: StatuslineConfig = {
56
+ palette: "tokyo-night",
57
+ density: "compact",
58
+ separator: "none",
59
+ segments: DEFAULT_SEGMENTS,
60
+ segmentText: {
61
+ brand: { prefix: "", suffix: "" },
62
+ provider: { prefix: "🔌 ", suffix: "" },
63
+ model: { prefix: "🤖 ", suffix: "" },
64
+ thinking: { prefix: "🧠 ", suffix: "" },
65
+ cwd: { prefix: "📁 ", suffix: "" },
66
+ branch: { prefix: "🌿 ", suffix: "" },
67
+ tools: { prefix: "", suffix: "" },
68
+ context: { prefix: "🪟 ctx ", suffix: "" },
69
+ tokens: { prefix: "🔢 ", suffix: "" },
70
+ cost: { prefix: "💸 $", suffix: "" },
71
+ time: { prefix: "🕒 ", suffix: "" },
72
+ turn: { prefix: "🔁 #", suffix: "" },
73
+ },
74
+ extensionStatusIcons: DEFAULT_EXTENSION_STATUS_ICONS,
75
+ };
76
+
77
+ export const DEFAULT_STATUSLINE_DOCUMENT = `${JSON.stringify(DEFAULT_STATUSLINE_CONFIG, null, "\t")}\n`;
78
+
79
+ export interface StatuslineConfigDiagnostic {
80
+ severity: "warning" | "error";
81
+ code: "unknown" | "invalid" | "parse" | "io";
82
+ path: string;
83
+ message: string;
84
+ }
85
+
86
+ export interface LoadedStatuslineSettings {
87
+ config: StatuslineConfig;
88
+ source: "built-in" | "user";
89
+ settingsPath: string;
90
+ rawDocument?: string;
91
+ diagnostics: StatuslineConfigDiagnostic[];
92
+ }
93
+
94
+ interface InitialFileSystem {
95
+ mkdirSync: typeof mkdirSync;
96
+ writeFileSync: typeof writeFileSync;
97
+ linkSync: typeof linkSync;
98
+ rmSync: typeof rmSync;
99
+ }
100
+
101
+ interface AtomicFileSystem {
102
+ mkdirSync: typeof mkdirSync;
103
+ writeFileSync: typeof writeFileSync;
104
+ renameSync: typeof renameSync;
105
+ rmSync: typeof rmSync;
106
+ }
107
+
23
108
  let pendingSettingsNotice: string | undefined;
24
109
 
25
- export interface StatuslineSettings {
26
- extensionStatusIcons: Record<string, string>;
110
+ export function settingsFilePath(agentDir = getAgentDir()): string {
111
+ return join(agentDir, SETTINGS_FILE_NAME);
27
112
  }
28
113
 
29
114
  export function createDefaultConfig(): StatuslineConfig {
115
+ return cloneConfig(DEFAULT_STATUSLINE_CONFIG);
116
+ }
117
+
118
+ export function normalizeStatuslineConfig(value: unknown): {
119
+ config: StatuslineConfig;
120
+ diagnostics: StatuslineConfigDiagnostic[];
121
+ } {
122
+ const config = createDefaultConfig();
123
+ const diagnostics: StatuslineConfigDiagnostic[] = [];
124
+ if (!isRecord(value)) {
125
+ return {
126
+ config,
127
+ diagnostics: [invalidDiagnostic("", "Settings must contain a JSON object", "error")],
128
+ };
129
+ }
130
+
131
+ const knownRoot = new Set([
132
+ "palette",
133
+ "density",
134
+ "separator",
135
+ "segments",
136
+ "segmentText",
137
+ "extensionStatusIcons",
138
+ ]);
139
+ for (const key of Object.keys(value)) {
140
+ if (!knownRoot.has(key)) diagnostics.push(unknownDiagnostic(key));
141
+ }
142
+
143
+ normalizeEnum(value, "palette", PALETTE_NAMES, config, diagnostics);
144
+ normalizeEnum(value, "density", DENSITIES, config, diagnostics);
145
+ normalizeEnum(value, "separator", SEPARATOR_NAMES, config, diagnostics);
146
+
147
+ if (value.segments !== undefined) {
148
+ if (!Array.isArray(value.segments)) {
149
+ diagnostics.push(invalidDiagnostic("segments", "Expected an array of segment names"));
150
+ } else {
151
+ const segments: ConfigSegmentName[] = [];
152
+ const seen = new Set<SegmentName>();
153
+ for (const [index, item] of value.segments.entries()) {
154
+ const path = `segments[${index}]`;
155
+ if (typeof item !== "string" || !isConfigSegmentName(item)) {
156
+ diagnostics.push(invalidDiagnostic(path, "Unknown or non-string segment name"));
157
+ continue;
158
+ }
159
+ if (item === LINE_BREAK_SEGMENT_NAME) {
160
+ if (segments.at(-1) === LINE_BREAK_SEGMENT_NAME) {
161
+ diagnostics.push(
162
+ invalidDiagnostic(path, "Consecutive line_break segments are not allowed"),
163
+ );
164
+ continue;
165
+ }
166
+ segments.push(item);
167
+ continue;
168
+ }
169
+ if (seen.has(item)) {
170
+ diagnostics.push(invalidDiagnostic(path, `Duplicate segment ${JSON.stringify(item)}`));
171
+ continue;
172
+ }
173
+ seen.add(item);
174
+ segments.push(item);
175
+ }
176
+ config.segments = segments;
177
+ }
178
+ }
179
+
180
+ if (value.segmentText !== undefined) {
181
+ if (!isRecord(value.segmentText)) {
182
+ diagnostics.push(invalidDiagnostic("segmentText", "Expected an object"));
183
+ } else {
184
+ for (const [name, presentation] of Object.entries(value.segmentText)) {
185
+ const path = `segmentText.${name}`;
186
+ if (!isSegmentName(name)) {
187
+ diagnostics.push(unknownDiagnostic(path));
188
+ continue;
189
+ }
190
+ if (!isRecord(presentation)) {
191
+ diagnostics.push(invalidDiagnostic(path, "Expected an object"));
192
+ continue;
193
+ }
194
+ for (const key of Object.keys(presentation)) {
195
+ if (key !== "prefix" && key !== "suffix") {
196
+ diagnostics.push(unknownDiagnostic(`${path}.${key}`));
197
+ }
198
+ }
199
+ for (const field of ["prefix", "suffix"] as const) {
200
+ const fieldValue = presentation[field];
201
+ if (fieldValue === undefined) continue;
202
+ if (typeof fieldValue !== "string") {
203
+ diagnostics.push(invalidDiagnostic(`${path}.${field}`, "Expected a string"));
204
+ continue;
205
+ }
206
+ if (/[\r\n\u2028\u2029]/u.test(fieldValue)) {
207
+ diagnostics.push(
208
+ invalidDiagnostic(`${path}.${field}`, "Line breaks are not allowed; use line_break"),
209
+ );
210
+ continue;
211
+ }
212
+ if (hasControlCharacter(fieldValue)) {
213
+ diagnostics.push(
214
+ invalidDiagnostic(`${path}.${field}`, "Control characters are not allowed"),
215
+ );
216
+ continue;
217
+ }
218
+ config.segmentText[name][field] = fieldValue;
219
+ }
220
+ }
221
+ }
222
+ }
223
+
224
+ if (value.extensionStatusIcons !== undefined) {
225
+ if (!isRecord(value.extensionStatusIcons)) {
226
+ diagnostics.push(invalidDiagnostic("extensionStatusIcons", "Expected an object"));
227
+ } else {
228
+ for (const [key, icon] of Object.entries(value.extensionStatusIcons)) {
229
+ if (typeof icon !== "string") {
230
+ diagnostics.push(invalidDiagnostic(`extensionStatusIcons.${key}`, "Expected a string"));
231
+ continue;
232
+ }
233
+ Object.defineProperty(config.extensionStatusIcons, key, {
234
+ value: icon,
235
+ enumerable: true,
236
+ configurable: true,
237
+ writable: true,
238
+ });
239
+ }
240
+ }
241
+ }
242
+
243
+ return { config, diagnostics };
244
+ }
245
+
246
+ export function loadStatuslineSettings(settingsPath: string): LoadedStatuslineSettings {
247
+ let rawDocument: string;
248
+ try {
249
+ rawDocument = readFileSync(settingsPath, "utf8");
250
+ } catch (error) {
251
+ if (!pathExists(settingsPath)) {
252
+ return builtInSettings(settingsPath);
253
+ }
254
+ return builtInSettings(settingsPath, [
255
+ diagnostic("error", "io", "", `Unable to read settings: ${formatError(error)}`),
256
+ ]);
257
+ }
258
+
259
+ let parsed: unknown;
260
+ try {
261
+ parsed = JSON.parse(rawDocument);
262
+ } catch (error) {
263
+ return {
264
+ ...builtInSettings(settingsPath, [
265
+ diagnostic("error", "parse", "", `Unable to parse JSON: ${formatError(error)}`),
266
+ ]),
267
+ rawDocument,
268
+ };
269
+ }
270
+ const normalized = normalizeStatuslineConfig(parsed);
30
271
  return {
31
- preset: readStatuslinePreset(),
32
- palette: "candy",
33
- density: "compact",
34
- separator: "dot",
35
- showLabels: false,
36
- segments: [...DEFAULT_SEGMENTS],
37
- extensionStatusIcons: readStatuslineSettings().extensionStatusIcons,
272
+ config: normalized.config,
273
+ source: normalized.diagnostics.some((item) => item.severity === "error") ? "built-in" : "user",
274
+ settingsPath,
275
+ rawDocument,
276
+ diagnostics: normalized.diagnostics,
38
277
  };
39
278
  }
40
279
 
41
- export function readStatuslineSettings(settingsPath?: string): StatuslineSettings {
42
- if (settingsPath) return readSettingsFile(settingsPath);
280
+ export function loadOrCreateStatuslineSettings(
281
+ agentDir = getAgentDir(),
282
+ overrides: Partial<InitialFileSystem> = {},
283
+ ): LoadedStatuslineSettings {
43
284
  pendingSettingsNotice = undefined;
44
- const canonicalPath = join(getAgentDir(), SETTINGS_FILE);
45
- const legacyPath = join(getAgentDir(), LEGACY_SETTINGS_FILE);
46
- if (existsSync(canonicalPath)) {
47
- const canonical = readSettingsFileResult(canonicalPath);
48
- const notices: string[] = [];
49
- if (!canonical.valid) notices.push(`${SETTINGS_FILE} is invalid and was ignored.`);
50
- if (existsSync(legacyPath)) {
51
- notices.push(`${LEGACY_SETTINGS_FILE} ignored because ${SETTINGS_FILE} takes precedence.`);
285
+ const canonicalPath = settingsFilePath(agentDir);
286
+ const legacyPath = join(agentDir, LEGACY_SETTINGS_FILE_NAME);
287
+ if (pathExists(canonicalPath)) {
288
+ if (pathExists(legacyPath)) {
289
+ pendingSettingsNotice = `${LEGACY_SETTINGS_FILE_NAME} ignored because ${SETTINGS_FILE_NAME} takes precedence.`;
52
290
  }
53
- if (notices.length > 0) pendingSettingsNotice = notices.join("\n");
54
- return canonical.settings;
291
+ return loadStatuslineSettings(canonicalPath);
292
+ }
293
+ if (pathExists(legacyPath)) return migrateLegacySettings(canonicalPath, legacyPath);
294
+ return createInitialSettings(canonicalPath, overrides);
295
+ }
296
+
297
+ function createInitialSettings(
298
+ canonicalPath: string,
299
+ overrides: Partial<InitialFileSystem>,
300
+ ): LoadedStatuslineSettings {
301
+ const fs = { mkdirSync, writeFileSync, linkSync, rmSync, ...overrides };
302
+ const temporaryPath = temporarySettingsPath(canonicalPath);
303
+ try {
304
+ fs.mkdirSync(dirname(canonicalPath), { recursive: true });
305
+ fs.writeFileSync(temporaryPath, DEFAULT_STATUSLINE_DOCUMENT, {
306
+ encoding: "utf8",
307
+ flag: "wx",
308
+ });
309
+ try {
310
+ fs.linkSync(temporaryPath, canonicalPath);
311
+ } catch (error) {
312
+ if (isAlreadyExistsError(error)) return loadStatuslineSettings(canonicalPath);
313
+ throw error;
314
+ }
315
+ return loadStatuslineSettings(canonicalPath);
316
+ } catch (error) {
317
+ return builtInSettings(canonicalPath, [
318
+ diagnostic("warning", "io", "", `Unable to create default settings: ${formatError(error)}`),
319
+ ]);
320
+ } finally {
321
+ removeTemporaryFile(fs.rmSync, temporaryPath);
55
322
  }
56
- if (!existsSync(legacyPath)) return { extensionStatusIcons: {} };
57
- const legacy = readSettingsFileResult(legacyPath);
58
- if (!legacy.valid) {
59
- pendingSettingsNotice = `${LEGACY_SETTINGS_FILE} is invalid and was ignored.`;
60
- return legacy.settings;
323
+ }
324
+
325
+ function migrateLegacySettings(
326
+ canonicalPath: string,
327
+ legacyPath: string,
328
+ ): LoadedStatuslineSettings {
329
+ const legacy = loadStatuslineSettings(legacyPath);
330
+ if (
331
+ legacy.source !== "user" ||
332
+ legacy.rawDocument === undefined ||
333
+ blockingDiagnostics(legacy.diagnostics).length > 0
334
+ ) {
335
+ pendingSettingsNotice = `${LEGACY_SETTINGS_FILE_NAME} is invalid and was ignored.`;
336
+ return legacy;
61
337
  }
62
- let installedIdentity: FileIdentity;
338
+ let identity: FileIdentity;
63
339
  try {
64
- installedIdentity = installFileExclusively(canonicalPath, legacy.contents ?? "");
340
+ identity = installFileExclusively(canonicalPath, legacy.rawDocument);
65
341
  } catch (error) {
66
- if (existsSync(canonicalPath)) {
67
- const canonical = readSettingsFileResult(canonicalPath);
68
- pendingSettingsNotice = [
69
- ...(!canonical.valid ? [`${SETTINGS_FILE} is invalid and was ignored.`] : []),
70
- `${LEGACY_SETTINGS_FILE} ignored because ${SETTINGS_FILE} was created concurrently.`,
71
- ].join("\n");
72
- return canonical.settings;
342
+ if (pathExists(canonicalPath)) {
343
+ pendingSettingsNotice = `${LEGACY_SETTINGS_FILE_NAME} ignored because ${SETTINGS_FILE_NAME} was created concurrently.`;
344
+ return loadStatuslineSettings(canonicalPath);
73
345
  }
74
346
  pendingSettingsNotice = `Statusline settings migration failed: ${formatError(error)}. The legacy file was used for this session.`;
75
- return legacy.settings;
76
- }
77
- if (!fileContentsEqual(legacyPath, legacy.contents ?? "")) {
78
- pendingSettingsNotice = removeFileIfIdentityMatches(
79
- canonicalPath,
80
- installedIdentity,
81
- legacy.contents ?? "",
82
- )
83
- ? `${LEGACY_SETTINGS_FILE} changed during migration; the stale ${SETTINGS_FILE} snapshot was removed.`
84
- : `${LEGACY_SETTINGS_FILE} changed during migration, but ${SETTINGS_FILE} was replaced concurrently and takes precedence on the next load.`;
85
- return legacy.settings;
347
+ return legacy;
348
+ }
349
+ if (!fileContentsEqual(legacyPath, legacy.rawDocument)) {
350
+ pendingSettingsNotice = removeFileIfIdentityMatches(canonicalPath, identity, legacy.rawDocument)
351
+ ? `${LEGACY_SETTINGS_FILE_NAME} changed during migration; the stale ${SETTINGS_FILE_NAME} snapshot was removed.`
352
+ : `${LEGACY_SETTINGS_FILE_NAME} changed during migration, but ${SETTINGS_FILE_NAME} was replaced concurrently and takes precedence on the next load.`;
353
+ return legacy;
86
354
  }
87
355
  try {
88
356
  rmSync(legacyPath);
89
- pendingSettingsNotice = `Statusline settings migrated from ${LEGACY_SETTINGS_FILE} to ${SETTINGS_FILE}.`;
357
+ pendingSettingsNotice = `Statusline settings migrated from ${LEGACY_SETTINGS_FILE_NAME} to ${SETTINGS_FILE_NAME}.`;
358
+ } catch (error) {
359
+ pendingSettingsNotice = `Statusline settings migrated to ${SETTINGS_FILE_NAME}, but ${LEGACY_SETTINGS_FILE_NAME} could not be removed: ${formatError(error)}.`;
360
+ }
361
+ return loadStatuslineSettings(canonicalPath);
362
+ }
363
+
364
+ export function saveStatuslineSettingsDocument(
365
+ settingsPath: string,
366
+ rawDocument: string,
367
+ overrides: Partial<AtomicFileSystem> = {},
368
+ ): LoadedStatuslineSettings {
369
+ let parsed: unknown;
370
+ try {
371
+ parsed = JSON.parse(rawDocument);
90
372
  } catch (error) {
91
- pendingSettingsNotice = `Statusline settings migrated to ${SETTINGS_FILE}, but ${LEGACY_SETTINGS_FILE} could not be removed: ${formatError(error)}.`;
373
+ throw new Error(`Unable to parse JSON: ${formatError(error)}`);
374
+ }
375
+ const normalized = normalizeStatuslineConfig(parsed);
376
+ const blocking = blockingDiagnostics(normalized.diagnostics);
377
+ if (blocking.length > 0) {
378
+ throw new Error(blocking.map((item) => `${item.path || "root"}: ${item.message}`).join("\n"));
379
+ }
380
+
381
+ const fs = { mkdirSync, writeFileSync, renameSync, rmSync, ...overrides };
382
+ const temporaryPath = temporarySettingsPath(settingsPath);
383
+ try {
384
+ fs.mkdirSync(dirname(settingsPath), { recursive: true });
385
+ fs.writeFileSync(temporaryPath, rawDocument, { encoding: "utf8", flag: "wx" });
386
+ fs.renameSync(temporaryPath, settingsPath);
387
+ } finally {
388
+ removeTemporaryFile(fs.rmSync, temporaryPath);
389
+ }
390
+ return {
391
+ config: normalized.config,
392
+ source: "user",
393
+ settingsPath,
394
+ rawDocument,
395
+ diagnostics: normalized.diagnostics,
396
+ };
397
+ }
398
+
399
+ export function consumeStatuslineSettingsNotice(): string | undefined {
400
+ const notice = pendingSettingsNotice;
401
+ pendingSettingsNotice = undefined;
402
+ return notice;
403
+ }
404
+
405
+ export function readStatuslineSettings(settingsPath?: string): StatuslineConfig {
406
+ return settingsPath
407
+ ? loadStatuslineSettings(settingsPath).config
408
+ : loadOrCreateStatuslineSettings().config;
409
+ }
410
+
411
+ export function normalizeStatuslineSettings(value: unknown): StatuslineConfig {
412
+ return normalizeStatuslineConfig(value).config;
413
+ }
414
+
415
+ function normalizeEnum<
416
+ K extends "palette" | "density" | "separator",
417
+ T extends StatuslineConfig[K],
418
+ >(
419
+ value: Record<string, unknown>,
420
+ field: K,
421
+ accepted: readonly T[],
422
+ config: StatuslineConfig,
423
+ diagnostics: StatuslineConfigDiagnostic[],
424
+ ) {
425
+ const candidate = value[field];
426
+ if (candidate === undefined) return;
427
+ if (typeof candidate !== "string" || !accepted.includes(candidate as T)) {
428
+ diagnostics.push(
429
+ invalidDiagnostic(field, `Expected one of: ${accepted.map(String).join(", ")}`),
430
+ );
431
+ return;
432
+ }
433
+ config[field] = candidate as StatuslineConfig[K];
434
+ }
435
+
436
+ function cloneConfig(config: StatuslineConfig): StatuslineConfig {
437
+ return {
438
+ ...config,
439
+ segments: [...config.segments],
440
+ segmentText: Object.fromEntries(
441
+ SEGMENT_NAMES.map((name) => [name, { ...config.segmentText[name] }]),
442
+ ) as StatuslineConfig["segmentText"],
443
+ extensionStatusIcons: { ...config.extensionStatusIcons },
444
+ };
445
+ }
446
+
447
+ function builtInSettings(
448
+ settingsPath: string,
449
+ diagnostics: StatuslineConfigDiagnostic[] = [],
450
+ ): LoadedStatuslineSettings {
451
+ return {
452
+ config: createDefaultConfig(),
453
+ source: "built-in",
454
+ settingsPath,
455
+ diagnostics,
456
+ };
457
+ }
458
+
459
+ function hasControlCharacter(value: string): boolean {
460
+ for (const character of value) {
461
+ const codePoint = character.codePointAt(0) ?? 0;
462
+ if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) return true;
463
+ }
464
+ return false;
465
+ }
466
+
467
+ function isRecord(value: unknown): value is Record<string, unknown> {
468
+ return typeof value === "object" && value !== null && !Array.isArray(value);
469
+ }
470
+
471
+ function isConfigSegmentName(value: string): value is ConfigSegmentName {
472
+ return value === LINE_BREAK_SEGMENT_NAME || isSegmentName(value);
473
+ }
474
+
475
+ function isSegmentName(value: string): value is SegmentName {
476
+ return (SEGMENT_NAMES as readonly string[]).includes(value);
477
+ }
478
+
479
+ function blockingDiagnostics(
480
+ diagnostics: readonly StatuslineConfigDiagnostic[],
481
+ ): StatuslineConfigDiagnostic[] {
482
+ return diagnostics.filter((item) => item.code !== "unknown");
483
+ }
484
+
485
+ function unknownDiagnostic(path: string): StatuslineConfigDiagnostic {
486
+ return diagnostic("warning", "unknown", path, `Unknown setting ${JSON.stringify(path)}`);
487
+ }
488
+
489
+ function invalidDiagnostic(
490
+ path: string,
491
+ message: string,
492
+ severity: StatuslineConfigDiagnostic["severity"] = "warning",
493
+ ): StatuslineConfigDiagnostic {
494
+ return diagnostic(severity, "invalid", path, message);
495
+ }
496
+
497
+ function diagnostic(
498
+ severity: StatuslineConfigDiagnostic["severity"],
499
+ code: StatuslineConfigDiagnostic["code"],
500
+ path: string,
501
+ message: string,
502
+ ): StatuslineConfigDiagnostic {
503
+ return { severity, code, path, message };
504
+ }
505
+
506
+ function temporarySettingsPath(settingsPath: string): string {
507
+ return join(dirname(settingsPath), `.${SETTINGS_FILE_NAME}.${randomUUID()}.tmp`);
508
+ }
509
+
510
+ function removeTemporaryFile(remove: typeof rmSync, temporaryPath: string) {
511
+ try {
512
+ remove(temporaryPath, { force: true });
513
+ } catch {
514
+ // Best-effort cleanup must not replace the original operation result.
92
515
  }
93
- return legacy.settings;
94
516
  }
95
517
 
96
518
  type FileIdentity = { dev: number; ino: number };
97
519
 
98
520
  function installFileExclusively(filePath: string, contents: string): FileIdentity {
99
- const tempFile = join(dirname(filePath), `.${SETTINGS_FILE}.${randomUUID()}.tmp`);
521
+ mkdirSync(dirname(filePath), { recursive: true });
522
+ const temporaryPath = temporarySettingsPath(filePath);
100
523
  try {
101
- writeFileSync(tempFile, contents, { encoding: "utf8", flag: "wx" });
102
- const identity = lstatSync(tempFile);
103
- linkSync(tempFile, filePath);
524
+ writeFileSync(temporaryPath, contents, { encoding: "utf8", flag: "wx" });
525
+ const identity = lstatSync(temporaryPath);
526
+ linkSync(temporaryPath, filePath);
104
527
  return { dev: identity.dev, ino: identity.ino };
105
528
  } finally {
106
- try {
107
- rmSync(tempFile, { force: true });
108
- } catch {
109
- // Preserve the migration result if best-effort temp cleanup fails.
110
- }
529
+ removeTemporaryFile(rmSync, temporaryPath);
111
530
  }
112
531
  }
113
532
 
@@ -115,7 +534,7 @@ function removeFileIfIdentityMatches(
115
534
  filePath: string,
116
535
  expected: FileIdentity,
117
536
  expectedContents: string,
118
- ) {
537
+ ): boolean {
119
538
  try {
120
539
  const current = lstatSync(filePath);
121
540
  if (current.dev !== expected.dev || current.ino !== expected.ino) return false;
@@ -127,7 +546,7 @@ function removeFileIfIdentityMatches(
127
546
  }
128
547
  }
129
548
 
130
- function fileContentsEqual(path: string, expected: string) {
549
+ function fileContentsEqual(path: string, expected: string): boolean {
131
550
  try {
132
551
  return readFileSync(path, "utf8") === expected;
133
552
  } catch {
@@ -135,55 +554,19 @@ function fileContentsEqual(path: string, expected: string) {
135
554
  }
136
555
  }
137
556
 
138
- export function consumeStatuslineSettingsNotice() {
139
- const notice = pendingSettingsNotice;
140
- pendingSettingsNotice = undefined;
141
- return notice;
142
- }
143
-
144
- function readSettingsFile(settingsPath: string): StatuslineSettings {
145
- return readSettingsFileResult(settingsPath).settings;
146
- }
147
-
148
- function readSettingsFileResult(settingsPath: string): {
149
- settings: StatuslineSettings;
150
- valid: boolean;
151
- contents?: string;
152
- } {
153
- let contents: string;
557
+ function pathExists(path: string): boolean {
154
558
  try {
155
- contents = readFileSync(settingsPath, "utf8");
156
- const parsed = JSON.parse(contents) as unknown;
157
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
158
- return { settings: { extensionStatusIcons: {} }, valid: false, contents };
159
- }
160
- return { settings: normalizeStatuslineSettings(parsed), valid: true, contents };
559
+ lstatSync(path);
560
+ return true;
161
561
  } catch {
162
- return { settings: { extensionStatusIcons: {} }, valid: false };
163
- }
164
- }
165
-
166
- export function normalizeStatuslineSettings(value: unknown): StatuslineSettings {
167
- if (!value || typeof value !== "object" || Array.isArray(value))
168
- return { extensionStatusIcons: {} };
169
- const icons = (value as { extensionStatusIcons?: unknown }).extensionStatusIcons;
170
- if (!icons || typeof icons !== "object" || Array.isArray(icons)) {
171
- return { extensionStatusIcons: {} };
562
+ return false;
172
563
  }
173
- return {
174
- extensionStatusIcons: Object.fromEntries(
175
- Object.entries(icons).filter(
176
- (entry): entry is [string, string] => typeof entry[1] === "string",
177
- ),
178
- ),
179
- };
180
564
  }
181
565
 
182
- function readStatuslinePreset(): StatuslinePresetName {
183
- const value = process.env.PI_STATUSLINE_PRESET?.trim().toLowerCase();
184
- return value === "classic" || value === "tokyo-night" ? value : DEFAULT_PRESET;
566
+ function isAlreadyExistsError(error: unknown): boolean {
567
+ return error instanceof Error && "code" in error && error.code === "EEXIST";
185
568
  }
186
569
 
187
- function formatError(error: unknown) {
570
+ function formatError(error: unknown): string {
188
571
  return error instanceof Error ? error.message : String(error);
189
572
  }