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