From 5a1af9f7e6d85216bfafe168e092ca6aab3048f9 Mon Sep 17 00:00:00 2001 From: smoido Date: Sat, 12 Sep 2026 21:58:58 +0300 Subject: [PATCH] Run on macOS: bash 3.2, arrow keys, and honest session reporting macOS ships bash 3.2, and the port used three things it does not have: mapfile, fractional `read -t`, and GNU `stat -c`. The first two took the interactive menu down - `mapfile: command not found` in the preset picker, and an arrow key that parsed as a bare Escape, leaving `[C` in the tty for zsh to report as a bad pattern. read_key now reads the escape tail through the terminal itself: icanon off with min 0 / time 1, so a plain read returns the moment a byte arrives and gives up after ~0.1s. bash 3.2's `read -t 0` cannot be used to poll for this - it reports nothing even with bytes buffered (checked against 3.2.57). CSI sequences are consumed whole, so modified keys like Ctrl+Right no longer leak their tail either. Verified interactively under bash 3.2.57 in a pty: arrows, pgup/pgdn, home/end, enter, backspace, and a bare Escape all parse, with no stray bytes in the tty. Session listing reads /proc, which macOS does not have; it now says so rather than silently reporting no running sessions - the answer that gets people to switch out from under a live session. install.sh swaps GNU `find -print -quit` for a glob, and the README notes both. Co-Authored-By: Claude Code --- README.md | 9 ++++ linux/claude-mode | 104 ++++++++++++++++++++++++++++++++++++++++------ linux/install.sh | 9 ++-- 3 files changed, 107 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index d0660a3..0ea345a 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,15 @@ Secrets have no DPAPI equivalent here, so the vault picks the best backend available and says which one it chose: macOS Keychain, libsecret, `pass`, or a 0600 file that is honestly labelled as unencrypted. +It runs on the **bash 3.2 that macOS ships** — no Homebrew bash needed. The +menu reads arrow keys through the terminal's own timing (`min 0 time 1`) rather +than a fractional `read` timeout, which 3.2 rejects, and the port avoids +`mapfile` and GNU-only `stat`/`find` flags for the same reason. + +Session listing reads `/proc`, so it is **Linux-only**. On macOS a switch says +it cannot check for running sessions instead of claiming there are none — so +restart Claude Code yourself after switching. + ### Omarchy bar widget ```bash diff --git a/linux/claude-mode b/linux/claude-mode index 847632c..902c2fe 100755 --- a/linux/claude-mode +++ b/linux/claude-mode @@ -631,8 +631,15 @@ cm_self_session() { } # TSV: pid \t ppid \t tty \t busy \t cwd \t self \t parent-cmd +# Session discovery reads /proc, so it is Linux-only. Elsewhere the tool +# cannot see running sessions at all - which has to be said rather than +# silently reported as "none running", since that is the answer that gets +# people to switch out from under a live session. +cm_sessions_supported() { [ -d /proc/self ]; } + cm_session_rows() { local self pid ppid tty cwd busy isself d + cm_sessions_supported || return 0 self="$(cm_self_session 2>/dev/null || true)" local pids=() before=() after=() pp @@ -700,6 +707,13 @@ cmd_sessions() { return 0 fi + if ! cm_sessions_supported; then + head_ 'running sessions' + warn 'session control needs /proc, so it is Linux-only' + say 'on macOS, restart Claude Code yourself after a switch' + return 0 + fi + if [ "${n:-0}" -eq 0 ]; then head_ 'running sessions' ok 'no Claude Code sessions running' @@ -1481,21 +1495,74 @@ reapply_if_active() { ui_interactive() { [ -t 0 ] && [ -t 1 ]; } -# Read one keypress, normalised to a word. +# Read one keypress, normalised to a word. Escape sequences are consumed +# whole: leaving `[C` behind in the tty is what made zsh report +# "bad pattern: [C" after the menu exited. +# Read the bytes that follow an Escape, up to two of them, and print them. +# +# There is no portable short-timeout `read` here. bash 3.2 - which is what +# macOS ships - rejects fractional timeouts outright, and its `read -t 0` +# availability poll reports nothing even when bytes are sitting in the buffer +# (verified against 3.2.57), so polling cannot be used to tell a bare Escape +# from the start of a sequence. +# +# On a tty the terminal itself answers this: with icanon off and min 0 / +# time 1, a plain read returns the moment a byte arrives and gives up after +# ~0.1s otherwise. So Escape costs 0.1s, not the full second `-t 1` would +# take. Off a tty there is no such knob, so that path falls back to `-t 1`. +cm_read_esc_tail() { + local save out='' a raw=0 + if [ -t 0 ] && save="$(stty -g 2>/dev/null)"; then + stty -echo -icanon min 0 time 1 2>/dev/null + raw=1 + fi + + if [ "$raw" -eq 1 ]; then IFS= read -r a 2>/dev/null; else + IFS= read -rsn1 -t 1 a 2>/dev/null || a=''; fi + out="$a" + + case "$out" in + 'O') # SS3 - one more byte is the key itself + if [ "$raw" -eq 1 ]; then IFS= read -r a 2>/dev/null; + else IFS= read -rsn1 -t 1 a 2>/dev/null || a=''; fi + out="$out$a" ;; + '[') # CSI - parameters, then one final byte. Consume the lot: a + # half-eaten sequence is what leaves `[C` in the tty for the + # shell to report as a globbing error. + while [ "${#out}" -lt 8 ]; do + if [ "$raw" -eq 1 ]; then IFS= read -r a 2>/dev/null; + else IFS= read -rsn1 -t 1 a 2>/dev/null || a=''; fi + [ -n "$a" ] || break + out="$out$a" + case "$a" in + [0-9]|';'|'?'|'<'|'>'|'='|':'|' ') continue ;; + *) break ;; + esac + done ;; + esac + + [ "$raw" -eq 1 ] && stty "$save" 2>/dev/null + printf '%s' "$out" +} + read_key() { - local k rest extra + local k IFS= read -rsn1 k 2>/dev/null || return 1 if [ "$k" = $'\033' ]; then - IFS= read -rsn2 -t 0.05 rest 2>/dev/null || rest='' - case "$rest" in + local tail; tail="$(cm_read_esc_tail)" + case "$tail" in '[A') printf 'up' ;; '[B') printf 'down' ;; '[C') printf 'right' ;; '[D') printf 'left' ;; '[H') printf 'home' ;; '[F') printf 'end' ;; - '[5') IFS= read -rsn1 -t 0.05 extra 2>/dev/null; printf 'pgup' ;; - '[6') IFS= read -rsn1 -t 0.05 extra 2>/dev/null; printf 'pgdn' ;; + '[5~') printf 'pgup' ;; + '[6~') printf 'pgdn' ;; + 'OA') printf 'up' ;; # SS3 variants, cursor mode + 'OB') printf 'down' ;; + 'OC') printf 'right' ;; + 'OD') printf 'left' ;; '') printf 'esc' ;; *) printf 'other' ;; esac @@ -1649,7 +1716,8 @@ ui_pick_preset() { local provider="$1" def names name UI_PICKED='' def="$(default_preset_for "$provider")" - mapfile -t names < <(presets_for_provider "$provider") + names=() + while IFS= read -r name; do names+=("$name"); done < <(presets_for_provider "$provider") [ "${#names[@]}" -gt 0 ] || { err "no presets for '$provider'"; return 1; } ui_reset_items @@ -1707,10 +1775,10 @@ ui_pick_model() { ui_edit_preset() { local name="${1:-}" if [ -z "$name" ]; then - local names; mapfile -t names < <(preset_names) + local names=() n + while IFS= read -r n; do names+=("$n"); done < <(preset_names) [ "${#names[@]}" -gt 0 ] || { warn 'no presets'; return; } ui_reset_items - local n for n in "${names[@]}"; do ui_add_item "$(printf '%-18s [%s]' "$n" "$(jget "$(preset_path "$n")" provider)")" \ "$(preset_summary_line "$(preset_path "$n")")" @@ -1746,10 +1814,10 @@ ui_new_preset() { ui_select 'new preset - which provider' '' || return local provider="${provs[$UI_SEL]}" - local sibs; mapfile -t sibs < <(presets_for_provider "$provider") + local sibs=() s + while IFS= read -r s; do sibs+=("$s"); done < <(presets_for_provider "$provider") ui_reset_items ui_add_item '' "empty $provider preset - pick every model yourself" - local s for s in "${sibs[@]}"; do ui_add_item "copy of $s" "$(preset_summary_line "$(preset_path "$s")")"; done ui_select 'start from' '' || return local choice=$UI_SEL @@ -2069,6 +2137,12 @@ cm_confirm_sessions() { CM_SESSION_ACTION=none CM_SESSION_ROWS='' + if ! cm_sessions_supported; then + printf '\n' + warn 'cannot list running sessions here (no /proc) - the switch will not wait for them' + return 0 + fi + rows="$(cm_session_rows)" n="$(printf '%s' "$rows" | grep -c . || true)" [ "${n:-0}" -gt 0 ] || return 0 @@ -2143,6 +2217,12 @@ cm_project_dir() { printf '%s/projects/%s' "$CM_SETTINGS_DIR" "$(printf '%s' "${1:-$PWD}" | sed 's|/|-|g')" } +# Modification time as an epoch second. GNU stat spells it -c %Y, BSD/macOS +# stat spells it -f %m. +cm_file_mtime() { + stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0 +} + cmd_repair_session() { local target='' apply=0 reinject=1 scan_all=0 as_json=0 dir='' a file verdict age for a in "$@"; do @@ -2239,7 +2319,7 @@ print(' %-40s %s' % (os.path.basename(d['path'])[:-6], state)) # A transcript that is still being appended to belongs to a session that is # still alive; truncating it underneath a running process helps nobody. - age=$(( $(date +%s) - $(stat -c %Y "$file" 2>/dev/null || echo 0) )) + age=$(( $(date +%s) - $(cm_file_mtime "$file") )) if [ "$age" -lt 90 ] && [ "$apply" -eq 1 ]; then err "that transcript was written to ${age}s ago - it looks live" say 'close the session that owns it first' diff --git a/linux/install.sh b/linux/install.sh index 994c472..6ba044c 100755 --- a/linux/install.sh +++ b/linux/install.sh @@ -37,9 +37,12 @@ if [ ! -f "$SRC/claude-mode" ] || [ ! -f "$SRC/cm-json.py" ]; then TMP="$(mktemp -d "${TMPDIR:-/tmp}/claude-mode-src.XXXXXX")" printf 'no local checkout found - fetching source from %s\n' "$REPO_URL" curl -fsSL "$REPO_URL/archive/master.tar.gz" | tar -xz -C "$TMP" - EXTRACTED="$(find "$TMP" -type f -name claude-mode -print -quit)" - [ -n "$EXTRACTED" ] || { fail 'archive did not contain linux/claude-mode - repo layout changed?'; exit 1; } - SRC="$(dirname "$EXTRACTED")" + # a plain glob rather than find -print -quit: BSD find (macOS) has no -quit + SRC='' + for _c in "$TMP"/*/linux/claude-mode "$TMP"/linux/claude-mode; do + [ -f "$_c" ] && { SRC="$(dirname "$_c")"; break; } + done + [ -n "$SRC" ] || { fail 'archive did not contain linux/claude-mode - repo layout changed?'; exit 1; } fi printf '\ninstalling claude-mode -> %s\n' "$ROOT"