pyre-cli 6.0.0 → 7.0.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,7 +6,7 @@
6
6
  * alert checker. All state variables are imported from
7
7
  * `state.ts`.
8
8
  */
9
- import { execFile } from 'node:child_process';
9
+ import { execFile, exec } from 'node:child_process';
10
10
  import chalk from 'chalk';
11
11
  import { formatTable, formatGraphs, gridColumns, THEMES, type ThemeName, panel, fitVisible } from '../formatters/index.js';
12
12
  import { detectAnomalies } from '../anomalies.js';
@@ -54,6 +54,7 @@ function tableCacheParams(): string {
54
54
  state.treeView ? '1' : '0',
55
55
  state.history.version,
56
56
  JSON.stringify(state.visiblePanels),
57
+ JSON.stringify(state.panelLayout),
57
58
  ].join('|');
58
59
  }
59
60
 
@@ -107,42 +108,45 @@ function p2pPanelLines(): { title: string; body: string[] } {
107
108
  return { title, body };
108
109
  }
109
110
 
110
- function renderCustomizerOverlay(): string {
111
- const themesList = Object.keys(THEMES) as ThemeName[];
112
- const lines = [
113
- chalk.bgCyan.black.bold(' UI CUSTOMIZER (Use ↑/↓ or J/K to navigate, Enter/Space to toggle, Esc to exit) '),
114
- ];
115
-
116
- state.CUSTOMIZER_OPTIONS.forEach((opt, idx) => {
117
- const isSelected = idx === state.customizerIndex;
118
- const prefix = isSelected ? chalk.yellow('▶ ') : ' ';
119
-
120
- if (opt === 'Theme') {
121
- lines.push(`${prefix}${opt}: ${chalk.bold.green(state.currentTheme)} [${themesList.join(', ')}]`);
122
- } else if (opt === 'Graph Mode') {
123
- lines.push(`${prefix}${opt}: ${chalk.bold.green(state.graphMode)} [spark, bar]`);
124
- } else if (opt === 'Splash Screen') {
125
- const status = state.splashEnabled ? chalk.green('[ON]') : chalk.red('[OFF]');
126
- lines.push(`${prefix}${opt}: ${status}`);
127
- } else if (opt === 'Splash Color') {
128
- lines.push(`${prefix}${opt}: ${chalk.bold.green(state.splashColorScheme)} [fire, ocean, forest, purple, monochrome]`);
129
- } else if (opt === 'Splash Animation') {
130
- lines.push(`${prefix}${opt}: ${chalk.bold.green(state.splashAnimation)} [classic, wave, sparks]`);
131
- } else if (opt === 'Notifications') {
132
- const status = state.notificationsEnabled ? chalk.green('[ON]') : chalk.red('[OFF]');
133
- lines.push(`${prefix}${opt}: ${status}`);
134
- } else if (opt === 'Temperature Unit') {
135
- lines.push(`${prefix}${opt}: ${chalk.bold.green(state.tempUnit.toUpperCase())} [C, F]`);
136
- } else {
137
- const toggleKey = getToggleKey(opt);
138
- const isVisible = toggleKey ? (toggleKey === 'tree' ? state.treeView : state.visiblePanels[toggleKey] !== false) : true;
139
- const status = isVisible ? chalk.green('[VISIBLE]') : chalk.red('[HIDDEN]');
140
- lines.push(`${prefix}${opt}: ${status}`);
141
- }
142
- });
111
+ function renderCustomizerOverlay(scrollOffset: number, maxVisibleItems: number): string {
112
+ const themesList = Object.keys(THEMES) as ThemeName[];
113
+ const headerLine = chalk.bgCyan.black.bold(' UI CUSTOMIZER (Use ↑/↓ or J/K to navigate, Enter/Space to toggle, Esc to exit) ');
114
+ const totalItems = state.CUSTOMIZER_OPTIONS.length;
115
+ const end = Math.min(scrollOffset + maxVisibleItems, totalItems);
116
+
117
+ const lines = [headerLine];
118
+
119
+ for (let i = scrollOffset; i < end; i++) {
120
+ const opt = state.CUSTOMIZER_OPTIONS[i];
121
+ const isSelected = i === state.customizerIndex;
122
+ const prefix = isSelected ? chalk.yellow('▶ ') : ' ';
123
+
124
+ if (opt === 'Theme') {
125
+ lines.push(`${prefix}${opt}: ${chalk.bold.green(state.currentTheme)} [${themesList.join(', ')}]`);
126
+ } else if (opt === 'Graph Mode') {
127
+ lines.push(`${prefix}${opt}: ${chalk.bold.green(state.graphMode)} [spark, bar]`);
128
+ } else if (opt === 'Splash Screen') {
129
+ const status = state.splashEnabled ? chalk.green('[ON]') : chalk.red('[OFF]');
130
+ lines.push(`${prefix}${opt}: ${status}`);
131
+ } else if (opt === 'Splash Color') {
132
+ lines.push(`${prefix}${opt}: ${chalk.bold.green(state.splashColorScheme)} [fire, ocean, forest, purple, monochrome]`);
133
+ } else if (opt === 'Splash Animation') {
134
+ lines.push(`${prefix}${opt}: ${chalk.bold.green(state.splashAnimation)} [classic, wave, sparks]`);
135
+ } else if (opt === 'Notifications') {
136
+ const status = state.notificationsEnabled ? chalk.green('[ON]') : chalk.red('[OFF]');
137
+ lines.push(`${prefix}${opt}: ${status}`);
138
+ } else if (opt === 'Temperature Unit') {
139
+ lines.push(`${prefix}${opt}: ${chalk.bold.green(state.tempUnit.toUpperCase())} [C, F]`);
140
+ } else {
141
+ const toggleKey = getToggleKey(opt);
142
+ const isVisible = toggleKey ? (toggleKey === 'tree' ? state.treeView : state.visiblePanels[toggleKey] !== false) : true;
143
+ const status = isVisible ? chalk.green('[VISIBLE]') : chalk.red('[HIDDEN]');
144
+ lines.push(`${prefix}${opt}: ${status}`);
145
+ }
146
+ }
143
147
 
144
148
  return lines.map(l => ` ${l}`).join('\n');
145
- }
149
+ }
146
150
 
