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
|
# 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; " \
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue