@eiei114/pi-sub-core 1.5.1

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +178 -0
  3. package/index.ts +540 -0
  4. package/package.json +35 -0
  5. package/src/cache.ts +546 -0
  6. package/src/config.ts +35 -0
  7. package/src/dependencies.ts +37 -0
  8. package/src/errors.ts +71 -0
  9. package/src/paths.ts +55 -0
  10. package/src/provider.ts +66 -0
  11. package/src/providers/detection.ts +51 -0
  12. package/src/providers/impl/anthropic.ts +174 -0
  13. package/src/providers/impl/antigravity.ts +226 -0
  14. package/src/providers/impl/codex.ts +186 -0
  15. package/src/providers/impl/copilot.ts +176 -0
  16. package/src/providers/impl/gemini.ts +130 -0
  17. package/src/providers/impl/kiro.ts +92 -0
  18. package/src/providers/impl/zai.ts +120 -0
  19. package/src/providers/index.ts +5 -0
  20. package/src/providers/metadata.ts +16 -0
  21. package/src/providers/registry.ts +54 -0
  22. package/src/providers/settings.ts +109 -0
  23. package/src/providers/status.ts +25 -0
  24. package/src/settings/behavior.ts +58 -0
  25. package/src/settings/menu.ts +83 -0
  26. package/src/settings/tools.ts +38 -0
  27. package/src/settings/ui.ts +450 -0
  28. package/src/settings-types.ts +95 -0
  29. package/src/settings-ui.ts +1 -0
  30. package/src/settings.ts +137 -0
  31. package/src/status.ts +245 -0
  32. package/src/storage/lock.ts +150 -0
  33. package/src/storage.ts +61 -0
  34. package/src/types.ts +33 -0
  35. package/src/ui/keybindings.ts +92 -0
  36. package/src/ui/settings-list.ts +290 -0
  37. package/src/usage/controller.ts +250 -0
  38. package/src/usage/fetch.ts +215 -0
  39. package/src/usage/types.ts +5 -0
  40. package/src/utils.ts +158 -0
  41. package/test/all.test.ts +9 -0
  42. package/test/cache.test.ts +157 -0
  43. package/test/controller.test.ts +101 -0
  44. package/test/detection.test.ts +24 -0
  45. package/test/extension.test.ts +233 -0
  46. package/test/helpers.ts +48 -0
  47. package/test/keybindings.test.ts +59 -0
  48. package/test/lock.test.ts +49 -0
  49. package/test/prioritize.test.ts +81 -0
  50. package/test/providers.test.ts +385 -0
  51. package/test/status.test.ts +70 -0
  52. package/tsconfig.json +5 -0
