300 lines
11 KiB
Bash
Executable File
300 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# mcode-status — z.ai GLM 5-hour usage segment for the MiniMax Code statusline.
|
|
#
|
|
# Output (one line, Aygea palette):
|
|
# 🔥 23% · 4:12p(2h14m) (+ optional 🧠 mem segment, see AYGEA_SL_MEM)
|
|
#
|
|
# Registered in ~/.minimax/config.yaml as tui.customStatusLine.command with
|
|
# colorMode: ansi. MiniMax Code re-runs it every intervalSeconds and renders
|
|
# stdout as the "custom-command" item — mcode is the scheduler, so there is
|
|
# NO cron/systemd dependency. Network refreshes happen in a detached,
|
|
# flock-guarded background job; the statusline call itself never blocks.
|
|
#
|
|
# Credentials (checked in order):
|
|
# 1. SecretVault secret $AYGEA_SL_SECRET_NAME (default zai_api_key) via
|
|
# `secretvault run` — the public-repo-safe path. Endpoint defaults to
|
|
# https://api.z.ai (override with AYGEA_SL_URL).
|
|
# 2. Legacy ~/.secretvault/credential.json (url + clientKey, the Aygea
|
|
# Claude-statusline layout) → endpoint ${url}/proxy/zai/api/...
|
|
#
|
|
# Cache (shared with the Aygea Claude statusline):
|
|
# ~/.cache/aygea-sl/quota.json { "pct": 23, "reset_iso": "...", "ts": ... }
|
|
#
|
|
# Env tunables:
|
|
# AYGEA_SL_SECRET_NAME vault secret name (default zai_api_key)
|
|
# AYGEA_SL_URL quota base URL (default https://api.z.ai)
|
|
# AYGEA_SL_NO_VAULT=1 skip SecretVault, use credential.json only
|
|
# AYGEA_SL_CRED_FILE credential.json path (default ~/.secretvault/credential.json)
|
|
# AYGEA_SL_QUOTA_CACHE cache path (default ~/.cache/aygea-sl/quota.json)
|
|
# AYGEA_SL_REFRESH_AFTER refresh when cache older (default 150 sec)
|
|
# AYGEA_SL_MAX_AGE hide when cache older (default 360 sec)
|
|
# AYGEA_SL_CURL_TIMEOUT curl --max-time (default 6)
|
|
# AYGEA_SL_ONLY_GLM=1 show only when session model name contains "glm"
|
|
# AYGEA_SL_MEM=1 prepend an 🧠 system-memory segment (Linux/macOS)
|
|
# NO_COLOR plain text output
|
|
#
|
|
# Modes: (none) statusline mode — always exit 0, print segment or nothing
|
|
# --refresh one foreground refresh, exit non-zero on failure
|
|
#
|
|
# Requires: jq, curl. Optional: secretvault, flock, setsid.
|
|
|
|
set -u
|
|
export LC_ALL=C
|
|
|
|
SECRET_NAME="${AYGEA_SL_SECRET_NAME:-zai_api_key}"
|
|
NO_VAULT="${AYGEA_SL_NO_VAULT:-0}"
|
|
CRED_FILE="${AYGEA_SL_CRED_FILE:-$HOME/.secretvault/credential.json}"
|
|
CACHE_FILE="${AYGEA_SL_QUOTA_CACHE:-$HOME/.cache/aygea-sl/quota.json}"
|
|
CACHE_DIR="$(dirname "$CACHE_FILE")"
|
|
REFRESH_AFTER="${AYGEA_SL_REFRESH_AFTER:-150}"
|
|
MAX_AGE="${AYGEA_SL_MAX_AGE:-360}"
|
|
CURL_TIMEOUT="${AYGEA_SL_CURL_TIMEOUT:-6}"
|
|
ONLY_GLM="${AYGEA_SL_ONLY_GLM:-0}"
|
|
MEM="${AYGEA_SL_MEM:-0}"
|
|
URL_BASE="${AYGEA_SL_URL:-https://api.z.ai}"
|
|
|
|
is_int() { case "$1" in ''|*[!0-9]*) return 1;; *) return 0;; esac; }
|
|
|
|
# Scratch files used by do_refresh; cleaned by the EXIT trap. Global (not
|
|
# function-local) so the trap can never hit unbound vars under set -u.
|
|
HDR_TMP=""; RESP_TMP=""
|
|
trap 'rm -f "${HDR_TMP:-}" "${RESP_TMP:-}" 2>/dev/null' EXIT
|
|
|
|
############################################
|
|
# Aygea dark palette (truecolor -> 256 -> plain)
|
|
############################################
|
|
if [ -n "${NO_COLOR:-}" ]; then MODE=none
|
|
elif [ "${COLORTERM:-}" = "truecolor" ] || [ "${COLORTERM:-}" = "24bit" ]; then MODE=truecolor
|
|
elif [ -z "${TERM##*256*}" ]; then MODE=256
|
|
else MODE=none; fi
|
|
|
|
if [ "$MODE" = truecolor ]; then
|
|
C_BLUE=$'\033[38;2;106;143;211m'; C_LAV=$'\033[38;2;201;184;232m'
|
|
C_PINK=$'\033[38;2;216;140;168m'; C_PCT=$'\033[38;2;192;209;230m'
|
|
C_MEM=$'\033[38;2;184;232;216m'; C_FRAME=$'\033[38;2;42;58;94m'
|
|
R=$'\033[0m'
|
|
elif [ "$MODE" = 256 ]; then
|
|
C_BLUE=$'\033[38;5;67m'; C_LAV=$'\033[38;5;182m'; C_PINK=$'\033[38;5;175m'
|
|
C_PCT=$'\033[38;5;152m'; C_MEM=$'\033[38;5;158m'; C_FRAME=$'\033[38;5;24m'
|
|
R=$'\033[0m'
|
|
else
|
|
C_BLUE=""; C_LAV=""; C_PINK=""; C_PCT=""; C_MEM=""; C_FRAME=""; R=""
|
|
fi
|
|
SEP="${C_FRAME} · ${R}"
|
|
|
|
pct_color() {
|
|
if [ "$1" -gt 80 ]; then printf %s "$C_PINK"
|
|
elif [ "$1" -ge 50 ]; then printf %s "$C_LAV"
|
|
else printf %s "$C_BLUE"; fi
|
|
}
|
|
|
|
############################################
|
|
# Credentials — SecretVault first, credential.json fallback.
|
|
# The key never appears in argv, logs, or this file.
|
|
############################################
|
|
CRED_SRC="" # "vault" | "cred"
|
|
|
|
resolve_creds() {
|
|
if [ "$NO_VAULT" != "1" ] && command -v secretvault >/dev/null 2>&1; then
|
|
case "$SECRET_NAME" in
|
|
*[!A-Za-z0-9_]*|'') ;; # unsafe name -> skip vault
|
|
*)
|
|
VK=$(secretvault run --secret "$SECRET_NAME" -- \
|
|
bash -c 'printf %s "${'"$SECRET_NAME"'}"' 2>/dev/null | tr -d '[:space:]')
|
|
if [ -n "$VK" ]; then CRED_SRC="vault"; return 0; fi
|
|
;;
|
|
esac
|
|
fi
|
|
if [ -r "$CRED_FILE" ] && command -v jq >/dev/null 2>&1; then
|
|
VK=$(jq -r '.clientKey // empty' "$CRED_FILE" 2>/dev/null | tr -d '[:space:]')
|
|
if [ -n "$VK" ]; then CRED_SRC="cred"; return 0; fi
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
endpoint_for_src() {
|
|
if [ "$CRED_SRC" = "vault" ]; then
|
|
printf '%s/api/monitor/usage/quota/limit' "${URL_BASE%/}"
|
|
else
|
|
local u
|
|
u=$(jq -r '.url // empty' "$CRED_FILE" 2>/dev/null)
|
|
[ -n "$u" ] && printf '%s/proxy/zai/api/monitor/usage/quota/limit' "${u%/}"
|
|
fi
|
|
}
|
|
|
|
############################################
|
|
# Refresh: fetch quota, rewrite cache atomically.
|
|
# Same schema as the Aygea Claude statusline: { pct, reset_iso, ts }.
|
|
# The Authorization header reaches curl via a 0600 temp file so the key
|
|
# never lands in any process argv.
|
|
############################################
|
|
do_refresh() {
|
|
command -v jq >/dev/null 2>&1 || return 1
|
|
resolve_creds || return 1
|
|
local ep resp reset_ms reset_iso epoch
|
|
ep=$(endpoint_for_src); [ -n "$ep" ] || return 1
|
|
|
|
mkdir -p "$CACHE_DIR" 2>/dev/null || return 1
|
|
HDR_TMP="$CACHE_DIR/.hdr.$$"
|
|
RESP_TMP="$CACHE_FILE.tmp.$$"
|
|
printf 'Authorization: Bearer %s\n' "$VK" > "$HDR_TMP" || return 1
|
|
chmod 600 "$HDR_TMP" 2>/dev/null
|
|
|
|
resp=$(curl -sS --max-time "$CURL_TIMEOUT" \
|
|
--header @"$HDR_TMP" -H 'Accept: application/json' "$ep" 2>/dev/null) || return 1
|
|
|
|
reset_ms=$(printf '%s' "$resp" | jq -r '
|
|
([.data.limits[]? | select(.unit == 3)][0]) as $t
|
|
| if $t == null or $t == {} then empty else ($t.nextResetTime // 0) end' 2>/dev/null) || return 1
|
|
is_int "$reset_ms" || return 1
|
|
|
|
if [ "$reset_ms" -gt 0 ]; then
|
|
epoch=$((reset_ms / 1000))
|
|
reset_iso=$(date -d "@$epoch" +%Y-%m-%dT%H:%M:%S 2>/dev/null \
|
|
|| date -r "$epoch" +%Y-%m-%dT%H:%M:%S 2>/dev/null)
|
|
else
|
|
reset_iso=""
|
|
fi
|
|
|
|
printf '%s' "$resp" | jq -r --arg iso "$reset_iso" '
|
|
([.data.limits[]? | select(.unit == 3)][0]) as $t
|
|
| if $t == null or $t == {} then empty else
|
|
{ pct: (($t.percentage // 0) | round),
|
|
reset_iso: (if $iso == "" then null else $iso end),
|
|
ts: (now | floor) }
|
|
end
|
|
' > "$RESP_TMP" 2>/dev/null || return 1
|
|
[ -s "$RESP_TMP" ] || { rm -f "$RESP_TMP"; return 1; }
|
|
mv -f "$RESP_TMP" "$CACHE_FILE"
|
|
}
|
|
|
|
spawn_bg_refresh() {
|
|
if command -v setsid >/dev/null 2>&1; then
|
|
setsid "$0" --refresh-bg >/dev/null 2>&1 </dev/null &
|
|
else
|
|
( "$0" --refresh-bg >/dev/null 2>&1 </dev/null ) &
|
|
disown 2>/dev/null || true
|
|
fi
|
|
}
|
|
|
|
refresh_bg_locked() {
|
|
mkdir -p "$CACHE_DIR" 2>/dev/null || exit 0
|
|
if command -v flock >/dev/null 2>&1; then
|
|
exec 9>"$CACHE_DIR/.refresh.lock"
|
|
flock -n 9 || exit 0 # another refresh is already running
|
|
do_refresh || exit 0
|
|
else
|
|
do_refresh || exit 0
|
|
fi
|
|
}
|
|
|
|
############################################
|
|
# Cache reading + segment rendering
|
|
############################################
|
|
cache_age() {
|
|
[ -r "$CACHE_FILE" ] || return 1
|
|
local ts now
|
|
ts=$(jq -r '.ts // 0' "$CACHE_FILE" 2>/dev/null) || return 1
|
|
is_int "$ts" || return 1
|
|
now=$(date +%s)
|
|
if [ "$ts" -gt "$now" ]; then printf 0; else printf '%s' "$((now - ts))"; fi
|
|
}
|
|
|
|
# "4:12p(2h14m)" from reset_iso (local time). Portable GNU/BSD date.
|
|
reset_info() {
|
|
[ -n "$1" ] || return 0
|
|
local now rsec epoch clk rh rm
|
|
epoch=$(date -d "$1" +%s 2>/dev/null || date -j -f '%Y-%m-%dT%H:%M:%S' "$1" +%s 2>/dev/null) || return 0
|
|
now=$(date +%s); rsec=$((epoch - now))
|
|
clk=$(date -d "@$epoch" +%_I:%M%p 2>/dev/null || date -r "$epoch" +%_I:%M%p 2>/dev/null)
|
|
[ -n "$clk" ] || return 0
|
|
clk=$(printf '%s' "$clk" | sed 's/^ *//; s/^0//; s/PM$/p/; s/AM$/a/')
|
|
if [ "$rsec" -ge 0 ]; then
|
|
rh=$((rsec / 3600)); rm=$(((rsec % 3600) / 60))
|
|
printf '%s(%sh%sm)' "$clk" "$rh" "$rm"
|
|
else
|
|
printf '%s' "$clk"
|
|
fi
|
|
}
|
|
|
|
print_segment() {
|
|
[ -r "$CACHE_FILE" ] || return 1
|
|
local pct rinfo seg
|
|
pct=$(jq -r '.pct // empty' "$CACHE_FILE" 2>/dev/null) || return 1
|
|
is_int "$pct" || return 1
|
|
[ "$pct" -lt 0 ] && pct=0; [ "$pct" -gt 100 ] && pct=100
|
|
rinfo=$(reset_info "$(jq -r '.reset_iso // ""' "$CACHE_FILE" 2>/dev/null)")
|
|
seg="$(pct_color "$pct")🔥 ${pct}%${R}"
|
|
[ -n "$rinfo" ] && seg="${seg}${C_PCT} · ${rinfo}${R}"
|
|
if [ "$MEM" = "1" ]; then
|
|
local m; m=$(mem_segment)
|
|
[ -n "$m" ] && seg="${m}${SEP}${seg}"
|
|
fi
|
|
printf '%s' "$seg"
|
|
}
|
|
|
|
# 🧠 used/totalG — Linux /proc/meminfo or macOS vm_stat. Empty on failure.
|
|
mem_segment() {
|
|
local used_kb tot_kb
|
|
if [ -r /proc/meminfo ]; then
|
|
read -r tot_kb av_kb < <(awk '/^MemTotal:/{t=$2} /^MemAvailable:/{a=$2} END{print t" "a}' /proc/meminfo 2>/dev/null)
|
|
is_int "$tot_kb" && [ "$tot_kb" -gt 0 ] && is_int "$av_kb" || return 0
|
|
used_kb=$((tot_kb - av_kb)); [ "$used_kb" -lt 0 ] && used_kb=0
|
|
elif command -v vm_stat >/dev/null 2>&1; then
|
|
local ps free_b
|
|
ps=$(pagesize 2>/dev/null || echo 16384)
|
|
free_b=$(vm_stat 2>/dev/null | awk -v ps="$ps" '
|
|
/Pages free/ {gsub(/[^0-9]/,""); f=$1}
|
|
/Pages inactive/ {gsub(/[^0-9]/,""); n=$1}
|
|
/Pages speculative/ {gsub(/[^0-9]/,""); s=$1}
|
|
END{if (f!="") print (f+n+s)*ps}')
|
|
is_int "$free_b" || return 0
|
|
tot_kb=$(sysctl -n hw.memsize 2>/dev/null)
|
|
is_int "$tot_kb" || return 0
|
|
tot_kb=$((tot_kb / 1024))
|
|
used_kb=$((tot_kb - free_b / 1024)); [ "$used_kb" -lt 0 ] && used_kb=0
|
|
else
|
|
return 0
|
|
fi
|
|
local mpct ug tg mc
|
|
mpct=$((used_kb * 100 / tot_kb))
|
|
ug=$(awk -v k="$used_kb" 'BEGIN{printf "%.1f", k/1048576}')
|
|
tg=$(awk -v k="$tot_kb" 'BEGIN{printf "%.0f", k/1048576}')
|
|
if [ "$mpct" -gt 80 ]; then mc="$C_PINK"
|
|
elif [ "$mpct" -ge 50 ]; then mc="$C_LAV"
|
|
else mc="$C_MEM"; fi
|
|
printf '%s🧠 %s/%sG%s' "$mc" "$ug" "$tg" "$R"
|
|
}
|
|
|
|
############################################
|
|
# Main
|
|
############################################
|
|
case "${1:-}" in
|
|
--refresh) do_refresh; exit $? ;;
|
|
--refresh-bg) refresh_bg_locked; exit 0 ;;
|
|
esac
|
|
|
|
# Statusline mode: drain mcode's stdin JSON, always exit 0.
|
|
payload=$(cat 2>/dev/null || printf '')
|
|
|
|
if [ "$ONLY_GLM" = "1" ]; then
|
|
model=$(printf '%s' "$payload" | jq -r '.model // empty' 2>/dev/null)
|
|
printf '%s' "$model" | grep -qi 'glm' || exit 0
|
|
fi
|
|
|
|
age=$(cache_age || true)
|
|
|
|
# Fresh cache: print it — sub-millisecond, no network, no subprocess storm.
|
|
if [ -n "${age:-}" ] && is_int "$age" && [ "$age" -le "$REFRESH_AFTER" ]; then
|
|
print_segment || true
|
|
exit 0
|
|
fi
|
|
|
|
# Stale/missing: detached refresh now; show still-valid stale data meanwhile.
|
|
spawn_bg_refresh
|
|
if [ -n "${age:-}" ] && is_int "$age" && [ "$age" -le "$MAX_AGE" ]; then
|
|
print_segment || true
|
|
fi
|
|
exit 0
|