evo-cli 0.5.1__tar.gz → 0.7.0__tar.gz

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 (39) hide show
  1. {evo_cli-0.5.1 → evo_cli-0.7.0}/PKG-INFO +1 -1
  2. evo_cli-0.7.0/evo_cli/VERSION +1 -0
  3. {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/cli.py +4 -0
  4. evo_cli-0.7.0/evo_cli/commands/sysmon.py +545 -0
  5. evo_cli-0.7.0/evo_cli/commands/wifi.py +837 -0
  6. {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli.egg-info/PKG-INFO +1 -1
  7. {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli.egg-info/SOURCES.txt +5 -1
  8. evo_cli-0.7.0/tests/test_sysmon.py +91 -0
  9. evo_cli-0.7.0/tests/test_wifi.py +154 -0
  10. evo_cli-0.5.1/evo_cli/VERSION +0 -1
  11. {evo_cli-0.5.1 → evo_cli-0.7.0}/Containerfile +0 -0
  12. {evo_cli-0.5.1 → evo_cli-0.7.0}/HISTORY.md +0 -0
  13. {evo_cli-0.5.1 → evo_cli-0.7.0}/LICENSE +0 -0
  14. {evo_cli-0.5.1 → evo_cli-0.7.0}/MANIFEST.in +0 -0
  15. {evo_cli-0.5.1 → evo_cli-0.7.0}/README.md +0 -0
  16. {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/__init__.py +0 -0
  17. {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/__main__.py +0 -0
  18. {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/base.py +0 -0
  19. {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/commands/__init__.py +0 -0
  20. {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/commands/cloudflare.py +0 -0
  21. {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/commands/fix_claude.py +0 -0
  22. {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/commands/gdrive.py +0 -0
  23. {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/commands/localproxy.py +0 -0
  24. {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/commands/miniconda.py +0 -0
  25. {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/commands/netcheck.py +0 -0
  26. {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/commands/plantuml.py +0 -0
  27. {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/commands/site2s.py +0 -0
  28. {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/commands/ssh.py +0 -0
  29. {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/console.py +0 -0
  30. {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli.egg-info/dependency_links.txt +0 -0
  31. {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli.egg-info/entry_points.txt +0 -0
  32. {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli.egg-info/requires.txt +0 -0
  33. {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli.egg-info/top_level.txt +0 -0
  34. {evo_cli-0.5.1 → evo_cli-0.7.0}/pyproject.toml +0 -0
  35. {evo_cli-0.5.1 → evo_cli-0.7.0}/setup.cfg +0 -0
  36. {evo_cli-0.5.1 → evo_cli-0.7.0}/tests/__init__.py +0 -0
  37. {evo_cli-0.5.1 → evo_cli-0.7.0}/tests/test_cli.py +0 -0
  38. {evo_cli-0.5.1 → evo_cli-0.7.0}/tests/test_fix_claude.py +0 -0
  39. {evo_cli-0.5.1 → evo_cli-0.7.0}/tests/test_plantuml.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.5.1
3
+ Version: 0.7.0
4
4
  Summary: Evolution CLI - a developer toolbox for setting up dev machines
5
5
  Author: maycuatroi
6
6
  Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
@@ -0,0 +1 @@
1
+ 0.7.0
@@ -10,6 +10,8 @@ from evo_cli.commands.netcheck import netcheck
10
10
  from evo_cli.commands.plantuml import plantuml
11
11
  from evo_cli.commands.site2s import site2s
12
12
  from evo_cli.commands.ssh import setupssh
13
+ from evo_cli.commands.sysmon import sysmon
14
+ from evo_cli.commands.wifi import wifi
13
15
 
14
16
  click.rich_click.USE_MARKDOWN = True
15
17
  click.rich_click.SHOW_ARGUMENTS = True
@@ -41,6 +43,8 @@ cli.add_command(site2s)
41
43
  cli.add_command(localproxy)
42
44
  cli.add_command(netcheck)
43
45
  cli.add_command(plantuml)
46
+ cli.add_command(sysmon)
47
+ cli.add_command(wifi)
44
48
 
45
49
 
46
50
  def main():
@@ -0,0 +1,545 @@
1
+ import json as jsonlib
2
+ import os
3
+ import platform
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ import time
9
+
10
+ import rich_click as click
11
+ from rich.table import Table
12
+ from rich.text import Text
13
+
14
+ from evo_cli.console import console, error, info, step, success, warning
15
+
16
+ # Thresholds used to turn raw numbers into a plain-language verdict.
17
+ TEMP_WARM = 70.0 # °C - normal under load, worth noting
18
+ TEMP_HOT = 90.0 # °C - heavy load / poor cooling, may throttle soon
19
+ CPU_BUSY = 85.0 # % overall CPU usage
20
+ MEM_HIGH = 85.0 # % memory in use
21
+ NOMINAL_PRESSURE = {"nominal", ""}
22
+
23
+ EPILOG = Text.from_markup(
24
+ "[bold]Examples[/bold]\n\n"
25
+ " [cyan]evo sysmon[/cyan] one-shot temperature + performance snapshot\n"
26
+ " [cyan]evo sysmon -w 2[/cyan] live dashboard, refresh every 2s (Ctrl-C to stop)\n"
27
+ " [cyan]evo sysmon -n 10[/cyan] show the top 10 CPU-hungry processes\n"
28
+ " [cyan]evo sysmon --json[/cyan] machine-readable output\n"
29
+ " [cyan]evo sysmon --install-deps[/cyan] install the smctemp sensor reader (macOS)\n\n"
30
+ "[dim]On Apple Silicon, real °C values come from the SMC via 'smctemp'\n"
31
+ "(brew install narugit/tap/smctemp). Without it, only battery temperature\n"
32
+ "and macOS thermal-pressure level are available. On Linux it reads\n"
33
+ "/sys/class/thermal sensors. Everything degrades gracefully.[/dim]"
34
+ )
35
+
36
+
37
+ # --- low-level helpers -------------------------------------------------------
38
+ def _run(cmd, timeout=6):
39
+ """Run a command, return its stdout on success, else None (never raises)."""
40
+ try:
41
+ out = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
42
+ except (OSError, subprocess.SubprocessError):
43
+ return None
44
+ return out.stdout if out.returncode == 0 else None
45
+
46
+
47
+ def fmt_bytes(n):
48
+ if n is None:
49
+ return "-"
50
+ value = float(n)
51
+ for unit in ("B", "KB", "MB", "GB", "TB"):
52
+ if value < 1024 or unit == "TB":
53
+ return f"{value:.0f}{unit}" if unit == "B" else f"{value:.1f}{unit}"
54
+ value /= 1024
55
+ return f"{value:.1f}TB"
56
+
57
+
58
+ # --- temperature: macOS ------------------------------------------------------
59
+ def _mac_smc_temp(flag):
60
+ """Read CPU (-c) or GPU (-g) temperature via smctemp; °C float or None."""
61
+ if not shutil.which("smctemp"):
62
+ return None
63
+ out = _run(["smctemp", flag, "-f"]) # -f -> decimal output
64
+ if not out:
65
+ return None
66
+ try:
67
+ value = float(out.strip().splitlines()[0])
68
+ except (ValueError, IndexError):
69
+ return None
70
+ return value if value > 0 else None
71
+
72
+
73
+ def _mac_battery_temp():
74
+ """Battery temperature from ioreg (AppleSmartBattery), reported in 0.01 °C."""
75
+ out = _run(["ioreg", "-r", "-n", "AppleSmartBattery"])
76
+ if not out:
77
+ return None
78
+ match = re.search(r'"Temperature"\s*=\s*(\d+)', out)
79
+ return int(match.group(1)) / 100.0 if match else None
80
+
81
+
82
+ def _mac_thermal_pressure():
83
+ """macOS thermal-pressure level (Nominal/Moderate/Heavy/...) via powermetrics.
84
+
85
+ Needs root; we use ``sudo -n`` so it stays silent (returns None) when sudo
86
+ credentials are not already cached - never prompts for a password.
87
+ """
88
+ out = _run(
89
+ ["sudo", "-n", "powermetrics", "--samplers", "thermal", "-n", "1", "-i", "200"],
90
+ timeout=10,
91
+ )
92
+ if not out:
93
+ return None
94
+ match = re.search(r"pressure level:\s*(\w+)", out)
95
+ return match.group(1) if match else None
96
+
97
+
98
+ # --- temperature: Linux ------------------------------------------------------
99
+ def _linux_temps():
100
+ """Read every /sys/class/thermal zone; return list of {label, value}."""
101
+ entries = []
102
+ base = "/sys/class/thermal"
103
+ if not os.path.isdir(base):
104
+ return entries
105
+ for zone in sorted(os.listdir(base)):
106
+ if not zone.startswith("thermal_zone"):
107
+ continue
108
+ try:
109
+ raw = open(f"{base}/{zone}/temp").read().strip()
110
+ label = open(f"{base}/{zone}/type").read().strip()
111
+ entries.append({"label": label, "value": int(raw) / 1000.0})
112
+ except (OSError, ValueError):
113
+ continue
114
+ return entries
115
+
116
+
117
+ def collect_temps():
118
+ system = platform.system()
119
+ data = {
120
+ "platform": system,
121
+ "entries": [],
122
+ "cpu": None,
123
+ "pressure": None,
124
+ "source": None,
125
+ "smctemp_available": None,
126
+ }
127
+ if system == "Darwin":
128
+ cpu = _mac_smc_temp("-c")
129
+ gpu = _mac_smc_temp("-g")
130
+ battery = _mac_battery_temp()
131
+ data["smctemp_available"] = bool(shutil.which("smctemp"))
132
+ data["source"] = "smctemp" if data["smctemp_available"] else "ioreg"
133
+ data["pressure"] = _mac_thermal_pressure()
134
+ for label, value in (("CPU", cpu), ("GPU", gpu), ("Battery", battery)):
135
+ if value is not None:
136
+ data["entries"].append({"label": label, "value": value})
137
+ data["cpu"] = cpu
138
+ elif system == "Linux":
139
+ data["source"] = "sysfs"
140
+ data["entries"] = _linux_temps()
141
+ for entry in data["entries"]:
142
+ low = entry["label"].lower()
143
+ if any(k in low for k in ("cpu", "package", "core", "tctl", "x86_pkg", "soc")):
144
+ data["cpu"] = entry["value"]
145
+ break
146
+ if data["cpu"] is None and data["entries"]:
147
+ data["cpu"] = max(e["value"] for e in data["entries"])
148
+ return data
149
+
150
+
151
+ # --- performance: CPU usage --------------------------------------------------
152
+ def _mac_cpu_usage():
153
+ """Overall CPU usage % from two top samples (the first is since boot)."""
154
+ out = _run(["top", "-l", "2", "-n", "0"], timeout=8)
155
+ if not out:
156
+ return None
157
+ samples = re.findall(r"CPU usage:.*?([\d.]+)% idle", out)
158
+ if not samples:
159
+ return None
160
+ return round(100.0 - float(samples[-1]), 1)
161
+
162
+
163
+ def _linux_cpu_usage():
164
+ def snapshot():
165
+ with open("/proc/stat") as handle:
166
+ fields = list(map(int, handle.readline().split()[1:]))
167
+ idle = fields[3] + (fields[4] if len(fields) > 4 else 0)
168
+ return idle, sum(fields)
169
+
170
+ try:
171
+ idle1, total1 = snapshot()
172
+ time.sleep(0.3)
173
+ idle2, total2 = snapshot()
174
+ except (OSError, ValueError):
175
+ return None
176
+ delta = total2 - total1
177
+ if delta <= 0:
178
+ return None
179
+ return round((1 - (idle2 - idle1) / delta) * 100, 1)
180
+
181
+
182
+ def cpu_usage():
183
+ system = platform.system()
184
+ if system == "Darwin":
185
+ return _mac_cpu_usage()
186
+ if system == "Linux":
187
+ return _linux_cpu_usage()
188
+ return None
189
+
190
+
191
+ # --- performance: memory -----------------------------------------------------
192
+ def _mac_memory():
193
+ total_out = _run(["sysctl", "-n", "hw.memsize"])
194
+ vm = _run(["vm_stat"])
195
+ if not total_out or not vm:
196
+ return None
197
+ try:
198
+ total = int(total_out.strip())
199
+ except ValueError:
200
+ return None
201
+ page = 4096
202
+ page_match = re.search(r"page size of (\d+) bytes", vm)
203
+ if page_match:
204
+ page = int(page_match.group(1))
205
+
206
+ def pages(label):
207
+ match = re.search(rf"{label}:\s+(\d+)", vm)
208
+ return int(match.group(1)) if match else 0
209
+
210
+ # Mirrors Activity Monitor's "Memory Used": app + wired + compressed.
211
+ used = (pages("Pages active") + pages("Pages wired down") + pages("Pages occupied by compressor")) * page
212
+ return {"total": total, "used": used, "percent": round(used / total * 100, 1)}
213
+
214
+
215
+ def _linux_memory():
216
+ info_map = {}
217
+ try:
218
+ with open("/proc/meminfo") as handle:
219
+ for line in handle:
220
+ key, _, value = line.partition(":")
221
+ info_map[key.strip()] = int(value.split()[0]) * 1024
222
+ except (OSError, ValueError):
223
+ return None
224
+ total = info_map.get("MemTotal")
225
+ if not total:
226
+ return None
227
+ available = info_map.get("MemAvailable", info_map.get("MemFree", 0))
228
+ used = total - available
229
+ return {"total": total, "used": used, "percent": round(used / total * 100, 1)}
230
+
231
+
232
+ def memory():
233
+ system = platform.system()
234
+ if system == "Darwin":
235
+ return _mac_memory()
236
+ if system == "Linux":
237
+ return _linux_memory()
238
+ return None
239
+
240
+
241
+ # --- performance: misc -------------------------------------------------------
242
+ def load_average():
243
+ try:
244
+ return list(os.getloadavg())
245
+ except (OSError, AttributeError):
246
+ return None
247
+
248
+
249
+ def uptime_seconds():
250
+ system = platform.system()
251
+ if system == "Linux":
252
+ try:
253
+ return float(open("/proc/uptime").read().split()[0])
254
+ except (OSError, ValueError):
255
+ return None
256
+ if system == "Darwin":
257
+ out = _run(["sysctl", "-n", "kern.boottime"])
258
+ if out:
259
+ match = re.search(r"sec\s*=\s*(\d+)", out)
260
+ if match:
261
+ return time.time() - int(match.group(1))
262
+ return None
263
+
264
+
265
+ def cpu_model():
266
+ system = platform.system()
267
+ if system == "Darwin":
268
+ return (_run(["sysctl", "-n", "machdep.cpu.brand_string"]) or "").strip() or None
269
+ if system == "Linux":
270
+ try:
271
+ for line in open("/proc/cpuinfo"):
272
+ if line.lower().startswith("model name"):
273
+ return line.split(":", 1)[1].strip()
274
+ except OSError:
275
+ pass
276
+ return None
277
+
278
+
279
+ def top_processes(count):
280
+ system = platform.system()
281
+ if system == "Darwin":
282
+ out = _run(["ps", "-Ao", "pid,%cpu,%mem,comm", "-r"])
283
+ else:
284
+ out = _run(["ps", "-Ao", "pid,%cpu,%mem,comm", "--sort=-%cpu"])
285
+ if not out:
286
+ return []
287
+ rows = []
288
+ for line in out.strip().splitlines()[1 : count + 1]:
289
+ parts = line.split(None, 3)
290
+ if len(parts) < 4:
291
+ continue
292
+ pid, cpu, mem, comm = parts
293
+ try:
294
+ rows.append({"pid": pid, "cpu": float(cpu), "mem": float(mem), "command": comm.split("/")[-1]})
295
+ except ValueError:
296
+ continue
297
+ return rows
298
+
299
+
300
+ def collect(top_n):
301
+ return {
302
+ "host": platform.node(),
303
+ "system": f"{platform.system()} {platform.release()}",
304
+ "cpu_model": cpu_model(),
305
+ "cpu_count": os.cpu_count(),
306
+ "temps": collect_temps(),
307
+ "cpu_percent": cpu_usage(),
308
+ "memory": memory(),
309
+ "load": load_average(),
310
+ "uptime": uptime_seconds(),
311
+ "processes": top_processes(top_n),
312
+ }
313
+
314
+
315
+ # --- rendering ---------------------------------------------------------------
316
+ def _temp_cell(value):
317
+ if value is None:
318
+ return "[dim]-[/dim]"
319
+ style = "success"
320
+ if value >= TEMP_HOT:
321
+ style = "error"
322
+ elif value >= TEMP_WARM:
323
+ style = "warning"
324
+ return f"[{style}]{value:.1f}°C[/{style}]"
325
+
326
+
327
+ def _pct_cell(value, threshold):
328
+ if value is None:
329
+ return "[dim]-[/dim]"
330
+ style = "error" if value >= threshold else "info"
331
+ return f"[{style}]{value:.0f}%[/{style}]"
332
+
333
+
334
+ def _fmt_uptime(seconds):
335
+ if seconds is None:
336
+ return "-"
337
+ seconds = int(seconds)
338
+ days, rem = divmod(seconds, 86400)
339
+ hours, rem = divmod(rem, 3600)
340
+ minutes = rem // 60
341
+ if days:
342
+ return f"{days}d {hours}h {minutes}m"
343
+ if hours:
344
+ return f"{hours}h {minutes}m"
345
+ return f"{minutes}m"
346
+
347
+
348
+ def render_temps(temps):
349
+ table = Table(title="Temperature", title_style="accent", show_header=True, header_style="accent")
350
+ table.add_column("Sensor", style="info", no_wrap=True)
351
+ table.add_column("Temp", justify="right")
352
+ table.add_column("Status", justify="left")
353
+ if not temps["entries"]:
354
+ table.add_row("[dim]no sensors[/dim]", "-", "-")
355
+ for entry in temps["entries"]:
356
+ value = entry["value"]
357
+ if value >= TEMP_HOT:
358
+ status = "[error]hot[/error]"
359
+ elif value >= TEMP_WARM:
360
+ status = "[warning]warm[/warning]"
361
+ else:
362
+ status = "[success]ok[/success]"
363
+ table.add_row(entry["label"], _temp_cell(value), status)
364
+ if temps.get("pressure"):
365
+ pressure = temps["pressure"]
366
+ hot = pressure.lower() not in NOMINAL_PRESSURE
367
+ cell = f"[{'error' if hot else 'success'}]{pressure}[/]"
368
+ table.add_row("Thermal pressure", cell, "[dim]macOS[/dim]")
369
+ console.print(table)
370
+
371
+
372
+ def render_perf(data):
373
+ table = Table(title="Performance", title_style="accent", show_header=True, header_style="accent")
374
+ table.add_column("Metric", style="info", no_wrap=True)
375
+ table.add_column("Value", justify="right")
376
+ table.add_column("Detail", justify="left")
377
+
378
+ ncpu = data["cpu_count"] or 0
379
+ table.add_row("CPU usage", _pct_cell(data["cpu_percent"], CPU_BUSY), f"{ncpu} logical cores")
380
+
381
+ load = data["load"]
382
+ if load:
383
+ over = ncpu and load[0] > ncpu
384
+ load_style = "error" if over else "success"
385
+ load_text = f"[{load_style}]{load[0]:.2f}[/] {load[1]:.2f} {load[2]:.2f}"
386
+ table.add_row("Load avg", load_text, "1m 5m 15m")
387
+
388
+ mem = data["memory"]
389
+ if mem:
390
+ detail = f"{fmt_bytes(mem['used'])} / {fmt_bytes(mem['total'])}"
391
+ table.add_row("Memory", _pct_cell(mem["percent"], MEM_HIGH), detail)
392
+
393
+ table.add_row("Uptime", _fmt_uptime(data["uptime"]), data["cpu_model"] or "")
394
+ console.print(table)
395
+
396
+
397
+ def render_processes(processes):
398
+ if not processes:
399
+ return
400
+ table = Table(title="Top processes (by CPU)", title_style="accent", show_header=True, header_style="accent")
401
+ table.add_column("PID", style="dim", justify="right", no_wrap=True)
402
+ table.add_column("CPU%", justify="right")
403
+ table.add_column("MEM%", justify="right")
404
+ table.add_column("Command", style="info")
405
+ for proc in processes:
406
+ cpu_style = "error" if proc["cpu"] >= 100 else ("warning" if proc["cpu"] >= 25 else "")
407
+ cpu_text = f"[{cpu_style}]{proc['cpu']:.1f}[/]" if cpu_style else f"{proc['cpu']:.1f}"
408
+ table.add_row(proc["pid"], cpu_text, f"{proc['mem']:.1f}", proc["command"])
409
+ console.print(table)
410
+
411
+
412
+ def build_notes(data):
413
+ notes = []
414
+ temps = data["temps"]
415
+ cpu_t = temps.get("cpu")
416
+ if cpu_t is not None:
417
+ if cpu_t >= TEMP_HOT:
418
+ notes.append(("warning", f"CPU is hot ({cpu_t:.0f}°C) - heavy load or limited cooling"))
419
+ elif cpu_t >= TEMP_WARM:
420
+ notes.append(("info", f"CPU is warm ({cpu_t:.0f}°C) - normal while busy"))
421
+ pressure = temps.get("pressure")
422
+ if pressure and pressure.lower() not in NOMINAL_PRESSURE:
423
+ notes.append(("warning", f"Thermal pressure is {pressure} - the system may be throttling"))
424
+
425
+ cpu_u = data["cpu_percent"]
426
+ if cpu_u is not None and cpu_u >= CPU_BUSY:
427
+ notes.append(("warning", f"CPU usage is high ({cpu_u:.0f}%)"))
428
+
429
+ load = data["load"]
430
+ ncpu = data["cpu_count"]
431
+ if load and ncpu and load[0] > ncpu:
432
+ notes.append(("warning", f"Load average {load[0]:.2f} exceeds {ncpu} cores - tasks are queuing"))
433
+
434
+ mem = data["memory"]
435
+ if mem and mem["percent"] >= MEM_HIGH:
436
+ notes.append(("warning", f"Memory usage is high ({mem['percent']:.0f}%)"))
437
+
438
+ procs = data["processes"]
439
+ if procs and procs[0]["cpu"] >= 100 and (cpu_u is None or cpu_u >= CPU_BUSY or (cpu_t and cpu_t >= TEMP_WARM)):
440
+ top = procs[0]
441
+ notes.append(("info", f"Biggest CPU consumer: {top['command']} ({top['cpu']:.0f}%, pid {top['pid']})"))
442
+
443
+ if temps["platform"] == "Darwin" and temps.get("smctemp_available") is False:
444
+ notes.append(
445
+ (
446
+ "info",
447
+ "Install 'smctemp' for real CPU/GPU °C readings: "
448
+ "evo sysmon --install-deps (or brew install narugit/tap/smctemp)",
449
+ )
450
+ )
451
+ return notes
452
+
453
+
454
+ def render(data, show_procs):
455
+ render_temps(data["temps"])
456
+ console.print()
457
+ render_perf(data)
458
+ if show_procs:
459
+ console.print()
460
+ render_processes(data["processes"])
461
+
462
+ notes = build_notes(data)
463
+ step("Verdict")
464
+ if not notes:
465
+ success("All good - temperatures, CPU, memory and load are within normal range.")
466
+ else:
467
+ dispatch = {"warning": warning, "error": error, "info": info}
468
+ for level, message in notes:
469
+ dispatch.get(level, info)(message)
470
+
471
+
472
+ def install_deps():
473
+ if platform.system() != "Darwin":
474
+ info("Dependency install is only needed/supported on macOS (Apple Silicon).")
475
+ return
476
+ if shutil.which("smctemp"):
477
+ success("smctemp is already installed.")
478
+ return
479
+ if not shutil.which("brew"):
480
+ error("Homebrew not found. Install it from https://brew.sh first.")
481
+ sys.exit(1)
482
+ info("Installing smctemp via Homebrew (narugit/tap/smctemp)...")
483
+ result = subprocess.run(["brew", "install", "narugit/tap/smctemp"])
484
+ if result.returncode == 0 and shutil.which("smctemp"):
485
+ success("smctemp installed - real CPU/GPU temperatures are now available.")
486
+ else:
487
+ error("Install failed. Try manually: brew install narugit/tap/smctemp")
488
+ sys.exit(1)
489
+
490
+
491
+ @click.command("sysmon", epilog=EPILOG)
492
+ @click.option("-n", "--top", "top_n", default=5, show_default=True, help="Number of top CPU processes to show.")
493
+ @click.option("-w", "--watch", "watch", default=0.0, help="Refresh continuously every N seconds (Ctrl-C to stop).")
494
+ @click.option("--no-procs", "no_procs", is_flag=True, help="Hide the top-processes table.")
495
+ @click.option("--json", "as_json", is_flag=True, help="Print result as JSON.")
496
+ @click.option("--install-deps", "do_install", is_flag=True, help="Install the smctemp sensor reader (macOS) and exit.")
497
+ def sysmon(top_n, watch, no_procs, as_json, do_install):
498
+ """Show machine temperature and performance at a glance.
499
+
500
+ Reports **CPU / GPU / battery temperature**, overall **CPU usage**,
501
+ **memory** pressure, **load average**, uptime and the **top CPU-hungry
502
+ processes**, then prints a plain-language verdict that flags overheating,
503
+ throttling, an oversubscribed CPU or memory pressure.
504
+
505
+ On Apple Silicon real °C values are read from the SMC via `smctemp`
506
+ (`evo sysmon --install-deps` to set it up); without it you still get battery
507
+ temperature and the macOS thermal-pressure level. On Linux it reads
508
+ `/sys/class/thermal`. With `-w/--watch` it becomes a live dashboard.
509
+ """
510
+ if do_install:
511
+ step("evo sysmon - install deps")
512
+ install_deps()
513
+ return
514
+
515
+ show_procs = not no_procs
516
+
517
+ if as_json:
518
+ console.print_json(jsonlib.dumps(collect(top_n), ensure_ascii=False, default=str))
519
+ return
520
+
521
+ if watch and watch > 0:
522
+ try:
523
+ while True:
524
+ console.clear()
525
+ step("evo sysmon")
526
+ with console.status("[info]sampling...[/info]", spinner="dots"):
527
+ data = collect(top_n)
528
+ render(data, show_procs)
529
+ console.print(f"[dim]refreshing every {watch:g}s - Ctrl-C to stop[/dim]")
530
+ time.sleep(watch)
531
+ except KeyboardInterrupt:
532
+ console.print()
533
+ info("stopped")
534
+ return
535
+
536
+ step("evo sysmon")
537
+ try:
538
+ with console.status("[info]sampling...[/info]", spinner="dots"):
539
+ data = collect(top_n)
540
+ render(data, show_procs)
541
+ except click.ClickException:
542
+ raise
543
+ except Exception as exc:
544
+ error(str(exc))
545
+ sys.exit(1)