@@ -0,0 +1,450 @@
1
+ /**
2
+ * Settings UI for sub-core
3
+ */
4
+
5
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
6
+ import { DynamicBorder, getSettingsListTheme } from "@mariozechner/pi-coding-agent";
7
+ import { Container, Input, type SelectItem, SelectList, Spacer, Text } from "@mariozechner/pi-tui";
8
+ import { SettingsList, type SettingItem, CUSTOM_OPTION } from "../ui/settings-list.js";
9
+ import type { ProviderName } from "../types.js";
10
+ import type { Settings } from "../settings-types.js";
11
+ import { getDefaultSettings } from "../settings-types.js";
12
+ import { getSettings, saveSettings, resetSettings } from "../settings.js";
13
+ import { PROVIDER_DISPLAY_NAMES } from "../providers/metadata.js";
14
+ import { buildProviderSettingsItems, applyProviderSettingsChange } from "../providers/settings.js";
15
+ import { buildRefreshItems, applyRefreshChange } from "./behavior.js";
16
+ import { buildToolItems, applyToolChange } from "./tools.js";
17
+ import { buildMainMenuItems, buildProviderListItems, buildProviderOrderItems, type TooltipSelectItem } from "./menu.js";
18
+
19
+ /**
20
+ * Settings category
21
+ */
22
+ type ProviderCategory = `provider-${ProviderName}`;
23
+
24
+ type SettingsCategory =
25
+ | "main"
26
+ | "providers"
27
+ | ProviderCategory
28
+ | "behavior"
29
+ | "status-refresh"
30
+ | "tools"
31
+ | "provider-order";
32
+
33
+ /**
34
+ * Extract provider name from category
35
+ */
36
+ function getProviderFromCategory(category: SettingsCategory): ProviderName | null {
37
+ const match = category.match(/^provider-(\w+)$/);
38
+ if (match && match[1] !== "order") {
39
+ return match[1] as ProviderName;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /**
45
+ * Show the settings UI
46
+ */
47
+ export async function showSettingsUI(
48
+ ctx: ExtensionContext,
49
+ onSettingsChange?: (settings: Settings) => void | Promise<void>
50
+ ): Promise<Settings> {
51
+ let settings = getSettings();
52
+ let currentCategory: SettingsCategory = "main";
53
+ let providerOrderSelectedIndex = 0;
54
+ let providerOrderReordering = false;
55
+ let suppressProviderOrderChange = false;
56
+
57
+ return new Promise((resolve) => {
58
+ ctx.ui.custom<Settings>((tui, theme, _kb, done) => {
59
+ let container = new Container();
60
+ let activeList: SelectList | SettingsList | null = null;
61
+ const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value));
62
+
63
+ const buildInputSubmenu = (
64
+ label: string,
65
+ parseValue: (value: string) => string | null,
66
+ formatInitial?: (value: string) => string,
67
+ ) => {
68
+ return (currentValue: string, done: (selectedValue?: string) => void) => {
69
+ const input = new Input();
70
+ input.focused = true;
71
+ input.setValue(formatInitial ? formatInitial("") : "");
72
+ input.onSubmit = (value) => {
73
+ const parsed = parseValue(value);
74
+ if (!parsed) return;
75
+ done(parsed);
76
+ };
77
+ input.onEscape = () => {
78
+ done();
79
+ };
80
+
81
+ const inputContainer = new Container();
82
+ inputContainer.addChild(new Text(theme.fg("muted", label), 1, 0));
83
+ inputContainer.addChild(new Spacer(1));
84
+ inputContainer.addChild(input);
85
+
86
+ return {
87
+ render: (width: number) => inputContainer.render(width),
88
+ invalidate: () => inputContainer.invalidate(),
89
+ handleInput: (data: string) => input.handleInput(data),
90
+ };
91
+ };
92
+ };
93
+
94
+ const parseRefreshInterval = (raw: string): string | null => {
95
+ const trimmed = raw.trim().toLowerCase();
96
+ if (!trimmed) {
97
+ ctx.ui.notify("Enter a value", "warning");
98
+ return null;
99
+ }
100
+ if (trimmed === "off") return "off";
101
+ const cleaned = trimmed.replace(/s$/, "");
102
+ const parsed = Number.parseInt(cleaned, 10);
103
+ if (Number.isNaN(parsed)) {
104
+ ctx.ui.notify("Enter seconds", "warning");
105
+ return null;
106
+ }
107
+ const clamped = parsed <= 0 ? 0 : clamp(parsed, 5, 3600);
108
+ return clamped === 0 ? "off" : `${clamped}s`;
109
+ };
110
+
111
+ const parseMinRefreshInterval = (raw: string): string | null => {
112
+ const trimmed = raw.trim().toLowerCase();
113
+ if (!trimmed) {
114
+ ctx.ui.notify("Enter a value", "warning");
115
+ return null;
116
+ }
117
+ if (trimmed === "off") return "off";
118
+ const cleaned = trimmed.replace(/s$/, "");
119
+ const parsed = Number.parseInt(cleaned, 10);
120
+ if (Number.isNaN(parsed)) {
121
+ ctx.ui.notify("Enter seconds", "warning");
122
+ return null;
123
+ }
124
+ const clamped = parsed <= 0 ? 0 : clamp(parsed, 5, 3600);
125
+ return clamped === 0 ? "off" : `${clamped}s`;
126
+ };
127
+
128
+ const parseCurrencySymbol = (raw: string): string | null => {
129
+ const trimmed = raw.trim();
130
+ if (!trimmed) {
131
+ ctx.ui.notify("Enter a symbol or 'none'", "warning");
132
+ return null;
133
+ }
134
+ if (trimmed.toLowerCase() === "none") return "none";
135
+ return trimmed;
136
+ };
137
+
138
+ function rebuild(): void {
139
+ container = new Container();
140
+ let tooltipText: Text | null = null;
141
+
142
+ const attachTooltip = (items: TooltipSelectItem[], selectList: SelectList): void => {
143
+ if (!items.some((item) => item.tooltip)) return;
144
+ const tooltipComponent = new Text("", 1, 0);
145
+ const setTooltip = (item?: TooltipSelectItem | null) => {
146
+ const tooltip = item?.tooltip?.trim();
147
+ tooltipComponent.setText(tooltip ? theme.fg("dim", tooltip) : "");
148
+ };
149
+ setTooltip(selectList.getSelectedItem() as TooltipSelectItem | null);
150
+ const existingHandler = selectList.onSelectionChange;
151
+ selectList.onSelectionChange = (item) => {
152
+ if (existingHandler) existingHandler(item);
153
+ setTooltip(item as TooltipSelectItem);
154
+ tui.requestRender();
155
+ };
156
+ tooltipText = tooltipComponent;
157
+ };
158
+
159
+ // Top border
160
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
161
+
162
+ // Title
163
+ const titles: Record<string, string> = {
164
+ main: "sub-core Settings",
165
+ providers: "Provider Settings",
166
+ behavior: "Usage Refresh Settings",
167
+ "status-refresh": "Status Refresh Settings",
168
+ tools: "Tool Settings",
169
+ "provider-order": "Provider Order",
170
+ };
171
+ const providerCategory = getProviderFromCategory(currentCategory);
172
+ const title = providerCategory
173
+ ? `${PROVIDER_DISPLAY_NAMES[providerCategory]} Settings`
174
+ : titles[currentCategory] ?? "sub-core Settings";
175
+ container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
176
+ container.addChild(new Spacer(1));
177
+
178
+ if (currentCategory === "main") {
179
+ const items = buildMainMenuItems(settings);
180
+ const selectList = new SelectList(items, Math.min(items.length, 10), {
181
+ selectedPrefix: (t: string) => theme.fg("accent", t),
182
+ selectedText: (t: string) => theme.fg("accent", t),
183
+ description: (t: string) => theme.fg("muted", t),
184
+ scrollInfo: (t: string) => theme.fg("dim", t),
185
+ noMatch: (t: string) => theme.fg("warning", t),
186
+ });
187
+ attachTooltip(items, selectList);
188
+ selectList.onSelect = (item) => {
189
+ if (item.value === "reset") {
190
+ settings = resetSettings();
191
+ if (onSettingsChange) void onSettingsChange(settings);
192
+ ctx.ui.notify("Settings reset to defaults", "info");
193
+ rebuild();
194
+ tui.requestRender();
195
+ } else {
196
+ currentCategory = item.value as SettingsCategory;
197
+ rebuild();
198
+ tui.requestRender();
199
+ }
200
+ };
201
+ selectList.onCancel = () => {
202
+ saveSettings(settings);
203
+ done(settings);
204
+ };
205
+ activeList = selectList;
206
+ container.addChild(selectList);
207
+ } else if (currentCategory === "providers") {
208
+ const items = buildProviderListItems(settings);
209
+ const selectList = new SelectList(items, Math.min(items.length, 10), {
210
+ selectedPrefix: (t: string) => theme.fg("accent", t),
211
+ selectedText: (t: string) => theme.fg("accent", t),
212
+ description: (t: string) => theme.fg("muted", t),
213
+ scrollInfo: (t: string) => theme.fg("dim", t),
214
+ noMatch: (t: string) => theme.fg("warning", t),
215
+ });
216
+ attachTooltip(items, selectList);
217
+ selectList.onSelect = (item) => {
218
+ currentCategory = item.value as SettingsCategory;
219
+ rebuild();
220
+ tui.requestRender();
221
+ };
222
+ selectList.onCancel = () => {
223
+ currentCategory = "main";
224
+ rebuild();
225
+ tui.requestRender();
226
+ };
227
+ activeList = selectList;
228
+ container.addChild(selectList);
229
+ } else if (currentCategory === "provider-order") {
230
+ const items = buildProviderOrderItems(settings);
231
+ const isReordering = providerOrderReordering;
232
+ const selectList = new SelectList(items, Math.min(items.length, 10), {
233
+ selectedPrefix: (t: string) => isReordering ? theme.fg("warning", t) : theme.fg("accent", t),
234
+ selectedText: (t: string) => isReordering ? theme.fg("warning", t) : theme.fg("accent", t),
235
+ description: (t: string) => theme.fg("muted", t),
236
+ scrollInfo: (t: string) => theme.fg("dim", t),
237
+ noMatch: (t: string) => theme.fg("warning", t),
238
+ });
239
+
240
+ if (items.length > 0) {
241
+ suppressProviderOrderChange = true;
242
+ providerOrderSelectedIndex = Math.min(providerOrderSelectedIndex, items.length - 1);
243
+ selectList.setSelectedIndex(providerOrderSelectedIndex);
244
+ suppressProviderOrderChange = false;
245
+ }
246
+
247
+ selectList.onSelectionChange = (item) => {
248
+ if (suppressProviderOrderChange) return;
249
+
250
+ const newIndex = items.findIndex((listItem) => listItem.value === item.value);
251
+ if (newIndex === -1) return;
252
+
253
+ if (!providerOrderReordering) {
254
+ providerOrderSelectedIndex = newIndex;
255
+ return;
256
+ }
257
+
258
+ const activeProviders = settings.providerOrder.filter((provider) => {
259
+ const enabled = settings.providers[provider].enabled;
260
+ return enabled !== "off" && enabled !== false;
261
+ });
262
+ const oldIndex = providerOrderSelectedIndex;
263
+ if (newIndex === oldIndex) return;
264
+ if (oldIndex < 0 || oldIndex >= activeProviders.length) return;
265
+
266
+ const provider = activeProviders[oldIndex];
267
+ const updatedActive = [...activeProviders];
268
+ updatedActive.splice(oldIndex, 1);
269
+ updatedActive.splice(newIndex, 0, provider);
270
+
271
+ let activeIndex = 0;
272
+ settings.providerOrder = settings.providerOrder.map((existing) => {
273
+ const enabled = settings.providers[existing].enabled;
274
+ if (enabled === "off" || enabled === false) return existing;
275
+ const next = updatedActive[activeIndex];
276
+ activeIndex += 1;
277
+ return next;
278
+ });
279
+
280
+ providerOrderSelectedIndex = newIndex;
281
+ saveSettings(settings);
282
+ if (onSettingsChange) void onSettingsChange(settings);
283
+ rebuild();
284
+ tui.requestRender();
285
+ };
286
+
287
+ attachTooltip(items, selectList);
288
+
289
+ selectList.onSelect = () => {
290
+ if (items.length === 0) return;
291
+ providerOrderReordering = !providerOrderReordering;
292
+ rebuild();
293
+ tui.requestRender();
294
+ };
295
+
296
+ selectList.onCancel = () => {
297
+ if (providerOrderReordering) {
298
+ providerOrderReordering = false;
299
+ rebuild();
300
+ tui.requestRender();
301
+ return;
302
+ }
303
+ currentCategory = "main";
304
+ rebuild();
305
+ tui.requestRender();
306
+ };
307
+
308
+ activeList = selectList;
309
+ container.addChild(selectList);
310
+ } else {
311
+ let items: SettingItem[];
312
+ let handleChange: (id: string, value: string) => void;
313
+ let backCategory: SettingsCategory = "main";
314
+
315
+ const provider = getProviderFromCategory(currentCategory);
316
+ if (provider) {
317
+ items = buildProviderSettingsItems(settings, provider);
318
+ const customHandlers: Record<string, ReturnType<typeof buildInputSubmenu>> = {};
319
+ if (provider === "anthropic") {
320
+ customHandlers.extraUsageCurrencySymbol = buildInputSubmenu(
321
+ "Extra Usage Currency Symbol",
322
+ parseCurrencySymbol,
323
+ undefined,
324
+ );
325
+ }
326
+ for (const item of items) {
327
+ if (item.values?.includes(CUSTOM_OPTION) && customHandlers[item.id]) {
328
+ item.submenu = customHandlers[item.id];
329
+ }
330
+ }
331
+ handleChange = (id, value) => {
332
+ settings = applyProviderSettingsChange(settings, provider, id, value);
333
+ saveSettings(settings);
334
+ if (onSettingsChange) void onSettingsChange(settings);
335
+ };
336
+ backCategory = "providers";
337
+ } else if (currentCategory === "tools") {
338
+ items = buildToolItems(settings.tools);
339
+ handleChange = (id, value) => {
340
+ settings = applyToolChange(settings, id, value);
341
+ saveSettings(settings);
342
+ if (onSettingsChange) void onSettingsChange(settings);
343
+ };
344
+ backCategory = "main";
345
+ } else {
346
+ const refreshTarget = currentCategory === "status-refresh" ? settings.statusRefresh : settings.behavior;
347
+ items = buildRefreshItems(refreshTarget);
348
+ const customHandlers: Record<string, ReturnType<typeof buildInputSubmenu>> = {
349
+ refreshInterval: buildInputSubmenu("Auto-refresh Interval (seconds)", parseRefreshInterval),
350
+ minRefreshInterval: buildInputSubmenu("Minimum Refresh Interval (seconds)", parseMinRefreshInterval),
351
+ };
352
+ for (const item of items) {
353
+ if (item.values?.includes(CUSTOM_OPTION) && customHandlers[item.id]) {
354
+ item.submenu = customHandlers[item.id];
355
+ }
356
+ }
357
+ handleChange = (id, value) => {
358
+ applyRefreshChange(refreshTarget, id, value);
359
+ saveSettings(settings);
360
+ if (onSettingsChange) void onSettingsChange(settings);
361
+ };
362
+ backCategory = "main";
363
+ }
364
+
365
+ const settingsHintText = "↓ navigate • ←/→ change • Enter/Space edit custom • Esc to cancel";
366
+ const customTheme = {
367
+ ...getSettingsListTheme(),
368
+ hint: (text: string) => {
369
+ if (text.includes("Enter/Space")) {
370
+ return theme.fg("dim", settingsHintText);
371
+ }
372
+ return theme.fg("dim", text);
373
+ },
374
+ };
375
+ const settingsList = new SettingsList(
376
+ items,
377
+ Math.min(items.length + 2, 15),
378
+ customTheme,
379
+ handleChange,
380
+ () => {
381
+ currentCategory = backCategory;
382
+ rebuild();
383
+ tui.requestRender();
384
+ }
385
+ );
386
+ activeList = settingsList;
387
+ container.addChild(settingsList);
388
+ }
389
+
390
+ const usesSettingsList =
391
+ currentCategory === "behavior" ||
392
+ currentCategory === "status-refresh" ||
393
+ currentCategory === "tools" ||
394
+ getProviderFromCategory(currentCategory) !== null;
395
+ if (!usesSettingsList) {
396
+ let helpText: string;
397
+ if (currentCategory === "main" || currentCategory === "providers") {
398
+ helpText = "↑↓ navigate • Enter/Space select • Esc back";
399
+ } else if (currentCategory === "provider-order") {
400
+ helpText = providerOrderReordering
401
+ ? "↑↓ move provider • Esc back"
402
+ : "↑↓ navigate • Enter/Space select • Esc back";
403
+ } else {
404
+ helpText = "↑↓ navigate • Enter/Space to change • Esc to cancel";
405
+ }
406
+ if (tooltipText) {
407
+ container.addChild(new Spacer(1));
408
+ container.addChild(tooltipText);
409
+ }
410
+ container.addChild(new Spacer(1));
411
+ container.addChild(new Text(theme.fg("dim", helpText), 1, 0));
412
+ }
413
+
414
+ // Bottom border
415
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
416
+ }
417
+
418
+
419
+ rebuild();
420
+
421
+ return {
422
+ render(width: number) {
423
+ return container.render(width);
424
+ },
425
+ invalidate() {
426
+ container.invalidate();
427
+ },
428
+ handleInput(data: string) {
429
+ if (data === " ") {
430
+ if (currentCategory === "provider-order") {
431
+ providerOrderReordering = !providerOrderReordering;
432
+ rebuild();
433
+ tui.requestRender();
434
+ return;
435
+ }
436
+ if (activeList && "handleInput" in activeList && activeList.handleInput) {
437
+ activeList.handleInput("\r");
438
+ }
439
+ tui.requestRender();
440
+ return;
441
+ }
442
+ if (activeList && "handleInput" in activeList && activeList.handleInput) {
443
+ activeList.handleInput(data);
444
+ }
445
+ tui.requestRender();
446
+ },
447
+ };
448
+ }).then(resolve);
449
+ });
450
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Settings types and defaults for sub-core
3
+ */
4
+
5
+ import type {
6
+ CoreSettings,
7
+ CoreProviderSettingsMap,
8
+ CoreProviderSettings,
9
+ BehaviorSettings,
10
+ ProviderName,
11
+ ProviderEnabledSetting,
12
+ } from "@eiei114/pi-sub-shared";
13
+ import { PROVIDERS, getDefaultCoreSettings } from "@eiei114/pi-sub-shared";
14
+
15
+ export type {
16
+ CoreProviderSettings,
17
+ CoreProviderSettingsMap,
18
+ BehaviorSettings,
19
+ CoreSettings,
20
+ ProviderEnabledSetting,
21
+ } from "@eiei114/pi-sub-shared";
22
+
23
+ /**
24
+ * Tool registration settings
25
+ */
26
+ export interface ToolSettings {
27
+ usageTool: boolean;
28
+ allUsageTool: boolean;
29
+ }
30
+
31
+ /**
32
+ * All settings
33
+ */
34
+ export interface Settings extends CoreSettings {
35
+ /** Version for migration */
36
+ version: number;
37
+ /** Tool registration settings */
38
+ tools: ToolSettings;
39
+ }
40
+
41
+ /**
42
+ * Current settings version
43
+ */
44
+ export const SETTINGS_VERSION = 3;
45
+
46
+ /**
47
+ * Default settings
48
+ */
49
+ export function getDefaultSettings(): Settings {
50
+ const coreDefaults = getDefaultCoreSettings();
51
+ return {
52
+ version: SETTINGS_VERSION,
53
+ tools: {
54
+ usageTool: false,
55
+ allUsageTool: false,
56
+ },
57
+ providers: coreDefaults.providers,
58
+ behavior: coreDefaults.behavior,
59
+ statusRefresh: coreDefaults.statusRefresh,
60
+ providerOrder: coreDefaults.providerOrder,
61
+ defaultProvider: coreDefaults.defaultProvider,
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Deep merge two objects
67
+ */
68
+ function deepMerge<T extends object>(target: T, source: Partial<T>): T {
69
+ const result = { ...target } as T;
70
+ for (const key of Object.keys(source) as (keyof T)[]) {
71
+ const sourceValue = source[key];
72
+ const targetValue = result[key];
73
+ if (
74
+ sourceValue !== undefined &&
75
+ typeof sourceValue === "object" &&
76
+ sourceValue !== null &&
77
+ !Array.isArray(sourceValue) &&
78
+ typeof targetValue === "object" &&
79
+ targetValue !== null &&
80
+ !Array.isArray(targetValue)
81
+ ) {
82
+ result[key] = deepMerge(targetValue as object, sourceValue as object) as T[keyof T];
83
+ } else if (sourceValue !== undefined) {
84
+ result[key] = sourceValue as T[keyof T];
85
+ }
86
+ }
87
+ return result;
88
+ }
89
+
90
+ /**
91
+ * Merge settings with defaults (no legacy migrations).
92
+ */
93
+ export function mergeSettings(loaded: Partial<Settings>): Settings {
94
+ return deepMerge(getDefaultSettings(), loaded);
95
+ }
@@ -0,0 +1 @@
1
+ export { showSettingsUI } from "./settings/ui.js";
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Settings persistence for sub-core
3
+ */
4
+
5
+ import * as path from "node:path";
6
+ import type { Settings } from "./settings-types.js";
7
+ import { getDefaultSettings, mergeSettings, SETTINGS_VERSION } from "./settings-types.js";
8
+ import { getStorage } from "./storage.js";
9
+ import { getLegacySettingsPath, getSettingsPath } from "./paths.js";
10
+ import { clearCache } from "./cache.js";
11
+
12
+ /**
13
+ * Settings file path
14
+ */
15
+ export const SETTINGS_PATH = getSettingsPath();
16
+ const LEGACY_SETTINGS_PATH = getLegacySettingsPath();
17
+
18
+ /**
19
+ * In-memory settings cache
20
+ */
21
+ let cachedSettings: Settings | undefined;
22
+
23
+ type LoadedSettings = {
24
+ settings: Settings;
25
+ loadedVersion: number;
26
+ };
27
+
28
+ /**
29
+ * Ensure the settings directory exists
30
+ */
31
+ function ensureSettingsDir(): void {
32
+ const storage = getStorage();
33
+ const dir = path.dirname(SETTINGS_PATH);
34
+ storage.ensureDir(dir);
35
+ }
36
+
37
+ function loadSettingsFromDisk(settingsPath: string): LoadedSettings | null {
38
+ const storage = getStorage();
39
+ if (!storage.exists(settingsPath)) return null;
40
+ const content = storage.readFile(settingsPath);
41
+ if (!content) return null;
42
+ const loaded = JSON.parse(content) as Partial<Settings>;
43
+ const loadedVersion = typeof loaded.version === "number" ? loaded.version : 0;
44
+ const merged = mergeSettings(loaded);
45
+ return { settings: merged, loadedVersion };
46
+ }
47
+
48
+ function applyVersionMigration(settings: Settings, loadedVersion: number): { settings: Settings; needsSave: boolean } {
49
+ if (loadedVersion < SETTINGS_VERSION) {
50
+ clearCache();
51
+ return { settings: { ...settings, version: SETTINGS_VERSION }, needsSave: true };
52
+ }
53
+ return { settings, needsSave: false };
54
+ }
55
+
56
+ function tryLoadSettings(settingsPath: string): LoadedSettings | null {
57
+ try {
58
+ return loadSettingsFromDisk(settingsPath);
59
+ } catch (error) {
60
+ console.error(`Failed to load settings from ${settingsPath}:`, error);
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Load settings from disk
67
+ */
68
+ export function loadSettings(): Settings {
69
+ if (cachedSettings) {
70
+ return cachedSettings;
71
+ }
72
+
73
+ const diskSettings = tryLoadSettings(SETTINGS_PATH);
74
+ if (diskSettings) {
75
+ const { settings: next, needsSave } = applyVersionMigration(diskSettings.settings, diskSettings.loadedVersion);
76
+ if (needsSave) {
77
+ saveSettings(next);
78
+ }
79
+ cachedSettings = next;
80
+ return cachedSettings;
81
+ }
82
+
83
+ const legacySettings = tryLoadSettings(LEGACY_SETTINGS_PATH);
84
+ if (legacySettings) {
85
+ const { settings: next } = applyVersionMigration(legacySettings.settings, legacySettings.loadedVersion);
86
+ const saved = saveSettings(next);
87
+ if (saved) {
88
+ getStorage().removeFile(LEGACY_SETTINGS_PATH);
89
+ }
90
+ cachedSettings = next;
91
+ return cachedSettings;
92
+ }
93
+
94
+ // Return defaults if file doesn't exist or failed to load
95
+ cachedSettings = getDefaultSettings();
96
+ return cachedSettings;
97
+ }
98
+
99
+ /**
100
+ * Save settings to disk
101
+ */
102
+ export function saveSettings(settings: Settings): boolean {
103
+ const storage = getStorage();
104
+ try {
105
+ ensureSettingsDir();
106
+ const content = JSON.stringify(settings, null, 2);
107
+ storage.writeFile(SETTINGS_PATH, content);
108
+ cachedSettings = settings;
109
+ return true;
110
+ } catch (error) {
111
+ console.error(`Failed to save settings to ${SETTINGS_PATH}:`, error);
112
+ return false;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Reset settings to defaults
118
+ */
119
+ export function resetSettings(): Settings {
120
+ const defaults = getDefaultSettings();
121
+ saveSettings(defaults);
122
+ return defaults;
123
+ }
124
+
125
+ /**
126
+ * Get current settings (cached)
127
+ */
128
+ export function getSettings(): Settings {
129
+ return loadSettings();
130
+ }
131
+
132
+ /**
133
+ * Clear the settings cache (force reload on next access)
134
+ */
135
+ export function clearSettingsCache(): void {
136
+ cachedSettings = undefined;
137
+ }