pyre-cli 4.17.0 → 5.1.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.
@@ -6,11 +6,28 @@
6
6
  * alert checker. All state variables are imported from
7
7
  * `state.ts`.
8
8
  */
9
+ import { execFile } from 'node:child_process';
9
10
  import chalk from 'chalk';
10
11
  import { formatTable, formatGraphs, gridColumns, THEMES, type ThemeName, panel } from '../formatters/index.js';
12
+ import { detectAnomalies } from '../anomalies.js';
11
13
  import { state, setStatus, SIGNAL_OPTIONS, getToggleKey } from './state.js';
12
14
  import type { StatsData } from '../monitors/index.js';
13
15
 
16
+ function formatTempForUnit(c: number): string {
17
+ if (state.tempUnit === 'f') {
18
+ const f = c * (9 / 5) + 32;
19
+ return `${f.toFixed(1)}°F`;
20
+ }
21
+ return `${c.toFixed(1)}°C`;
22
+ }
23
+
24
+ function applyTempUnit(text: string): string {
25
+ if (state.tempUnit === 'f') {
26
+ return text.replace(/([\d.]+)°C \/ ([\d.]+)°F/g, '$2°F');
27
+ }
28
+ return text.replace(/([\d.]+)°C \/ ([\d.]+)°F/g, '$1°C');
29
+ }
30
+
14
31
  // --- render caching -----------------------------------------------------------
15
32
 
16
33
  let _cachedTable = '';
@@ -103,28 +120,33 @@ function renderCustomizerOverlay(): string {
103
120
  chalk.bgCyan.black.bold(' UI CUSTOMIZER (Use ↑/↓ or J/K to navigate, Enter/Space to toggle, Esc to exit) '),
104
121
  ];
105
122
 
106
- state.CUSTOMIZER_OPTIONS.forEach((opt, idx) => {
107
- const isSelected = idx === state.customizerIndex;
108
- const prefix = isSelected ? chalk.yellow('▶ ') : ' ';
109
-
110
- if (opt === 'Theme') {
111
- lines.push(`${prefix}${opt}: ${chalk.bold.green(state.currentTheme)} [${themesList.join(', ')}]`);
112
- } else if (opt === 'Graph Mode') {
113
- lines.push(`${prefix}${opt}: ${chalk.bold.green(state.graphMode)} [spark, bar]`);
114
- } else if (opt === 'Splash Screen') {
115
- const status = state.splashEnabled ? chalk.green('[ON]') : chalk.red('[OFF]');
116
- lines.push(`${prefix}${opt}: ${status}`);
117
- } else if (opt === 'Splash Color') {
118
- lines.push(`${prefix}${opt}: ${chalk.bold.green(state.splashColorScheme)} [fire, ocean, forest, purple, monochrome]`);
119
- } else if (opt === 'Splash Animation') {
120
- lines.push(`${prefix}${opt}: ${chalk.bold.green(state.splashAnimation)} [classic, wave, sparks]`);
121
- } else {
122
- const toggleKey = getToggleKey(opt);
123
- const isVisible = toggleKey ? (toggleKey === 'tree' ? state.treeView : state.visiblePanels[toggleKey] !== false) : true;
124
- const status = isVisible ? chalk.green('[VISIBLE]') : chalk.red('[HIDDEN]');
125
- lines.push(`${prefix}${opt}: ${status}`);
126
- }
127
- });
123
+ state.CUSTOMIZER_OPTIONS.forEach((opt, idx) => {
124
+ const isSelected = idx === state.customizerIndex;
125
+ const prefix = isSelected ? chalk.yellow('▶ ') : ' ';
126
+
127
+ if (opt === 'Theme') {
128
+ lines.push(`${prefix}${opt}: ${chalk.bold.green(state.currentTheme)} [${themesList.join(', ')}]`);
129
+ } else if (opt === 'Graph Mode') {
130
+ lines.push(`${prefix}${opt}: ${chalk.bold.green(state.graphMode)} [spark, bar]`);
131
+ } else if (opt === 'Splash Screen') {
132
+ const status = state.splashEnabled ? chalk.green('[ON]') : chalk.red('[OFF]');
133
+ lines.push(`${prefix}${opt}: ${status}`);
134
+ } else if (opt === 'Splash Color') {
135
+ lines.push(`${prefix}${opt}: ${chalk.bold.green(state.splashColorScheme)} [fire, ocean, forest, purple, monochrome]`);
136
+ } else if (opt === 'Splash Animation') {
137
+ lines.push(`${prefix}${opt}: ${chalk.bold.green(state.splashAnimation)} [classic, wave, sparks]`);
138
+ } else if (opt === 'Notifications') {
139
+ const status = state.notificationsEnabled ? chalk.green('[ON]') : chalk.red('[OFF]');
140
+ lines.push(`${prefix}${opt}: ${status}`);
141
+ } else if (opt === 'Temperature Unit') {
142
+ lines.push(`${prefix}${opt}: ${chalk.bold.green(state.tempUnit.toUpperCase())} [C, F]`);
143
+ } else {
144
+ const toggleKey = getToggleKey(opt);
145
+ const isVisible = toggleKey ? (toggleKey === 'tree' ? state.treeView : state.visiblePanels[toggleKey] !== false) : true;
146
+ const status = isVisible ? chalk.green('[VISIBLE]') : chalk.red('[HIDDEN]');
147
+ lines.push(`${prefix}${opt}: ${status}`);
148
+ }
149
+ });
128
150
 
