From a49ea98efd2f3cdac11f829072c28093e1db3455 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 7 Jun 2026 11:20:35 -0700 Subject: [PATCH] =?UTF-8?q?feat(@scripts):=20=E2=9C=A8=20add=20claude-rc?= =?UTF-8?q?=20manager=20and=20crc=20launcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .gitignore | 2 + README.md | 82 +++++++++++++++++++++ bin/claude-rc | 147 +++++++++++++++++++++++++++++++++++++ bin/crc | 151 +++++++++++++++++++++++++++++++++++++++ install.sh | 52 ++++++++++++++ projects.example | 9 +++ units/claude-rc@.service | 22 ++++++ 7 files changed, 465 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 bin/claude-rc create mode 100755 bin/crc create mode 100644 install.sh create mode 100644 projects.example create mode 100644 units/claude-rc@.service diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a573cee --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.log +*.pid diff --git a/README.md b/README.md new file mode 100644 index 0000000..f0fd03d --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# claude-rc + +Run **Claude Code Remote Control** (`claude rc`) servers so projects are +drivable from [claude.ai/code](https://claude.ai/code) and the Claude mobile app +— either as always-on, reboot-surviving services, or as ad-hoc throwaways. + +Two tools: + +| Tool | Purpose | Persistence | +|------|---------|-------------| +| `claude-rc` | central **manager** of always-on servers, supervised by `systemd --user` from a registry | survives reboot (linger) + crash (`Restart=always`) | +| `crc` | **ad-hoc** headless launcher for one dir on one host | survives terminal drops; dies on reboot | + +Both run `claude rc` **headless** — detached, logged, with `--spawn` and +`--permission-mode` preset so it never blocks on the interactive spawn-mode +prompt. + +## Why no tmux + +The obvious approach — park `claude rc` in a tmux session — is a trap: a shared +user tmux server can be restarted out from under you (e.g. by another +supervisor) and your sessions vanish. `claude rc` needs no TTY (it connects fine +with stdin closed), so **systemd alone** gives boot-persistence + crash-restart, +and `crc` uses a plain logged background process. No tmux anywhere. + +## Install + +```sh +./install.sh # symlink bin/*, install the unit, seed registry +sudo loginctl enable-linger "$USER" # once, so units start at boot (Linux) +claude-rc sync # bring up everything in the registry +``` + +Requires `claude` (Claude Code) on `$PATH` and a logged-in subscription account. + +## Manager — `claude-rc` + +Registry (`~/.config/claude-rc/projects`) is the single source of truth: + +``` +name=dir # dir relative to $HOME, or absolute, or ~/... +``` + +Each entry → a systemd template instance `claude-rc@.service` running +`claude rc --name ` in ``. + +```sh +claude-rc list # registry + unit state + dir +claude-rc status [name] # state + claude.ai/code URL +claude-rc url # just the URL +claude-rc add # register + enable --now +claude-rc rm # disable + unregister +claude-rc sync # reconcile units to the registry +claude-rc logs [-f] # journal +claude-rc restart|stop|start +``` + +Defaults (override via env in a unit drop-in): +- `CLAUDE_RC_SPAWN=worktree` — isolated git worktree per spawned session. +- `CLAUDE_RC_PERM=bypassPermissions` — spawned sessions skip permission prompts. + +## Ad-hoc — `crc` + +```sh +crc # launch headless; prints the URL. host=local for here +crc --status | --log | --stop +crc --spawn same-dir --perm default -- +``` + +`host` is any ssh target, or `local`. State lives under +`~/.local/state/claude-rc/` on the target. Re-running reports the existing +server instead of duplicating it. + +## Layout + +``` +bin/claude-rc manager (runs on the host; drive remotely over ssh) +bin/crc ad-hoc headless launcher +units/claude-rc@.service systemd --user template +projects.example registry seed +install.sh symlinks + unit install + registry seed +``` diff --git a/bin/claude-rc b/bin/claude-rc new file mode 100755 index 0000000..08feff2 --- /dev/null +++ b/bin/claude-rc @@ -0,0 +1,147 @@ +#!/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@.service` running `claude rc --name +# ` in . 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 print the environment URL +# add register + enable --now +# rm disable + unregister +# sync reconcile units to the registry (boot/after edits) +# logs [journalctl-args…] +# restart|stop|start +# _run 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 " >&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. + # --permission-mode sets the mode for spawned sessions; bypassPermissions + # so phone/web sessions run without permission prompts (override with + # CLAUDE_RC_PERM=default|acceptEdits|plan|...). + exec claude rc --name "$name" \ + --spawn "${CLAUDE_RC_SPAWN:-worktree}" \ + --permission-mode "${CLAUDE_RC_PERM:-bypassPermissions}" + ;; + 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 " >&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 diff --git a/bin/crc b/bin/crc new file mode 100755 index 0000000..b993ab5 --- /dev/null +++ b/bin/crc @@ -0,0 +1,151 @@ +#!/bin/sh +# crc — launch a non-persistent, HEADLESS `claude rc` (Remote Control) server in +# a target dir on a target host, and print its claude.ai/code URL. +# +# Headless: claude rc runs detached with output logged (no tmux, no tty) and +# `--spawn` preset, so it never blocks on the interactive spawn-mode prompt. It +# survives terminal/ssh drops but NOT a host reboot (non-persistent). For servers +# that must survive reboot, register them with the `claude-rc` manager instead. +# +# Drive the session from claude.ai/code or the Claude mobile app via the printed +# URL. Re-running just reports the existing server (never duplicates it). +# +# Usage: +# crc # apricot.lan, mirror of $PWD +# crc # , mirror of $PWD +# crc # , explicit dir (abs path or ~/...) +# crc --stop # stop that server +# crc --status # status + URL only (don't start) +# crc --log # tail its log +# crc ... -- # extra args passed to `claude rc` +# +# host: any ssh target (alias, user@host, IP), or local/./localhost. When +# is omitted, $PWD is mirrored to the same path under the remote's $HOME. +# +# Options: +# --spawn worktree | same-dir | session (default: worktree) +# --perm permission mode for spawned sessions: +# bypassPermissions | default | acceptEdits | plan | dontAsk | auto +# (default: bypassPermissions) +# +# Env: CRC_HOST default host when none given (default: apricot.lan) +set -eu + +host=${CRC_HOST:-apricot.lan} +action=launch # launch | stop | status | log +spawn=worktree +perm=bypassPermissions +have_host=0 +dir_set=0 +dir='' +rc_args='' + +usage() { sed -n '2,31p' "$0" | sed 's/^# \{0,1\}//'; } + +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + --stop) action=stop; shift ;; + --status) action=status; shift ;; + --log) action=log; shift ;; + --spawn) [ $# -ge 2 ] || { echo "crc: --spawn needs a value" >&2; exit 2; }; spawn=$2; shift 2 ;; + --perm|--permission-mode) [ $# -ge 2 ] || { echo "crc: $1 needs a value" >&2; exit 2; }; perm=$2; shift 2 ;; + --) shift; rc_args=$*; break ;; + -*) echo "crc: unknown option: $1" >&2; exit 2 ;; + *) + if [ $have_host -eq 0 ]; then host=$1; have_host=1 + elif [ $dir_set -eq 0 ]; then dir=$1; dir_set=1 + else echo "crc: too many arguments: $1" >&2; exit 2 + fi + shift ;; + esac +done + +# --- resolve dir + a stable session name (slug) ---------------------------- +rel='' +abs='' +slug_src='' +if [ $dir_set -eq 1 ]; then + abs=$dir + # Normalize so ~/x, $HOME/x and /abs/$HOME/x map to the same slug (the shell + # may have expanded ~ to $HOME before crc saw it). + case "$dir" in + "$HOME"/*) slug_src=${dir#"$HOME"/} ;; + '~/'*) slug_src=${dir#\~/} ;; + *) slug_src=$dir ;; + esac +else + case "$PWD" in + "$HOME") rel='' ; slug_src='home' ;; + "$HOME"/*) rel=${PWD#"$HOME"/} ; slug_src=$rel ;; + *) rel='' ; slug_src='home' ;; + esac +fi +slug=$(printf %s "$slug_src" | tr '[:upper:]' '[:lower:]' | sed -e 's#[^a-z0-9]\{1,\}#-#g' -e 's#^-##' -e 's#-$##') +[ -n "$slug" ] || slug='home' +name="crc-${slug}" + +# --- remote bootstrap (base64'd to survive every quoting layer) ------------ +# Runs on the target (local or via ssh). Manages a detached, logged claude rc +# keyed by $name under the state dir; idempotent (won't double-launch). +bootf=$(mktemp "${TMPDIR:-/tmp}/crc.XXXXXX") +trap 'rm -f "$bootf"' EXIT INT TERM +cat > "$bootf" </dev/null)" 2>/dev/null; } +envid() { grep -aoE 'env_[A-Za-z0-9]+' "\$LOG" 2>/dev/null | tail -1; } +report() { + if alive; then + e=\$(envid) + echo "\$NAME: running (pid \$(cat "\$PIDF")) in \$DIR" + if [ -n "\$e" ]; then echo " https://claude.ai/code?environment=\$e" + else echo " (URL not in log yet — retry: crc ... --status)"; fi + else + echo "\$NAME: not running" + fi +} + +case "\$ACTION" in + stop) + if alive; then kill "\$(cat "\$PIDF")" 2>/dev/null && echo "stopped \$NAME"; else echo "\$NAME not running"; fi + rm -f "\$PIDF" ;; + status) report ;; + log) [ -f "\$LOG" ] && tail -n 40 "\$LOG" || echo "no log: \$LOG" ;; + launch) + if alive; then + echo "crc: \$NAME already running — reporting existing server" + else + cd "\$DIR" 2>/dev/null || { echo "crc: directory not found: \$DIR" >&2; exit 1; } + : > "\$LOG" + nohup claude rc --name "\$NAME" --spawn "\$SPAWN" --permission-mode "\$PERM" \$RC_ARGS >"\$LOG" 2>&1 & + echo \$! > "\$PIDF" + i=0; while [ \$i -lt 20 ] && [ -z "\$(envid)" ] && alive; do sleep 1; i=\$((i+1)); done + fi + report ;; +esac +BOOT +boot_b64=$(base64 < "$bootf" | tr -d '\n') +run_remote="printf %s '${boot_b64}' | base64 -d | sh" + +case "$host" in + local|localhost|.) eval "$run_remote" ;; + *) ssh "$host" "$run_remote" ;; +esac diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..af65e1b --- /dev/null +++ b/install.sh @@ -0,0 +1,52 @@ +#!/bin/sh +# install.sh — install the claude-rc system on this host (idempotent). +# +# - symlinks bin/* into the first of ~/.local/bin | ~/bin that is on $PATH +# - installs the systemd --user template unit (copied, so `systemctl enable` +# can manage its own symlinks cleanly) +# - seeds ~/.config/claude-rc/projects from projects.example if absent +# +# After install: enable linger once (so units start at boot without login): +# sudo loginctl enable-linger "$USER" +# then bring the registered servers up: +# claude-rc sync +set -eu + +repo=$(cd "$(dirname "$0")" && pwd) + +# --- bin symlinks ---------------------------------------------------------- +target="" +for c in "$HOME/.local/bin" "$HOME/bin"; do + case ":$PATH:" in *":$c:"*) target=$c; break ;; esac +done +[ -n "$target" ] || target="$HOME/.local/bin" +mkdir -p "$target" +for f in "$repo"/bin/*; do + ln -sfn "$f" "$target/$(basename "$f")" + echo "link: $target/$(basename "$f")" +done +case ":$PATH:" in *":$target:"*) ;; *) echo "note: add $target to PATH" ;; esac + +# --- systemd --user template unit ------------------------------------------ +if command -v systemctl >/dev/null 2>&1; then + ud="$HOME/.config/systemd/user" + mkdir -p "$ud" + cp "$repo/units/claude-rc@.service" "$ud/claude-rc@.service" + echo "copy: $ud/claude-rc@.service" + systemctl --user daemon-reload && echo "ok: systemd --user daemon-reloaded" +else + echo "note: systemctl --user not available — persistent units are Linux-only" +fi + +# --- registry -------------------------------------------------------------- +reg="$HOME/.config/claude-rc/projects" +if [ ! -f "$reg" ]; then + mkdir -p "$(dirname "$reg")" + cp "$repo/projects.example" "$reg" + echo "seed: $reg (edit it, then: claude-rc sync)" +else + echo "ok: registry exists: $reg" +fi + +echo +echo "done. next: 'sudo loginctl enable-linger $USER' (boot-start) then 'claude-rc sync'" diff --git a/projects.example b/projects.example new file mode 100644 index 0000000..cc0063e --- /dev/null +++ b/projects.example @@ -0,0 +1,9 @@ +# claude-rc registry — one project per line: name=dir +# dir is relative to $HOME (or absolute, or ~/...). +# '#' starts a comment; blank lines are ignored. +# Edit with `claude-rc add ` / `claude-rc rm `, or by hand +# then `claude-rc sync`. install.sh seeds ~/.config/claude-rc/projects from this +# file if none exists. +magic-civ=Code/@projects/@magic-civilization +cocottetech=Code/@projects/@cocottetech +lp=Code/@projects/@lilith/lilith-platform.live diff --git a/units/claude-rc@.service b/units/claude-rc@.service new file mode 100644 index 0000000..290741b --- /dev/null +++ b/units/claude-rc@.service @@ -0,0 +1,22 @@ +[Unit] +Description=claude rc (Remote Control) server — %i +Documentation=man:claude(1) +After=network-online.target time-sync.target +Wants=network-online.target + +[Service] +# Centrally managed by `claude-rc` (registry: ~/.config/claude-rc/projects). +# Direct under systemd (no tmux — a shared tmux server gets wiped). systemd + +# Linger give boot-persistence; Restart=always gives crash-recovery. The +# claude.ai/code URL lands in the journal: journalctl --user -u claude-rc@%i +Type=simple +Environment=PATH=%h/.local/bin:%h/.local/share/pnpm:/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin +ExecStart=%h/.local/bin/claude-rc _run %i +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=claude-rc-%i + +[Install] +WantedBy=default.target