diff --git a/bin/rclaude b/bin/rclaude index 81f7303..993f141 100755 --- a/bin/rclaude +++ b/bin/rclaude @@ -1,38 +1,127 @@ #!/bin/sh -# rclaude [dir] +# rclaude — durable Claude Code sessions, local or remote. # -# Durable Claude Code session, local or remote. Two layers of resilience: -# -# 1. tmux on survives terminal/transport drops (network, lid close, -# ssh kill, terminal crash) — works even when is the local box -# because the local terminal can also die independently. +# Two layers of resilience: +# 1. tmux on survives terminal/transport drops. # 2. `claude --continue` resumes the per-directory session from disk after -# anything kills the host itself (reboot, crash, OOM). +# the host itself dies (reboot, crash, OOM). # -# Re-running with the same + always lands you back in the same -# conversation: tmux reattaches if alive, claude --continue picks up from +# Re-running with the same target lands you back in the same conversation: +# tmux reattaches if alive; claude --continue picks up from # ~/.claude/projects// otherwise. # -# can be: -# - any ssh-reachable target (Host alias, user@hostname, IP) -# - "local", "localhost", or the local short/long hostname → no ssh, -# just a local tmux session (still detachable with Ctrl-b d) +# Permission mode: --dangerously-skip-permissions is on by default. Override +# with RCLAUDE_PERMS=default (or any --permission-mode value). # -# Permission mode: --dangerously-skip-permissions is on by default — these -# are sessions on hosts you own. Override with RCLAUDE_PERMS=default (or any -# other --permission-mode value) if you want prompts back. +# Hosts scanned by `list`/`resume` default to: local + apricot. Override with +# RCLAUDE_HOSTS="apricot black quinn-vps". # # Usage: -# rclaude # local, current pwd (shorthand) -# rclaude . # same -# rclaude apricot # remote home dir on apricot -# rclaude apricot ~/Code/@projects/foo # remote, specific dir -# rclaude local . # local, current pwd (explicit) -# rclaude local ~/Code/@projects/foo # local, specific dir -# rclaude $(hostname) ~ # also local (hostname match) +# rclaude # local, $PWD +# rclaude . # local, $PWD +# rclaude # remote $HOME on +# rclaude # remote (or local) at +# rclaude list # show active sessions across hosts +# rclaude resume [pattern] # reattach (interactive if pattern matches >1) set -eu +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +is_local() { + case $1 in + local|localhost|127.0.0.1|::1) return 0 ;; + esac + [ "$1" = "$(hostname)" ] && return 0 + [ "$1" = "$(hostname -s 2>/dev/null)" ] && return 0 + return 1 +} + +# List claude-* tmux sessions on a host. Output: one line per session, +# format: "\t\t" +list_sessions_on() { + _host=$1 + if is_local "$_host"; then + command -v tmux >/dev/null 2>&1 || return 0 # no local tmux: nothing to list + _raw=$(tmux ls 2>/dev/null || true) + else + _raw=$(ssh -o BatchMode=yes -o ConnectTimeout=3 "$_host" 'tmux ls 2>/dev/null' || true) + fi + # tmux ls lines look like: claude-foo: 1 windows (created ...) [80x24] + printf %s "$_raw" | awk -v host="$_host" ' + /^claude-/ { + name=$1; sub(/:$/, "", name); + $1=""; + sub(/^[[:space:]]+/, ""); + printf "%s\t%s\t%s\n", host, name, $0 + } + ' +} + +# All hosts to scan for list/resume. +scan_hosts() { + printf "local\n" + for h in ${RCLAUDE_HOSTS:-apricot}; do + printf "%s\n" "$h" + done +} + +# --------------------------------------------------------------------------- +# Subcommands +# --------------------------------------------------------------------------- + +cmd_list() { + _any=0 + printf "%-10s %-50s %s\n" "HOST" "SESSION" "DETAIL" + scan_hosts | while IFS= read -r h; do + list_sessions_on "$h" | while IFS="$(printf '\t')" read -r host name detail; do + printf "%-10s %-50s %s\n" "$host" "$name" "$detail" + _any=1 + done + done +} + +cmd_resume() { + _pattern=${1:-} + _matches=$(scan_hosts | while IFS= read -r h; do list_sessions_on "$h"; done) + if [ -n "$_pattern" ]; then + _matches=$(printf '%s\n' "$_matches" | grep -F -- "$_pattern" || true) + fi + _count=0 + [ -n "$_matches" ] && _count=$(printf '%s\n' "$_matches" | wc -l | tr -d ' ') + if [ "$_count" -eq 0 ]; then + echo "no matching sessions${_pattern:+ for pattern '$_pattern'}" >&2 + exit 1 + fi + if [ "$_count" -gt 1 ]; then + echo "multiple matches; refine pattern:" >&2 + printf '%s\n' "$_matches" | awk -F'\t' '{printf " %-10s %s\n", $1, $2}' >&2 + exit 1 + fi + _host=$(printf '%s\n' "$_matches" | awk -F'\t' 'NR==1{print $1}') + _name=$(printf '%s\n' "$_matches" | awk -F'\t' 'NR==1{print $2}') + if is_local "$_host"; then + exec tmux attach -t "$_name" + else + exec ssh -t "$_host" tmux attach -t "$_name" + fi +} + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- + +case ${1:-} in + list) shift; cmd_list "$@"; exit ;; + resume) shift; cmd_resume "$@"; exit ;; +esac + +# --------------------------------------------------------------------------- +# Default behavior: launch (or reattach to) a session. +# --------------------------------------------------------------------------- + # Argument resolution: # `rclaude` → local, $PWD # `rclaude .` → local, $PWD @@ -46,15 +135,6 @@ else dir=${2:-} fi -is_local() { - case $1 in - local|localhost|127.0.0.1|::1) return 0 ;; - esac - [ "$1" = "$(hostname)" ] && return 0 - [ "$1" = "$(hostname -s 2>/dev/null)" ] && return 0 - return 1 -} - # Defaults + `.` expansion now that we know whether we're local or remote. if is_local "$host"; then case ${dir:-.} in @@ -79,12 +159,13 @@ case $perms in esac if is_local "$host"; then - # No ssh hop — local tmux. dir is already an absolute path here. + if ! command -v tmux >/dev/null 2>&1; then + echo "rclaude: tmux not installed locally — install via 'brew install tmux' (macOS) or your package manager" >&2 + exit 1 + fi cd "$dir" exec tmux new-session -A -s "$session" "exec claude --continue ${flag}" fi -# Remote: tmux on the other side of an ssh -t. exec replaces the shell so -# the tmux pane dies cleanly when claude exits. inner="cd ${dir} && exec claude --continue ${flag}" exec ssh -t "$host" "tmux new-session -A -s '${session}' \"${inner}\""