rclaude: add list and resume subcommands
list: tabular view of active claude-* tmux sessions across local + RCLAUDE_HOSTS resume [pattern]: substring match → unique attach, multi → list+exit, none → error also: graceful fallback when local tmux is missing
This commit is contained in:
parent
ebf578dc3f
commit
29dcfce7b6
1 changed files with 116 additions and 35 deletions
151
bin/rclaude
151
bin/rclaude
|
|
@ -1,38 +1,127 @@
|
|||
#!/bin/sh
|
||||
# rclaude <host> [dir]
|
||||
# rclaude — durable Claude Code sessions, local or remote.
|
||||
#
|
||||
# Durable Claude Code session, local or remote. Two layers of resilience:
|
||||
#
|
||||
# 1. tmux on <host> survives terminal/transport drops (network, lid close,
|
||||
# ssh kill, terminal crash) — works even when <host> is the local box
|
||||
# because the local terminal can also die independently.
|
||||
# Two layers of resilience:
|
||||
# 1. tmux on <host> 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 <host> + <dir> 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/<encoded-cwd>/ otherwise.
|
||||
#
|
||||
# <host> 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 <host> # remote $HOME on <host>
|
||||
# rclaude <host> <dir> # remote (or local) at <dir>
|
||||
# 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: "<host>\t<session_name>\t<rest_from_tmux_ls>"
|
||||
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}\""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue