@narumitw/pi-statusline 0.21.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,13 +1,43 @@
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",
13
43
  "provider",
@@ -21,94 +51,482 @@ const DEFAULT_SEGMENTS: SegmentName[] = [
21
51
  "cost",
22
52
  "time",
23
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
+
24
108
  let pendingSettingsNotice: string | undefined;
25
109
 
26
- export interface StatuslineSettings {
27
- extensionStatusIcons: Record<string, string>;
110
+ export function settingsFilePath(agentDir = getAgentDir()): string {
111
+ return join(agentDir, SETTINGS_FILE_NAME);
28
112
  }
29
113
 
30
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);
31
271
  return {
32
- preset: readStatuslinePreset(),
33
- palette: "candy",
34
- density: "compact",
35
- separator: "dot",
36
- showLabels: false,
37
- segments: [...DEFAULT_SEGMENTS],
38
- 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,
39
277
  };
40
278
  }
41
279
 
42
- export function readStatuslineSettings(settingsPath?: string): StatuslineSettings {
43
- if (settingsPath) return readSettingsFile(settingsPath);
280
+ export function loadOrCreateStatuslineSettings(
281
+ agentDir = getAgentDir(),
282
+ overrides: Partial<InitialFileSystem> = {},
283
+ ): LoadedStatuslineSettings {
44
284
  pendingSettingsNotice = undefined;
45
- const canonicalPath = join(getAgentDir(), SETTINGS_FILE);
46
- const legacyPath = join(getAgentDir(), LEGACY_SETTINGS_FILE);
47
- if (existsSync(canonicalPath)) {
48
- const canonical = readSettingsFileResult(canonicalPath);
49
- const notices: string[] = [];
50
- if (!canonical.valid) notices.push(`${SETTINGS_FILE} is invalid and was ignored.`);
51
- if (existsSync(legacyPath)) {
52
- 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.`;
53
290
  }
54
- if (notices.length > 0) pendingSettingsNotice = notices.join("\n");
55
- 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);
56
322
  }
57
- if (!existsSync(legacyPath)) return { extensionStatusIcons: {} };
58
- const legacy = readSettingsFileResult(legacyPath);
59
- if (!legacy.valid) {
60
- pendingSettingsNotice = `${LEGACY_SETTINGS_FILE} is invalid and was ignored.`;
61
- 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;
62
337
  }
63
- let installedIdentity: FileIdentity;
338
+ let identity: FileIdentity;
64
339
  try {
65
- installedIdentity = installFileExclusively(canonicalPath, legacy.contents ?? "");
340
+ identity = installFileExclusively(canonicalPath, legacy.rawDocument);
66
341
  } catch (error) {
67
- if (existsSync(canonicalPath)) {
68
- const canonical = readSettingsFileResult(canonicalPath);
69
- pendingSettingsNotice = [
70
- ...(!canonical.valid ? [`${SETTINGS_FILE} is invalid and was ignored.`] : []),
71
- `${LEGACY_SETTINGS_FILE} ignored because ${SETTINGS_FILE} was created concurrently.`,
72
- ].join("\n");
73
- 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);
74
345
  }
75
346
  pendingSettingsNotice = `Statusline settings migration failed: ${formatError(error)}. The legacy file was used for this session.`;
76
- return legacy.settings;
77
- }
78
- if (!fileContentsEqual(legacyPath, legacy.contents ?? "")) {
79
- pendingSettingsNotice = removeFileIfIdentityMatches(
80
- canonicalPath,
81
- installedIdentity,
82
- legacy.contents ?? "",
83
- )
84
- ? `${LEGACY_SETTINGS_FILE} changed during migration; the stale ${SETTINGS_FILE} snapshot was removed.`
85
- : `${LEGACY_SETTINGS_FILE} changed during migration, but ${SETTINGS_FILE} was replaced concurrently and takes precedence on the next load.`;
86
- 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;
87
354
  }
88
355
  try {
89
356
  rmSync(legacyPath);
90
- 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);
91
372
  } catch (error) {
92
- 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.
93
515
  }
94
- return legacy.settings;
95
516
  }
96
517
 
97
518
  type FileIdentity = { dev: number; ino: number };
98
519
 
99
520
  function installFileExclusively(filePath: string, contents: string): FileIdentity {
100
- const tempFile = join(dirname(filePath), `.${SETTINGS_FILE}.${randomUUID()}.tmp`);
521
+ mkdirSync(dirname(filePath), { recursive: true });
522
+ const temporaryPath = temporarySettingsPath(filePath);
101
523
  try {
102
- writeFileSync(tempFile, contents, { encoding: "utf8", flag: "wx" });
103
- const identity = lstatSync(tempFile);
104
- linkSync(tempFile, filePath);
524
+ writeFileSync(temporaryPath, contents, { encoding: "utf8", flag: "wx" });
525
+ const identity = lstatSync(temporaryPath);
526
+ linkSync(temporaryPath, filePath);
105
527
  return { dev: identity.dev, ino: identity.ino };
106
528
  } finally {
107
- try {
108
- rmSync(tempFile, { force: true });
109
- } catch {
110
- // Preserve the migration result if best-effort temp cleanup fails.
111
- }
529
+ removeTemporaryFile(rmSync, temporaryPath);
112
530
  }
113
531
  }
114
532
 
@@ -116,7 +534,7 @@ function removeFileIfIdentityMatches(
116
534
  filePath: string,
117
535
  expected: FileIdentity,
118
536
  expectedContents: string,
119
- ) {
537
+ ): boolean {
120
538
  try {
121
539
  const current = lstatSync(filePath);
122
540
  if (current.dev !== expected.dev || current.ino !== expected.ino) return false;
@@ -128,7 +546,7 @@ function removeFileIfIdentityMatches(
128
546
  }
129
547
  }
130
548
 
131
- function fileContentsEqual(path: string, expected: string) {
549
+ function fileContentsEqual(path: string, expected: string): boolean {
132
550
  try {
133
551
  return readFileSync(path, "utf8") === expected;
134
552
  } catch {
@@ -136,55 +554,19 @@ function fileContentsEqual(path: string, expected: string) {
136
554
  }
137
555
  }
138
556
 
139
- export function consumeStatuslineSettingsNotice() {
140
- const notice = pendingSettingsNotice;
141
- pendingSettingsNotice = undefined;
142
- return notice;
143
- }
144
-
145
- function readSettingsFile(settingsPath: string): StatuslineSettings {
146
- return readSettingsFileResult(settingsPath).settings;
147
- }
148
-
149
- function readSettingsFileResult(settingsPath: string): {
150
- settings: StatuslineSettings;
151
- valid: boolean;
152
- contents?: string;
153
- } {
154
- let contents: string;
557
+ function pathExists(path: string): boolean {
155
558
  try {
156
- contents = readFileSync(settingsPath, "utf8");
157
- const parsed = JSON.parse(contents) as unknown;
158
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
159
- return { settings: { extensionStatusIcons: {} }, valid: false, contents };
160
- }
161
- return { settings: normalizeStatuslineSettings(parsed), valid: true, contents };
559
+ lstatSync(path);
560
+ return true;
162
561
  } catch {
163
- return { settings: { extensionStatusIcons: {} }, valid: false };
164
- }
165
- }
166
-
167
- export function normalizeStatuslineSettings(value: unknown): StatuslineSettings {
168
- if (!value || typeof value !== "object" || Array.isArray(value))
169
- return { extensionStatusIcons: {} };
170
- const icons = (value as { extensionStatusIcons?: unknown }).extensionStatusIcons;
171
- if (!icons || typeof icons !== "object" || Array.isArray(icons)) {
172
- return { extensionStatusIcons: {} };
562
+ return false;
173
563
  }
174
- return {
175
- extensionStatusIcons: Object.fromEntries(
176
- Object.entries(icons).filter(
177
- (entry): entry is [string, string] => typeof entry[1] === "string",
178
- ),
179
- ),
180
- };
181
564
  }
182
565
 
183
- function readStatuslinePreset(): StatuslinePresetName {
184
- const value = process.env.PI_STATUSLINE_PRESET?.trim().toLowerCase();
185
- 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";
186
568
  }
187
569
 
188
- function formatError(error: unknown) {
570
+ function formatError(error: unknown): string {
189
571
  return error instanceof Error ? error.message : String(error);
190
572
  }