129
151
  return lines.map(l => ` ${l}`).join('\n');
130
152
  }
@@ -170,26 +192,31 @@ function render() {
170
192
  const lines: string[] = [];
171
193
  const theme = THEMES[state.currentTheme] || THEMES.default;
172
194
 
173
- const params = tableCacheParams();
174
- const tableStr = _tableDataDirty || state.lastData !== _cachedTableData || params !== _cachedTableParams
175
- ? (() => {
176
- const out = formatTable(state.lastData, {
177
- width: state.termWidth,
178
- sortBy: state.sortMode,
179
- filter: state.processFilter || undefined,
180
- processLimit: processRowBudget(),
181
- theme: state.currentTheme,
182
- visible: state.visiblePanels,
183
- treeView: state.treeView,
184
- activePanel: state.activePanel,
185
- });
186
- _cachedTableData = state.lastData;
187
- _cachedTableParams = params;
188
- _cachedTable = out;
189
- _tableDataDirty = false;
190
- return out;
191
- })()
192
- : _cachedTable;
195
+ const params = tableCacheParams();
196
+ const anomalies = detectAnomalies(state.lastData, state.history);
197
+ const anomalyCacheKey = anomalies.map(a => `${a.metric}:${a.zScore.toFixed(2)}`).join(',');
198
+ const cacheParams = params + '|' + anomalyCacheKey;
199
+ const tableStr = _tableDataDirty || state.lastData !== _cachedTableData || cacheParams !== _cachedTableParams
200
+ ? (() => {
201
+ const out = formatTable(state.lastData, {
202
+ width: state.termWidth,
203
+ sortBy: state.sortMode,
204
+ filter: state.processFilter || undefined,
205
+ processLimit: processRowBudget(),
206
+ theme: state.currentTheme,
207
+ visible: state.visiblePanels,
208
+ treeView: state.treeView,
209
+ activePanel: state.activePanel,
210
+ anomalies,
211
+ tempUnit: state.tempUnit,
212
+ });
213
+ _cachedTableData = state.lastData;
214
+ _cachedTableParams = cacheParams;
215
+ _cachedTable = applyTempUnit(out);
216
+ _tableDataDirty = false;
217
+ return _cachedTable;
218
+ })()
219
+ : _cachedTable;
193
220
 
194
221
  const bodyLines: string[] = [];
195
222
  if (state.activePanel === 'p2p') {
@@ -206,8 +233,8 @@ function render() {
206
233
  const out = formatGraphs(state.history, state.termWidth, state.currentTheme, state.graphMode);
207
234
  _cachedGraphsVersion = state.history.version;
208
235
  _cachedGraphsParams = graphsParams;
209
- _cachedGraphs = out;
210
- return out;
236
+ _cachedGraphs = applyTempUnit(out);
237
+ return _cachedGraphs;
211
238
  })()
212
239
  : _cachedGraphs;
213
240
 
@@ -254,16 +281,17 @@ function render() {
254
281
 
255
282
  function footerLine(): string {
256
283
  const row1: [string, string][] = [
257
- ['q', 'quit'],
258
- ['c', 'customize UI'],
259
- ['←/→', 'tab'],
260
- ['1-9', 'panels'],
261
- ['g', state.showGraphs ? 'hide graphs' : 'show graphs'],
262
- ['b', `graph:${state.graphMode}`],
263
- ['t', state.treeView ? 'flat' : 'tree'],
264
- ['d', state.detailed ? 'basic' : 'detailed'],
265
- ['p', state.paused ? 'resume' : 'pause'],
266
- ];
284
+ ['q', 'quit'],
285
+ ['c', 'customize UI'],
286
+ ['←/→', 'tab'],
287
+ ['1-9', 'panels'],
288
+ ['g', state.showGraphs ? 'hide graphs' : 'show graphs'],
289
+ ['b', `graph:${state.graphMode}`],
290
+ ['t', state.treeView ? 'flat' : 'tree'],
291
+ ['d', state.detailed ? 'basic' : 'detailed'],
292
+ ['T', `temp:${state.tempUnit}`],
293
+ ['p', state.paused ? 'resume' : 'pause'],
294
+ ];
267
295
  const row2: [string, string][] = [
268
296
  ['s', `sort:${state.sortMode}`],
269
297
  ['/', 'filter'],
@@ -286,20 +314,40 @@ function footerLine(): string {
286
314
  if (state.p2pServerRunning) badges.push(chalk.green.bold(`P2P ${state.p2pBind || '0.0.0.0'}:${state.p2pPort}`));
287
315
  const badgeStr = badges.join(' ');
288
316
 
289
- return badgeStr ? `${str1}\n${str2} ${badgeStr}` : `${str1}\n${str2}`;
290
- }
317
+ return badgeStr ? `${str1}\n${str2} ${badgeStr}` : `${str1}\n${str2}`;
318
+ }
319
+
320
+ function sendNotification(title: string, message: string) {
321
+ if (!state.notificationsEnabled) return;
322
+ execFile('osascript', ['-e', `display notification "${message}" with title "${title}"`], (err) => {
323
+ if (err) {
324
+ // Silently ignore notification failures (e.g., terminal not in focus, no notification permission)
325
+ }
326
+ });
327
+ }
291
328
 
292
329
  function checkAlerts(data: StatsData) {
293
- const temp = data.cpu.temperature ?? data.thermal.temperatures?.cpu_die ?? null;
294
- const hot = data.cpu.usage >= state.CPU_ALERT_PCT || (temp !== null && temp >= state.TEMP_ALERT_C);
295
- if (hot && !state.alerted) {
296
- state.alerted = true;
297
- process.stdout.write('\x07');
298
- const reason = data.cpu.usage >= state.CPU_ALERT_PCT ? `CPU at ${data.cpu.usage}%` : `Temp at ${temp}°C`;
299
- setStatus(chalk.red(`⚠ Alert: ${reason}`), 5000);
300
- } else if (!hot) {
301
- state.alerted = false;
330
+ const temp = data.cpu.temperature ?? data.thermal.temperatures?.cpu_die ?? null;
331
+ const hot = data.cpu.usage >= state.CPU_ALERT_PCT || (temp !== null && temp >= state.TEMP_ALERT_C);
332
+ const anomalies = detectAnomalies(data, state.history);
333
+ const hasCriticalAnomaly = anomalies.some(a => a.severity === 'critical');
334
+ const hasWarningAnomaly = anomalies.some(a => a.severity === 'warning');
335
+ const anomalyTriggered = hasCriticalAnomaly || hasWarningAnomaly;
336
+
337
+ if ((hot || anomalyTriggered) && !state.alerted) {
338
+ state.alerted = true;
339
+ process.stdout.write('\x07');
340
+ const reasons: string[] = [];
341
+ if (data.cpu.usage >= state.CPU_ALERT_PCT) reasons.push(`CPU at ${data.cpu.usage}%`);
342
+ if (temp !== null && temp >= state.TEMP_ALERT_C) reasons.push(`Temp at ${formatTempForUnit(temp)}`);
343
+ for (const a of anomalies) {
344
+ reasons.push(`${a.metric} anomaly (σ=${a.zScore.toFixed(1)})`);
345
+ }
346
+ setStatus(chalk.red(`⚠ Alert: ${reasons.join('; ')}`), 5000);
347
+ sendNotification('pyre Alert', reasons.join('; '));
348
+ } else if (!hot && !anomalyTriggered) {
349
+ state.alerted = false;
350
+ }
302
351
  }
303
- }
304
352
 
305
353
  export { render, footerLine, renderCustomizerOverlay, checkAlerts, processRowBudget, invalidateTableCache, invalidateFrame };
package/src/live/state.ts CHANGED
@@ -11,6 +11,7 @@ import { History } from '../history.js';
11
11
  import type { StatsData } from '../monitors/index.js';
12
12
  import type { ExportFormat, InputMode, SortMode, GraphMode } from './types.js';
13
13
  import type { ThemeName, VisibleItems } from '../formatters/types.js';
14
+ import { readConfig, type PyreConfig } from '../state/config.js';
14
15
 
15
16
  export const SIGNAL_OPTIONS = ['SIGTERM', 'SIGKILL', 'SIGINT', 'SIGHUP', 'SIGSTOP', 'SIGCONT'] as const;
16
17
 
@@ -18,54 +19,61 @@ import { type SplashColorScheme, type SplashAnimation } from '../splash.js';
18
19
 
19
20
  export { type SplashColorScheme, type SplashAnimation };
20
21
 
22
+ const config = readConfig();
23
+
21
24
  const state = {
22
25
  intervalHandle: null as NodeJS.Timeout | null,
23
26
  uiIntervalHandle: null as NodeJS.Timeout | null,
24
27
  running: false,
25
28
  paused: false,
26
- detailed: false,
27
- interval: 2,
28
- showGraphs: true,
29
- graphMode: 'spark' as GraphMode,
29
+ detailed: config.detailed,
30
+ interval: config.interval,
31
+ showGraphs: config.showGraphs,
32
+ graphMode: config.graphMode as GraphMode,
30
33
  exportFormat: 'json' as ExportFormat,
34
+ tempUnit: 'c' as 'c' | 'f',
31
35
 
32
-
33
- exportDir: './pyre-exports',
34
- currentTheme: 'default' as ThemeName,
35
- visiblePanels: {
36
- cpu: true,
37
- mem: true,
38
- gpu: true,
39
- power: true,
40
- battery: true,
41
- thermal: true,
42
- network: true,
43
- packets: true,
44
- tasks: true,
45
- disk: true,
46
- process: true,
47
- } as VisibleItems,
48
- logging: false,
49
- logStream: null as fs.WriteStream | null,
50
- statusMessage: '',
51
- statusTimer: null as NodeJS.Timeout | null,
52
- lastData: null as StatsData | null,
53
- history: new History(40),
54
- keypressHandler: null as ((str: string, key: readline.Key) => void) | null,
55
- termWidth: process.stdout.columns || 80,
56
- termHeight: process.stdout.rows || 24,
57
- sortMode: 'cpu' as SortMode,
58
- processFilter: '',
59
- inputMode: null as InputMode,
60
- inputBuffer: '',
61
- targetPid: '',
62
- selectedSignal: 'SIGTERM' as typeof SIGNAL_OPTIONS[number],
63
- treeView: false,
64
- SIGNAL_OPTIONS,
65
- customizerIndex: 0,
36
+ exportDir: config.exportDir,
37
+ currentTheme: config.theme as ThemeName,
38
+ visiblePanels: {
39
+ cpu: config.visiblePanels.cpu,
40
+ mem: config.visiblePanels.mem,
41
+ gpu: config.visiblePanels.gpu,
42
+ power: config.visiblePanels.power,
43
+ battery: config.visiblePanels.battery,
44
+ thermal: config.visiblePanels.thermal,
45
+ network: config.visiblePanels.network,
46
+ packets: config.visiblePanels.packets,
47
+ tasks: config.visiblePanels.tasks,
48
+ disk: config.visiblePanels.disk,
49
+ process: config.visiblePanels.process,
50
+ } as VisibleItems,
51
+ logging: false,
52
+ logStream: null as fs.WriteStream | null,
53
+ statusMessage: '',
54
+ statusTimer: null as NodeJS.Timeout | null,
55
+ lastData: null as StatsData | null,
56
+ history: new History(40),
57
+ keypressHandler: null as ((str: string, key: readline.Key) => void) | null,
58
+ termWidth: process.stdout.columns || 80,
59
+ termHeight: process.stdout.rows || 24,
60
+ sortMode: config.sortMode as SortMode,
61
+ processFilter: '',
62
+ inputMode: null as InputMode,
63
+ inputBuffer: '',
64
+ targetPid: '',
65
+ selectedSignal: 'SIGTERM' as typeof SIGNAL_OPTIONS[number],
66
+ treeView: config.treeView,
67
+ SIGNAL_OPTIONS,
68
+ customizerIndex: 0,
66
69
  CUSTOMIZER_OPTIONS: [
67
70
  'Theme',
68
71
  'Graph Mode',
72
+ 'Splash Screen',
73
+ 'Splash Color',
74
+ 'Splash Animation',
75
+ 'Notifications',
76
+ 'Temperature Unit',
69
77
  'Toggle CPU',
70
78
  'Toggle Memory',
71
79
  'Toggle GPU',
@@ -78,39 +86,37 @@ const state = {
78
86
  'Toggle Disk',
79
87
  'Toggle Processes',
80
88
  'Toggle Tree View',
81
- 'Splash Screen',
82
- 'Splash Color',
83
- 'Splash Animation',
84
89
  ],
85
- CPU_ALERT_PCT: 90,
86
- TEMP_ALERT_C: 95,
87
- alerted: false,
88
- activePanel: 'grid' as 'grid' | 'cpu' | 'mem' | 'gpu' | 'power' | 'battery' | 'thermal' | 'network' | 'packets' | 'tasks' | 'disk' | 'process' | 'p2p',
89
- mouseEnabled: true,
90
- PANEL_TABS: [
91
- { id: 'cpu', label: 'CPU', key: '1' },
92
- { id: 'mem', label: 'Memory', key: '2' },
93
- { id: 'gpu', label: 'GPU', key: '3' },
94
- { id: 'power', label: 'Power', key: '4' },
95
- { id: 'battery', label: 'Battery', key: '5' },
96
- { id: 'thermal', label: 'Thermal', key: '6' },
97
- { id: 'network', label: 'Network', key: '7' },
98
- { id: 'packets', label: 'Packets', key: '8' },
99
- { id: 'tasks', label: 'Tasks', key: '9' },
100
- { id: 'disk', label: 'Disk', key: '0' },
101
- { id: 'process', label: 'Process', key: 'P' },
102
- { id: 'p2p', label: 'P2P', key: 'R' },
103
- ] as const,
104
- p2pEvents: [] as { type: string; detail: string; ts: number }[],
105
- p2pBind: '',
106
- p2pServer: null as { stop: () => void; peerEventHistory: { type: string; detail: string; ts: number }[] } | null,
107
- p2pServerRunning: false,
108
- p2pPort: 9876,
109
- p2pPassword: 'pyre',
110
- splashEnabled: true,
111
- splashColorScheme: 'fire' as SplashColorScheme,
112
- splashAnimation: 'classic' as SplashAnimation,
113
- };
90
+ CPU_ALERT_PCT: config.cpuAlertPct,
91
+ TEMP_ALERT_C: config.tempAlertC,
92
+ notificationsEnabled: config.notificationsEnabled,
93
+ alerted: false,
94
+ activePanel: 'grid' as 'grid' | 'cpu' | 'mem' | 'gpu' | 'power' | 'battery' | 'thermal' | 'network' | 'packets' | 'tasks' | 'disk' | 'process' | 'p2p',
95
+ mouseEnabled: config.mouseEnabled,
96
+ PANEL_TABS: [
97
+ { id: 'cpu', label: 'CPU', key: '1' },
98
+ { id: 'mem', label: 'Memory', key: '2' },
99
+ { id: 'gpu', label: 'GPU', key: '3' },
100
+ { id: 'power', label: 'Power', key: '4' },
101
+ { id: 'battery', label: 'Battery', key: '5' },
102
+ { id: 'thermal', label: 'Thermal', key: '6' },
103
+ { id: 'network', label: 'Network', key: '7' },
104
+ { id: 'packets', label: 'Packets', key: '8' },
105
+ { id: 'tasks', label: 'Tasks', key: '9' },
106
+ { id: 'disk', label: 'Disk', key: '0' },
107
+ { id: 'process', label: 'Process', key: 'P' },
108
+ { id: 'p2p', label: 'P2P', key: 'R' },
109
+ ] as const,
110
+ p2pEvents: [] as { type: string; detail: string; ts: number }[],
111
+ p2pBind: '',
112
+ p2pServer: null as { stop: () => void; peerEventHistory: { type: string; detail: string; ts: number }[] } | null,
113
+ p2pServerRunning: false,
114
+ p2pPort: config.p2pPort,
115
+ p2pPassword: config.p2pPassword,
116
+ splashEnabled: config.splashEnabled,
117
+ splashColorScheme: config.splashColorScheme as SplashColorScheme,
118
+ splashAnimation: config.splashAnimation as SplashAnimation,
119
+ };
114
120
 
115
121
  function setStatus(msg: string, ms = 3000) {
116
122
  state.statusMessage = msg;
package/src/live/types.ts CHANGED
@@ -11,9 +11,12 @@ export interface LiveOptions {
11
11
  exportDir?: string;
12
12
  autoLog?: boolean;
13
13
  theme?: ThemeName;
14
+ alertCpu?: number;
15
+ alertTemp?: number;
16
+ tempUnit?: 'c' | 'f';
14
17
  }
15
18
 
16
- export type ExportFormat = 'json' | 'csv' | 'tsv';
19
+ export type ExportFormat = 'json' | 'csv' | 'tsv' | 'html' | 'md';
17
20
 
18
21
  type InputMode = null | 'filter' | 'kill' | 'signal' | 'customizer' | 'p2p';
19
22
 
@@ -12,12 +12,14 @@
12
12
  * returning their best-effort values.
13
13
  */
14
14
  import chalk from 'chalk';
15
+ import os from 'node:os';
15
16
  import { run } from './run.js';
16
17
  import { getSmcMetrics, parseSuffix } from './smc.js';
17
18
  import type { StatsData, CpuData, MemoryData, ThermalData, BatteryData, PowerData, DiskData, NetworkData, ProcessData, GpuData, PacketData, NetworkProcess } from './types.js';
18
19
 
19
20
  const SP_TTL_MS = 10_000;
20
21
  const NETSTAT_TTL_MS = 1000;
22
+ let prevCpuTimes: { total: number; idle: number }[] | null = null;
21
23
  const ROUTE_TTL_MS = 5_000;
22
24
  const SYSCTL_TTL_MS = 60_000;
23
25
 
@@ -338,7 +340,27 @@ export async function collectCpu(): Promise<CpuData> {
338
340
  // ignore
339
341
  }
340
342
 
341
- return { brand, cores, physicalCores, frequency, usage, loadAvg, temperature };
343
+ let coreUsage: number[] = [];
344
+ try {
345
+ const cpus = os.cpus();
346
+ const current = cpus.map(cpu => ({
347
+ total: cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq,
348
+ idle: cpu.times.idle,
349
+ }));
350
+ if (prevCpuTimes) {
351
+ coreUsage = prevCpuTimes.map((prev, i) => {
352
+ const totalDelta = current[i].total - prev.total;
353
+ const idleDelta = current[i].idle - prev.idle;
354
+ if (totalDelta === 0) return 0;
355
+ return Math.round(((totalDelta - idleDelta) / totalDelta) * 100);
356
+ });
357
+ }
358
+ prevCpuTimes = current;
359
+ } catch {
360
+ // ignore
361
+ }
362
+
363
+ return { brand, cores, physicalCores, frequency, usage, loadAvg, temperature, coreUsage };
342
364
  }
343
365
 
344
366
  export async function collectMemory(): Promise<MemoryData> {
@@ -68,6 +68,7 @@ export interface CpuData {
68
68
  usage: number;
69
69
  loadAvg: number[];
70
70
  temperature?: number;
71
+ coreUsage?: number[];
71
72
  }
72
73
 
73
74
  export interface MemoryData {
@@ -0,0 +1,130 @@
1
+ /**
2
+ * pyre config file module.
3
+ *
4
+ * Reads and writes `~/.config/pyre/config.json` to persist
5
+ * user preferences across sessions.
6
+ *
7
+ * The config file stores theme, interval, panel visibility,
8
+ * sort mode, tree view, graph mode, alert thresholds, P2P
9
+ * defaults, export directory, and splash preferences.
10
+ */
11
+
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+
16
+ const CONFIG_DIR = path.join(os.homedir(), '.config', 'pyre');
17
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
18
+
19
+ export interface PyreConfig {
20
+ theme?: string;
21
+ interval?: number;
22
+ exportDir?: string;
23
+ detailed?: boolean;
24
+ sortMode?: string;
25
+ treeView?: boolean;
26
+ graphMode?: string;
27
+ showGraphs?: boolean;
28
+ autoLog?: boolean;
29
+ mouseEnabled?: boolean;
30
+ cpuAlertPct?: number;
31
+ tempAlertC?: number;
32
+ notificationsEnabled?: boolean;
33
+ p2pPort?: number;
34
+ p2pPassword?: string;
35
+ splashEnabled?: boolean;
36
+ splashColorScheme?: string;
37
+ splashAnimation?: string;
38
+ visiblePanels?: {
39
+ cpu?: boolean;
40
+ mem?: boolean;
41
+ gpu?: boolean;
42
+ power?: boolean;
43
+ battery?: boolean;
44
+ thermal?: boolean;
45
+ network?: boolean;
46
+ packets?: boolean;
47
+ tasks?: boolean;
48
+ disk?: boolean;
49
+ process?: boolean;
50
+ tree?: boolean;
51
+ };
52
+ }
53
+
54
+ export const DEFAULT_CONFIG: Required<PyreConfig> = {
55
+ theme: 'default',
56
+ interval: 2,
57
+ exportDir: './pyre-exports',
58
+ detailed: false,
59
+ sortMode: 'cpu',
60
+ treeView: false,
61
+ graphMode: 'spark',
62
+ showGraphs: true,
63
+ autoLog: false,
64
+ mouseEnabled: true,
65
+ cpuAlertPct: 90,
66
+ tempAlertC: 95,
67
+ notificationsEnabled: true,
68
+ p2pPort: 9876,
69
+ p2pPassword: 'pyre',
70
+ splashEnabled: true,
71
+ splashColorScheme: 'fire',
72
+ splashAnimation: 'classic',
73
+ visiblePanels: {
74
+ cpu: true,
75
+ mem: true,
76
+ gpu: true,
77
+ power: true,
78
+ battery: true,
79
+ thermal: true,
80
+ network: true,
81
+ packets: true,
82
+ tasks: true,
83
+ disk: true,
84
+ process: true,
85
+ tree: false,
86
+ },
87
+ };
88
+
89
+ export function readConfig(): Required<PyreConfig> {
90
+ try {
91
+ if (!fs.existsSync(CONFIG_FILE)) return { ...DEFAULT_CONFIG };
92
+ const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
93
+ const parsed = JSON.parse(raw) as Partial<PyreConfig>;
94
+ return deepMerge(DEFAULT_CONFIG, parsed);
95
+ } catch {
96
+ return { ...DEFAULT_CONFIG };
97
+ }
98
+ }
99
+
100
+ export function writeConfig(config: Partial<PyreConfig>): void {
101
+ try {
102
+ if (!fs.existsSync(CONFIG_DIR)) {
103
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
104
+ }
105
+ const merged = deepMerge(readConfig(), config);
106
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + '\n');
107
+ } catch {
108
+ // ignore write errors
109
+ }
110
+ }
111
+
112
+ function deepMerge(target: any, source: any): any {
113
+ const result = { ...target };
114
+ for (const key of Object.keys(source)) {
115
+ if (source[key] !== undefined && source[key] !== null) {
116
+ if (typeof source[key] === 'object' && !Array.isArray(source[key]) && typeof target[key] === 'object' && !Array.isArray(target[key])) {
117
+ result[key] = deepMerge(target[key] || {}, source[key]);
118
+ } else {
119
+ result[key] = source[key];
120
+ }
121
+ }
122
+ }
123
+ return result;
124
+ }
125
+
126
+ export function getConfigPath(): string {
127
+ return CONFIG_FILE;
128
+ }
129
+
130
+ export { CONFIG_FILE };