feat(@scripts): ✨ add remote host resolution helper
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
45daeb61ba
commit
1c575ad263
3 changed files with 172 additions and 23 deletions
40
bin/rclaude
40
bin/rclaude
|
|
@ -1006,6 +1006,30 @@ cmd_resume() {
|
|||
# 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() {
|
||||
_self=$(resolve_self)
|
||||
_repo=$(cd "$(dirname "$_self")/.." 2>/dev/null && pwd)
|
||||
|
|
@ -1020,12 +1044,6 @@ cmd_version() {
|
|||
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() {
|
||||
# `rclaude voice` — toggle / inspect the rvoice push-to-talk binding.
|
||||
# rvoice itself is a separate script (bin/rvoice) driven by Hammerspoon.
|
||||
|
|
@ -1167,8 +1185,16 @@ build_inner() {
|
|||
elif [ "${RCLAUDE_RESUME:-0}" = "1" ]; then
|
||||
_resume_flag="--continue"
|
||||
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' \
|
||||
"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)); " \
|
||||
"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; " \
|
||||
|
|
|
|||
|
|
@ -13,6 +13,15 @@
|
|||
-- Behavior: hold Right-Option to talk. Release to transcribe + inject into
|
||||
-- the active iTerm2 tab's remote tmux session. Taps shorter than 200ms are
|
||||
-- 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 = {}
|
||||
|
||||
|
|
@ -44,45 +53,142 @@ local function isDisabled()
|
|||
if f then f:close(); return true end
|
||||
return false
|
||||
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
|
||||
|
||||
-- Run rvoice <cmd> in the background; capture stderr to the system log so
|
||||
-- failures are visible via Hammerspoon's console.
|
||||
local function run(cmd)
|
||||
local t = hs.task.new("/bin/sh", function(exit, _, err)
|
||||
-- Click the menubar to open the action log (handy for quick debugging).
|
||||
if M.menubar then
|
||||
M.menubar:setClickCallback(function()
|
||||
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
|
||||
hs.printf("[rvoice] %s exited %d: %s", cmd, exit, err or "")
|
||||
hs.printf("[rvoice] %s exited %d: %s", cmd, exit, stderr or "")
|
||||
end
|
||||
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()
|
||||
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
|
||||
-- eventtap.flagsChanged; we watch for the rightAlt flag transitioning.
|
||||
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()
|
||||
if code ~= 61 then return false end -- 61 = Right Option
|
||||
local flags = e:getFlags()
|
||||
local pressed = flags.alt or false
|
||||
if pressed and not holding then
|
||||
holding = true
|
||||
run("start")
|
||||
doStart()
|
||||
elseif (not pressed) and holding then
|
||||
holding = false
|
||||
run("stop")
|
||||
doStop()
|
||||
end
|
||||
return false -- don't swallow the modifier; other apps may use it
|
||||
end)
|
||||
|
||||
M.tap:start()
|
||||
hs.alert.show("rvoice: Right ⌥ to talk")
|
||||
hs.alert.show("rvoice: Right ⌥ to talk", 1.5)
|
||||
|
||||
return M
|
||||
|
|
|
|||
|
|
@ -78,3 +78,20 @@ test_get_home_unknown_returns_zero() {
|
|||
test_get_home_local_returns_HOME() {
|
||||
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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue