
Remote Gpu
- 2 installs
- 3 repo stars
- Updated August 5, 2026
- broomva/skills
remote-gpu is a Claude Code skill that orchestrates a headless GPU server from a local Mac, submitting and monitoring jobs over SSH or an HTTP API.
About
remote-gpu is a Claude Code skill that orchestrates a headless GPU server from a local Mac or workstation. It provides shell functions and a FastAPI job server to submit, monitor, cancel, and download GPU jobs over SSH or HTTP. Developers use it to run remote training, inference, and video generation, or to launch Claude Code sessions and agent loops on GPU hardware. It also covers SSH setup, tunnels, and rsync between the control machine and the server.
- Operate a headless GPU server (NUC, cloud VM, SSH host) from a local Mac
- Submit, monitor, cancel, and download GPU jobs over SSH or an HTTP API
- Run remote Claude Code sessions, training, inference, and video generation
Remote Gpu by the numbers
- 2 all-time installs (skills.sh)
- Ranked #1,138 of 1,435 DevOps & CI/CD skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
remote-gpu capabilities & compatibility
Free skill; you supply the remote GPU machine (NUC or cloud VM).
- Capabilities
- orchestration · devops
- Use cases
- orchestration · devops
- Platforms
- macOS
- Runs
- Local or remote
- Pricing
- Free
What remote-gpu says it does
Operate a headless GPU server from your Mac. Submit jobs (training, inference, agents), monitor progress, and retrieve results — all over SSH or HTTP API.
gpu-submit "python train.py --epochs 10" --workdir ~/project
Start a Claude Code session on the NUC
npx skills add https://github.com/broomva/skills --skill remote-gpuAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 3 |
| Last updated | August 5, 2026 |
| Repository | broomva/skills ↗ |
What it does
Submit and manage GPU training, inference, and agent jobs on a headless remote server from a local Mac over SSH or HTTP.
Who is it for?
Running GPU training, inference, video generation, or remote Claude Code sessions on a headless server from a Mac
Skip if: Local-only GPU work or workflows that do not involve a remote SSH-accessible machine
When should I use this skill?
Running GPU workloads remotely, managing jobs on a headless GPU server, or setting up SSH tunnels to a GPU machine
What you get
A job-based control loop that submits, monitors, cancels, and downloads GPU work over SSH or HTTP.
- gpu-remote.sh shell functions
- gpu-server.py FastAPI job server
- setup-nuc.sh provisioning script
By the numbers
- 10 gpu-* shell commands
- 8 HTTP API endpoints
- default job timeout 3600s
Files
Remote GPU Orchestrator
Operate a headless GPU server from your Mac. Submit jobs (training, inference, agents), monitor progress, and retrieve results — all over SSH or HTTP API.
Architecture
┌─────────────────┐ SSH / HTTP API ┌──────────────────────┐
│ Mac (Control) │ ──────────────────────────▶ │ NUC / GPU Server │
│ │ │ │
│ - Claude Code │ Commands: │ - RTX 4090 (12GB) │
│ - This skill │ submit_job │ - gpu-server.py │
│ - gpu-remote.sh │ check_status │ - Job queue │
│ │ stream_logs │ - Claude Code │
│ │ download_results │ - autoany / symphony│
│ │ run_claude_session │ - LTX-2 / training │
└─────────────────┘ └──────────────────────┘Quick Setup
1. Configure SSH Access
# On Mac — set up passwordless SSH to NUC
ssh-keygen -t ed25519 -f ~/.ssh/nuc_gpu
ssh-copy-id -i ~/.ssh/nuc_gpu.pub user@NUC_IP
# Add to ~/.ssh/config
cat >> ~/.ssh/config << 'EOF'
Host nuc-gpu
HostName NUC_IP_ADDRESS
User YOUR_USER
IdentityFile ~/.ssh/nuc_gpu
Port 22
ServerAliveInterval 60
EOF
# Test
ssh nuc-gpu "nvidia-smi"2. Install Server on NUC
# SSH into NUC
ssh nuc-gpu
# Copy and start the server
pip install fastapi uvicorn psutil
python gpu-server.py --port 8420 --workdir ~/gpu-jobsOr run scripts/setup-nuc.sh nuc-gpu from Mac to automate.
3. Use from Mac
# Via SSH (simplest)
source scripts/gpu-remote.sh
gpu-submit "python train.py --epochs 10" --workdir ~/project
gpu-status
gpu-logs job-abc123
gpu-download job-abc123
# Via HTTP API (if gpu-server.py running)
curl http://nuc-gpu:8420/submit -d '{"command":"python train.py"}'
curl http://nuc-gpu:8420/jobsJob Types
Training Runs
# Submit a training job
gpu-submit "cd ~/project && python train.py --config config.yaml" \
--name "lora-training-v2" \
--workdir ~/project
# Monitor GPU usage during training
gpu-watch # streams nvidia-smi every 5sVideo Generation (LTX-2)
gpu-submit "cd ~/LTX-2 && source .venv/bin/activate && \
python -m ltx_pipelines.run \
--config configs/ltx-2.3-22b-distilled-2stage.yaml \
--quantization fp8-cast \
--prompt 'A drone shot over mountains at dawn' \
--height 704 --width 1216 --num_frames 97 \
--output /tmp/output.mp4" \
--name "ltx-mountains" \
--download /tmp/output.mp4Claude Code Sessions
# Start a Claude Code session on the NUC
gpu-claude "Fix the failing tests in ~/project" --workdir ~/project
# Start with a specific branch
gpu-claude "Implement the feature described in PLAN.md" \
--workdir ~/project --branch feature/new-apiAutoany EGRI Loops
# Run an EGRI optimization loop on GPU
gpu-submit "cd ~/autoany && cargo run -- \
--config egri.toml \
--max-iterations 50 \
--target-metric accuracy" \
--name "egri-optimization"Symphony Orchestrations
# Launch a symphony workflow on GPU
gpu-submit "cd ~/symphony && cargo run -- \
orchestrate workflow.toml" \
--name "symphony-pipeline"Commands Reference
All commands work via the gpu-remote.sh shell functions:
| Command | Description |
|---|---|
gpu-submit CMD | Submit a job, returns job ID |
gpu-status | Show all jobs and GPU state |
gpu-logs JOB_ID | Stream logs from a job |
gpu-cancel JOB_ID | Cancel a running job |
gpu-download JOB_ID [FILE] | Download job output files |
gpu-watch | Live GPU monitoring (nvidia-smi) |
gpu-claude PROMPT | Start Claude Code session on NUC |
gpu-ssh | Interactive SSH to NUC |
gpu-sync DIR | rsync a directory to/from NUC |
gpu-tunnel PORT | SSH tunnel a port from NUC to localhost |
Options
--name NAME Human-readable job name
--workdir DIR Working directory on NUC
--branch BRANCH Git branch to checkout before running
--download FILE Auto-download this file when job completes
--gpu GPU_ID Target GPU index (default: 0)
--timeout SECS Job timeout (default: 3600)HTTP API (gpu-server.py)
If running the Python API server on the NUC:
| Endpoint | Method | Description |
|---|---|---|
/submit | POST | Submit job {command, name, workdir, timeout} |
/jobs | GET | List all jobs with status |
/jobs/{id} | GET | Job detail (status, logs, files) |
/jobs/{id}/logs | GET | Stream job logs (SSE) |
/jobs/{id}/cancel | POST | Cancel running job |
/jobs/{id}/files | GET | List output files |
/jobs/{id}/files/{name} | GET | Download a file |
/status | GET | GPU info, disk, memory |
See references/api-reference.md for full API documentation.
Configuration
Create ~/.config/gpu-remote/config.toml on Mac:
[server]
host = "nuc-gpu" # SSH host alias or IP
port = 8420 # API server port (if using HTTP)
user = "your-user" # SSH user
mode = "ssh" # "ssh" or "api"
[defaults]
workdir = "~/gpu-jobs"
timeout = 3600
gpu_id = 0
[sync]
exclude = [".git", "node_modules", "__pycache__", ".venv"]Troubleshooting
- SSH timeout: Add
ServerAliveInterval 60to SSH config - CUDA OOM: Check
gpu-statusfor other jobs using VRAM, cancel or wait - Job stuck: Use
gpu-logs JOB_IDto check output,gpu-cancel JOB_IDto kill - Server down: SSH in and restart:
ssh nuc-gpu "python gpu-server.py &" - File transfer slow: Use
gpu-sync(rsync) instead of individual downloads
GPU Remote Server — API Reference
Base URL
http://NUC_HOST:8420Authentication
None by default. Bind to localhost and use SSH tunnel for security:
ssh -L 8420:localhost:8420 nuc-gpu
# Then access at http://localhost:8420Endpoints
GET /status
Server and GPU health check.
Response:
{
"gpu": {
"name": "NVIDIA GeForce RTX 4090",
"memory_used_mb": 1024,
"memory_total_mb": 12288,
"utilization_pct": 45,
"temperature_c": 62
},
"disk": {
"total_gb": 500.0,
"used_gb": 120.5,
"free_gb": 379.5
},
"active_jobs": 1,
"queued_jobs": 2,
"total_jobs": 15
}POST /submit
Submit a job for execution.
Request:
{
"command": "python train.py --epochs 10",
"name": "training-v2",
"workdir": "/home/user/project",
"timeout": 3600,
"gpu_id": 0
}Response:
{
"job_id": "training-v2-a1b2c3d4",
"status": "queued"
}GET /jobs
List all jobs, newest first.
Response:
[
{
"id": "training-v2-a1b2c3d4",
"name": "training-v2",
"status": "running",
"created_at": 1711500000.0,
"started_at": 1711500001.0,
"finished_at": null,
"exit_code": null
}
]GET /jobs/{job_id}
Get detailed job info.
GET /jobs/{job_id}/logs?stream=stdout
Get job output logs. Use stream=stderr for error output.
Response:
{
"logs": "Epoch 1/10: loss=0.45\nEpoch 2/10: loss=0.32\n",
"status": "running"
}POST /jobs/{job_id}/cancel
Cancel a running or queued job.
GET /jobs/{job_id}/files
List output files in the job directory.
GET /jobs/{job_id}/files/{filename}
Download a specific file from the job directory.
Job Lifecycle
queued → running → completed (exit 0)
→ failed (exit != 0 or timeout)
→ cancelled (user cancelled)Common Patterns
Submit LTX-2 video generation
curl -X POST http://localhost:8420/submit \
-H "Content-Type: application/json" \
-d '{
"command": "cd ~/LTX-2 && source .venv/bin/activate && python -m ltx_pipelines.run --config configs/ltx-2.3-22b-distilled-2stage.yaml --quantization fp8-cast --prompt \"Mountains at dawn\" --output ~/gpu-jobs/output.mp4",
"name": "ltx-mountains",
"timeout": 600
}'Submit training run
curl -X POST http://localhost:8420/submit \
-H "Content-Type: application/json" \
-d '{
"command": "cd ~/project && python train.py --config config.yaml",
"name": "lora-training",
"workdir": "/home/user/project",
"timeout": 7200
}'Poll until complete
JOB_ID="training-v2-a1b2c3d4"
while true; do
STATUS=$(curl -s "http://localhost:8420/jobs/$JOB_ID" | jq -r .status)
echo "Status: $STATUS"
[[ "$STATUS" == "completed" || "$STATUS" == "failed" ]] && break
sleep 10
done#!/usr/bin/env bash
# gpu-remote.sh — Shell functions for orchestrating a headless GPU server
# Source this file: source gpu-remote.sh
# Requires: ssh, rsync, jq (optional for API mode)
set -euo pipefail
# --- Configuration ---
GPU_CONFIG="${HOME}/.config/gpu-remote/config.toml"
GPU_HOST="${GPU_HOST:-nuc-gpu}"
GPU_PORT="${GPU_PORT:-8420}"
GPU_MODE="${GPU_MODE:-ssh}" # "ssh" or "api"
GPU_WORKDIR="${GPU_WORKDIR:-~/gpu-jobs}"
GPU_USER="${GPU_USER:-$(whoami)}"
# Load config if exists
if [[ -f "$GPU_CONFIG" ]]; then
_parse_toml_value() { grep "^$1" "$GPU_CONFIG" 2>/dev/null | sed 's/.*= *"\(.*\)"/\1/' | head -1; }
GPU_HOST="${GPU_HOST:-$(_parse_toml_value host)}"
GPU_PORT="${GPU_PORT:-$(_parse_toml_value port)}"
GPU_MODE="${GPU_MODE:-$(_parse_toml_value mode)}"
GPU_WORKDIR="${GPU_WORKDIR:-$(_parse_toml_value workdir)}"
fi
# --- Helpers ---
_gpu_ssh() { ssh -o ConnectTimeout=10 "$GPU_HOST" "$@"; }
_gpu_api() {
local method="$1" path="$2"; shift 2
curl -s -X "$method" "http://${GPU_HOST}:${GPU_PORT}${path}" "$@"
}
_generate_job_id() { echo "job-$(date +%s)-$(head -c 4 /dev/urandom | xxd -p)"; }
_log() { echo "[gpu-remote] $*" >&2; }
# --- Core Commands ---
gpu-submit() {
# Submit a command to run on the GPU server
# Usage: gpu-submit "command" [--name NAME] [--workdir DIR] [--timeout SECS] [--download FILE]
local cmd="" name="" workdir="$GPU_WORKDIR" timeout=3600 download="" gpu_id=0 branch=""
while [[ $# -gt 0 ]]; do
case $1 in
--name) name="$2"; shift 2 ;;
--workdir) workdir="$2"; shift 2 ;;
--timeout) timeout="$2"; shift 2 ;;
--download) download="$2"; shift 2 ;;
--gpu) gpu_id="$2"; shift 2 ;;
--branch) branch="$2"; shift 2 ;;
--help)
echo "Usage: gpu-submit COMMAND [--name NAME] [--workdir DIR] [--timeout SECS] [--download FILE] [--gpu ID] [--branch BRANCH]"
return 0 ;;
*) cmd="$1"; shift ;;
esac
done
if [[ -z "$cmd" ]]; then echo "Error: no command provided"; return 1; fi
local job_id
job_id=$(_generate_job_id)
[[ -n "$name" ]] && job_id="${name}-$(date +%s)"
if [[ "$GPU_MODE" == "api" ]]; then
_gpu_api POST /submit \
-H "Content-Type: application/json" \
-d "{\"command\":\"$cmd\",\"name\":\"${name:-$job_id}\",\"workdir\":\"$workdir\",\"timeout\":$timeout}"
return
fi
# SSH mode
local job_dir="${GPU_WORKDIR}/${job_id}"
local branch_cmd=""
[[ -n "$branch" ]] && branch_cmd="git checkout $branch 2>/dev/null || true; "
_log "Submitting job: $job_id"
_gpu_ssh "mkdir -p $job_dir && cat > ${job_dir}/run.sh" <<SCRIPT
#!/bin/bash
set -euo pipefail
export CUDA_VISIBLE_DEVICES=$gpu_id
cd $workdir
${branch_cmd}
echo \$\$ > ${job_dir}/pid
echo "running" > ${job_dir}/status
echo "\$(date -Iseconds)" > ${job_dir}/started
{
timeout $timeout bash -c '$cmd'
echo \$? > ${job_dir}/exitcode
echo "completed" > ${job_dir}/status
} > ${job_dir}/stdout.log 2> ${job_dir}/stderr.log &
echo \$! > ${job_dir}/pid
disown
SCRIPT
_gpu_ssh "chmod +x ${job_dir}/run.sh && nohup ${job_dir}/run.sh &>/dev/null &"
_log "Job submitted: $job_id"
echo "$job_id"
# Auto-download on completion (background monitor)
if [[ -n "$download" ]]; then
(
while true; do
sleep 10
status=$(_gpu_ssh "cat ${job_dir}/status 2>/dev/null" || echo "unknown")
if [[ "$status" == "completed" ]]; then
_log "Job $job_id completed, downloading $download"
scp "${GPU_HOST}:${download}" "./"
break
elif [[ "$status" != "running" ]]; then
break
fi
done
) &
fi
}
gpu-status() {
# Show GPU state and all jobs
if [[ "$GPU_MODE" == "api" ]]; then
_gpu_api GET /status | jq . 2>/dev/null || _gpu_api GET /status
echo ""
_gpu_api GET /jobs | jq . 2>/dev/null || _gpu_api GET /jobs
return
fi
echo "=== GPU Status ==="
_gpu_ssh "nvidia-smi --query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu --format=csv,noheader" 2>/dev/null || echo "nvidia-smi unavailable"
echo ""
echo "=== Jobs ==="
_gpu_ssh "for d in ${GPU_WORKDIR}/job-* ${GPU_WORKDIR}/*-[0-9]*; do
[ -d \"\$d\" ] || continue
name=\$(basename \$d)
status=\$(cat \$d/status 2>/dev/null || echo 'unknown')
started=\$(cat \$d/started 2>/dev/null || echo 'n/a')
printf '%-30s %-12s %s\n' \"\$name\" \"\$status\" \"\$started\"
done" 2>/dev/null || echo "No jobs found"
}
gpu-logs() {
# Stream logs from a job
local job_id="${1:?Usage: gpu-logs JOB_ID}"
local log_type="${2:-stdout}" # stdout or stderr
if [[ "$GPU_MODE" == "api" ]]; then
_gpu_api GET "/jobs/${job_id}/logs"
return
fi
_gpu_ssh "tail -f ${GPU_WORKDIR}/${job_id}/${log_type}.log 2>/dev/null || echo 'No logs yet'"
}
gpu-cancel() {
# Cancel a running job
local job_id="${1:?Usage: gpu-cancel JOB_ID}"
if [[ "$GPU_MODE" == "api" ]]; then
_gpu_api POST "/jobs/${job_id}/cancel"
return
fi
local pid
pid=$(_gpu_ssh "cat ${GPU_WORKDIR}/${job_id}/pid 2>/dev/null")
if [[ -n "$pid" ]]; then
_gpu_ssh "kill -TERM $pid 2>/dev/null; echo 'cancelled' > ${GPU_WORKDIR}/${job_id}/status"
_log "Cancelled job $job_id (pid $pid)"
else
_log "No PID found for $job_id"
fi
}
gpu-download() {
# Download files from a job
local job_id="${1:?Usage: gpu-download JOB_ID [FILE]}"
local file="${2:-}"
if [[ -n "$file" ]]; then
scp "${GPU_HOST}:${file}" "./"
else
# Download all files from job directory
local dest="./${job_id}-output"
mkdir -p "$dest"
scp -r "${GPU_HOST}:${GPU_WORKDIR}/${job_id}/" "$dest/"
_log "Downloaded to $dest"
fi
}
gpu-watch() {
# Live GPU monitoring
_gpu_ssh "watch -n 5 nvidia-smi" 2>/dev/null || \
while true; do _gpu_ssh "nvidia-smi"; sleep 5; clear; done
}
gpu-claude() {
# Start a Claude Code session on the NUC
# Usage: gpu-claude "prompt" [--workdir DIR] [--branch BRANCH]
local prompt="" workdir="" branch=""
while [[ $# -gt 0 ]]; do
case $1 in
--workdir) workdir="$2"; shift 2 ;;
--branch) branch="$2"; shift 2 ;;
--help)
echo "Usage: gpu-claude PROMPT [--workdir DIR] [--branch BRANCH]"
return 0 ;;
*) prompt="$1"; shift ;;
esac
done
if [[ -z "$prompt" ]]; then echo "Error: no prompt provided"; return 1; fi
local cd_cmd=""
[[ -n "$workdir" ]] && cd_cmd="cd $workdir && "
[[ -n "$branch" ]] && cd_cmd="${cd_cmd}git checkout $branch 2>/dev/null; "
_log "Starting Claude Code session on $GPU_HOST"
# Use ssh -t for interactive session
ssh -t "$GPU_HOST" "${cd_cmd}claude --print '${prompt}'"
}
gpu-ssh() {
# Interactive SSH to NUC
ssh -t "$GPU_HOST" "${@:-bash}"
}
gpu-sync() {
# Sync a directory to/from NUC
# Usage: gpu-sync local_dir [remote_dir] [--from]
local local_dir="${1:?Usage: gpu-sync LOCAL_DIR [REMOTE_DIR] [--from]}"
local remote_dir="${2:-$local_dir}"
local direction="to"
[[ "${3:-}" == "--from" ]] && direction="from"
local exclude_args="--exclude .git --exclude node_modules --exclude __pycache__ --exclude .venv --exclude target"
if [[ "$direction" == "to" ]]; then
rsync -avz --progress $exclude_args "$local_dir/" "${GPU_HOST}:${remote_dir}/"
else
rsync -avz --progress $exclude_args "${GPU_HOST}:${remote_dir}/" "$local_dir/"
fi
}
gpu-tunnel() {
# SSH tunnel a port from NUC to localhost
local remote_port="${1:?Usage: gpu-tunnel REMOTE_PORT [LOCAL_PORT]}"
local local_port="${2:-$remote_port}"
_log "Tunneling ${GPU_HOST}:${remote_port} → localhost:${local_port}"
ssh -N -L "${local_port}:localhost:${remote_port}" "$GPU_HOST"
}
# --- Initialization ---
_log "GPU remote functions loaded. Host: $GPU_HOST, Mode: $GPU_MODE"
_log "Commands: gpu-submit, gpu-status, gpu-logs, gpu-cancel, gpu-download, gpu-watch, gpu-claude, gpu-ssh, gpu-sync, gpu-tunnel"
#!/usr/bin/env python3
"""gpu-server.py — Lightweight job server for a headless GPU machine.
Run on the NUC/GPU server:
pip install fastapi uvicorn psutil
python gpu-server.py --port 8420 --workdir ~/gpu-jobs
Accepts job submissions via HTTP, manages a single-GPU queue, and serves results.
"""
import argparse
import asyncio
import logging
import os
import shutil
import subprocess
import time
import uuid
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("gpu-server")
# --- Models ---
class JobStatus(str, Enum):
queued = "queued"
running = "running"
completed = "completed"
failed = "failed"
cancelled = "cancelled"
class SubmitRequest(BaseModel):
command: str
name: Optional[str] = None
workdir: Optional[str] = None
timeout: int = 3600
gpu_id: int = 0
@dataclass
class Job:
id: str
command: str
name: str
workdir: str
timeout: int
gpu_id: int
status: JobStatus = JobStatus.queued
created_at: float = field(default_factory=time.time)
started_at: Optional[float] = None
finished_at: Optional[float] = None
exit_code: Optional[int] = None
process: Optional[asyncio.subprocess.Process] = None
log_path: Optional[Path] = None
# --- Server ---
app = FastAPI(title="GPU Remote Server", version="1.0.0")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
JOBS: dict[str, Job] = {}
JOB_QUEUE: asyncio.Queue = asyncio.Queue()
WORKDIR: Path = Path.home() / "gpu-jobs"
def _gpu_info() -> dict:
"""Get GPU info via nvidia-smi."""
try:
result = subprocess.run(
["nvidia-smi", "--query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu",
"--format=csv,noheader,nounits"],
capture_output=True, text=True, timeout=10,
)
if result.returncode == 0:
parts = [p.strip() for p in result.stdout.strip().split(",")]
return {
"name": parts[0],
"memory_used_mb": int(parts[1]),
"memory_total_mb": int(parts[2]),
"utilization_pct": int(parts[3]),
"temperature_c": int(parts[4]),
}
except Exception:
pass
return {"error": "nvidia-smi unavailable"}
def _disk_info() -> dict:
total, used, free = shutil.disk_usage(WORKDIR)
return {"total_gb": round(total / 1e9, 1), "used_gb": round(used / 1e9, 1), "free_gb": round(free / 1e9, 1)}
async def _run_job(job: Job):
"""Execute a job as a subprocess."""
job.status = JobStatus.running
job.started_at = time.time()
job_dir = WORKDIR / job.id
job_dir.mkdir(parents=True, exist_ok=True)
job.log_path = job_dir
stdout_log = open(job_dir / "stdout.log", "w")
stderr_log = open(job_dir / "stderr.log", "w")
env = os.environ.copy()
env["CUDA_VISIBLE_DEVICES"] = str(job.gpu_id)
try:
job.process = await asyncio.create_subprocess_shell(
job.command,
stdout=stdout_log,
stderr=stderr_log,
cwd=job.workdir if os.path.isdir(job.workdir) else str(WORKDIR),
env=env,
)
try:
job.exit_code = await asyncio.wait_for(job.process.wait(), timeout=job.timeout)
job.status = JobStatus.completed if job.exit_code == 0 else JobStatus.failed
except asyncio.TimeoutError:
job.process.terminate()
job.exit_code = -1
job.status = JobStatus.failed
log.warning(f"Job {job.id} timed out after {job.timeout}s")
except Exception as e:
job.status = JobStatus.failed
job.exit_code = -1
stderr_log.write(f"\nServer error: {e}\n")
log.error(f"Job {job.id} failed: {e}")
finally:
stdout_log.close()
stderr_log.close()
job.finished_at = time.time()
job.process = None
log.info(f"Job {job.id} finished: {job.status.value} (exit={job.exit_code})")
async def _worker():
"""Process jobs from the queue one at a time."""
while True:
job_id = await JOB_QUEUE.get()
job = JOBS.get(job_id)
if job and job.status == JobStatus.queued:
await _run_job(job)
JOB_QUEUE.task_done()
@app.on_event("startup")
async def startup():
WORKDIR.mkdir(parents=True, exist_ok=True)
asyncio.create_task(_worker())
log.info(f"GPU server started. Workdir: {WORKDIR}")
# --- Endpoints ---
@app.get("/status")
async def status():
return {
"gpu": _gpu_info(),
"disk": _disk_info(),
"active_jobs": sum(1 for j in JOBS.values() if j.status == JobStatus.running),
"queued_jobs": sum(1 for j in JOBS.values() if j.status == JobStatus.queued),
"total_jobs": len(JOBS),
}
@app.post("/submit")
async def submit(req: SubmitRequest):
job_id = f"{req.name or 'job'}-{uuid.uuid4().hex[:8]}"
job = Job(
id=job_id,
command=req.command,
name=req.name or job_id,
workdir=req.workdir or str(WORKDIR),
timeout=req.timeout,
gpu_id=req.gpu_id,
)
JOBS[job_id] = job
await JOB_QUEUE.put(job_id)
log.info(f"Job submitted: {job_id} — {req.command[:80]}")
return {"job_id": job_id, "status": "queued"}
@app.get("/jobs")
async def list_jobs():
return [
{
"id": j.id, "name": j.name, "status": j.status.value,
"created_at": j.created_at, "started_at": j.started_at,
"finished_at": j.finished_at, "exit_code": j.exit_code,
}
for j in sorted(JOBS.values(), key=lambda x: x.created_at, reverse=True)
]
@app.get("/jobs/{job_id}")
async def get_job(job_id: str):
job = JOBS.get(job_id)
if not job:
raise HTTPException(404, "Job not found")
return {
"id": job.id, "name": job.name, "command": job.command,
"status": job.status.value, "workdir": job.workdir,
"created_at": job.created_at, "started_at": job.started_at,
"finished_at": job.finished_at, "exit_code": job.exit_code,
}
@app.get("/jobs/{job_id}/logs")
async def get_logs(job_id: str, stream: str = "stdout"):
job = JOBS.get(job_id)
if not job:
raise HTTPException(404, "Job not found")
log_file = WORKDIR / job_id / f"{stream}.log"
if not log_file.exists():
return {"logs": "", "status": job.status.value}
return {"logs": log_file.read_text(), "status": job.status.value}
@app.post("/jobs/{job_id}/cancel")
async def cancel_job(job_id: str):
job = JOBS.get(job_id)
if not job:
raise HTTPException(404, "Job not found")
if job.process and job.status == JobStatus.running:
job.process.terminate()
job.status = JobStatus.cancelled
job.finished_at = time.time()
return {"status": "cancelled"}
if job.status == JobStatus.queued:
job.status = JobStatus.cancelled
return {"status": "cancelled"}
return {"status": job.status.value, "message": "Job not running"}
@app.get("/jobs/{job_id}/files")
async def list_files(job_id: str):
job_dir = WORKDIR / job_id
if not job_dir.exists():
raise HTTPException(404, "Job directory not found")
files = []
for f in job_dir.iterdir():
if f.is_file():
files.append({"name": f.name, "size_bytes": f.stat().st_size})
return files
@app.get("/jobs/{job_id}/files/{filename}")
async def download_file(job_id: str, filename: str):
file_path = WORKDIR / job_id / filename
if not file_path.exists() or not file_path.is_file():
raise HTTPException(404, "File not found")
return FileResponse(file_path, filename=filename)
# --- Main ---
if __name__ == "__main__":
import uvicorn
parser = argparse.ArgumentParser(description="GPU Remote Server")
parser.add_argument("--port", type=int, default=8420, help="Server port (default: 8420)")
parser.add_argument("--host", default="0.0.0.0", help="Bind address (default: 0.0.0.0)")
parser.add_argument("--workdir", default=str(Path.home() / "gpu-jobs"), help="Job working directory")
args = parser.parse_args()
WORKDIR = Path(args.workdir)
WORKDIR.mkdir(parents=True, exist_ok=True)
uvicorn.run(app, host=args.host, port=args.port)
#!/usr/bin/env bash
# setup-nuc.sh — Bootstrap a headless GPU server (NUC or similar) from Mac
# Usage: bash setup-nuc.sh SSH_HOST [--with-ltx] [--with-claude] [--with-autoany]
set -euo pipefail
SSH_HOST="${1:?Usage: setup-nuc.sh SSH_HOST [--with-ltx] [--with-claude] [--with-autoany]}"
shift
INSTALL_LTX=false
INSTALL_CLAUDE=false
INSTALL_AUTOANY=false
while [[ $# -gt 0 ]]; do
case $1 in
--with-ltx) INSTALL_LTX=true; shift ;;
--with-claude) INSTALL_CLAUDE=true; shift ;;
--with-autoany) INSTALL_AUTOANY=true; shift ;;
--all) INSTALL_LTX=true; INSTALL_CLAUDE=true; INSTALL_AUTOANY=true; shift ;;
--help)
echo "Usage: setup-nuc.sh SSH_HOST [--with-ltx] [--with-claude] [--with-autoany] [--all]"
echo ""
echo "Bootstraps a headless GPU server with:"
echo " Base: gpu-server.py, Python deps, nvidia drivers check"
echo " --with-ltx Install LTX-2 video generation"
echo " --with-claude Install Claude Code CLI"
echo " --with-autoany Install autoany + Rust toolchain"
echo " --all Install everything"
exit 0 ;;
*) echo "Unknown: $1"; exit 1 ;;
esac
done
log() { echo "[setup-nuc] $*"; }
# --- Preflight ---
log "Testing SSH connection to $SSH_HOST..."
ssh -o ConnectTimeout=10 "$SSH_HOST" "echo 'SSH OK'" || { log "ERROR: Cannot SSH to $SSH_HOST"; exit 1; }
log "Checking GPU..."
ssh "$SSH_HOST" "nvidia-smi --query-gpu=name,memory.total --format=csv,noheader" || { log "WARNING: nvidia-smi failed — GPU drivers may not be installed"; }
# --- Base Setup ---
log "=== Installing base dependencies ==="
ssh "$SSH_HOST" << 'REMOTE'
set -euo pipefail
# Ensure Python 3.12+
if ! python3 -c "import sys; assert sys.version_info >= (3, 12)" 2>/dev/null; then
echo "Installing Python 3.12..."
sudo apt-get update -qq && sudo apt-get install -y -qq python3.12 python3.12-venv python3-pip 2>/dev/null || {
echo "WARNING: Could not install Python 3.12 via apt. Please install manually."
}
fi
# Install uv
if ! command -v uv &>/dev/null; then
echo "Installing uv..."
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
fi
# Install server deps
pip install --quiet fastapi uvicorn psutil httpx 2>/dev/null || \
pip3 install --quiet fastapi uvicorn psutil httpx
# Create workdir
mkdir -p ~/gpu-jobs
echo "Base setup complete"
REMOTE
# --- Copy gpu-server.py ---
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
log "Copying gpu-server.py to $SSH_HOST..."
scp "$SCRIPT_DIR/gpu-server.py" "${SSH_HOST}:~/gpu-server.py"
# --- Create systemd service ---
log "Setting up gpu-server as systemd service..."
ssh "$SSH_HOST" << 'REMOTE'
cat > /tmp/gpu-server.service << 'SERVICE'
[Unit]
Description=GPU Remote Server
After=network.target
[Service]
Type=simple
User=$USER
WorkingDirectory=%h
ExecStart=/usr/bin/python3 %h/gpu-server.py --port 8420
Restart=always
RestartSec=5
Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin
[Install]
WantedBy=multi-user.target
SERVICE
# Replace $USER with actual username
sed -i "s/\$USER/$(whoami)/g" /tmp/gpu-server.service
sudo cp /tmp/gpu-server.service /etc/systemd/system/gpu-server.service 2>/dev/null || {
echo "WARNING: Could not install systemd service (not running systemd or no sudo)."
echo "Start manually: python3 ~/gpu-server.py --port 8420 &"
}
sudo systemctl daemon-reload 2>/dev/null
sudo systemctl enable gpu-server 2>/dev/null
sudo systemctl start gpu-server 2>/dev/null
echo "gpu-server service installed and started"
REMOTE
# --- Optional: LTX-2 ---
if $INSTALL_LTX; then
log "=== Installing LTX-2 ==="
ssh "$SSH_HOST" << 'REMOTE'
set -euo pipefail
export PATH="$HOME/.local/bin:$PATH"
if [ ! -d ~/LTX-2 ]; then
git clone https://github.com/Lightricks/LTX-2.git ~/LTX-2
fi
cd ~/LTX-2
uv sync --frozen
echo "LTX-2 installed. Download models with:"
echo " huggingface-cli download Lightricks/LTX-2.3 ltx-2.3-22b-distilled.safetensors --local-dir models/"
REMOTE
fi
# --- Optional: Claude Code ---
if $INSTALL_CLAUDE; then
log "=== Installing Claude Code ==="
ssh "$SSH_HOST" << 'REMOTE'
if ! command -v claude &>/dev/null; then
npm install -g @anthropic-ai/claude-code 2>/dev/null || {
echo "Installing Node.js first..."
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - 2>/dev/null
sudo apt-get install -y nodejs 2>/dev/null
npm install -g @anthropic-ai/claude-code
}
fi
echo "Claude Code installed: $(claude --version 2>/dev/null || echo 'check PATH')"
REMOTE
fi
# --- Optional: Autoany + Rust ---
if $INSTALL_AUTOANY; then
log "=== Installing Rust + Autoany ==="
ssh "$SSH_HOST" << 'REMOTE'
set -euo pipefail
# Install Rust
if ! command -v rustup &>/dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source "$HOME/.cargo/env"
fi
# Clone autoany if not present
if [ ! -d ~/autoany ]; then
git clone https://github.com/broomva/autoany.git ~/autoany
fi
cd ~/autoany
cargo build --release
echo "Autoany installed"
REMOTE
fi
# --- Configure local Mac ---
log "=== Configuring local Mac ==="
mkdir -p ~/.config/gpu-remote
cat > ~/.config/gpu-remote/config.toml << EOF
[server]
host = "$SSH_HOST"
port = 8420
mode = "ssh"
[defaults]
workdir = "~/gpu-jobs"
timeout = 3600
gpu_id = 0
[sync]
exclude = [".git", "node_modules", "__pycache__", ".venv", "target"]
EOF
log ""
log "=== Setup Complete ==="
log ""
log "Usage from Mac:"
log " source $(dirname "$SCRIPT_DIR")/scripts/gpu-remote.sh"
log " gpu-status # Check GPU and jobs"
log " gpu-submit 'python train.py' # Submit a job"
log " gpu-claude 'Fix tests' --workdir ~ # Remote Claude session"
log ""
log "Or use the HTTP API:"
log " curl http://${SSH_HOST}:8420/status"
Related skills
FAQ
How do I submit a job to the remote GPU?
Source scripts/gpu-remote.sh and run gpu-submit with a command and workdir, or POST to the /submit HTTP endpoint on gpu-server.py.
Does it require an HTTP server on the GPU box?
No. The simplest mode is SSH via gpu-remote.sh shell functions; the FastAPI gpu-server.py HTTP API is optional.