feat: add yolo host

New NixOS VM on proxmox, replacing the Ubuntu box. Reuses the existing
Hyprland config; remote access is Sunshine/Moonlight rather than xrdp,
since xrdp cannot drive a wayland compositor.

Two fixes here are not yolo-specific and affect any fresh install:
git at system level (nix needs it for the type = "git" hyprland input,
but git only came from home-manager, so neither rebuild could run), and
dropping the yogurt input, whose private repo made home-manager
un-evaluatable without GitHub auth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
naps62
2026-08-17 06:30:54 +00:00
parent f7947375e3
commit 77bdbe108d
12 changed files with 838 additions and 47 deletions
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env bash
# Manage the live list of Claude Code Remote Control projects.
# The projects file is the source of truth; sync reconciles systemd to match.
set -euo pipefail
LIST="${CLAUDE_RC_LIST:-$HOME/.config/claude-rc/projects}"
UNIT=claude-rc
die() { echo "claude-rc: $*" >&2; exit 1; }
# configured paths: strip comments, an optional "| Display Name" suffix,
# surrounding space, and any trailing slash.
wanted_paths() {
[ -f "$LIST" ] || return 0
sed -e 's/#.*//' -e 's/|.*//' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' "$LIST" \
| grep -v '^$' \
| sed -e 's#/\+$##' \
| sort -u
}
# display name for a path, if the list defines one
name_for() {
[ -f "$LIST" ] || return 0
awk -F'|' -v t="$1" '
{ line=$0; sub(/#.*/,"",line)
p=line; if (index(line,"|")) { p=substr(line,1,index(line,"|")-1) }
gsub(/^[[:space:]]+|[[:space:]]+$/,"",p); sub(/\/+$/,"",p)
if (p == t && index(line,"|")) {
n=substr(line,index(line,"|")+1)
gsub(/^[[:space:]]+|[[:space:]]+$/,"",n)
if (n != "") { print n; exit }
}
}' "$LIST" 2>/dev/null
}
# instance names systemd should have enabled
wanted_units() {
wanted_paths | while read -r p; do
printf '%s@%s.service\n' "$UNIT" "$(systemd-escape -p "$p")"
done | sort -u
}
# instance names systemd currently has
current_units() {
systemctl --user list-units --all --no-legend --plain "${UNIT}@*.service" 2>/dev/null \
| awk '{print $1}' | sort -u
}
cmd_sync() {
local want have add rm
want=$(wanted_units); have=$(current_units)
add=$(comm -23 <(printf '%s\n' "$want") <(printf '%s\n' "$have") | grep -v '^$' || true)
rm=$(comm -13 <(printf '%s\n' "$want") <(printf '%s\n' "$have") | grep -v '^$' || true)
if [ -n "$rm" ]; then
while read -r u; do
echo "- stop $u"
systemctl --user disable --now "$u" >/dev/null 2>&1 || true
done <<<"$rm"
fi
if [ -n "$add" ]; then
while read -r u; do
echo "+ start $u"
systemctl --user enable --now "$u" >/dev/null 2>&1 \
|| echo " FAILED: $u (see: claude-rc log $u)" >&2
done <<<"$add"
fi
[ -z "$add$rm" ] && echo "in sync ($(wanted_paths | grep -c . || true) projects)"
return 0
}
cmd_list() {
local n=0
printf '%-46s %-9s %s\n' PROJECT STATE GIT
while read -r p; do
n=$((n+1))
local u state git
u="${UNIT}@$(systemd-escape -p "$p").service"
state=$(systemctl --user is-active "$u" 2>/dev/null || true)
if [ ! -d "$p" ]; then git="MISSING DIR"
elif git -C "$p" rev-parse --git-dir >/dev/null 2>&1; then git="worktree"
else git="same-dir (not git)"; fi
printf '%-46s %-9s %s\n' "${p/#$HOME/\~}" "$state" "$git"
done < <(wanted_paths)
[ "$n" = 0 ] && echo "(no projects configured; claude-rc add <path>)"
return 0
}
cmd_add() {
local p="${1:-$PWD}"
p=$(cd "$p" 2>/dev/null && pwd) || die "no such directory: ${1:-$PWD}"
if wanted_paths | grep -qxF "$p"; then echo "already listed: $p"; return 0; fi
# revive a commented-out entry if present, else append; keeps the file tidy
# across add/rm cycles instead of accumulating dead duplicates.
if awk -v t="$p" '
{ l=$0; sub(/^[[:space:]]*#[[:space:]]*/,"",l); sub(/\/+$/,"",l)
gsub(/^[[:space:]]+|[[:space:]]+$/,"",l)
if ($0 ~ /^[[:space:]]*#/ && l == t) found=1 }
END { exit !found }' "$LIST"; then
awk -v t="$p" '
{ l=$0; sub(/^[[:space:]]*#[[:space:]]*/,"",l); sub(/\/+$/,"",l)
gsub(/^[[:space:]]+|[[:space:]]+$/,"",l)
if (!done && $0 ~ /^[[:space:]]*#/ && l == t) { print t; done=1 }
else print $0 }
' "$LIST" > "$LIST.tmp" && mv "$LIST.tmp" "$LIST"
else
printf '%s\n' "$p" >> "$LIST"
fi
echo "added: $p"
cmd_sync
}
cmd_rm() {
local p="${1:?usage: claude-rc rm <path>}"
p=$(cd "$p" 2>/dev/null && pwd) || p="${p%/}"
wanted_paths | grep -qxF "$p" || die "not listed: $p"
# comment out rather than delete, so the entry is easy to restore.
# awk, not sed: the path is data, so no regex metachar/delimiter escaping.
awk -v target="$p" '
{
line = $0
sub(/#.*/, "", line)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", line)
sub(/\/+$/, "", line)
if (line != "" && line == target) print "# " $0
else print $0
}
' "$LIST" > "$LIST.tmp" && mv "$LIST.tmp" "$LIST"
echo "removed: $p"
cmd_sync
}
cmd_edit() { "${EDITOR:-vi}" "$LIST"; cmd_sync; }
cmd_log() {
local p="${1:?usage: claude-rc log <path>}"
p=$(cd "$p" 2>/dev/null && pwd) || die "no such directory: $p"
journalctl --user -u "${UNIT}@$(systemd-escape -p "$p").service" -n 40 --no-pager
}
case "${1:-list}" in
sync) cmd_sync ;;
list|ls|status) cmd_list ;;
add) shift; cmd_add "${1:-}" ;;
rm|remove|del) shift; cmd_rm "${1:-}" ;;
edit) cmd_edit ;;
log|logs) shift; cmd_log "${1:-}" ;;
*) cat >&2 <<EOF
usage: claude-rc <cmd>
list projects + running state (default)
add [path] add project (default: cwd)
rm <path> remove project (comments it out)
edit \$EDITOR the list, then sync
sync force reconcile
log <path> journal for one project
list file: $LIST
EOF
exit 1 ;;
esac
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Launch a Claude Code Remote Control server for one project dir.
# Invoked by claude-rc@.service; arg is the project path.
set -euo pipefail
dir="${1:?usage: claude-rc-run <project-dir>}"
if [ ! -d "$dir" ]; then
echo "claude-rc-run: no such directory: $dir" >&2
exit 78 # EX_CONFIG - systemd won't spin on Restart=on-failure
fi
# --spawn worktree needs a git repo; fall back to same-dir if not one
spawn=worktree
git -C "$dir" rev-parse --git-dir >/dev/null 2>&1 || {
echo "claude-rc-run: $dir is not a git repo, using --spawn same-dir" >&2
spawn=same-dir
}
# Optional display name: a projects-file line may read
# /path/to/repo | My Display Name
# Falls back to the bare directory name.
LIST="${CLAUDE_RC_LIST:-$HOME/.config/claude-rc/projects}"
name=$(awk -F'|' -v t="$dir" '
{ line=$0; sub(/#.*/,"",line)
p=line; if (index(line,"|")) { p=substr(line,1,index(line,"|")-1) }
gsub(/^[[:space:]]+|[[:space:]]+$/,"",p); sub(/\/+$/,"",p)
if (p == t && index(line,"|")) {
n=substr(line,index(line,"|")+1)
gsub(/^[[:space:]]+|[[:space:]]+$/,"",n)
if (n != "") { print n; exit }
}
}' "$LIST" 2>/dev/null || true)
[ -n "$name" ] || name="$(basename "$dir")"
cd "$dir"
exec claude remote-control \
--spawn "$spawn" \
--permission-mode bypassPermissions \
--name "$name"
+36
View File
@@ -0,0 +1,36 @@
{
pkgs,
inputs,
...
}:
{
imports = [
../common/programs/default.nix
../common/programs/desktop
../common/programs/zen-browser.nix
../common/programs/hyprland
../common/programs/kitty
../common/programs/gpg.nix
../common/features/xdg.nix
../common/features/downloads-cleanup.nix
./monitors.nix
./services.nix
];
custom.hyprland.cursorSize = 32;
# The agent session manager the hourlog/week-review timers drive. Was a
# hand-installed binary in ~/.local/bin on the Ubuntu box.
home.packages = [ inputs.agent-of-empires.packages.${pkgs.system}.default ];
# Blur and shadow cost a fullscreen pass per frame, and every frame here is
# also x264-encoded for the stream — on a virtio-gpu with no VirGL, in software.
wayland.windowManager.hyprland.extraConfig = ''
hl.config({
decoration = {
blur = { enabled = false },
shadow = { enabled = false },
},
})
'';
}
+11
View File
@@ -0,0 +1,11 @@
_:
{
# Hyprland 0.55+ is Lua-only (see home/common/programs/hyprland).
#
# Matches every output rather than naming one: the virtio-gpu connector name
# varies by qemu display backend (Virtual-1 vs Virtual-0). Explicit mode, not
# `preferred` — this is the resolution Sunshine streams.
wayland.windowManager.hyprland.extraConfig = ''
hl.monitor({ output = "", mode = "2560x1440@60", position = "0x0", scale = 1 })
'';
}
+193
View File
@@ -0,0 +1,193 @@
{
pkgs,
inputs,
...
}:
# The user services this box exists to run, ported from hand-written units in
# ~/.config/systemd/user on the Ubuntu machine.
#
# NOT self-contained: every ExecStart under ~/.bun or ~/.local/bin is an
# imperatively-installed binary, and the WorkingDirectories are clones of
# separate repos. Nix owns the unit definitions here, nothing more.
let
# A user unit gets almost no PATH by default; these are the profile dirs the
# original units got for free from the system PATH on Ubuntu.
toolPath = "%h/.local/bin:%h/.nix-profile/bin:/etc/profiles/per-user/naps62/bin:/run/current-system/sw/bin";
sem = inputs.sem.packages.${pkgs.system}.default;
# runtimeInputs is prepended to PATH, not a replacement, so `claude` still
# resolves from the unit's own PATH.
claude-rc-run = pkgs.writeShellApplication {
name = "claude-rc-run";
runtimeInputs = with pkgs; [
git
gawk
coreutils
];
text = builtins.readFile ./bin/claude-rc-run;
};
claude-rc = pkgs.writeShellApplication {
name = "claude-rc";
runtimeInputs = with pkgs; [
git
gawk
gnused
gnugrep
coreutils
systemd
];
text = builtins.readFile ./bin/claude-rc;
};
in
{
home.packages = [
claude-rc
sem
pkgs.bun
];
systemd.user.services = {
rev = {
Unit = {
Description = "rev always-on local code review server";
After = [ "network.target" ];
};
Service = {
Type = "simple";
WorkingDirectory = "%h/tea/yolo/rev";
# nodejs_26, not pkgs.nodejs: rev's package.json sets engines >=26 and
# the nixpkgs default is 24.
ExecStart = "${pkgs.nodejs_26}/bin/node server/index.ts";
Environment = [
"NODE_ENV=production"
"REV_ROOTS=%h"
"REV_DEPTH=3"
"REV_SEM_BIN=${sem}/bin/sem"
"PATH=${toolPath}"
];
Restart = "always";
RestartSec = 2;
};
Install.WantedBy = [ "default.target" ];
};
rev-deploy = {
Unit = {
Description = "rev-deploy Gitea webhook listener that deploys rev on push to main";
After = [ "network.target" ];
};
Service = {
Type = "simple";
WorkingDirectory = "%h/tea/yolo/rev";
ExecStart = "${pkgs.bun}/bin/bun scripts/deploy-webhook.ts";
EnvironmentFile = "%h/.config/rev/deploy.env";
Environment = [ "PATH=${toolPath}" ];
Restart = "always";
RestartSec = 2;
};
Install.WantedBy = [ "default.target" ];
};
"claude-rc@" = {
Unit = {
Description = "Claude Code Remote Control (/%I)";
Documentation = [ "https://code.claude.com/docs/en/remote-control" ];
After = [ "network-online.target" ];
Wants = [ "network-online.target" ];
StopWhenUnneeded = false;
};
Service = {
Type = "simple";
# Leading "-": a missing dir must not be fatal, or systemd fails with
# 200/CHDIR before claude-rc-run can report the friendlier exit 78.
WorkingDirectory = "-/%I";
ExecStart = "${claude-rc-run}/bin/claude-rc-run /%I";
Environment = [ "PATH=${toolPath}" ];
# `always`, not `on-failure`: a >10min outage times the session out and
# the process exits 0, which on-failure would not restart.
Restart = "always";
RestartSec = 15;
# 78 = dir gone; 200 = systemd CHDIR failure. Without these, a deleted
# project dir restart-loops every 15s forever.
RestartPreventExitStatus = "78 200";
StandardOutput = "append:%h/.local/state/claude-rc/%i.log";
StandardError = "inherit";
};
Install.WantedBy = [ "default.target" ];
};
claude-rc-sync = {
Unit.Description = "Reconcile Claude Remote Control servers with the project list";
Service = {
Type = "oneshot";
ExecStart = "${claude-rc}/bin/claude-rc sync";
Environment = [ "PATH=${toolPath}" ];
};
};
hourlog = {
Unit = {
Description = "Start the Friday hour log in a tmux session";
Documentation = [ "https://git.naps.pt/yolo/agent-skills" ];
ConditionPathIsDirectory = "%h/tea/yolo/agent-skills";
};
Service = {
Type = "oneshot";
ExecStart = "%h/tea/yolo/agent-skills/bin/hourlog-session.sh";
Environment = [ "PATH=${toolPath}" ];
# This unit may be what starts the tmux server; the default cgroup kill
# would take it back down as soon as ExecStart returns.
KillMode = "process";
};
};
week-review = {
Unit = {
Description = "Start the weekly agent-skills review in a tmux session";
Documentation = [ "https://git.naps.pt/yolo/agent-skills" ];
ConditionPathIsDirectory = "%h/tea/yolo/agent-skills";
};
Service = {
Type = "oneshot";
ExecStart = "%h/tea/yolo/agent-skills/bin/week-review-session.sh";
Environment = [ "PATH=${toolPath}" ];
KillMode = "process";
};
};
};
systemd.user.timers = {
hourlog = {
Unit.Description = "Friday hour log, 18:00 Europe/Lisbon";
Timer = {
# Zone suffix pinned because the machine clock is UTC; keeps it at 18:00
# wall time across DST.
OnCalendar = "Fri 18:00 Europe/Lisbon";
Persistent = true;
AccuracySec = "1min";
};
Install.WantedBy = [ "timers.target" ];
};
week-review = {
Unit.Description = "Weekly agent-skills review, Fridays 17:00 Europe/Lisbon";
Timer = {
OnCalendar = "Fri 17:00 Europe/Lisbon";
Persistent = true;
AccuracySec = "1min";
};
Install.WantedBy = [ "timers.target" ];
};
};
systemd.user.paths.claude-rc = {
Unit.Description = "Watch the Claude Remote Control project list for edits";
Path = {
PathChanged = "%h/.config/claude-rc/projects";
Unit = "claude-rc-sync.service";
};
Install.WantedBy = [ "default.target" ];
};
}