cd /news/developer-tools/sysk-a-cli-health-monitor-in-bash-an… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-118062] src=dev.to β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

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.

read8 min views1 publishedSep 1, 2026

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:

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

/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

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 codes01_privileged.sh

β€” root / sudo handling02_check_deps.sh

β€” missing binaries03_install_deps.sh

β€” install those binaries04_cleanup.sh

β€” cleanup and traps05_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:

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

filtersource

field in the YAML tells the engine where to readresolve

function walks that pathA uniform snapshot schema is still future work.

Inference pipeline:

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

).

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:

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

.

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

thanks for being along with me in my journey

── more in #developer-tools 4 stories Β· sorted by recency
── more on @sysk 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/sysk-a-cli-health-mo…] indexed:0 read:8min 2026-09-01 Β· β€”