commit 9ad073d4fc36ce10772e180a6f207ef33fa21cb1 Author: aygea Date: Mon Oct 19 22:00:17 2026 -0700 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..453f276 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# mcode-status + +Aygea-branded **z.ai GLM usage** segment for the [MiniMax Code](https://codex.minimaxi.com) statusline โ€” shows your current 5-hour token-window burn right in the TUI: + +``` +๐Ÿ”ฅ 23% ยท 4:12p(2h14m) +``` + +Styled with the Aygea palette (blue โ†’ lavender โ†’ pink as the window fills), rendered via mcode's `colorMode: ansi` statusline option, with plain-text fallback. + +## Install (one line) + +```bash +curl -fsSL https://git.itsaygea.com/admin/mcode-sl/raw/branch/main/install.sh | bash +``` + +The installer is self-contained (the statusline script is embedded in it) and: + +1. installs `~/.local/bin/mcode-zai-usage` +2. checks SecretVault for your `zai_api_key` secret (never prints it) +3. merges the `tui:` section into `~/.minimax/config.yaml` โ€” atomic write, timestamped backup, refuses ambiguous structures, everything outside the `tui:` block stays byte-identical +4. primes the quota cache so the segment appears immediately + +Then **restart the MiniMax Code TUI**. + +## Secret setup (once per machine) + +This looks like an example entry, but it's the actual setup: create a SecretVault secret named `zai_api_key` holding your z.ai API key (for `https://api.z.ai`): + +- via the SecretVault MCP `create_secret` tool in any agent session, or your usual SecretVault flow + +No key in the repo, no key in argv, no key in the config file โ€” the statusline reads it at runtime with `secretvault run`. A legacy `~/.secretvault/credential.json` (`url` + `clientKey`, the Claude-statusline layout) also works as a fallback. + +## No cron, no systemd + +MiniMax Code re-runs the command every `intervalSeconds` โ€” mcode *is* the scheduler. The script never blocks the TUI on the network: display comes from a local cache, and a detached, flock-guarded background job refreshes it when the cache is >150s old (sharing `~/.cache/aygea-sl/quota.json` with the Aygea Claude statusline if both are installed). + +## Config (managed in `~/.minimax/config.yaml`) + +```yaml +tui: + statusLine: + - current-dir + - git-branch + - approval-mode + - model-with-reasoning + - context-remaining + - custom-command + customStatusLine: + command: /home/you/.local/bin/mcode-zai-usage + intervalSeconds: 60 + timeoutMs: 10000 + colorMode: ansi +``` + +Installer env overrides: `MCODE_SL_ITEMS` (item list), `MCODE_SL_INTERVAL`, `MCODE_SL_TIMEOUT`, `MCODE_SL_SECRET`. + +## Script env tunables + +| Variable | Default | Meaning | +|---|---|---| +| `AYGEA_SL_SECRET_NAME` | `zai_api_key` | SecretVault secret name | +| `AYGEA_SL_URL` | `https://api.z.ai` | quota API base URL | +| `AYGEA_SL_NO_VAULT=1` | off | skip SecretVault, use credential.json | +| `AYGEA_SL_QUOTA_CACHE` | `~/.cache/aygea-sl/quota.json` | cache path | +| `AYGEA_SL_REFRESH_AFTER` | `150` | refresh when cache older (sec) | +| `AYGEA_SL_MAX_AGE` | `360` | hide when cache older (sec) | +| `AYGEA_SL_CURL_TIMEOUT` | `6` | per-fetch curl `--max-time` | +| `AYGEA_SL_ONLY_GLM=1` | off | show only for glm models | +| `AYGEA_SL_MEM=1` | off | prepend an ๐Ÿง  memory segment (Linux/macOS) | +| `NO_COLOR` | โ€” | plain text output | + +## Uninstall + +```bash +curl -fsSL https://git.itsaygea.com/admin/mcode-sl/raw/branch/main/install.sh | bash -s -- --uninstall +``` + +## Development + +Requires `jq` + `curl`; `secretvault` optional (fallback: credential.json); `python3` for the config merge. Test locally: + +```bash +printf '{}' | ./zai-usage.sh # statusline preview +./zai-usage.sh --refresh # one foreground refresh +``` + +`install.sh` embeds a copy of `zai-usage.sh` for `curl | bash` installs; checkout installs prefer the sibling file. After editing `zai-usage.sh`, regenerate the embedded copy: + +```bash +python3 - <<'EOF' +import re +src = open('zai-usage.sh').read(); assert src.endswith("\n") +ins = open('install.sh').read() +new = re.sub(r"(cat <<'ZAI_EMBED_EOF'\n).*?ZAI_EMBED_EOF\n", + lambda m: m.group(1) + src + "ZAI_EMBED_EOF\n", ins, flags=re.S) +open('install.sh', 'w').write(new) +EOF +``` diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..c4fd76b --- /dev/null +++ b/install.sh @@ -0,0 +1,653 @@ +#!/usr/bin/env bash +# +# mcode-status installer โ€” Aygea z.ai GLM usage segment for MiniMax Code. +# +# bash install.sh install / update from a repo checkout +# curl -fsSL /install.sh | bash install from anywhere (piped) +# bash install.sh --uninstall remove script + config keys +# +# What it does: +# 1. installs zai-usage.sh -> ~/.local/bin/mcode-zai-usage +# (piped mode uses the embedded copy; checkout mode prefers the +# sibling zai-usage.sh so edits apply without re-embedding) +# 2. checks SecretVault for the $MCODE_SL_SECRET secret (default +# zai_api_key) โ€” the key itself is never printed or stored here +# 3. surgically merges the tui section into ~/.minimax/config.yaml: +# atomic write (temp + fsync + rename), timestamped backup taken +# BEFORE any change, duplicate-structure refusal, everything outside +# the tui block preserved byte-for-byte +# 4. primes the quota cache with one bounded foreground fetch +# +# No cron/systemd needed: MiniMax Code re-runs the command every +# intervalSeconds itself, and the script refreshes its cache in a detached +# background job when it goes stale. +# +# Secret setup (once per machine) โ€” looks like an example entry, isn't: +# secret name: zai_api_key (z.ai API key for https://api.z.ai) +# via the SecretVault MCP `create_secret` tool, or your usual flow. +# +# Env overrides: +# MCODE_SL_SECRET vault secret name (default zai_api_key) +# MCODE_SL_ITEMS comma list of statusline items (see DEFAULT_ITEMS) +# MCODE_SL_INTERVAL intervalSeconds (default 60; mcode clamps to >=10) +# MCODE_SL_TIMEOUT timeoutMs (default 10000; clamped 500..30000) + +set -u +export LC_ALL=C + +DEFAULT_ITEMS="current-dir,git-branch,approval-mode,model-with-reasoning,context-remaining,custom-command" +ITEMS="${MCODE_SL_ITEMS:-$DEFAULT_ITEMS}" +INTERVAL="${MCODE_SL_INTERVAL:-60}" +TIMEOUT="${MCODE_SL_TIMEOUT:-10000}" +SECRET_NAME="${MCODE_SL_SECRET:-zai_api_key}" + +BIN_DIR="${HOME}/.local/bin" +DEST="${BIN_DIR}/mcode-zai-usage" +CONFIG="${HOME}/.minimax/config.yaml" + +mode=install +[ "${1:-}" = "--uninstall" ] && mode=uninstall + +info() { printf ' %s\n' "$1"; } +die() { printf 'install: %s\n' "$1" >&2; exit 1; } +have() { command -v "$1" >/dev/null 2>&1; } + +# Prefer a sibling zai-usage.sh (checkout mode); otherwise extract embedded. +SCRIPT_SOURCE="" +_self="${BASH_SOURCE[0]:-}" +if [ -n "$_self" ] && [ -f "$_self" ]; then + _dir="$(cd "$(dirname "$_self")" && pwd)" + [ -f "$_dir/zai-usage.sh" ] && SCRIPT_SOURCE="$_dir/zai-usage.sh" +fi + +materialize_script() { + local target="$1" + if [ -n "$SCRIPT_SOURCE" ]; then + install -m 755 "$SCRIPT_SOURCE" "$target" + else + embed_zai_usage > "$target.tmp.$$" + chmod 755 "$target.tmp.$$" + mv -f "$target.tmp.$$" "$target" + fi +} + +# --------------------------------------------------------------------------- +# Embedded copy of zai-usage.sh โ€” fallback for `curl | bash` installs. +# Checkout installs prefer the sibling file. After editing zai-usage.sh, +# regenerate this block (command in README). +# --------------------------------------------------------------------------- +embed_zai_usage() { + cat <<'ZAI_EMBED_EOF' +#!/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 2>&1 /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 +ZAI_EMBED_EOF +} + +############################################ +# Config merge โ€” python3 stdlib line surgery with hard safety rails: +# * refuses ambiguous structures (multiple tui blocks / duplicate keys) +# * validates the result (structural checks + PyYAML when available) +# * atomic commit: temp file + fsync + os.replace, mode preserved (0600 new) +# * timestamped backup taken BEFORE the first modification +# * everything outside the tui block byte-identical (incl. trailing NL) +############################################ +merge_config() { + have python3 || die "python3 required. Add this to $CONFIG manually: +tui: + statusLine: [$ITEMS] + customStatusLine: {command: $DEST, intervalSeconds: $INTERVAL, timeoutMs: $TIMEOUT, colorMode: ansi}" + python3 - "$CONFIG" "$DEST" "$ITEMS" "$INTERVAL" "$TIMEOUT" "$mode" <<'PY' +import sys, re, os, time, shutil + +cfg_path, cmd, items_csv, interval, timeout, mode = sys.argv[1:7] +items = [i.strip() for i in items_csv.split(",") if i.strip()] + +try: + with open(cfg_path, encoding="utf-8") as f: + text = f.read() +except FileNotFoundError: + text = "" +lines = text.splitlines() +had_nl = text.endswith("\n") if text else True + +TOP = re.compile(r"^([A-Za-z_][A-Za-z0-9_-]*):\s*(#.*)?$") +TUI = re.compile(r"^tui:\s*(#.*)?$") + +def die(msg): + print("merge-refused: " + msg, file=sys.stderr) + sys.exit(3) + +# --- guard: refuse ambiguous structures rather than guess --- +tui_idxs = [i for i, l in enumerate(lines) if TUI.match(l)] +if len(tui_idxs) > 1: + die("multiple top-level 'tui:' blocks โ€” merge them manually first") + +def block_bounds(start): + end = next((j for j in range(start + 1, len(lines)) if TOP.match(lines[j])), len(lines)) + return start + 1, end + +if tui_idxs: + a, b = block_bounds(tui_idxs[0]) + for key in ("statusLine", "customStatusLine"): + n = sum(1 for l in lines[a:b] if re.match(r"^ %s:\s*(#.*)?$" % key, l)) + if n > 1: + die("multiple '%s:' keys inside tui block โ€” clean up manually first" % key) + +def custom_block(): + return [" customStatusLine:", + f" command: {cmd}", + f" intervalSeconds: {interval}", + f" timeoutMs: {timeout}", + " colorMode: ansi"] + +def status_block(): + return [" statusLine:"] + [f" - {i}" for i in items] + +def validate(new_lines): + ti = next((i for i, l in enumerate(new_lines) if TUI.match(l)), None) + if ti is not None: + a = ti + 1 + b = next((j for j in range(a, len(new_lines)) if TOP.match(new_lines[j])), len(new_lines)) + block = new_lines[a:b] + if any(l.startswith("\t") for l in block): + die("tab indentation inside tui block") + keys2 = [m.group(1) for l in block + if (m := re.match(r"^ ([A-Za-z_][A-Za-z0-9_-]*):", l))] + if len(keys2) != len(set(keys2)): + die("duplicate keys inside tui block: %s" % keys2) + tops = [m.group(1) for l in new_lines if (m := TOP.match(l))] + if len(tops) != len(set(tops)): + die("duplicate top-level keys after merge: %s" % sorted(set(tops))) + try: + import yaml + yaml.safe_load("\n".join(new_lines)) + except ImportError: + pass + except Exception as e: + die("merged YAML failed PyYAML validation: %s" % e) + +def commit(new_lines, label): + validate(new_lines) + data = "\n".join(new_lines) + ("\n" if had_nl or not new_lines else "") + existed = os.path.exists(cfg_path) + st = os.stat(cfg_path) if existed else None + tmp = "%s.tmp.%d" % (cfg_path, os.getpid()) + try: + with open(tmp, "w", encoding="utf-8") as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + os.chmod(tmp, (st.st_mode & 0o7777) if st else 0o600) + msg = label + if existed: + bak = "%s.bak.%s" % (cfg_path, time.strftime("%Y%m%d-%H%M%S")) + shutil.copy2(cfg_path, bak) + msg += " (backup: %s)" % os.path.basename(bak) + os.replace(tmp, cfg_path) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + print(msg) + +if mode in ("remove", "uninstall"): + if not tui_idxs: + print("noop-no-tui"); sys.exit(0) + a, b = block_bounds(tui_idxs[0]) + block, dropped = [], False + i, in_custom = a, False + while i < b: + l = lines[i] + if re.match(r"^ customStatusLine:", l): + in_custom, dropped = True, True + i += 1; continue + if in_custom: + if re.match(r"^ \S", l): + in_custom = False + else: + i += 1; continue + block.append(l); i += 1 + res, kept_any, dropped_item = [], False, False + i, in_items = 0, False + while i < len(block): + l = block[i] + if re.match(r"^ statusLine:", l): + in_items = True; res.append(l); i += 1; continue + if in_items: + m = re.match(r"^ - (.+)$", l) + if m: + if m.group(1).strip() == "custom-command": + dropped_item = True; i += 1; continue + kept_any = True; res.append(l); i += 1; continue + if re.match(r"^ \S", l) or TOP.match(l): + in_items = False + if not kept_any: + res.pop() + res.append(l); i += 1 + if not dropped and not dropped_item: + print("noop-clean"); sys.exit(0) + lines[tui_idxs[0] + 1:b] = res + commit(lines, "removed") + sys.exit(0) + +# ---- install / update ---- +if not tui_idxs: + if lines: + lines.append("") + lines += ["tui:"] + status_block() + custom_block() + commit(lines, "created-tui") + sys.exit(0) + +ti = tui_idxs[0] +a, b = block_bounds(ti) +block = lines[a:b] +dirty = False + +sl_idx = next((k for k, l in enumerate(block) if re.match(r"^ statusLine:", l)), None) +if sl_idx is None: + block = status_block() + block + dirty = True +else: + end = sl_idx + 1 + while end < len(block) and re.match(r"^ - ", block[end]): + end += 1 + have_items = [re.sub(r"^ - ", "", block[k]).strip() for k in range(sl_idx + 1, end)] + if "custom-command" not in have_items: + block.insert(end, " - custom-command") + dirty = True +for l in block: + if re.match(r"^ - build-mode\s*$", l): + print("WARN: statusLine contains build-mode โ€” mcode disables the custom command then.", file=sys.stderr) + break + +cs_idx = next((k for k, l in enumerate(block) if re.match(r"^ customStatusLine:", l)), None) +if cs_idx is None: + block += custom_block() + dirty = True +else: + cend = cs_idx + 1 + while cend < len(block) and re.match(r"^ ", block[cend]): + cend += 1 + def set_val(key, val): + for k in range(cs_idx + 1, cend): + m = re.match(r"^(\s+%s:\s*).*$" % key, block[k]) + if m: + block[k] = f"{m.group(1)}{val}" + return True + return False + for key, val in (("command", cmd), ("intervalSeconds", interval), + ("timeoutMs", timeout), ("colorMode", "ansi")): + if not set_val(key, val): + block.insert(cend, f" {key}: {val}") + cend += 1 + dirty = True + +if not dirty: + print("already-configured") +else: + lines[a:b] = block + commit(lines, "updated") +PY +} + +############################################ +# Uninstall +############################################ +if [ "$mode" = "uninstall" ]; then + rm -f "$DEST" && info "removed $DEST" + if [ -f "$CONFIG" ]; then + merge_config || die "config merge refused โ€” file left untouched" + fi + printf 'Done.\n' + exit 0 +fi + +############################################ +# Install +############################################ +for dep in jq curl; do + have "$dep" || info "WARN: '$dep' not found โ€” segment stays hidden until it is installed." +done + +# SecretVault presence check โ€” never prints the secret itself. +if have secretvault; then + if secretvault run --secret "$SECRET_NAME" -- true >/dev/null 2>&1; then + info "SecretVault: secret '$SECRET_NAME' found" + else + info "WARN: SecretVault has no readable secret '$SECRET_NAME' yet." + info " Create it (z.ai API key) via the SecretVault create_secret tool;" + info " until then the segment stays hidden (credential.json also works)." + fi +else + info "WARN: secretvault CLI not found โ€” falling back to ~/.secretvault/credential.json." +fi + +mkdir -p "$BIN_DIR" +materialize_script "$DEST" +info "installed $DEST" + +if [ ! -d "$(dirname "$CONFIG")" ]; then + mkdir -p "$(dirname "$CONFIG")" +fi +action=$(merge_config) || die "config merge refused โ€” file left untouched (reason above)" +info "config: $action" + +if "$DEST" --refresh 2>/dev/null; then + info "quota cache primed: $(printf '{}' | "$DEST" 2>/dev/null || echo '(hidden)')" +else + info "cache prime skipped (offline or key not set) โ€” self-heals on the next interval" +fi + +cat </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 2>&1 /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