2026-06-06 18:59:46 -07:00
|
|
|
#!/bin/sh
|
2026-06-06 20:47:28 -07:00
|
|
|
# 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.
|
2026-06-06 18:59:46 -07:00
|
|
|
#
|
2026-06-06 20:47:28 -07:00
|
|
|
# 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.
|
2026-06-06 19:09:18 -07:00
|
|
|
#
|
2026-06-06 20:47:28 -07:00
|
|
|
# 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).
|
2026-06-06 18:59:46 -07:00
|
|
|
#
|
|
|
|
|
# Usage:
|
2026-06-06 20:47:28 -07:00
|
|
|
# crc # apricot.lan, mirror of $PWD
|
|
|
|
|
# crc <host> # <host>, mirror of $PWD
|
|
|
|
|
# crc <host> <dir> # <host>, explicit dir (abs path or ~/...)
|
|
|
|
|
# crc <host> <dir> --stop # stop that server
|
|
|
|
|
# crc <host> <dir> --status # status + URL only (don't start)
|
|
|
|
|
# crc <host> <dir> --log # tail its log
|
|
|
|
|
# crc ... -- <args> # extra args passed to `claude rc`
|
2026-06-06 18:59:46 -07:00
|
|
|
#
|
2026-06-06 20:47:28 -07:00
|
|
|
# host: any ssh target (alias, user@host, IP), or local/./localhost. When <dir>
|
|
|
|
|
# is omitted, $PWD is mirrored to the same path under the remote's $HOME.
|
2026-06-06 19:09:18 -07:00
|
|
|
#
|
2026-06-06 20:47:28 -07:00
|
|
|
# Options:
|
2026-06-06 20:55:06 -07:00
|
|
|
# --spawn <m> worktree | same-dir | session (default: worktree)
|
|
|
|
|
# --perm <m> permission mode for spawned sessions:
|
|
|
|
|
# bypassPermissions | default | acceptEdits | plan | dontAsk | auto
|
|
|
|
|
# (default: bypassPermissions)
|
2026-06-06 20:47:28 -07:00
|
|
|
#
|
|
|
|
|
# Env: CRC_HOST default host when none given (default: apricot.lan)
|
2026-06-06 18:59:46 -07:00
|
|
|
set -eu
|
|
|
|
|
|
|
|
|
|
host=${CRC_HOST:-apricot.lan}
|
2026-06-06 20:47:28 -07:00
|
|
|
action=launch # launch | stop | status | log
|
|
|
|
|
spawn=worktree
|
2026-06-06 20:55:06 -07:00
|
|
|
perm=bypassPermissions
|
2026-06-06 19:09:18 -07:00
|
|
|
have_host=0
|
|
|
|
|
dir_set=0
|
|
|
|
|
dir=''
|
2026-06-06 20:47:28 -07:00
|
|
|
rc_args=''
|
|
|
|
|
|
2026-06-06 20:55:06 -07:00
|
|
|
usage() { sed -n '2,31p' "$0" | sed 's/^# \{0,1\}//'; }
|
2026-06-06 18:59:46 -07:00
|
|
|
|
2026-06-06 19:09:18 -07:00
|
|
|
while [ $# -gt 0 ]; do
|
|
|
|
|
case "$1" in
|
2026-06-06 20:47:28 -07:00
|
|
|
-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 ;;
|
2026-06-06 20:55:06 -07:00
|
|
|
--perm|--permission-mode) [ $# -ge 2 ] || { echo "crc: $1 needs a value" >&2; exit 2; }; perm=$2; shift 2 ;;
|
2026-06-06 20:47:28 -07:00
|
|
|
--) shift; rc_args=$*; break ;;
|
|
|
|
|
-*) echo "crc: unknown option: $1" >&2; exit 2 ;;
|
2026-06-06 19:09:18 -07:00
|
|
|
*)
|
|
|
|
|
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
|
2026-06-06 18:59:46 -07:00
|
|
|
|
2026-06-06 20:47:28 -07:00
|
|
|
# --- resolve dir + a stable session name (slug) ----------------------------
|
2026-06-06 19:09:18 -07:00
|
|
|
rel=''
|
|
|
|
|
abs=''
|
|
|
|
|
slug_src=''
|
|
|
|
|
if [ $dir_set -eq 1 ]; then
|
|
|
|
|
abs=$dir
|
2026-06-06 20:47:28 -07:00
|
|
|
# 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).
|
2026-06-06 20:39:41 -07:00
|
|
|
case "$dir" in
|
|
|
|
|
"$HOME"/*) slug_src=${dir#"$HOME"/} ;;
|
|
|
|
|
'~/'*) slug_src=${dir#\~/} ;;
|
|
|
|
|
*) slug_src=$dir ;;
|
|
|
|
|
esac
|
2026-06-06 19:09:18 -07:00
|
|
|
else
|
|
|
|
|
case "$PWD" in
|
2026-06-06 20:47:28 -07:00
|
|
|
"$HOME") rel='' ; slug_src='home' ;;
|
2026-06-06 19:09:18 -07:00
|
|
|
"$HOME"/*) rel=${PWD#"$HOME"/} ; slug_src=$rel ;;
|
2026-06-06 20:47:28 -07:00
|
|
|
*) rel='' ; slug_src='home' ;;
|
2026-06-06 19:09:18 -07:00
|
|
|
esac
|
|
|
|
|
fi
|
2026-06-06 20:47:28 -07:00
|
|
|
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}"
|
2026-06-06 19:09:18 -07:00
|
|
|
|
2026-06-06 20:47:28 -07:00
|
|
|
# --- 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" <<BOOT
|
|
|
|
|
set -eu
|
|
|
|
|
NAME=$(printf %q "$name")
|
2026-06-06 19:09:18 -07:00
|
|
|
REL=$(printf %q "$rel")
|
|
|
|
|
ABS=$(printf %q "$abs")
|
2026-06-06 20:47:28 -07:00
|
|
|
SPAWN=$(printf %q "$spawn")
|
2026-06-06 20:55:06 -07:00
|
|
|
PERM=$(printf %q "$perm")
|
2026-06-06 20:47:28 -07:00
|
|
|
ACTION=$(printf %q "$action")
|
2026-06-06 19:09:18 -07:00
|
|
|
RC_ARGS=$(printf %q "$rc_args")
|
2026-06-06 20:47:28 -07:00
|
|
|
|
2026-06-06 19:57:33 -07:00
|
|
|
if [ "\$ABS" = "~" ]; then DIR=\$HOME
|
|
|
|
|
elif [ -n "\$ABS" ] && [ "\${ABS#~/}" != "\$ABS" ]; then DIR="\$HOME/\${ABS#~/}"
|
|
|
|
|
elif [ -n "\$ABS" ]; then DIR=\$ABS
|
|
|
|
|
else DIR="\$HOME\${REL:+/\$REL}"
|
2026-06-06 19:49:55 -07:00
|
|
|
fi
|
2026-06-06 19:09:18 -07:00
|
|
|
|
2026-06-06 20:47:28 -07:00
|
|
|
SD=\${XDG_STATE_HOME:-\$HOME/.local/state}/claude-rc
|
|
|
|
|
mkdir -p "\$SD"
|
|
|
|
|
LOG="\$SD/\$NAME.log"; PIDF="\$SD/\$NAME.pid"
|
2026-06-06 19:09:18 -07:00
|
|
|
|
2026-06-06 20:47:28 -07:00
|
|
|
alive() { [ -f "\$PIDF" ] && kill -0 "\$(cat "\$PIDF" 2>/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
|
|
|
|
|
}
|
2026-06-06 19:09:18 -07:00
|
|
|
|
2026-06-06 20:47:28 -07:00
|
|
|
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"
|
2026-06-06 20:55:06 -07:00
|
|
|
nohup claude rc --name "\$NAME" --spawn "\$SPAWN" --permission-mode "\$PERM" \$RC_ARGS </dev/null >>"\$LOG" 2>&1 &
|
2026-06-06 20:47:28 -07:00
|
|
|
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"
|
2026-06-06 19:09:18 -07:00
|
|
|
|
|
|
|
|
case "$host" in
|
2026-06-06 20:47:28 -07:00
|
|
|
local|localhost|.) eval "$run_remote" ;;
|
|
|
|
|
*) ssh "$host" "$run_remote" ;;
|
2026-06-06 19:09:18 -07:00
|
|
|
esac
|