sysk a CLI health monitor in bash and python, how i built it A developer detailed the design of Sysk, a command-line system health monitor built in Bash and Python, in a blog post. The tool collects machine data by reading kernel-exported files, with Python handling inference and decision engines. The post covers the four-stage pipeline and portability considerations. Design notes on Sysk, a small system health monitor. Sysk is a command-line system health monitor built in Bash and Python. Bash collects machine data, handles flags, and drives the user interface. Python runs the inference and decision engines. This article records the design of v1: what the system does, which decisions shaped it, and where those decisions cost something. The pipeline is four stages: php collect -- infer -- decide -- print v1 collects data across five modules: Each module presented a different reading problem. The rest of the article follows the pipeline above. The main collection decision was to read kernel-exported files rather than treat programs such as top or free as the source of truth. Advantages Disadvantages Files and directories used php /proc/meminfo - memory /proc/cpuinfo - cpu identity /proc/loadavg - load average /proc/stat - cpu accounting /proc/uptime - uptime /sys/block - disks /sys/class/thermal - thermal zones and cooling /sys/class/hwmon - hardware monitors fans, sensors This rule is not absolute. Hardware identity and sound did not fit it cleanly. dmidecode , smartctl , and pactl are still dependencies. Sound, in particular, is read through pactl rather than a kernel file. Finding the right file for each module was messy. Once the path was known, the output looked cryptic at first glance, but it was stable enough to parse. Reading files raised the next question: how should the project be structured? Each major function became its own script, sourced from a single entry point. That was easy to say and immediately produced four portability questions: Those questions produced the portable layout. 1. Resolve the application path export SCRIPT DIR="$ cd "$ dirname "${BASH SOURCE 0 }" " && pwd -P " This is the usual “where am I?” idiom. A pure string-manipulation version exists, but the cd / pwd -P form follows symlinks and is the one Sysk uses. 2. Export directory names from that root export SCRIPT DIR="$ cd "$ dirname "${BASH SOURCE 0 }" " && pwd " export XDG CONFIG HOME=${XDG CONFIG HOME:-${SCRIPT DIR}/.config} export DUMPS PATH=${DUMPS PATH:-${SCRIPT DIR}/logs} export CACHE PATH=${CACHE PATH:-${SCRIPT DIR}/.cache} export CONFIG DIR="$CACHE PATH/sysk" export ENGINE DIR=${ENGINE DIR:-${SCRIPT DIR}/engine} export RULE DIR="$ENGINE DIR/.rules" export RESULT DIR="$ENGINE DIR/results" export DATE="$ date +%Y %m %d " Defaults can be overridden by the environment. Paths are derived from SCRIPT DIR instead of being pasted in as /home/kit/... . 3. Source scripts in a fixed order php core - device scripts - hardware scripts for script in "$CORE DIR"/ .sh; do source "$script" done for dir in "$DEVICE DIR"/ ; do for script in "$dir"/ .sh; do source "$script" done done for script in "$HW DIR"/ .sh; do source "$script" done Core files are numbered so the source order is obvious: 00 error.sh 01 privileged.sh 02 check deps.sh 03 install deps.sh 04 cleanup.sh 05 parse flags.sh tui.sh 00 error.sh — named error codes 01 privileged.sh — root / sudo handling 02 check deps.sh — missing binaries 03 install deps.sh — install those binaries 04 cleanup.sh — cleanup and traps 05 parse flags.sh — getopts tui.sh — text interfaceSome reads dmidecode , parts of disk data need elevated permission. A one-line sudo "$@" was not enough. The questions were: sudo is missing? 01 privileged.sh answers those with small predicates: is root { $EUID -eq 0 && return "$ERR SUCCESS" return "$ERR FAILURE" } is sudo available { command -v sudo /dev/null 2 &1 && return "$ERR SUCCESS" return "$ERR FAILURE" } is sudo active { sudo -n true /dev/null 2 &1 && return "$ERR SUCCESS" return "$ERR FAILURE" } kill sudo { is sudo active && { sudo -k return "$ERR SUCCESS" } printf 'sudo not active, aborting...\n' &2 return "$ERR BAD USAGE" } Even with a file-first design, Sysk still depends on a few programs: dmidecode , smartctl , pactl , and jq . The flow is: php check dependencies - get permission - install missing ones command -v on each name; keep the missing ones. declare -r PACKAGE MANAGERS= apt dnf pacman zypper emerge os pkg manager="" get pkg manager { for pkg in "${PACKAGE MANAGERS @ }"; do if command -v "$pkg" /dev/null 2 &1; then os pkg manager=$pkg return 0 fi done printf 'unknown package manager, aborting...\n' &2 exit 1 } Each module binds the files it cares about and hides the parsing behind a writer. CPU readonly CPU INFO FILE="/proc/cpuinfo" readonly LOAD INFO FILE="/proc/loadavg" readonly CPU USAGE FILE="/proc/stat" readonly UPTIME INFO FILE="/proc/uptime" Memory readonly MEM INFO FILE="/proc/meminfo" Disks readonly DRIVE DIR="/sys/block" mapfile -t drives < < find "$DRIVE DIR"/ -maxdepth 1 -printf '%f\n' | grep -Ev ' ^loop|ram|zram ' Thermal readonly SYSTEM THERMAL ZONE PATH="/sys/class/thermal/thermal zone" readonly SYSTEM FAN INFO PATH="/sys/class/hwmon" Sound — collected through pactl , not /proc . Snapshots are JSON, written with jq and a per-module filter file: write memory json { local MEM CONFIG="$CONFIG DIR/memory $DATE.json" mkdir -p "$CONFIG DIR" jq -n \ --arg total mem "$ get total mem " \ --arg available mem "$ get available mem " \ -f "$FEATURE DIR/build memory.jq" \ "$MEM CONFIG" } Example thermal snapshot: { "average temp": 31.87, "thermal zones": 3, "zone temparatures": { "acpitz": "29.80", "x86 pkg temp": "38.00" }, "cooling": { "fan zones": "2", "fan speed": "fan1 input=\"0\"\nfan2 input=\"0\"" } } Problem. The JSON shape is not the same across modules. Some values are scalars, some are objects, some are nested. That leaked into inference and into the rule files. Fix for v1. build .jq filter source field in the YAML tells the engine where to read resolve function walks that pathA uniform snapshot schema is still future work. Inference pipeline: php load data - load rules - set status per field - write result The engine is a Python script. It uses the standard library plus PyYAML, and it reads the same environment variables Bash exported CACHE PATH , RULE DIR , RESULT DIR , DATE . php def load rules rule path: Path - dict: with rule path.open encoding="utf-8" as rule: return safe load rule def load json data path: Path - Any: try: with data path.open encoding="utf-8" as data: return load data except FileNotFoundError, OSError, JSONDecodeError as err: print f" ERROR failed to load {data path}: {err}", file=sys.stderr sys.exit 1 Rules live in engine/.rules as YAML. Each field names the value it wants through source : module: cpu check interval: 10 system vars: cpu cores: "auto" fields: - name: cpu usage percent source: "usage" type: "percentage" base value: 100 warning multiplier: 0.83 critical multiplier: 0.90 unit: "%" - name: load avg5 source: "load avg.1" type: "load" base value: "cores" warning multiplier: 1 critical multiplier: 1.5 unit: "" The collector JSON could not be renamed. The mapping therefore lives in the rules, not in a hard-coded Python dictionary. source is a small path: usage core info.cores usage load avg.1 resolve walks that path: php def resolve data: dict, source: str - Any: current = data for obj in source.split "." : if obj.isdigit : current = get list value current, int obj else: current = get dict value current, obj return current The helpers catch KeyError , IndexError , and TypeError , print on stderr, and exit. resolve itself stays a straight loop. Because cpu, memory, and thermal still do not share one value shape, evaluate dispatches to a small private helper per module evaluate cpu , and so on and merges the results into one dictionary. v1 loads the three modules it already has rules for. Disk and sound collection exist; their inference rules are not finished in the same way. The public main in inference.py is only orchestration: check environment variables, check input paths, create RESULT DIR if needed, evaluate, write result {DATE}.json . php load result - find warning and critical fields - write a cause log if needed The decision script does not re-parse /proc . It reads today’s result file. The decision I made for v1: if every field is OK , exit 0 and write nothing extra. If any field is not OK , write cause {DATE}.log and return a non-zero status 90 so Bash can tell an alert from a clean run. v1 uses short flags only, parsed with getopts : parse args { while getopts ':hvrm:' opt; do case "$opt" in h usage; exit 0 ;; v printf '%s version %s\n' "$PROGNAME" "$VERSION"; exit 0 ;; ... esac done } Action flags such as -h and -v print and exit inside the parser. Setting flags only store values. The TUI runs only when the session is a terminal and quiet mode is off. All of the pieces meet in one function: check files and directories parse flags install missing dependencies collect snapshots run inference run decision show the TUI unless -q was passed exit main { check req files || exit "$ERR FAILURE" check req dirs || exit "$ERR FAILURE" parse args "$@" install missing deps write cpu json & write memory json write thermal json write disk json write sound json mkdir -p "$DUMPS PATH" python3 "$ENGINE DIR/inference.py" && python3 "$ENGINE DIR/decision.py" display tui || exit "$ERR FAILURE" exit "$ERR SUCCESS" } main "$@" The first runs collected modules one after another. CPU sampling waits about one second to compute usage, so the whole collect stage felt slow. write cpu json & starts that slow job in the background so the other writers can proceed. That made the collect stage feel almost instant. This only stays correct if the entry point waits for the background job before inference starts. Otherwise Python can open a CPU file that is not finished yet. A wait after the writers is part of this design, not an optional extra. The project is small on purpose. Dated result files are already named result {DATE}.json so a later version can compare runs without changing the collector. sysk/ ├── lib/ │ ├── core/ 00 error.sh ... 05 parse flags.sh, tui.sh │ ├── devices/ │ └── hw/ ├── engine/ │ ├── .rules/ cpu.yml, memory.yml, thermal.yml │ ├── inference.py │ ├── decision.py │ └── results/ └── sysk entry point That is the architecture of v1: files in, JSON snapshots, YAML rules, a small path walker, a verdict, and a Bash wrapper that the user actually types. Many features could still be added. The shape above is what is running now. if you are interested in the project you can check it out at https://github.com/4kit1-glitch/sysk https://github.com/4kit1-glitch/sysk thanks for being along with me in my journey