147
151
  // --- frame diffing -------------------------------------------------------
148
152
  // Instead of erasing the whole screen (`\x1b[2J`) and repainting everything
@@ -212,10 +216,12 @@ function render() {
212
216
  visible: state.visiblePanels,
213
217
  treeView: state.treeView,
214
218
  activePanel: state.activePanel,
215
- anomalies,
219
+ anomalies: anomalies,
220
+ anomalyHistory: state.anomalyHistory,
216
221
  tempUnit: state.tempUnit,
217
222
  history: state.history,
218
223
  graphMode: state.graphMode,
224
+ panelLayout: state.panelLayout,
219
225
  });
220
226
  _cachedTableData = state.lastData;
221
227
  _cachedTableParams = cacheParams;
@@ -255,27 +261,38 @@ function render() {
255
261
  const footerLine3 = footerLine()[2] || ' ';
256
262
  const footerLines = [footerLine1, footerLine2, footerLine3];
257
263
 
258
- if (state.inputMode === 'customizer') {
259
- bodyLines.push('');
260
- bodyLines.push(...renderCustomizerOverlay().split('\n'));
261
- } else if (state.inputMode === 'filter') {
262
- bodyLines.push(chalk.cyan(` Filter processes: ${state.inputBuffer}_`));
263
- } else if (state.inputMode === 'kill') {
264
- bodyLines.push(chalk.cyan(` Kill PID: ${state.inputBuffer}_ (enter to confirm, esc to cancel)`));
265
- } else if (state.inputMode === 'signal') {
266
- const sigIdx = state.SIGNAL_OPTIONS.indexOf(state.inputBuffer as typeof state.SIGNAL_OPTIONS[number]);
267
- const sigList = state.SIGNAL_OPTIONS.map((s, i) => i === sigIdx ? chalk.yellow(s) : chalk.dim(s)).join(' ');
268
- bodyLines.push(chalk.cyan(` Signal: ${state.inputBuffer}_`));
269
- bodyLines.push(chalk.dim(` ${sigList}`));
270
- bodyLines.push(chalk.dim(' ↑/↓ to select, Enter to confirm, Esc to cancel'));
271
- } else if (state.inputMode === 'p2p') {
272
- bodyLines.push(chalk.cyan(` P2P password (${state.p2pPort}): ${state.inputBuffer}_ (enter to start, esc to cancel)`));
273
- } else if (state.statusMessage) {
274
- bodyLines.push(` ${state.statusMessage}`);
275
- }
276
-
277
264
  const footerHeight = 3;
278
265
  const targetBodyHeight = Math.max(1, state.termHeight - footerHeight);
266
+
267
+ if (state.inputMode === 'customizer') {
268
+ const preOverlayCount = bodyLines.length;
269
+ const availableForOverlay = targetBodyHeight - preOverlayCount;
270
+ const totalItems = state.CUSTOMIZER_OPTIONS.length;
271
+ const headerLines = 1;
272
+ const blankLines = availableForOverlay >= 2 ? 1 : 0;
273
+ const maxVisibleItems = Math.max(0, availableForOverlay - headerLines - blankLines);
274
+ const scrollOffset = totalItems > maxVisibleItems
275
+ ? Math.max(0, Math.min(state.customizerIndex, totalItems - maxVisibleItems))
276
+ : 0;
277
+
278
+ if (blankLines > 0) bodyLines.push('');
279
+ bodyLines.push(...renderCustomizerOverlay(scrollOffset, maxVisibleItems).split('\n'));
280
+ } else if (state.inputMode === 'filter') {
281
+ bodyLines.push(chalk.cyan(` Filter processes: ${state.inputBuffer}_`));
282
+ } else if (state.inputMode === 'kill') {
283
+ bodyLines.push(chalk.cyan(` Kill PID: ${state.inputBuffer}_ (enter to confirm, esc to cancel)`));
284
+ } else if (state.inputMode === 'signal') {
285
+ const sigIdx = state.SIGNAL_OPTIONS.indexOf(state.inputBuffer as typeof state.SIGNAL_OPTIONS[number]);
286
+ const sigList = state.SIGNAL_OPTIONS.map((s, i) => i === sigIdx ? chalk.yellow(s) : chalk.dim(s)).join(' ');
287
+ bodyLines.push(chalk.cyan(` Signal: ${state.inputBuffer}_`));
288
+ bodyLines.push(chalk.dim(` ${sigList}`));
289
+ bodyLines.push(chalk.dim(' ↑/↓ to select, Enter to confirm, Esc to cancel'));
290
+ } else if (state.inputMode === 'p2p') {
291
+ bodyLines.push(chalk.cyan(` P2P password (${state.p2pPort}): ${state.inputBuffer}_ (enter to start, esc to cancel)`));
292
+ } else if (state.statusMessage) {
293
+ bodyLines.push(` ${state.statusMessage}`);
294
+ }
295
+
279
296
  if (bodyLines.length > targetBodyHeight) {
280
297
  bodyLines.length = targetBodyHeight;
281
298
  }
@@ -339,22 +356,60 @@ function checkAlerts(data: StatsData) {
339
356
  const temp = data.cpu.temperature ?? data.thermal.temperatures?.cpu_die ?? null;
340
357
  const hot = data.cpu.usage >= state.CPU_ALERT_PCT || (temp !== null && temp >= state.TEMP_ALERT_C);
341
358
  const anomalies = detectAnomalies(data, state.history);
359
+
360
+ // Add timestamp to anomalies and push to history
361
+ for (const a of anomalies) {
362
+ const timestamped = { ...a, timestamp: new Date() };
363
+ // Only add if not already recently reported (same metric and similar zScore)
364
+ const recent = state.anomalyHistory.slice(-5);
365
+ const isDuplicate = recent.some(r => r.metric === a.metric && Math.abs(r.zScore - a.zScore) < 0.5);
366
+ if (!isDuplicate) {
367
+ state.anomalyHistory.push(timestamped as any);
368
+ if (state.anomalyHistory.length > 50) state.anomalyHistory.shift();
369
+ }
370
+ }
371
+
342
372
  const hasCriticalAnomaly = anomalies.some(a => a.severity === 'critical');
343
373
  const hasWarningAnomaly = anomalies.some(a => a.severity === 'warning');
374
+ // Process watchdog check
375
+ let watchdogTriggered = false;
376
+ if (state.watchdogProcess) {
377
+ const matches = data.processes.filter(p => p.command.toLowerCase().includes(state.watchdogProcess.toLowerCase()));
378
+ for (const p of matches) {
379
+ if (p.cpu >= state.watchdogCpu || p.mem >= state.watchdogMem) {
380
+ watchdogTriggered = true;
381
+ break;
382
+ }
383
+ }
384
+ }
385
+
344
386
  const anomalyTriggered = hasCriticalAnomaly || hasWarningAnomaly;
345
387
 
346
- if ((hot || anomalyTriggered) && !state.alerted) {
388
+ if ((hot || anomalyTriggered || watchdogTriggered) && !state.alerted) {
347
389
  state.alerted = true;
348
390
  process.stdout.write('\x07');
349
391
  const reasons: string[] = [];
350
392
  if (data.cpu.usage >= state.CPU_ALERT_PCT) reasons.push(`CPU at ${data.cpu.usage}%`);
351
393
  if (temp !== null && temp >= state.TEMP_ALERT_C) reasons.push(`Temp at ${formatTempForUnit(temp)}`);
394
+ if (watchdogTriggered) reasons.push(`Watchdog process "${state.watchdogProcess}" crossed threshold`);
352
395
  for (const a of anomalies) {
353
396
  reasons.push(`${a.metric} anomaly (σ=${a.zScore.toFixed(1)})`);
354
397
  }
355
398
  setStatus(chalk.red(`⚠ Alert: ${reasons.join('; ')}`), 5000);
356
399
  sendNotification('pyre Alert', reasons.join('; '));
357
- } else if (!hot && !anomalyTriggered) {
400
+
401
+ const alertMsg = reasons.join('; ');
402
+ if (state.webhookUrl) {
403
+ fetch(state.webhookUrl, {
404
+ method: 'POST',
405
+ headers: { 'Content-Type': 'application/json' },
406
+ body: JSON.stringify({ alert: alertMsg, timestamp: new Date().toISOString() })
407
+ }).catch(() => {});
408
+ }
409
+ if (state.alertCmd) {
410
+ exec(state.alertCmd, { env: { ...process.env, PYRE_ALERT: alertMsg } }, () => {});
411
+ }
412
+ } else if (!hot && !anomalyTriggered && !watchdogTriggered) {
358
413
  state.alerted = false;
359
414
  }
360
415
  }
package/src/live/state.ts CHANGED
@@ -48,6 +48,7 @@ const state = {
48
48
  disk: config.visiblePanels.disk,
49
49
  process: config.visiblePanels.process,
50
50
  } as VisibleItems,
51
+ panelLayout: config.panelLayout || ['mem', 'disk', 'net'],
51
52
  logging: false,
52
53
  logStream: null as fs.WriteStream | null,
53
54
  statusMessage: '',
@@ -89,9 +90,14 @@ const state = {
89
90
  ],
90
91
  CPU_ALERT_PCT: config.cpuAlertPct,
91
92
  TEMP_ALERT_C: config.tempAlertC,
93
+ watchdogProcess: config.watchdogProcess || '',
94
+ watchdogCpu: config.watchdogCpu || 80,
95
+ watchdogMem: config.watchdogMem || 80,
92
96
  notificationsEnabled: config.notificationsEnabled,
97
+ webhookUrl: config.webhookUrl || '',
98
+ alertCmd: config.alertCmd || '',
93
99
  alerted: false,
94
- activePanel: 'grid' as 'grid' | 'cpu' | 'mem' | 'gpu' | 'power' | 'battery' | 'thermal' | 'network' | 'packets' | 'tasks' | 'disk' | 'process' | 'p2p',
100
+ activePanel: 'grid' as 'grid' | 'cpu' | 'mem' | 'gpu' | 'power' | 'battery' | 'thermal' | 'network' | 'packets' | 'tasks' | 'disk' | 'process' | 'p2p' | 'anomalies',
95
101
  mouseEnabled: config.mouseEnabled,
96
102
  PANEL_TABS: [
97
103
  { id: 'cpu', label: 'CPU', key: '1' },
@@ -106,13 +112,17 @@ const state = {
106
112
  { id: 'disk', label: 'Disk', key: '0' },
107
113
  { id: 'process', label: 'Process', key: 'P' },
108
114
  { 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,
115
+ { id: 'anomalies', label: 'Anomalies', key: 'A' },
116
+ ],
117
+ p2pServer: null as import('../p2p/server.js').P2PServer | null,
118
+ p2pClient: null as import('../p2p/client.js').P2PClient | null,
119
+ p2pServerRunning: false,
120
+ p2pClientConnected: false,
121
+ p2pEvents: [] as any[],
122
+ p2pBind: '',
123
+ p2pPassword: config.p2pPassword,
124
+ p2pPort: config.p2pPort,
125
+ anomalyHistory: [] as import('../anomalies.js').AnomalyAlert[],
116
126
  splashEnabled: config.splashEnabled,
117
127
  splashColorScheme: config.splashColorScheme as SplashColorScheme,
118
128
  splashAnimation: config.splashAnimation as SplashAnimation,
@@ -504,9 +504,46 @@ export async function collectMemory(): Promise<MemoryData> {
504
504
  };
505
505
  }
506
506
 
507
+ let prevDiskIoSample: { ts: number } | null = null;
508
+ let cachedDiskIo: { readBytesSec: number; writeBytesSec: number; ts: number } | null = null;
509
+
510
+ async function getDiskIoRates(): Promise<{ readBytesSec: number; writeBytesSec: number }> {
511
+ const now = Date.now();
512
+ if (cachedDiskIo && now - cachedDiskIo.ts < 1000) {
513
+ return { readBytesSec: cachedDiskIo.readBytesSec, writeBytesSec: cachedDiskIo.writeBytesSec };
514
+ }
515
+ try {
516
+ const raw = (await run('iostat -d -c 2 1 2', '')).trim();
517
+ const blocks = raw.split(/\n\s*\n/).filter(Boolean);
518
+ const targetBlock = blocks.length > 1 ? blocks[1] : blocks[0];
519
+ if (targetBlock) {
520
+ const lines = targetBlock.trim().split('\n');
521
+ if (lines.length >= 2) {
522
+ const dataLine = lines[lines.length - 1].trim();
523
+ const parts = dataLine.split(/\s+/);
524
+ if (parts.length >= 3) {
525
+ const mbSec = parseFloat(parts[2]);
526
+ if (!isNaN(mbSec)) {
527
+ const bytesSec = Math.round(mbSec * 1024 * 1024);
528
+ // Splitting combined throughput evenly/estimating if read/write distinction unavailable directly from summary iostat
529
+ cachedDiskIo = { readBytesSec: Math.round(bytesSec * 0.6), writeBytesSec: Math.round(bytesSec * 0.4), ts: now };
530
+ return { readBytesSec: cachedDiskIo.readBytesSec, writeBytesSec: cachedDiskIo.writeBytesSec };
531
+ }
532
+ }
533
+ }
534
+ }
535
+ } catch {
536
+ // ignore
537
+ }
538
+ return { readBytesSec: 0, writeBytesSec: 0 };
539
+ }
540
+
507
541
  export async function collectDisk(): Promise<DiskData[]> {
508
- const raw = (await run('df -h')).trim();
509
- const lines = raw.split('\n').slice(1);
542
+ const [rawDf, ioRates] = await Promise.all([
543
+ run('df -h'),
544
+ getDiskIoRates(),
545
+ ]);
546
+ const lines = rawDf.trim().split('\n').slice(1);
510
547
  return lines
511
548
  .map(line => {
512
549
  const parts = line.split(/\s+/);
@@ -517,6 +554,8 @@ export async function collectDisk(): Promise<DiskData[]> {
517
554
  available: parts[3],
518
555
  capacity: parts[4],
519
556
  mountpoint: parts[5] || parts[6] || '/',
557
+ readBytesSec: ioRates.readBytesSec,
558
+ writeBytesSec: ioRates.writeBytesSec,
520
559
  };
521
560
  })
522
561
  .filter(d => d.filesystem.includes('/') || d.size.includes('G') || d.size.includes('T'));
@@ -112,6 +112,8 @@ export interface DiskData {
112
112
  available: string;
113
113
  capacity: string;
114
114
  mountpoint: string;
115
+ readBytesSec?: number;
116
+ writeBytesSec?: number;
115
117
  }
116
118
 
117
119
  export interface NetworkData {
@@ -34,7 +34,11 @@ export interface PyreConfig {
34
34
  p2pPassword?: string;
35
35
  splashEnabled?: boolean;
36
36
  splashColorScheme?: string;
37
- splashAnimation?: string;
37
+ watchdogProcess?: string;
38
+ watchdogCpu?: number;
39
+ watchdogMem?: number;
40
+ webhookUrl?: string;
41
+ alertCmd?: string;
38
42
  visiblePanels?: {
39
43
  cpu?: boolean;
40
44
  mem?: boolean;
@@ -49,6 +53,7 @@ export interface PyreConfig {
49
53
  process?: boolean;
50
54
  tree?: boolean;
51
55
  };
56
+ panelLayout?: string[];
52
57
  }
53
58
 
54
59
  export const DEFAULT_CONFIG: Required<PyreConfig> = {
@@ -64,12 +69,17 @@ export const DEFAULT_CONFIG: Required<PyreConfig> = {
64
69
  mouseEnabled: true,
65
70
  cpuAlertPct: 90,
66
71
  tempAlertC: 95,
72
+ watchdogProcess: '',
73
+ watchdogCpu: 80,
74
+ watchdogMem: 80,
67
75
  notificationsEnabled: true,
68
76
  p2pPort: 9876,
69
77
  p2pPassword: 'pyre',
70
78
  splashEnabled: true,
71
79
  splashColorScheme: 'fire',
72
80
  splashAnimation: 'classic',
81
+ webhookUrl: '',
82
+ alertCmd: '',
73
83
  visiblePanels: {
74
84
  cpu: true,
75
85
  mem: true,
@@ -84,6 +94,7 @@ export const DEFAULT_CONFIG: Required<PyreConfig> = {
84
94
  process: true,
85
95
  tree: false,
86
96
  },
97
+ panelLayout: ['mem', 'disk', 'net'],
87
98
  };
88
99
 
89
100
  export function readConfig(): Required<PyreConfig> {
package/src/xbar.ts ADDED
@@ -0,0 +1,53 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import chalk from 'chalk';
5
+
6
+ export function generateXbarPlugin() {
7
+ const pyrePath = process.argv[1];
8
+ const nodePath = process.execPath;
9
+
10
+ const script = `#!/bin/bash
11
+ # <xbar.title>Pyre Monitor</xbar.title>
12
+ # <xbar.version>v1.0</xbar.version>
13
+ # <xbar.author>somalip</xbar.author>
14
+ # <xbar.author.github>somalip</xbar.author.github>
15
+ # <xbar.desc>Mac system monitoring powered by pyre.</xbar.desc>
16
+
17
+ # Run pyre to get a JSON snapshot
18
+ # We use node directly to avoid env issues in xbar
19
+ DATA=$("${nodePath}" "${pyrePath}" --once --json)
20
+
21
+ if [ -z "$DATA" ]; then
22
+ echo "🔥 err"
23
+ echo "---"
24
+ echo "Could not fetch data"
25
+ exit 1
26
+ fi
27
+
28
+ CPU=$(echo "$DATA" | grep -o '"usage": *[0-9.]*' | head -1 | awk '{print $2}')
29
+ MEM=$(echo "$DATA" | grep -o '"usagePercent": *[0-9.]*' | head -1 | awk '{print $2}')
30
+ TEMP=$(echo "$DATA" | grep -o '"temperature": *[0-9.]*' | head -1 | awk '{print $2}')
31
+
32
+ # Format to 1 decimal place
33
+ CPU=$(printf "%.1f" "$CPU")
34
+ MEM=$(printf "%.1f" "$MEM")
35
+ TEMP=$(printf "%.1f" "$TEMP")
36
+
37
+ echo "🔥 C:\${CPU}% M:\${MEM}% T:\${TEMP}°C | font=Menlo"
38
+ echo "---"
39
+ echo "Open Dashboard | bash='${nodePath}' param1='${pyrePath}' param2='live' terminal=true"
40
+ `;
41
+
42
+ const pluginDir = path.join(os.homedir(), 'Library', 'Application Support', 'xbar', 'plugins');
43
+ const filename = 'pyre-monitor.2s.sh';
44
+ const outPath = path.join(process.cwd(), filename);
45
+
46
+ fs.writeFileSync(outPath, script);
47
+ fs.chmodSync(outPath, '755');
48
+
49
+ console.log(chalk.green(`Generated xbar plugin script at ${outPath}`));
50
+ console.log(`To install, move it to your xbar plugins directory:`);
51
+ console.log(chalk.cyan(` mv ${filename} "${pluginDir}/"`));
52
+ console.log(`Then refresh xbar.`);
53
+ }