diff --git a/bin/rclaude b/bin/rclaude index 07b0781..2b5f117 100755 --- a/bin/rclaude +++ b/bin/rclaude @@ -320,6 +320,41 @@ claude_slug() { printf %s "$1" | sed 's|[^A-Za-z0-9]|-|g' } +# POSIX single-quote escape — safe for embedding arbitrary user-provided +# strings inside an outer single-quoted context (e.g. ssh ''). +# sh_quote "" → '' +# sh_quote "hello" → 'hello' +# sh_quote "it's" → 'it'\''s' +# The classic '\'' trick: close the open quote, escape a literal ', reopen. +sh_quote() { + printf "'%s'" "$(printf %s "$1" | sed "s/'/'\\\\''/g")" +} + +# Row filter for the send subcommand. Reads TSV rows on stdin +# (host \t kind \t name/slug \t detail \t [cwd]) and emits the subset +# matching with . Disk rows are always dropped — you +# can't `tmux send-keys` to a session that isn't running. +# +# selector = all → every tmux row (pattern ignored) +# selector = host → tmux rows where col 1 == pattern +# selector = match → tmux rows where pattern (case-insensitive +# substring) appears in col 3 (session +# name slug) OR col 5 (cwd, if present) +filter_targets() { + _selector=$1 + _pattern=$2 + awk -F'\t' -v sel="$_selector" -v pat="$_pattern" ' + BEGIN { IGNORECASE = 1 } + $2 != "tmux" { next } + sel == "all" { print; next } + sel == "host" { if ($1 == pat) print; next } + sel == "match" { + if (index(tolower($3), tolower(pat)) > 0) { print; next } + if (NF >= 5 && index(tolower($5), tolower(pat)) > 0) { print; next } + } + ' +} + # Dedupe session rows (col 2 == "session") across hosts by UUID (col 3), # keeping the row with the highest mtime (col 6). Output sorted desc by mtime. dedupe_sessions() { @@ -1288,6 +1323,74 @@ cmd_version() { fi } +cmd_send() { + # Broadcast a prompt to live rclaude tmux sessions matching a selector. + # Useful for batch-prompting agents you have running across hosts. + # + # rclaude send "fix the build" → all live rclaude tmuxes + # rclaude send --on apricot "context update" → only apricot + # rclaude send --match lilith "rebase main" → tmux name or cwd matches + # rclaude send --no-enter "draft only..." → don't press Enter after + # rclaude send --dry-run "..." → show targets, don't send + _selector=all + _pattern="" + _enter=1 + _dry=0 + _text="" + while [ $# -gt 0 ]; do + case $1 in + --on) shift; _selector=host; _pattern=${1:-}; shift ;; + --on=*) _selector=host; _pattern=${1#--on=}; shift ;; + --match) shift; _selector=match; _pattern=${1:-}; shift ;; + --match=*) _selector=match; _pattern=${1#--match=}; shift ;; + --no-enter) _enter=0; shift ;; + --dry-run|-n) _dry=1; shift ;; + --) shift; _text=$*; break ;; + -h|--help) + cat <|--match ] [--no-enter] [--dry-run] + +Broadcasts to every live rclaude tmux session matching the selector +via 'tmux send-keys'. Disk-only sessions are skipped (can't send-keys to +something not running). +EOF + return 2 ;; + *) + if [ -z "$_text" ]; then _text=$1 + else _text="$_text $1" + fi + shift ;; + esac + done + if [ -z "$_text" ]; then + echo "rclaude send: missing text. See 'rclaude send --help'" >&2 + exit 2 + fi + _rows=$(scan_hosts | while IFS= read -r _h; do list_tmux_on "$_h"; done) + _targets=$(printf '%s\n' "$_rows" | filter_targets "$_selector" "$_pattern") + if [ -z "$_targets" ]; then + echo "rclaude send: no matching live tmux sessions" >&2 + exit 1 + fi + _count=$(printf '%s\n' "$_targets" | wc -l | tr -d ' ') + printf 'rclaude send: %s target(s)\n' "$_count" >&2 + printf '%s\n' "$_targets" | while IFS=$(printf '\t') read -r _h _kind _name _rest; do + printf ' → %s : %s\n' "$_h" "$_name" >&2 + [ "$_dry" = "1" ] && continue + _qtext=$(sh_quote "$_text") + _qname=$(sh_quote "$_name") + if is_local "$_h"; then + tmux send-keys -t "$_name" -l "$_text" + [ "$_enter" = "1" ] && tmux send-keys -t "$_name" Enter + else + ssh -o BatchMode=yes -o ConnectTimeout=5 "$_h" \ + "tmux send-keys -t $_qname -l $_qtext" + [ "$_enter" = "1" ] && \ + ssh -o BatchMode=yes "$_h" "tmux send-keys -t $_qname Enter" + fi + done +} + cmd_voice() { # `rclaude voice` — toggle / inspect the rvoice push-to-talk binding. # rvoice itself is a separate script (bin/rvoice) driven by Hammerspoon. @@ -1362,6 +1465,7 @@ case ${1:-} in send) shift; cmd_send "$@"; exit ;; setup|install) shift; cmd_setup "$@"; exit ;; voice) shift; cmd_voice "$@"; exit ;; + send) shift; cmd_send "$@"; exit ;; -v|--version) cmd_version; exit ;; -h|--help|help) cmd_help; exit ;; esac