{"slug": "sysk-a-cli-health-monitor-in-bash-and-python-how-i-built-it", "title": "sysk a CLI health monitor in bash and python, how i built it", "summary": "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.", "body_md": "Design notes on Sysk, a small system health monitor.\n\nSysk is a command-line system health monitor built in Bash and Python.\n\nBash collects machine data, handles flags, and drives the user interface. Python runs the inference and decision engines.\n\nThis article records the design of v1: what the system does, which decisions shaped it, and where those decisions cost something.\n\nThe pipeline is four stages:\n\n``` php\ncollect --> infer --> decide --> print\n```\n\nv1 collects data across five modules:\n\nEach module presented a different reading problem. The rest of the article follows the pipeline above.\n\nThe main collection decision was to read kernel-exported files rather than treat programs such as `top`\n\nor `free`\n\nas the source of truth.\n\n**Advantages**\n\n**Disadvantages**\n\n**Files and directories used**\n\n``` php\n/proc/meminfo          -> memory\n/proc/cpuinfo          -> cpu identity\n/proc/loadavg          -> load average\n/proc/stat             -> cpu accounting\n/proc/uptime           -> uptime\n\n/sys/block             -> disks\n/sys/class/thermal     -> thermal zones and cooling\n/sys/class/hwmon       -> hardware monitors (fans, sensors)\n```\n\nThis rule is not absolute. Hardware identity and sound did not fit it cleanly. `dmidecode`\n\n, `smartctl`\n\n, and `pactl`\n\nare still dependencies. Sound, in particular, is read through `pactl`\n\nrather than a kernel file.\n\nFinding 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.\n\nReading files raised the next question: how should the project be structured?\n\nEach major function became its own script, sourced from a single entry point. That was easy to say and immediately produced four portability questions:\n\nThose questions produced the portable layout.\n\n**1. Resolve the application path**\n\n```\nexport SCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd -P)\"\n```\n\nThis is the usual “where am I?” idiom. A pure string-manipulation version exists, but the `cd`\n\n/ `pwd -P`\n\nform follows symlinks and is the one Sysk uses.\n\n**2. Export directory names from that root**\n\n```\nexport SCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nexport XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-${SCRIPT_DIR}/.config}\nexport DUMPS_PATH=${DUMPS_PATH:-${SCRIPT_DIR}/logs}\nexport CACHE_PATH=${CACHE_PATH:-${SCRIPT_DIR}/.cache}\nexport CONFIG_DIR=\"$CACHE_PATH/sysk\"\nexport ENGINE_DIR=${ENGINE_DIR:-${SCRIPT_DIR}/engine}\nexport RULE_DIR=\"$ENGINE_DIR/.rules\"\nexport RESULT_DIR=\"$ENGINE_DIR/results\"\nexport DATE=\"$(date +%Y_%m_%d)\"\n```\n\nDefaults can be overridden by the environment. Paths are derived from `SCRIPT_DIR`\n\ninstead of being pasted in as `/home/kit/...`\n\n.\n\n**3. Source scripts in a fixed order**\n\n``` php\ncore -> device scripts -> hardware scripts\nfor script in \"$CORE_DIR\"/*.sh; do\n    source \"$script\"\ndone\n\nfor dir in \"$DEVICE_DIR\"/*; do\n    for script in \"$dir\"/*.sh; do\n        source \"$script\"\n    done\ndone\n\nfor script in \"$HW_DIR\"/*.sh; do\n    source \"$script\"\ndone\n```\n\nCore files are numbered so the source order is obvious:\n\n```\n00_error.sh\n01_privileged.sh\n02_check_deps.sh\n03_install_deps.sh\n04_cleanup.sh\n05_parse_flags.sh\ntui.sh\n```\n\n`00_error.sh`\n\n— named error codes`01_privileged.sh`\n\n— root / sudo handling`02_check_deps.sh`\n\n— missing binaries`03_install_deps.sh`\n\n— install those binaries`04_cleanup.sh`\n\n— cleanup and traps`05_parse_flags.sh`\n\n— `getopts`\n\n`tui.sh`\n\n— text interfaceSome reads (`dmidecode`\n\n, parts of disk data) need elevated permission. A one-line `sudo \"$@\"`\n\nwas not enough. The questions were:\n\n`sudo`\n\nis missing?`01_privileged.sh`\n\nanswers those with small predicates:\n\n```\nis_root() {\n    [[ $EUID -eq 0 ]] && return \"$ERR_SUCCESS\"\n    return \"$ERR_FAILURE\"\n}\n\nis_sudo_available() {\n    command -v sudo >/dev/null 2>&1 && return \"$ERR_SUCCESS\"\n    return \"$ERR_FAILURE\"\n}\n\nis_sudo_active() {\n    sudo -n true >/dev/null 2>&1 && return \"$ERR_SUCCESS\"\n    return \"$ERR_FAILURE\"\n}\n\nkill_sudo() {\n    is_sudo_active && {\n        sudo -k\n        return \"$ERR_SUCCESS\"\n    }\n    printf 'sudo not active, aborting...\\n' >&2\n    return \"$ERR_BAD_USAGE\"\n}\n```\n\nEven with a file-first design, Sysk still depends on a few programs: `dmidecode`\n\n, `smartctl`\n\n, `pactl`\n\n, and `jq`\n\n.\n\nThe flow is:\n\n``` php\ncheck dependencies -> get permission -> install missing ones\n```\n\n`command -v`\n\non each name; keep the missing ones.\n\n```\ndeclare -r PACKAGE_MANAGERS=(\n    apt dnf pacman zypper emerge\n)\n\nos_pkg_manager=\"\"\n\nget_pkg_manager() {\n    for pkg in \"${PACKAGE_MANAGERS[@]}\"; do\n        if command -v \"$pkg\" >/dev/null 2>&1; then\n            os_pkg_manager=$pkg\n            return 0\n        fi\n    done\n    printf 'unknown package manager, aborting...\\n' >&2\n    exit 1\n}\n```\n\nEach module binds the files it cares about and hides the parsing behind a writer.\n\n**CPU**\n\n```\nreadonly CPU_INFO_FILE=\"/proc/cpuinfo\"\nreadonly LOAD_INFO_FILE=\"/proc/loadavg\"\nreadonly CPU_USAGE_FILE=\"/proc/stat\"\nreadonly UPTIME_INFO_FILE=\"/proc/uptime\"\n```\n\n**Memory**\n\n```\nreadonly MEM_INFO_FILE=\"/proc/meminfo\"\n```\n\n**Disks**\n\n```\nreadonly DRIVE_DIR=\"/sys/block\"\n\nmapfile -t drives < <(\n    find \"$DRIVE_DIR\"/* -maxdepth 1 -printf '%f\\n' | grep -Ev '(^loop|ram|zram)'\n)\n```\n\n**Thermal**\n\n```\nreadonly SYSTEM_THERMAL_ZONE_PATH=\"/sys/class/thermal/thermal_zone\"\nreadonly SYSTEM_FAN_INFO_PATH=\"/sys/class/hwmon\"\n```\n\n**Sound** — collected through `pactl`\n\n, not `/proc`\n\n.\n\nSnapshots are JSON, written with `jq`\n\nand a per-module filter file:\n\n```\nwrite_memory_json() {\n    local MEM_CONFIG=\"$CONFIG_DIR/memory_$DATE.json\"\n    mkdir -p \"$CONFIG_DIR\"\n\n    jq -n \\\n        --arg total_mem \"$(get_total_mem)\" \\\n        --arg available_mem \"$(get_available_mem)\" \\\n        -f \"$FEATURE_DIR/build_memory.jq\" \\\n        > \"$MEM_CONFIG\"\n}\n```\n\nExample thermal snapshot:\n\n```\n{\n  \"average_temp\": 31.87,\n  \"thermal_zones\": 3,\n  \"zone_temparatures\": {\n    \"acpitz\": \"29.80\",\n    \"x86_pkg_temp\": \"38.00\"\n  },\n  \"cooling\": {\n    \"fan_zones\": \"2\",\n    \"fan_speed\": \"fan1_input=\\\"0\\\"\\nfan2_input=\\\"0\\\"\"\n  }\n}\n```\n\n**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.\n\n**Fix for v1.**\n\n`build_*.jq`\n\nfilter`source`\n\nfield in the YAML tells the engine where to read`resolve`\n\nfunction walks that pathA uniform snapshot schema is still future work.\n\nInference pipeline:\n\n``` php\nload data -> load rules -> set status per field -> write result\n```\n\nThe engine is a Python script. It uses the standard library plus PyYAML, and it reads the same environment variables Bash exported (`CACHE_PATH`\n\n, `RULE_DIR`\n\n, `RESULT_DIR`\n\n, `DATE`\n\n).\n\n``` php\ndef load_rules(rule_path: Path) -> dict:\n    with rule_path.open(encoding=\"utf-8\") as rule:\n        return safe_load(rule)\n\ndef load_json(data_path: Path) -> Any:\n    try:\n        with data_path.open(encoding=\"utf-8\") as data:\n            return load(data)\n    except (FileNotFoundError, OSError, JSONDecodeError) as err:\n        print(f\"[ERROR] failed to load {data_path}: {err}\", file=sys.stderr)\n        sys.exit(1)\n```\n\nRules live in `engine/.rules`\n\nas YAML. Each field names the value it wants through `source`\n\n:\n\n```\nmodule: cpu\ncheck_interval: 10\nsystem_vars:\n  cpu_cores: \"auto\"\n\nfields:\n  - name: cpu_usage_percent\n    source: \"usage\"\n    type: \"percentage\"\n    base_value: 100\n    warning_multiplier: 0.83\n    critical_multiplier: 0.90\n    unit: \"%\"\n\n  - name: load_avg5\n    source: \"load_avg.1\"\n    type: \"load\"\n    base_value: \"cores\"\n    warning_multiplier: 1\n    critical_multiplier: 1.5\n    unit: \"\"\n```\n\nThe collector JSON could not be renamed. The mapping therefore lives in the rules, not in a hard-coded Python dictionary.\n\n`source`\n\nis a small path:\n\n`usage`\n\n`core_info.cores_usage`\n\n`load_avg.1`\n\n`resolve`\n\nwalks that path:\n\n``` php\ndef resolve(data: dict, source: str) -> Any:\n    current = data\n    for obj in source.split(\".\"):\n        if obj.isdigit():\n            current = get_list_value(current, int(obj))\n        else:\n            current = get_dict_value(current, obj)\n    return current\n```\n\nThe helpers catch `KeyError`\n\n, `IndexError`\n\n, and `TypeError`\n\n, print on stderr, and exit. `resolve`\n\nitself stays a straight loop.\n\nBecause cpu, memory, and thermal still do not share one value shape, `evaluate`\n\ndispatches to a small private helper per module (`_evaluate_cpu`\n\n, 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.\n\nThe public `main`\n\nin `inference.py`\n\nis only orchestration: check environment variables, check input paths, create `RESULT_DIR`\n\nif needed, evaluate, write `result_{DATE}.json`\n\n.\n\n``` php\nload result -> find warning and critical fields -> write a cause log if needed\n```\n\nThe decision script does not re-parse `/proc`\n\n. It reads today’s result file.\n\nThe decision I made for v1: if every field is `OK`\n\n, exit 0 and write nothing extra. If any field is not `OK`\n\n, write `cause_{DATE}.log`\n\nand return a non-zero status (90) so Bash can tell an alert from a clean run.\n\nv1 uses short flags only, parsed with `getopts`\n\n:\n\n```\nparse_args() {\n    while getopts ':hvrm:' opt; do\n        case \"$opt\" in\n            h) usage; exit 0 ;;\n            v) printf '%s version %s\\n' \"$PROGNAME\" \"$VERSION\"; exit 0 ;;\n            # ...\n        esac\n    done\n}\n```\n\nAction flags such as `-h`\n\nand `-v`\n\nprint 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.\n\nAll of the pieces meet in one function:\n\n```\ncheck files and directories\nparse flags\ninstall missing dependencies\ncollect snapshots\nrun inference\nrun decision\nshow the TUI unless -q was passed\nexit\nmain() {\n    check_req_files || exit \"$ERR_FAILURE\"\n    check_req_dirs  || exit \"$ERR_FAILURE\"\n\n    parse_args \"$@\"\n    install_missing_deps\n\n    write_cpu_json &\n    write_memory_json\n    write_thermal_json\n    write_disk_json\n    write_sound_json\n\n    mkdir -p \"$DUMPS_PATH\"\n    python3 \"$ENGINE_DIR/inference.py\" && python3 \"$ENGINE_DIR/decision.py\"\n\n    display_tui || exit \"$ERR_FAILURE\"\n    exit \"$ERR_SUCCESS\"\n}\n\nmain \"$@\"\n```\n\nThe first runs collected modules one after another. CPU sampling waits about one second to compute usage, so the whole collect stage felt slow.\n\n`write_cpu_json &`\n\nstarts that slow job in the background so the other writers can proceed. That made the collect stage feel almost instant.\n\nThis 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`\n\nafter the writers is part of this design, not an optional extra.\n\nThe project is small on purpose.\n\nDated result files are already named `result_{DATE}.json`\n\nso a later version can compare runs without changing the collector.\n\n```\nsysk/\n├── lib/\n│   ├── core/          # 00_error.sh ... 05_parse_flags.sh, tui.sh\n│   ├── devices/\n│   └── hw/\n├── engine/\n│   ├── .rules/        # cpu.yml, memory.yml, thermal.yml\n│   ├── inference.py\n│   ├── decision.py\n│   └── results/\n└── sysk               # entry point\n```\n\nThat 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.\n\nif you are interested in the project you can check it out at [https://github.com/4kit1-glitch/sysk](https://github.com/4kit1-glitch/sysk)\n\nthanks for being along with me in my journey", "url": "https://wpnews.pro/news/sysk-a-cli-health-monitor-in-bash-and-python-how-i-built-it", "canonical_source": "https://dev.to/4kit1-glitch/sysk-a-cli-health-monitor-in-bash-and-python-how-i-built-it-361h", "published_at": "2026-09-01 17:55:18+00:00", "updated_at": "2026-09-01 18:24:11.711438+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Sysk", "Bash", "Python"], "alternates": {"html": "https://wpnews.pro/news/sysk-a-cli-health-monitor-in-bash-and-python-how-i-built-it", "markdown": "https://wpnews.pro/news/sysk-a-cli-health-monitor-in-bash-and-python-how-i-built-it.md", "text": "https://wpnews.pro/news/sysk-a-cli-health-monitor-in-bash-and-python-how-i-built-it.txt", "jsonld": "https://wpnews.pro/news/sysk-a-cli-health-monitor-in-bash-and-python-how-i-built-it.jsonld"}}