sysx-cli 1.0.0__py3-none-any.whl

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.
sysx.py ADDED
@@ -0,0 +1,897 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ sysx - 轻量级系统信息与开发辅助 CLI 工具
4
+
5
+ 纯 Python 标准库实现,零第三方依赖。
6
+ """
7
+
8
+ import argparse
9
+ import json
10
+ import os
11
+ import platform
12
+ import shutil
13
+ import socket
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ from datetime import datetime, timedelta
18
+
19
+ __version__ = "1.0.0"
20
+
21
+ # 系统级常量,用于 /proc 解析
22
+ try:
23
+ CLK_TCK = os.sysconf("SC_CLK_TCK")
24
+ except (ValueError, OSError, AttributeError):
25
+ CLK_TCK = 100
26
+
27
+ try:
28
+ PAGE_SIZE = os.sysconf("SC_PAGE_SIZE")
29
+ except (ValueError, OSError, AttributeError):
30
+ PAGE_SIZE = 4096
31
+
32
+ # ---------------------------------------------------------------- ANSI 颜色
33
+
34
+ class C:
35
+ """ANSI 颜色。当输出不是 TTY 或设置了 NO_COLOR 时自动禁用。"""
36
+ _enabled = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
37
+
38
+ RESET = "\033[0m"
39
+ BOLD = "\033[1m"
40
+ DIM = "\033[2m"
41
+ RED = "\033[31m"
42
+ GREEN = "\033[32m"
43
+ YELLOW = "\033[33m"
44
+ BLUE = "\033[34m"
45
+ MAGENTA = "\033[35m"
46
+ CYAN = "\033[36m"
47
+
48
+ @classmethod
49
+ def disable(cls):
50
+ cls._enabled = False
51
+ for name in ("RESET", "BOLD", "DIM", "RED", "GREEN",
52
+ "YELLOW", "BLUE", "MAGENTA", "CYAN"):
53
+ setattr(cls, name, "")
54
+
55
+
56
+ def _paint(text, color):
57
+ if not C._enabled:
58
+ return text
59
+ return f"{color}{text}{C.RESET}"
60
+
61
+
62
+ # ---------------------------------------------------------------- 工具函数
63
+
64
+ def human_bytes(n):
65
+ """把字节数格式化为人类可读形式。"""
66
+ size = float(n)
67
+ for unit in ("B", "KB", "MB", "GB", "TB", "PB"):
68
+ if size < 1024 or unit == "PB":
69
+ if unit == "B":
70
+ return f"{int(size)} {unit}"
71
+ return f"{size:.1f} {unit}"
72
+ size /= 1024
73
+ return f"{size:.1f} PB"
74
+
75
+
76
+ def human_duration(seconds):
77
+ """把秒数格式化为人类可读形式。"""
78
+ seconds = int(seconds)
79
+ days, rem = divmod(seconds, 86400)
80
+ hours, rem = divmod(rem, 3600)
81
+ minutes, secs = divmod(rem, 60)
82
+ parts = []
83
+ if days:
84
+ parts.append(f"{days}d")
85
+ if hours or days:
86
+ parts.append(f"{hours}h")
87
+ if minutes or hours or days:
88
+ parts.append(f"{minutes}m")
89
+ parts.append(f"{secs}s")
90
+ return " ".join(parts)
91
+
92
+
93
+ def read_text(path):
94
+ """安全读取文本文件,失败返回 None。"""
95
+ try:
96
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
97
+ return fh.read()
98
+ except (OSError, IOError):
99
+ return None
100
+
101
+
102
+ def render_table(headers, rows):
103
+ """渲染对齐的文本表格。"""
104
+ if not rows:
105
+ return ""
106
+ cols = len(headers)
107
+ widths = [len(str(h)) for h in headers]
108
+ for row in rows:
109
+ for i in range(cols):
110
+ cell = str(row[i]) if i < len(row) else ""
111
+ # 中文等宽字符粗略按 2 个宽度计算
112
+ width = sum(2 if ord(ch) > 0x2E80 else 1 for ch in cell)
113
+ widths[i] = max(widths[i], width)
114
+
115
+ def pad(text, width):
116
+ text = str(text)
117
+ cur = sum(2 if ord(ch) > 0x2E80 else 1 for ch in text)
118
+ return text + " " * max(0, width - cur)
119
+
120
+ out = []
121
+ out.append(_paint(" ".join(pad(h, widths[i]) for i, h in enumerate(headers)),
122
+ C.BOLD))
123
+ out.append(" ".join("-" * widths[i] for i in range(cols)))
124
+ for row in rows:
125
+ out.append(" ".join(pad(row[i] if i < len(row) else "", widths[i])
126
+ for i in range(cols)))
127
+ return "\n".join(out)
128
+
129
+
130
+ def emit(data, as_json, renderer):
131
+ """统一输出分发:JSON 模式下打印 JSON,否则调用渲染函数。"""
132
+ if as_json:
133
+ print(json.dumps(data, indent=2, ensure_ascii=False))
134
+ else:
135
+ renderer(data)
136
+
137
+
138
+ def section(title):
139
+ """打印小节标题。"""
140
+ print()
141
+ print(_paint(f"▸ {title}", C.BOLD + C.CYAN))
142
+
143
+
144
+ # ---------------------------------------------------------------- info 子命令
145
+
146
+ def collect_info(args=None):
147
+ uname = platform.uname()
148
+ boot_time = None
149
+ uptime_seconds = None
150
+
151
+ # Linux: 从 /proc/uptime 取更精确的运行时长
152
+ uptime_raw = read_text("/proc/uptime")
153
+ if uptime_raw:
154
+ try:
155
+ uptime_seconds = float(uptime_raw.split()[0])
156
+ boot_time = datetime.now() - timedelta(seconds=uptime_seconds)
157
+ except (ValueError, IndexError):
158
+ pass
159
+
160
+ load = None
161
+ loadavg = read_text("/proc/loadavg")
162
+ if loadavg:
163
+ parts = loadavg.split()
164
+ if len(parts) >= 3:
165
+ load = [float(parts[0]), float(parts[1]), float(parts[2])]
166
+
167
+ cpu_count = os.cpu_count()
168
+
169
+ return {
170
+ "hostname": socket.gethostname(),
171
+ "os": {
172
+ "system": uname.system,
173
+ "release": uname.release,
174
+ "version": uname.version,
175
+ "machine": uname.machine,
176
+ "distro": _read_distro(),
177
+ },
178
+ "kernel": uname.release,
179
+ "arch": uname.machine,
180
+ "cpu": {
181
+ "model": _read_cpu_model(),
182
+ "cores": cpu_count,
183
+ },
184
+ "python": {
185
+ "version": platform.python_version(),
186
+ "implementation": platform.python_implementation(),
187
+ "executable": sys.executable,
188
+ },
189
+ "uptime": {
190
+ "seconds": uptime_seconds,
191
+ "human": human_duration(uptime_seconds) if uptime_seconds else None,
192
+ "boot_time": boot_time.strftime("%Y-%m-%d %H:%M:%S") if boot_time else None,
193
+ },
194
+ "load_average": load,
195
+ }
196
+
197
+
198
+ def _read_distro():
199
+ content = read_text("/etc/os-release")
200
+ if not content:
201
+ return None
202
+ info = {}
203
+ for line in content.splitlines():
204
+ if "=" in line:
205
+ key, _, value = line.partition("=")
206
+ info[key.strip()] = value.strip().strip('"')
207
+ return info.get("PRETTY_NAME") or info.get("NAME")
208
+
209
+
210
+ def _read_cpu_model():
211
+ content = read_text("/proc/cpuinfo")
212
+ if not content:
213
+ return platform.processor() or None
214
+ for line in content.splitlines():
215
+ if line.lower().startswith("model name"):
216
+ return line.split(":", 1)[1].strip()
217
+ if line.lower().startswith("hardware"): # ARM 平台
218
+ return line.split(":", 1)[1].strip()
219
+ return platform.processor() or None
220
+
221
+
222
+ def render_info(data):
223
+ print(_paint(f" {data['hostname']}", C.BOLD + C.MAGENTA))
224
+ print()
225
+
226
+ rows = [
227
+ ("操作系统", data["os"]["distro"] or data["os"]["system"]),
228
+ ("内核版本", data["os"]["release"]),
229
+ ("架构", data["os"]["machine"]),
230
+ ("CPU", data["cpu"]["model"] or "未知"),
231
+ ("CPU 核心", data["cpu"]["cores"]),
232
+ ("Python", f"{data['python']['version']} ({data['python']['implementation']})"),
233
+ ]
234
+ if data["uptime"]["human"]:
235
+ rows.append(("运行时长", data["uptime"]["human"]))
236
+ if data["uptime"]["boot_time"]:
237
+ rows.append(("启动时间", data["uptime"]["boot_time"]))
238
+ if data["load_average"]:
239
+ rows.append(("负载 (1/5/15m)", " / ".join(f"{v:.2f}" for v in data["load_average"])))
240
+
241
+ print(render_table(["项目", "值"], rows))
242
+
243
+
244
+ # ---------------------------------------------------------------- mem 子命令
245
+
246
+ def collect_mem(args=None):
247
+ content = read_text("/proc/meminfo")
248
+ if not content:
249
+ # 非 Linux 平台无 /proc,返回结构化结果而非 None,
250
+ # 保证 JSON 消费方拿到的始终是对象
251
+ return {"supported": False, "reason": "当前平台无 /proc/meminfo"}
252
+
253
+ raw = {}
254
+ for line in content.splitlines():
255
+ if ":" in line:
256
+ key, _, value = line.partition(":")
257
+ value = value.strip().split()[0]
258
+ try:
259
+ raw[key.strip()] = int(value) * 1024 # kB -> bytes
260
+ except ValueError:
261
+ pass
262
+
263
+ total = raw.get("MemTotal", 0)
264
+ free = raw.get("MemFree", 0)
265
+ available = raw.get("MemAvailable", free)
266
+ buffers = raw.get("Buffers", 0)
267
+ cached = raw.get("Cached", 0) + raw.get("SReclaimable", 0)
268
+ used = total - available if total else 0
269
+ percent = (used / total * 100) if total else 0
270
+
271
+ swap_total = raw.get("SwapTotal", 0)
272
+ swap_free = raw.get("SwapFree", 0)
273
+ swap_used = swap_total - swap_free
274
+
275
+ return {
276
+ "supported": True,
277
+ "total": total,
278
+ "used": used,
279
+ "free": free,
280
+ "available": available,
281
+ "buffers": buffers,
282
+ "cached": cached,
283
+ "percent": round(percent, 1),
284
+ "swap": {
285
+ "total": swap_total,
286
+ "used": swap_used,
287
+ "free": swap_free,
288
+ "percent": round((swap_used / swap_total * 100) if swap_total else 0, 1),
289
+ },
290
+ }
291
+
292
+
293
+ def render_bar(percent, width=30):
294
+ """渲染进度条,按用量着色。"""
295
+ filled = int(width * percent / 100)
296
+ bar = "█" * filled + "░" * (width - filled)
297
+ if percent >= 90:
298
+ color = C.RED
299
+ elif percent >= 70:
300
+ color = C.YELLOW
301
+ else:
302
+ color = C.GREEN
303
+ return _paint(bar, color)
304
+
305
+
306
+ def render_mem(data):
307
+ if not data or not data.get("supported", True):
308
+ print(_paint(" 当前平台不支持内存信息读取(需要 Linux /proc)", C.YELLOW))
309
+ return
310
+ section("内存")
311
+ print(f" 总量 {human_bytes(data['total'])}")
312
+ print(f" 已用 {human_bytes(data['used'])} {render_bar(data['percent'])} "
313
+ f"{data['percent']}%")
314
+ print(f" 可用 {human_bytes(data['available'])}")
315
+ print(f" 缓存 {human_bytes(data['cached'])}")
316
+ print(f" 缓冲 {human_bytes(data['buffers'])}")
317
+
318
+ section("交换分区")
319
+ if data["swap"]["total"] == 0:
320
+ print(" 未启用交换分区")
321
+ else:
322
+ print(f" 总量 {human_bytes(data['swap']['total'])}")
323
+ print(f" 已用 {human_bytes(data['swap']['used'])} "
324
+ f"{render_bar(data['swap']['percent'])} {data['swap']['percent']}%")
325
+
326
+
327
+ # ---------------------------------------------------------------- disk 子命令
328
+
329
+ def collect_disk(args=None):
330
+ mounts = []
331
+ seen_devices = set()
332
+ for part in psutil_like_partitions():
333
+ # 容器环境中同一块设备常被 bind mount 到多个路径,按设备去重
334
+ if part["device"] in seen_devices:
335
+ continue
336
+ seen_devices.add(part["device"])
337
+ try:
338
+ usage = shutil.disk_usage(part["mountpoint"])
339
+ except (OSError, IOError):
340
+ continue
341
+ percent = (usage.used / usage.total * 100) if usage.total else 0
342
+ mounts.append({
343
+ "device": part["device"],
344
+ "mountpoint": part["mountpoint"],
345
+ "fstype": part["fstype"],
346
+ "total": usage.total,
347
+ "used": usage.used,
348
+ "free": usage.free,
349
+ "percent": round(percent, 1),
350
+ })
351
+ mounts.sort(key=lambda m: m["percent"], reverse=True)
352
+ return mounts
353
+
354
+
355
+ def psutil_like_partitions():
356
+ """从 /proc/mounts 解析挂载点,只保留真实块设备。"""
357
+ content = read_text("/proc/mounts")
358
+ if not content:
359
+ return []
360
+
361
+ skip_fs = {
362
+ "proc", "sysfs", "tmpfs", "devtmpfs", "devpts", "cgroup", "cgroup2",
363
+ "overlay", "squashfs", "autofs", "mqueue", "hugetlbfs", "debugfs",
364
+ "tracefs", "securityfs", "pstore", "bpf", "configfs", "fusectl",
365
+ "binfmt_misc", "rpc_pipefs", "nsfs", "ramfs", "efivarfs",
366
+ }
367
+ seen = set()
368
+ result = []
369
+ for line in content.splitlines():
370
+ fields = line.split()
371
+ if len(fields) < 3:
372
+ continue
373
+ device, mountpoint, fstype = fields[0], fields[1], fields[2]
374
+ if fstype in skip_fs:
375
+ continue
376
+ if not device.startswith("/dev/"):
377
+ continue
378
+ # 跳过容器/快照层的噪音挂载点
379
+ if mountpoint.startswith(("/.oldroot", "/.PlnPyKFp4CRfFtgC1",
380
+ "/snap/", "/var/lib/docker/")):
381
+ continue
382
+ if mountpoint in seen:
383
+ continue
384
+ seen.add(mountpoint)
385
+ # 反转义 /proc/mounts 中的八进制转义
386
+ mountpoint = (mountpoint.replace("\\040", " ")
387
+ .replace("\\011", "\t")
388
+ .replace("\\012", "\n"))
389
+ result.append({"device": device, "mountpoint": mountpoint, "fstype": fstype})
390
+ return result
391
+
392
+
393
+ def render_disk(data):
394
+ if not data:
395
+ print("未找到可用的块设备挂载点")
396
+ return
397
+ rows = []
398
+ for m in data:
399
+ rows.append([
400
+ m["mountpoint"],
401
+ m["device"],
402
+ m["fstype"],
403
+ human_bytes(m["total"]),
404
+ human_bytes(m["used"]),
405
+ human_bytes(m["free"]),
406
+ f"{m['percent']}%",
407
+ ])
408
+ print(render_table(
409
+ ["挂载点", "设备", "类型", "总量", "已用", "可用", "使用率"], rows))
410
+
411
+
412
+ # ---------------------------------------------------------------- proc 子命令
413
+
414
+ def _read_proc_stat(pid_str):
415
+ """读取单个进程的 stat,返回 (comm, state, cpu_ticks, rss_bytes)。"""
416
+ stat = read_text(f"/proc/{pid_str}/stat")
417
+ if not stat:
418
+ return None
419
+ rparen = stat.rfind(")")
420
+ if rparen < 0:
421
+ return None
422
+ open_paren = stat.find("(")
423
+ if open_paren < 0 or open_paren > rparen:
424
+ return None
425
+ comm = stat[open_paren + 1:rparen]
426
+ rest = stat[rparen + 2:].split()
427
+ if len(rest) < 22:
428
+ return None
429
+ try:
430
+ state = rest[0]
431
+ utime = int(rest[11])
432
+ stime = int(rest[12])
433
+ rss_pages = int(rest[21])
434
+ except (ValueError, IndexError):
435
+ return None
436
+ return {
437
+ "name": comm,
438
+ "state": state,
439
+ "cpu_ticks": utime + stime,
440
+ "rss": rss_pages * PAGE_SIZE,
441
+ }
442
+
443
+
444
+ def _read_total_cpu_ticks():
445
+ """读取系统整体累计 CPU tick 数。"""
446
+ content = read_text("/proc/stat")
447
+ if not content:
448
+ return None
449
+ first = content.splitlines()[0]
450
+ if not first.startswith("cpu "):
451
+ return None
452
+ try:
453
+ return sum(int(x) for x in first.split()[1:])
454
+ except ValueError:
455
+ return None
456
+
457
+
458
+ def collect_proc(limit=10, sort_by="cpu", interval=0.25):
459
+ """采样两次 /proc/stat,计算进程在采样窗口内的真实 CPU 占用率。"""
460
+ num_cpus = os.cpu_count() or 1
461
+
462
+ total_mem = 0
463
+ meminfo = read_text("/proc/meminfo")
464
+ if meminfo:
465
+ for line in meminfo.splitlines():
466
+ if line.startswith("MemTotal:"):
467
+ try:
468
+ total_mem = int(line.split()[1]) * 1024
469
+ except (ValueError, IndexError):
470
+ pass
471
+
472
+ def snapshot():
473
+ try:
474
+ pids = [e for e in os.listdir("/proc") if e.isdigit()]
475
+ except OSError:
476
+ return {}
477
+ data = {}
478
+ for pid_str in pids:
479
+ info = _read_proc_stat(pid_str)
480
+ if info is not None:
481
+ data[pid_str] = info
482
+ return data
483
+
484
+ first = snapshot()
485
+ first_total = _read_total_cpu_ticks()
486
+ time.sleep(interval)
487
+ second = snapshot()
488
+ second_total = _read_total_cpu_ticks()
489
+
490
+ if not second:
491
+ return []
492
+
493
+ # 窗口内系统总 tick 增量
494
+ delta_total = 0
495
+ if first_total is not None and second_total is not None:
496
+ delta_total = second_total - first_total
497
+ window_ticks = delta_total if delta_total > 0 else num_cpus * CLK_TCK * interval
498
+
499
+ procs = []
500
+ for pid_str, cur in second.items():
501
+ prev = first.get(pid_str)
502
+ delta = cur["cpu_ticks"] - prev["cpu_ticks"] if prev else cur["cpu_ticks"]
503
+ if delta < 0:
504
+ delta = 0
505
+ # 单核 100% 为满,允许超过 100%(多线程进程)
506
+ cpu_percent = delta / window_ticks * num_cpus * 100 if window_ticks else 0.0
507
+ cpu_percent = min(cpu_percent, num_cpus * 100)
508
+ procs.append({
509
+ "pid": int(pid_str),
510
+ "name": cur["name"],
511
+ "state": cur["state"],
512
+ "cpu_percent": round(cpu_percent, 1),
513
+ "rss": cur["rss"],
514
+ "mem_percent": round(cur["rss"] / total_mem * 100, 2) if total_mem else 0,
515
+ })
516
+
517
+ key = "rss" if sort_by == "mem" else "cpu_percent"
518
+ procs.sort(key=lambda p: p[key], reverse=True)
519
+ return procs[:limit]
520
+
521
+
522
+ def render_proc(data):
523
+ if not data:
524
+ print("未读到进程信息(需要 Linux /proc 文件系统)")
525
+ return
526
+ rows = []
527
+ for p in data:
528
+ rows.append([
529
+ p["pid"],
530
+ p["name"][:28],
531
+ p["state"],
532
+ f"{p['cpu_percent']}%",
533
+ human_bytes(p["rss"]),
534
+ f"{p['mem_percent']}%",
535
+ ])
536
+ print(render_table(["PID", "名称", "状态", "CPU", "内存", "内存占比"], rows))
537
+
538
+
539
+ # ---------------------------------------------------------------- net 子命令
540
+
541
+ def collect_net(args=None):
542
+ info = {
543
+ "hostname": socket.gethostname(),
544
+ "interfaces": [],
545
+ "dns": [],
546
+ }
547
+
548
+ # 从 /sys/class/net 读取网卡
549
+ try:
550
+ for name in sorted(os.listdir("/sys/class/net")):
551
+ iface = {"name": name, "address": None, "state": None, "speed": None}
552
+ addr = read_text(f"/sys/class/net/{name}/address")
553
+ if addr:
554
+ iface["address"] = addr.strip()
555
+ state = read_text(f"/sys/class/net/{name}/operstate")
556
+ if state:
557
+ iface["state"] = state.strip()
558
+ try:
559
+ with open(f"/sys/class/net/{name}/speed") as fh:
560
+ speed = int(fh.read().strip())
561
+ # 网卡未连接或虚拟网卡时内核返回负数
562
+ iface["speed"] = speed if speed > 0 else None
563
+ except (OSError, ValueError):
564
+ pass
565
+ info["interfaces"].append(iface)
566
+ except OSError:
567
+ pass
568
+
569
+ # 主机 IP(通过 UDP 连接探测,不会真的发包)
570
+ try:
571
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
572
+ sock.settimeout(0.5)
573
+ sock.connect(("8.8.8.8", 80))
574
+ info["local_ip"] = sock.getsockname()[0]
575
+ except (OSError, socket.error):
576
+ info["local_ip"] = None
577
+
578
+ # DNS 服务器
579
+ resolv = read_text("/etc/resolv.conf")
580
+ if resolv:
581
+ for line in resolv.splitlines():
582
+ line = line.strip()
583
+ if line.startswith("nameserver"):
584
+ parts = line.split()
585
+ if len(parts) >= 2:
586
+ info["dns"].append(parts[1])
587
+
588
+ return info
589
+
590
+
591
+ def render_net(data):
592
+ print(_paint(f" 主机名 {data['hostname']}", C.BOLD))
593
+ if data.get("local_ip"):
594
+ print(f" 本机 IP {_paint(data['local_ip'], C.GREEN)}")
595
+ if data.get("dns"):
596
+ print(f" DNS {', '.join(data['dns'])}")
597
+
598
+ section("网络接口")
599
+ if not data["interfaces"]:
600
+ print(" 未找到网络接口")
601
+ return
602
+ rows = []
603
+ for i in data["interfaces"]:
604
+ speed = f"{i['speed']} Mb/s" if i.get("speed") else "-"
605
+ state = i.get("state") or "-"
606
+ if state == "up":
607
+ state = _paint(state, C.GREEN)
608
+ elif state == "down":
609
+ state = _paint(state, C.DIM)
610
+ rows.append([i["name"], i.get("address") or "-", state, speed])
611
+ print(render_table(["接口", "MAC 地址", "状态", "速率"], rows))
612
+
613
+
614
+ # ---------------------------------------------------------------- env 子命令
615
+
616
+ def collect_env(args=None):
617
+ interesting = [
618
+ "SHELL", "TERM", "LANG", "LC_ALL", "USER", "HOME", "PATH",
619
+ "EDITOR", "VISUAL", "PAGER", "TZ", "PWD", "SSH_CONNECTION",
620
+ "VIRTUAL_ENV", "PYTHONPATH", "NODE_ENV", "GOPATH", "VIRTUAL_ENV_PROMPT",
621
+ ]
622
+ found = {}
623
+ for key in interesting:
624
+ value = os.environ.get(key)
625
+ if value:
626
+ found[key] = value
627
+ # 额外收录所有以常见前缀开头的变量
628
+ for key, value in sorted(os.environ.items()):
629
+ if key in found:
630
+ continue
631
+ if key.startswith(("PYTHON", "PIP", "NPM", "CARGO", "GO", "RUST", "JAVA", "DOCKER")):
632
+ found[key] = value
633
+ return found
634
+
635
+
636
+ def render_env(data):
637
+ if not data:
638
+ print("未找到相关环境变量")
639
+ return
640
+ rows = [[k, v if len(v) < 70 else v[:67] + "..."] for k, v in sorted(data.items())]
641
+ print(render_table(["变量", "值"], rows))
642
+
643
+
644
+ # ---------------------------------------------------------------- ports 子命令
645
+
646
+ def collect_ports(args=None):
647
+ """从 /proc/net/tcp 解析监听端口。"""
648
+ ports = []
649
+ for proto, path in (("tcp", "/proc/net/tcp"), ("tcp6", "/proc/net/tcp6")):
650
+ content = read_text(path)
651
+ if not content:
652
+ continue
653
+ lines = content.splitlines()[1:] # 跳过表头
654
+ for line in lines:
655
+ fields = line.split()
656
+ if len(fields) < 4:
657
+ continue
658
+ local = fields[1]
659
+ state = fields[3]
660
+ if state != "0A": # 0A = LISTEN
661
+ continue
662
+ try:
663
+ addr_hex, port_hex = local.rsplit(":", 1)
664
+ port = int(port_hex, 16)
665
+ except ValueError:
666
+ continue
667
+ try:
668
+ if proto == "tcp":
669
+ ip = ".".join(str(int(addr_hex[i:i + 2], 16))
670
+ for i in (6, 4, 2, 0))
671
+ else:
672
+ ip = "::"
673
+ if addr_hex == "0" * 32:
674
+ ip = "[::]"
675
+ except ValueError:
676
+ ip = "?"
677
+
678
+ ports.append({
679
+ "proto": proto,
680
+ "address": ip,
681
+ "port": port,
682
+ "inode": fields[9] if len(fields) > 9 else None,
683
+ })
684
+
685
+ # 去重并按端口排序
686
+ uniq = {}
687
+ for p in ports:
688
+ uniq[(p["proto"], p["port"])] = p
689
+ return sorted(uniq.values(), key=lambda p: p["port"])
690
+
691
+
692
+ def render_ports(data):
693
+ if not data:
694
+ print("未发现监听中的 TCP 端口")
695
+ return
696
+ rows = [[p["proto"], p["address"], p["port"]] for p in data]
697
+ print(render_table(["协议", "监听地址", "端口"], rows))
698
+
699
+
700
+ # ---------------------------------------------------------------- uptime 子命令
701
+
702
+ def collect_uptime(args=None):
703
+ raw = read_text("/proc/uptime")
704
+ load = read_text("/proc/loadavg")
705
+ result = {"seconds": None, "human": None, "load": None}
706
+ if raw:
707
+ try:
708
+ result["seconds"] = float(raw.split()[0])
709
+ result["human"] = human_duration(result["seconds"])
710
+ except (ValueError, IndexError):
711
+ pass
712
+ if load:
713
+ parts = load.split()
714
+ if len(parts) >= 3:
715
+ result["load"] = [float(parts[0]), float(parts[1]), float(parts[2])]
716
+ return result
717
+
718
+
719
+ def render_uptime(data):
720
+ if not data["human"]:
721
+ print("无法读取运行时间")
722
+ return
723
+ print(f" 运行时长 {_paint(data['human'], C.BOLD + C.GREEN)}")
724
+ if data["load"]:
725
+ print(f" 负载 {_paint(' / '.join(f'{v:.2f}' for v in data['load']), C.CYAN)}"
726
+ f" {_paint('(1 / 5 / 15 分钟)', C.DIM)}")
727
+
728
+
729
+ # ---------------------------------------------------------------- doctor 子命令
730
+
731
+ def _check_dns():
732
+ """尝试解析几个常见域名,任一成功即认为 DNS 可用。"""
733
+ for host in ("example.com", "github.com", "cloudflare.com"):
734
+ try:
735
+ ip = socket.gethostbyname(host)
736
+ return True, f"{host} → {ip}"
737
+ except (OSError, socket.error):
738
+ continue
739
+ return False, "所有域名解析均失败"
740
+
741
+
742
+ def _check_network():
743
+ """多目标 TCP 探测,任一可达即认为外网连通。"""
744
+ targets = [("1.1.1.1", 443), ("8.8.8.8", 53), ("223.5.5.5", 443)]
745
+ errors = []
746
+ for host, port in targets:
747
+ try:
748
+ # 用 with 确保异常路径下 socket 也会被关闭
749
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
750
+ sock.settimeout(3)
751
+ sock.connect((host, port))
752
+ return True, f"可访问 {host}:{port}"
753
+ except (OSError, socket.error) as exc:
754
+ errors.append(str(exc))
755
+ continue
756
+ detail = errors[0] if errors else "连接失败"
757
+ return False, detail[:60]
758
+
759
+
760
+ def collect_doctor(args=None):
761
+ """开发环境体检。"""
762
+ checks = []
763
+
764
+ def check(name, ok, detail=""):
765
+ checks.append({"name": name, "ok": ok, "detail": detail})
766
+
767
+ # Python
768
+ check("Python", True, f"{platform.python_version()} ({sys.executable})")
769
+
770
+ # 常用开发工具
771
+ tools = ["git", "curl", "wget", "gcc", "make", "docker", "node", "npm",
772
+ "go", "rustc", "java", "vim", "tmux"]
773
+ for tool in tools:
774
+ path = shutil.which(tool)
775
+ check(tool, path is not None, path or "未安装")
776
+
777
+ # 权限
778
+ check("root 权限", os.geteuid() == 0,
779
+ "当前是 root" if os.geteuid() == 0 else "普通用户")
780
+
781
+ # 网络探测
782
+ dns_ok, dns_detail = _check_dns()
783
+ check("DNS 解析", dns_ok, dns_detail)
784
+
785
+ net_ok, net_detail = _check_network()
786
+ check("外网连通", net_ok, net_detail)
787
+
788
+ # 磁盘余量
789
+ try:
790
+ usage = shutil.disk_usage("/")
791
+ free_percent = usage.free / usage.total * 100
792
+ check("根分区余量", free_percent > 10,
793
+ f"{human_bytes(usage.free)} 可用 ({free_percent:.1f}%)")
794
+ except OSError:
795
+ pass
796
+
797
+ # 内存余量
798
+ mem = collect_mem()
799
+ if mem and mem.get("supported", True):
800
+ check("内存余量", mem["percent"] < 90,
801
+ f"{human_bytes(mem['available'])} 可用")
802
+
803
+ return checks
804
+
805
+
806
+ def render_doctor(data):
807
+ if not data:
808
+ print("无检查项")
809
+ return
810
+ passed = sum(1 for c in data if c["ok"])
811
+ for c in data:
812
+ mark = _paint("✓", C.GREEN) if c["ok"] else _paint("✗", C.RED)
813
+ name = c["name"].ljust(14)
814
+ detail = c["detail"]
815
+ if not c["ok"]:
816
+ detail = _paint(detail, C.DIM)
817
+ print(f" {mark} {name} {detail}")
818
+
819
+ print()
820
+ total = len(data)
821
+ color = C.GREEN if passed == total else C.YELLOW
822
+ print(_paint(f" {passed}/{total} 项通过", color + C.BOLD))
823
+
824
+
825
+ # ---------------------------------------------------------------- CLI 定义
826
+
827
+ def build_parser():
828
+ parser = argparse.ArgumentParser(
829
+ prog="sysx",
830
+ description="轻量级系统信息与开发辅助 CLI 工具",
831
+ epilog="示例: sysx info 显示系统概览\n"
832
+ " sysx mem -j 以 JSON 输出内存信息\n"
833
+ " sysx doctor 检查开发环境",
834
+ formatter_class=argparse.RawDescriptionHelpFormatter,
835
+ )
836
+ parser.add_argument("-V", "--version", action="version",
837
+ version=f"sysx {__version__}")
838
+
839
+ sub = parser.add_subparsers(dest="command", metavar="<命令>")
840
+
841
+ def add(name, help_text, func, collector, renderer, extra=None):
842
+ p = sub.add_parser(name, help=help_text, description=help_text)
843
+ p.add_argument("-j", "--json", action="store_true",
844
+ help="以 JSON 格式输出")
845
+ if extra:
846
+ extra(p)
847
+ p.set_defaults(func=func, collector=collector, renderer=renderer)
848
+
849
+ add("info", "显示系统概览信息", None, collect_info, render_info)
850
+ add("mem", "显示内存与交换分区使用情况", None, collect_mem, render_mem)
851
+ add("disk", "显示磁盘挂载点与使用率", None, collect_disk, render_disk)
852
+
853
+ def proc_extra(p):
854
+ p.add_argument("-n", "--limit", type=int, default=10,
855
+ help="显示条数 (默认 10)")
856
+ p.add_argument("-s", "--sort", choices=["cpu", "mem"], default="cpu",
857
+ help="排序依据 (默认 cpu)")
858
+
859
+ def proc_collect(args):
860
+ return collect_proc(limit=args.limit, sort_by=args.sort)
861
+
862
+ add("proc", "显示占用最高的进程", None, proc_collect, render_proc,
863
+ extra=proc_extra)
864
+
865
+ add("net", "显示网络接口与 DNS 信息", None, collect_net, render_net)
866
+ add("env", "显示相关环境变量", None, collect_env, render_env)
867
+ add("ports", "显示监听中的 TCP 端口", None, collect_ports, render_ports)
868
+ add("uptime", "显示系统运行时长与负载", None, collect_uptime, render_uptime)
869
+ add("doctor", "检查开发环境完整性", None, collect_doctor, render_doctor)
870
+
871
+ return parser
872
+
873
+
874
+ def main(argv=None):
875
+ parser = build_parser()
876
+ args = parser.parse_args(argv)
877
+
878
+ if not args.command:
879
+ parser.print_help()
880
+ return 0
881
+
882
+ if getattr(args, "json", False) and not sys.stdout.isatty():
883
+ C.disable()
884
+
885
+ data = args.collector(args)
886
+
887
+ if args.json:
888
+ print(json.dumps(data, indent=2, ensure_ascii=False))
889
+ else:
890
+ args.renderer(data)
891
+ print()
892
+
893
+ return 0
894
+
895
+
896
+ if __name__ == "__main__":
897
+ sys.exit(main())
@@ -0,0 +1,258 @@
1
+ Metadata-Version: 2.4
2
+ Name: sysx-cli
3
+ Version: 1.0.0
4
+ Summary: 轻量级系统信息与开发辅助 CLI 工具,纯标准库实现,零第三方依赖
5
+ Author-email: maoxian3824 <maoxian3824@users.noreply.github.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/maoxian3824/sysx
8
+ Project-URL: Repository, https://github.com/maoxian3824/sysx
9
+ Project-URL: Documentation, https://github.com/maoxian3824/sysx#readme
10
+ Project-URL: Changelog, https://github.com/maoxian3824/sysx/blob/master/CHANGELOG.md
11
+ Project-URL: Issues, https://github.com/maoxian3824/sysx/issues
12
+ Keywords: cli,system,monitoring,devops,procfs,sysinfo,console
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Intended Audience :: System Administrators
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Operating System :: MacOS
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.8
21
+ Classifier: Programming Language :: Python :: 3.9
22
+ Classifier: Programming Language :: Python :: 3.10
23
+ Classifier: Programming Language :: Python :: 3.11
24
+ Classifier: Programming Language :: Python :: 3.12
25
+ Classifier: Topic :: System :: Monitoring
26
+ Classifier: Topic :: System :: Systems Administration
27
+ Classifier: Topic :: Utilities
28
+ Requires-Python: >=3.8
29
+ Description-Content-Type: text/markdown
30
+ License-File: LICENSE
31
+ Provides-Extra: dev
32
+ Requires-Dist: build>=1.0; extra == "dev"
33
+ Requires-Dist: twine>=5.0; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # sysx
37
+
38
+ > 轻量级系统信息与开发辅助 CLI 工具 — 纯 Python 标准库实现,零第三方依赖。
39
+
40
+ [![Tests](https://github.com/maoxian3824/sysx/actions/workflows/test.yml/badge.svg)](https://github.com/maoxian3824/sysx/actions/workflows/test.yml)
41
+ [![PyPI](https://img.shields.io/pypi/v/sysx-cli.svg)](https://pypi.org/project/sysx-cli/)
42
+ [![Python](https://img.shields.io/badge/python-3.8%2B-blue.svg)](https://www.python.org/)
43
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
44
+ [![Dependencies](https://img.shields.io/badge/dependencies-none-brightgreen.svg)](#)
45
+
46
+ `sysx` 把日常排查系统状态时零散用到的 `top`、`df`、`free`、`ip`、`ss` 等命令,
47
+ 收敛成一套统一、可读、支持 JSON 输出的子命令集。适合放进 CI 脚本、容器镜像,
48
+ 或者单纯想少记几个命令参数的时候。
49
+
50
+ ## 特性
51
+
52
+ - **零依赖** — 只用 Python 标准库,`pip install` 不需要拉任何第三方包
53
+ - **JSON 输出** — 每个子命令都支持 `-j`,方便喂给 `jq` 或脚本解析
54
+ - **自动配色** — 输出到终端时着色;重定向到文件或管道时自动禁用
55
+ - **只读安全** — 全部操作仅读取 `/proc`、`/sys`,不做任何修改
56
+ - **跨平台降级** — Linux 下功能最全,macOS / Windows 下优雅降级不报错
57
+
58
+ ## 安装
59
+
60
+ > **包名 vs 命令名**:PyPI 发行名是 `sysx-cli`(`sysx` 已被占用),
61
+ > 但安装后的运行命令仍是 `sysx`。
62
+
63
+ ### 方式一:pip 安装(推荐)
64
+
65
+ ```bash
66
+ pip install sysx-cli
67
+ sysx info
68
+ ```
69
+
70
+ ### 方式二:直接运行单文件(无需安装)
71
+
72
+ ```bash
73
+ curl -LO https://raw.githubusercontent.com/maoxian3824/sysx/master/sysx.py
74
+ python3 sysx.py info
75
+ ```
76
+
77
+ ### 方式三:从源码
78
+
79
+ ```bash
80
+ git clone https://github.com/maoxian3824/sysx.git
81
+ cd sysx
82
+ python3 sysx.py info
83
+ ```
84
+
85
+ ### 方式四:从 GitHub Release 安装
86
+
87
+ ```bash
88
+ curl -LO https://github.com/maoxian3824/sysx/releases/latest/download/sysx_cli-1.0.0-py3-none-any.whl
89
+ pip install sysx_cli-1.0.0-py3-none-any.whl
90
+ ```
91
+
92
+ ## 使用
93
+
94
+ ```
95
+ sysx <命令> [选项]
96
+ ```
97
+
98
+ ### 子命令一览
99
+
100
+ | 命令 | 说明 |
101
+ |---|---|
102
+ | `info` | 系统概览:OS、内核、CPU、内存、运行时长、负载 |
103
+ | `mem` | 内存与交换分区使用情况(带进度条) |
104
+ | `disk` | 磁盘挂载点与使用率(按使用率排序) |
105
+ | `proc` | 占用最高的进程,可按 CPU / 内存排序 |
106
+ | `net` | 网络接口、MAC、链路状态、本机 IP、DNS |
107
+ | `ports` | 监听中的 TCP 端口 |
108
+ | `env` | 相关环境变量(Shell、语言工具链、容器等) |
109
+ | `uptime` | 运行时长与 1/5/15 分钟负载 |
110
+ | `doctor` | 开发环境体检:工具链、网络、磁盘余量 |
111
+
112
+ ### 示例
113
+
114
+ **系统概览**
115
+
116
+ ```console
117
+ $ sysx info
118
+ 16b3429714a6
119
+
120
+ 项目 值
121
+ -------------- -------------------------------
122
+ 操作系统 Ubuntu 24.04.3 LTS
123
+ 内核版本 6.6.117-45.11.6.tl4.x86_64
124
+ 架构 x86_64
125
+ CPU AMD EPYC 9K84 96-Core Processor
126
+ CPU 核心 32
127
+ Python 3.11.1 (CPython)
128
+ 运行时长 1h 41m 33s
129
+ 启动时间 2026-10-06 17:45:39
130
+ 负载 (1/5/15m) 2.78 / 2.16 / 2.24
131
+ ```
132
+
133
+ **内存**
134
+
135
+ ```console
136
+ $ sysx mem
137
+
138
+ ▸ 内存
139
+ 总量 123.3 GB
140
+ 已用 16.5 GB ████░░░░░░░░░░░░░░░░░░░░░░░░░░ 13.4%
141
+ 可用 106.8 GB
142
+ 缓存 97.0 GB
143
+ 缓冲 1.4 GB
144
+
145
+ ▸ 交换分区
146
+ 未启用交换分区
147
+ ```
148
+
149
+ **进程(按 CPU 排序)**
150
+
151
+ ```console
152
+ $ sysx proc -n 5
153
+ PID 名称 状态 CPU 内存 内存占比
154
+ ---- --------------- -- ---- -------- -----
155
+ 1323 envd S 8.0% 15.9 MB 0.01%
156
+ 62 supervisord S 0.0% 13.8 MB 0.01%
157
+ 91 node S 0.0% 55.7 MB 0.04%
158
+ ```
159
+
160
+ **开发环境体检**
161
+
162
+ ```console
163
+ $ sysx doctor
164
+ ✓ Python 3.11.1 (/usr/bin/python3)
165
+ ✓ git /usr/bin/git
166
+ ✓ docker /usr/local/bin/docker
167
+ ✗ tmux 未安装
168
+ ✓ root 权限 当前是 root
169
+ ✓ DNS 解析 example.com → 172.66.147.243
170
+ ✓ 外网连通 可访问 8.8.8.8:53
171
+ ✓ 根分区余量 254.0 GB 可用 (99.2%)
172
+
173
+ 18/19 项通过
174
+ ```
175
+
176
+ **JSON 输出(配合 jq)**
177
+
178
+ ```bash
179
+ # 内存占用百分比
180
+ sysx mem -j | jq '.percent'
181
+
182
+ # 找出最占内存的 3 个进程名
183
+ sysx proc -j -n 3 -s mem | jq -r '.[].name'
184
+
185
+ # 列出所有监听端口号
186
+ sysx ports -j | jq -r '.[].port'
187
+ ```
188
+
189
+ ## 选项
190
+
191
+ | 选项 | 适用命令 | 说明 |
192
+ |---|---|---|
193
+ | `-j, --json` | 全部 | 以 JSON 格式输出 |
194
+ | `-n, --limit N` | `proc` | 显示条数(默认 10) |
195
+ | `-s, --sort {cpu,mem}` | `proc` | 排序依据(默认 cpu) |
196
+ | `-V, --version` | 全局 | 显示版本号 |
197
+ | `-h, --help` | 全局 | 显示帮助 |
198
+
199
+ ## 环境变量
200
+
201
+ | 变量 | 作用 |
202
+ |---|---|
203
+ | `NO_COLOR` | 设置后禁用彩色输出(遵循 [no-color.org](https://no-color.org/) 约定) |
204
+
205
+ ## 实现说明
206
+
207
+ `sysx` 不依赖 `psutil` 等第三方库,直接解析内核暴露的接口:
208
+
209
+ | 数据 | 来源 |
210
+ |---|---|
211
+ | 内存 | `/proc/meminfo` |
212
+ | 运行时长 | `/proc/uptime` |
213
+ | 负载 | `/proc/loadavg` |
214
+ | 进程列表 | `/proc/<pid>/stat` |
215
+ | CPU 占用率 | 两次采样 `/proc/stat` 计算增量 |
216
+ | 磁盘挂载 | `/proc/mounts` + `shutil.disk_usage` |
217
+ | 网络接口 | `/sys/class/net/*` |
218
+ | DNS | `/etc/resolv.conf` |
219
+ | 监听端口 | `/proc/net/tcp`、`/proc/net/tcp6` |
220
+
221
+ **关于 CPU 占用率**:直接读 `/proc/<pid>/stat` 只能拿到进程的累计 CPU 时间,
222
+ 除以总和会得出错误结果(长运行进程会被严重高估)。`sysx` 采用两次采样(间隔 250ms)
223
+ 计算增量占比,结果与 `top` 一致。
224
+
225
+ **容器环境适配**:在 Docker / Kubernetes 中,`/proc/mounts` 会包含大量 bind mount,
226
+ 同一块设备重复出现几十次。`sysx` 按设备去重并过滤快照层挂载点,输出干净的磁盘列表。
227
+
228
+ ## 兼容性
229
+
230
+ | 平台 | 状态 |
231
+ |---|---|
232
+ | Linux | 完整支持 |
233
+ | macOS | 部分支持(无 `/proc`,`info`/`disk`/`doctor` 可用) |
234
+ | Windows | 基础支持(`info`/`doctor` 可用) |
235
+
236
+ ## 开发
237
+
238
+ ```bash
239
+ # 运行测试套件(46 个用例,纯标准库 unittest)
240
+ python3 -m unittest discover -s tests -v
241
+
242
+ # 严格模式:资源泄漏视为错误
243
+ python3 -W error::ResourceWarning -m unittest discover -s tests
244
+
245
+ # 构建并校验
246
+ python3 -m build
247
+ python3 -m twine check dist/* # PyPI 元数据校验
248
+ python3 scripts/check_no_deps.py # 零依赖校验
249
+ ```
250
+
251
+ CI 在 Python 3.8 – 3.12 矩阵及 macOS 上自动运行上述检查,
252
+ 详见 [.github/workflows/test.yml](.github/workflows/test.yml)。
253
+
254
+ 发布到 PyPI 的完整流程见 [PUBLISHING.md](PUBLISHING.md)。
255
+
256
+ ## License
257
+
258
+ MIT
@@ -0,0 +1,7 @@
1
+ sysx.py,sha256=92ZRhv0JzqeOMRZsM6_1zPx7RNb1LetQT9pni1jLZps,28777
2
+ sysx_cli-1.0.0.dist-info/licenses/LICENSE,sha256=U7o0IcPxSPyYPZ-uRxlXQdZsT2ifj53qEkMEaiFEis4,1068
3
+ sysx_cli-1.0.0.dist-info/METADATA,sha256=DKxYzN41pQActgpMOYQ6qEzPBNSrfEOLc-VlDWDbu8c,8266
4
+ sysx_cli-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
+ sysx_cli-1.0.0.dist-info/entry_points.txt,sha256=0Bk1_a--CjkWP9pgMV9mtUmRTNfaMPtqM-UFN6K2L1k,35
6
+ sysx_cli-1.0.0.dist-info/top_level.txt,sha256=ipl8m5TWFrWuGLBTdRqRh8IFOcomm_VQfiWzpfPdLIg,5
7
+ sysx_cli-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sysx = sysx:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 maoxian3824
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ sysx