session-tools/bin/claude-rc
2026-06-06 20:54:27 -07:00

142 lines
5.6 KiB
Bash
Executable file

#!/bin/sh
# claude-rc — central manager for always-on `claude rc` (Remote Control) servers,
# supervised by systemd --user on this host. Runs ON the host (apricot); drive it
# from elsewhere over ssh (plum's `rc` function forwards here).
#
# Single source of truth is the registry:
# ~/.config/claude-rc/projects # lines: name=dir ('#' comments)
# `dir` is relative to $HOME (or absolute, or ~/...). Each entry maps to a
# systemd template instance `claude-rc@<name>.service` running `claude rc --name
# <name>` in <dir>. systemd + Linger give boot-persistence and Restart=always.
#
# Commands:
# list registry + unit state + dir
# status [name] state + claude.ai/code URL (all, or one)
# url <name> print the environment URL
# add <name> <dir> register + enable --now
# rm <name> disable + unregister
# sync reconcile units to the registry (boot/after edits)
# logs <name> [journalctl-args…]
# restart|stop|start <name>
# _run <name> internal: exec'd by the template unit
set -eu
REG=${CLAUDE_RC_REGISTRY:-$HOME/.config/claude-rc/projects}
TPL=claude-rc@ # systemd template prefix
uc() { systemctl --user "$@"; }
# name -> absolute dir (resolves rel-to-HOME, ~/ and absolute). Empty if absent.
reg_dir() {
[ -f "$REG" ] || return 0
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in ''|\#*) continue ;; esac
[ "${line%%=*}" = "$1" ] || continue
d=${line#*=}
case "$d" in
/*) printf %s "$d" ;;
'~/'*) printf %s "$HOME/${d#\~/}" ;;
*) printf %s "$HOME/$d" ;;
esac
return 0
done < "$REG"
}
reg_names() {
[ -f "$REG" ] || return 0
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in ''|\#*) continue ;; esac
printf '%s\n' "${line%%=*}"
done < "$REG"
}
url_of() {
journalctl --user -u "$TPL$1" -o cat -n 120 2>/dev/null \
| grep -oE 'env_[A-Za-z0-9]+' | tail -1
}
require() { [ -n "${1:-}" ] || { echo "claude-rc: missing <name>" >&2; exit 2; }; }
cmd=${1:-list}; [ $# -gt 0 ] && shift || true
case "$cmd" in
_run)
name=${1:-}; require "$name"
dir=$(reg_dir "$name")
[ -n "$dir" ] || { echo "claude-rc: '$name' not in $REG" >&2; exit 1; }
cd "$dir" || { echo "claude-rc: dir missing: $dir" >&2; exit 1; }
# --spawn is mandatory for headless operation: without it `claude rc`
# prompts "Choose [1/2]" for spawn mode and blocks forever. Default to
# worktree (isolated session per spawn — safe for concurrent agents);
# override per-instance with CLAUDE_RC_SPAWN=same-dir|session.
exec claude rc --name "$name" --spawn "${CLAUDE_RC_SPAWN:-worktree}"
;;
list|ls)
printf '%-16s %-10s %s\n' NAME STATE DIR
reg_names | while read -r n; do
printf '%-16s %-10s %s\n' "$n" "$(uc is-active "$TPL$n" 2>/dev/null || echo -)" "$(reg_dir "$n")"
done
;;
status|st)
show() {
env=$(url_of "$1")
printf '%-16s %-10s %s\n' "$1" "$(uc is-active "$TPL$1" 2>/dev/null || echo -)" \
"${env:+https://claude.ai/code?environment=$env}"
}
if [ -n "${1:-}" ]; then show "$1"; else reg_names | while read -r n; do show "$n"; done; fi
;;
url)
name=${1:-}; require "$name"
env=$(url_of "$name")
[ -n "$env" ] && echo "https://claude.ai/code?environment=$env" || { echo "claude-rc: no URL for $name" >&2; exit 1; }
;;
add)
name=${1:-}; dir=${2:-}
[ -n "$name" ] && [ -n "$dir" ] || { echo "usage: claude-rc add <name> <dir>" >&2; exit 2; }
mkdir -p "$(dirname "$REG")"; touch "$REG"
if reg_names | grep -qx "$name"; then
echo "claude-rc: '$name' already registered ($(reg_dir "$name"))"
else
printf '%s=%s\n' "$name" "$dir" >> "$REG"
echo "registered $name=$dir"
fi
uc enable --now "$TPL$name" && echo "enabled+started $TPL$name"
;;
rm|remove)
name=${1:-}; require "$name"
uc disable --now "$TPL$name" 2>/dev/null && echo "disabled $TPL$name" || true
if [ -f "$REG" ]; then
tmp=$(mktemp); grep -v "^$name=" "$REG" > "$tmp" 2>/dev/null || true; mv "$tmp" "$REG"
fi
echo "unregistered $name"
;;
sync)
# Bring every registered project up…
reg_names | while read -r n; do
uc enable --now "$TPL$n" >/dev/null 2>&1 && echo "up: $n" || echo "FAIL: $n"
done
# …and tear down any enabled instance no longer in the registry.
uc list-unit-files "${TPL}*.service" --no-legend 2>/dev/null | awk '{print $1}' | while read -r uf; do
inst=${uf#"$TPL"}; inst=${inst%.service}
[ -n "$inst" ] || continue
if ! reg_names | grep -qx "$inst"; then
uc disable --now "$TPL$inst" >/dev/null 2>&1 && echo "down: $inst (orphan)"
fi
done
;;
logs)
name=${1:-}; require "$name"; shift || true
set -- "$@"
[ $# -gt 0 ] || set -- -n 40 --no-pager
exec journalctl --user -u "$TPL$name" "$@"
;;
restart|stop|start)
name=${1:-}; require "$name"
uc "$cmd" "$TPL$name" && uc is-active "$TPL$name" 2>/dev/null || true
;;
-h|--help|help)
sed -n '2,33p' "$0" | sed 's/^# \{0,1\}//'
;;
*)
echo "claude-rc: unknown command '$cmd' (try: claude-rc help)" >&2; exit 2
;;
esac