feat(@scripts): add remote host resolution helper

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-17 18:42:31 -07:00
parent 45daeb61ba
commit 1c575ad263
3 changed files with 172 additions and 23 deletions

View file

@ -1006,6 +1006,30 @@ cmd_resume() {
# Dispatch # Dispatch
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Resolve the hostname THIS machine is reachable at from the remote. Used
# to tell the remote claude session where to forward audio/state back to.
# Override with RCLAUDE_BACK_HOST in config (e.g. if the local hostname
# isn't directly reachable from the remote — pick a wg1 mesh IP / .lan name).
caller_hostname() {
if [ -n "${RCLAUDE_BACK_HOST:-}" ]; then
printf %s "$RCLAUDE_BACK_HOST"
return
fi
_hn=$(hostname -s 2>/dev/null || hostname)
case $_hn in
*.*) printf %s "$_hn" ;;
*) printf '%s.lan' "$_hn" ;;
esac
}
# Guard: when sourced as a library (by tests/run-tests.sh), skip dispatch
# so callers can invoke individual helpers without launching anything.
# MUST be placed after every helper definition so all functions are
# available to the sourcing test runner.
if [ "${RCLAUDE_LIB_ONLY:-0}" = "1" ]; then
return 0 2>/dev/null || exit 0
fi
cmd_version() { cmd_version() {
_self=$(resolve_self) _self=$(resolve_self)
_repo=$(cd "$(dirname "$_self")/.." 2>/dev/null && pwd) _repo=$(cd "$(dirname "$_self")/.." 2>/dev/null && pwd)
@ -1020,12 +1044,6 @@ cmd_version() {
fi fi
} }
# Guard: when sourced as a library (by tests/run-tests.sh), skip dispatch
# so callers can invoke individual helpers without launching anything.
if [ "${RCLAUDE_LIB_ONLY:-0}" = "1" ]; then
return 0 2>/dev/null || exit 0
fi
cmd_voice() { cmd_voice() {
# `rclaude voice` — toggle / inspect the rvoice push-to-talk binding. # `rclaude voice` — toggle / inspect the rvoice push-to-talk binding.
# rvoice itself is a separate script (bin/rvoice) driven by Hammerspoon. # rvoice itself is a separate script (bin/rvoice) driven by Hammerspoon.
@ -1167,8 +1185,16 @@ build_inner() {
elif [ "${RCLAUDE_RESUME:-0}" = "1" ]; then elif [ "${RCLAUDE_RESUME:-0}" = "1" ]; then
_resume_flag="--continue" _resume_flag="--continue"
fi fi
# When launching on a remote host, tell its MCPs where to forward
# audio back to (so apricot's TTS plays on the local Mac, etc.). When
# local, leave the env alone — local MCPs play locally.
_back_env=""
if ! is_local "$host"; then
_back=$(caller_hostname)
_back_env="export SPEECH_PLAYBACK_HOST=${_back}; "
fi
printf '%s' \ printf '%s' \
"cd ${1} && rc_t=\$(date +%s); claude ${_resume_flag} ${flag}; rc_ec=\$?; " \ "${_back_env}cd ${1} && rc_t=\$(date +%s); claude ${_resume_flag} ${flag}; rc_ec=\$?; " \
"rc_e=\$(date +%s); rc_d=\$((rc_e - rc_t)); " \ "rc_e=\$(date +%s); rc_d=\$((rc_e - rc_t)); " \
"if [ \$rc_ec -ne 0 ] || [ \$rc_d -lt 2 ]; then " \ "if [ \$rc_ec -ne 0 ] || [ \$rc_d -lt 2 ]; then " \
"printf '\\n[rclaude] claude exited in %ds with code %d\\n' \$rc_d \$rc_ec; " \ "printf '\\n[rclaude] claude exited in %ds with code %d\\n' \$rc_d \$rc_ec; " \

View file

@ -13,6 +13,15 @@
-- Behavior: hold Right-Option to talk. Release to transcribe + inject into -- Behavior: hold Right-Option to talk. Release to transcribe + inject into
-- the active iTerm2 tab's remote tmux session. Taps shorter than 200ms are -- the active iTerm2 tab's remote tmux session. Taps shorter than 200ms are
-- ignored (configurable via RVOICE_MIN_MS env in rvoice config). -- ignored (configurable via RVOICE_MIN_MS env in rvoice config).
--
-- Visual feedback (in order, from least to most intrusive):
-- 1. Menu-bar dot — gray idle, red filled while recording, yellow during
-- transcription (a few hundred ms). Always present so you know rvoice
-- is loaded.
-- 2. Floating "● REC" overlay — top-center of the focused screen, only
-- visible while the key is held. Goes away on release.
-- 3. Transcript toast — short-lived alert with the recognized text after
-- a successful injection, or an error message on failure.
local M = {} local M = {}
@ -44,45 +53,142 @@ local function isDisabled()
if f then f:close(); return true end if f then f:close(); return true end
return false return false
end end
if isDisabled() then
hs.alert.show("rvoice: disabled (rclaude voice on to re-enable)") -- ──────────────────────────────────────────────────────────────────────────
return {} -- Visual feedback
-- ──────────────────────────────────────────────────────────────────────────
-- Menu-bar status dot. Always visible while rvoice is loaded.
M.menubar = hs.menubar.new()
local function setMenu(state)
-- state: "idle" | "recording" | "transcribing" | "disabled"
local glyph, tooltip
if state == "recording" then glyph = "🔴"; tooltip = "rvoice: recording"
elseif state == "transcribing" then glyph = "🟡"; tooltip = "rvoice: transcribing"
elseif state == "disabled" then glyph = ""; tooltip = "rvoice: disabled"
else glyph = "🎙️"; tooltip = "rvoice: idle (hold Right ⌥ to talk)"
end
if M.menubar then
M.menubar:setTitle(glyph)
M.menubar:setTooltip(tooltip)
end
end end
-- Run rvoice <cmd> in the background; capture stderr to the system log so -- Click the menubar to open the action log (handy for quick debugging).
-- failures are visible via Hammerspoon's console. if M.menubar then
local function run(cmd) M.menubar:setClickCallback(function()
local t = hs.task.new("/bin/sh", function(exit, _, err) hs.task.new("/bin/sh", nil, {"-c", RVOICE .. " log | tail -20 | pbcopy"}):start()
hs.alert.show("rvoice log copied to clipboard")
end)
end
if isDisabled() then
setMenu("disabled")
hs.alert.show("rvoice: disabled (rclaude voice on to re-enable)")
return M
end
setMenu("idle")
-- Floating "● REC" overlay shown while the key is held. hs.alert is built
-- for short pop-ups, but a long duration + an explicit closeAll on release
-- gives us the persistent-while-holding behavior we want with no per-frame
-- repaint cost.
local recAlertUUID = nil
local function showRecording()
recAlertUUID = hs.alert.show(
"🔴 REC",
{ textSize = 36, radius = 12, fillColor = { red = 0.85, alpha = 0.85 },
strokeColor = { white = 1, alpha = 0.6 }, strokeWidth = 2 },
hs.screen.mainScreen(),
9999 -- effectively indefinite; closed on key-up
)
end
local function hideRecording()
if recAlertUUID then hs.alert.closeSpecific(recAlertUUID); recAlertUUID = nil
else hs.alert.closeAll(0) end
end
-- Toast helpers for the post-transcription result.
local function toastOk(text)
local snippet = text
if #snippet > 120 then snippet = snippet:sub(1, 117) .. "" end
hs.alert.show(
"" .. snippet,
{ textSize = 16, radius = 8, fillColor = { green = 0.6, alpha = 0.85 } },
hs.screen.mainScreen(),
2.5
)
end
local function toastErr(text)
hs.alert.show(
"" .. text,
{ textSize = 16, radius = 8, fillColor = { red = 0.7, alpha = 0.85 } },
hs.screen.mainScreen(),
3.5
)
end
-- ──────────────────────────────────────────────────────────────────────────
-- Action dispatch
-- ──────────────────────────────────────────────────────────────────────────
-- Run rvoice <cmd> asynchronously. The callback receives stdout so we can
-- surface the transcript in a toast on success. rvoice writes the recognized
-- text to stdout when invoked as `rvoice stop --print-text`; for `start`
-- we just fire and forget.
local function runAsync(cmd, onDone)
local t = hs.task.new("/bin/sh", function(exit, stdout, stderr)
if onDone then onDone(exit, stdout or "", stderr or "") end
if exit ~= 0 then if exit ~= 0 then
hs.printf("[rvoice] %s exited %d: %s", cmd, exit, err or "") hs.printf("[rvoice] %s exited %d: %s", cmd, exit, stderr or "")
end end
end, {"-c", RVOICE .. " " .. cmd}) end, {"-c", RVOICE .. " " .. cmd})
-- Inherit user shell env so PATH for ffmpeg/jq is set and rvoice can
-- source ~/.config/rvoice/config to pick up any user overrides.
t:setEnvironment(hs.execute("env", true):gsub("\n$", "") and nil or nil)
t:start() t:start()
end end
local function doStart()
setMenu("recording")
showRecording()
runAsync("start")
end
local function doStop()
hideRecording()
setMenu("transcribing")
runAsync("stop --print-text", function(exit, stdout, stderr)
setMenu("idle")
local text = (stdout or ""):gsub("^%s+", ""):gsub("%s+$", "")
if exit == 0 and text ~= "" then
toastOk(text)
elseif exit == 0 then
-- silent success (e.g. taps below min duration) — no toast
else
local err = (stderr or ""):gsub("^%s+", ""):gsub("%s+$", "")
if err == "" then err = "transcription failed" end
toastErr(err)
end
end)
end
-- Right-Option keyDown/keyUp. Hammerspoon delivers modifier changes through -- Right-Option keyDown/keyUp. Hammerspoon delivers modifier changes through
-- eventtap.flagsChanged; we watch for the rightAlt flag transitioning. -- eventtap.flagsChanged; we watch for the rightAlt flag transitioning.
M.tap = hs.eventtap.new({ hs.eventtap.event.types.flagsChanged }, function(e) M.tap = hs.eventtap.new({ hs.eventtap.event.types.flagsChanged }, function(e)
-- macOS exposes the side via a per-key mask. Right-Option is 0x40 in the
-- raw `keyCode` event of type flagsChanged (code 61).
local code = e:getKeyCode() local code = e:getKeyCode()
if code ~= 61 then return false end -- 61 = Right Option if code ~= 61 then return false end -- 61 = Right Option
local flags = e:getFlags() local flags = e:getFlags()
local pressed = flags.alt or false local pressed = flags.alt or false
if pressed and not holding then if pressed and not holding then
holding = true holding = true
run("start") doStart()
elseif (not pressed) and holding then elseif (not pressed) and holding then
holding = false holding = false
run("stop") doStop()
end end
return false -- don't swallow the modifier; other apps may use it return false -- don't swallow the modifier; other apps may use it
end) end)
M.tap:start() M.tap:start()
hs.alert.show("rvoice: Right ⌥ to talk") hs.alert.show("rvoice: Right ⌥ to talk", 1.5)
return M return M

View file

@ -78,3 +78,20 @@ test_get_home_unknown_returns_zero() {
test_get_home_local_returns_HOME() { test_get_home_local_returns_HOME() {
assert_eq "$HOME" "$(get_home local)" assert_eq "$HOME" "$(get_home local)"
} }
# ---------------------------------------------------------------------------
# caller_hostname — used by build_inner to tell remote MCPs where to send
# things back (audio playback, etc.)
# ---------------------------------------------------------------------------
test_caller_hostname_env_override() {
assert_eq "wg1.10.9.0.3" "$(RCLAUDE_BACK_HOST=wg1.10.9.0.3 caller_hostname)"
}
test_caller_hostname_default_adds_lan() {
# Default behavior should produce a fqdn-ish form (either already
# has a dot, or .lan got appended). We don't assert the exact host,
# just that the result is non-empty and dotted.
_out=$(unset RCLAUDE_BACK_HOST; caller_hostname)
assert_contains "$_out" "." "caller_hostname output should be dotted"
}