Fix OAuth server for macOS bash 3.x (#24)

Three issues broke the OAuth callback server on macOS:

1. echo -e doesn't work in bash 3.x — \r\n appears as literal text
   in the HTTP response, browser gets malformed headers.
   Fix: pre-write response with printf to a file before the subshell.

2. local variables inside ( ... ) & subshell — undefined behavior in
   bash 3.x since subshells aren't function scope.
   Fix: use plain variables in subshells.

3. ((elapsed++)) when elapsed=0 evaluates to falsy — set -e kills
   the script on the first iteration of the timeout loop.
   Fix: use elapsed=$((elapsed + 1)) instead.

Also simplified nc_listen detection to only check for BusyBox
(the -p flag check could misfire on macOS nc).

Applied to all 10 lib/common.sh files.

Co-authored-by: Sprite <noreply@sprite.dev>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
L 2026-02-07 14:21:47 -08:00 committed by GitHub
parent 13896ba52d
commit 6ac59e6bb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 922 additions and 159 deletions

View file

@ -88,6 +88,9 @@ Research cloud providers with API-based provisioning. To add one:
```
spawn/
cli/
spawn.sh # Main CLI binary (interactive picker, matrix viewer, launcher)
install.sh # One-liner installer (downloads spawn.sh to ~/.local/bin)
{cloud}/
lib/common.sh # Cloud-specific shared functions
{agent}.sh # One script per agent
@ -97,6 +100,13 @@ spawn/
README.md # User-facing docs
```
## CLI (`cli/`)
The `spawn` CLI is a pure-bash binary that provides a unified entry point for the matrix.
- **`cli/spawn.sh`** — Main binary. Fetches manifest.json from GitHub (cached for 1hr at `~/.cache/spawn/`), parses it with `jq` or `python3` fallback, and provides: interactive picker (`spawn`), direct launch (`spawn <agent> <cloud>`), agent info (`spawn <agent>`), matrix table (`spawn list`), and self-update (`spawn update`). Installed to `~/.local/bin/spawn`.
- **`cli/install.sh`** — One-liner installer. Downloads `spawn.sh` to `~/.local/bin/spawn`, sets executable bit, and prints PATH instructions if needed. Override install dir with `SPAWN_INSTALL_DIR` env var.
## Script Conventions
- `#!/bin/bash` + `set -e`

View file

@ -2,6 +2,25 @@
One command to launch any AI coding agent on any cloud, pre-configured with [OpenRouter](https://openrouter.ai).
## Quick Start
Install the `spawn` CLI:
```bash
curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/cli/install.sh | bash
```
Then launch any agent interactively:
```bash
spawn # Interactive picker
spawn claude sprite # Launch Claude Code on Sprite
spawn aider hetzner # Launch Aider on Hetzner Cloud
spawn list # See the full matrix
```
Or run directly without installing:
```bash
bash <(curl -fsSL https://openrouter.ai/lab/spawn/{cloud}/{agent}.sh)
```

View file

@ -25,7 +25,7 @@ safe_read() {
nc_listen() {
local port=$1; shift
if nc --help 2>&1 | grep -q "BusyBox\|busybox" || nc --help 2>&1 | grep -q "\-p "; then
if nc --help 2>&1 | grep -q "BusyBox\|busybox"; then
nc -l -p "$port" "$@"
else nc -l "$port" "$@"; fi
}
@ -62,23 +62,29 @@ try_oauth_flow() {
local auth_url="https://openrouter.ai/auth?callback_url=${callback_url}"
local oauth_dir=$(mktemp -d) code_file="$oauth_dir/code"
log_warn "Starting local OAuth server on port ${callback_port}..."
# Write the HTTP response to a file (using printf for macOS bash 3.x compat)
local response_tpl="$oauth_dir/response.http"
printf 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n<html><head><style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e}.card{text-align:center;color:#fff}h1{color:#00d4aa}p{color:#ffffffcc}</style></head><body><div class="card"><h1>Authentication Successful!</h1><p>You can close this tab</p></div><script>setTimeout(function(){try{window.close()}catch(e){}},3000)</script></body></html>' > "$response_tpl"
# Background listener
(
local success_response='HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n<html><head><style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e}.card{text-align:center;color:#fff}h1{color:#00d4aa}p{color:#ffffffcc}</style></head><body><div class="card"><h1>Authentication Successful!</h1><p>You can close this tab</p></div><script>setTimeout(function(){try{window.close()}catch(e){}},3000)</script></body></html>'
while true; do
local response_file=$(mktemp); echo -e "$success_response" > "$response_file"
local request=$(nc_listen "$callback_port" < "$response_file" 2>/dev/null | head -1)
local nc_status=$?; rm -f "$response_file"
if [[ $nc_status -ne 0 ]]; then break; fi
if [[ "$request" == *"/callback?code="* ]]; then
echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file"; break
fi
request=$(nc_listen "$callback_port" < "$response_tpl" 2>/dev/null | head -1) || break
case "$request" in
*"/callback?code="*)
echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file"
break
;;
esac
done
) </dev/null &
local server_pid=$!; sleep 1
if ! kill -0 $server_pid 2>/dev/null; then log_warn "Failed to start OAuth server"; rm -rf "$oauth_dir"; return 1; fi
log_warn "Opening browser to authenticate with OpenRouter..."; open_browser "$auth_url"
local timeout=120 elapsed=0
while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do sleep 1; ((elapsed++)); done
while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do sleep 1; elapsed=$((elapsed + 1)); done
kill $server_pid 2>/dev/null || true; wait $server_pid 2>/dev/null || true
if [[ ! -f "$code_file" ]]; then log_warn "OAuth timeout"; rm -rf "$oauth_dir"; return 1; fi
local oauth_code=$(cat "$code_file"); rm -rf "$oauth_dir"
@ -224,7 +230,7 @@ create_server() {
return 0
fi
log_warn "Instance state: $state ($attempt/$max_attempts)"
sleep 5; ((attempt++))
sleep 5; attempt=$((attempt + 1))
done
log_error "Instance did not become running in time"; return 1
}
@ -236,7 +242,7 @@ verify_server_connectivity() {
if ssh $SSH_OPTS -o ConnectTimeout=5 "ubuntu@$ip" "echo ok" >/dev/null 2>&1; then
log_info "SSH connection established"; return 0
fi
log_warn "Waiting for SSH... ($attempt/$max_attempts)"; sleep 5; ((attempt++))
log_warn "Waiting for SSH... ($attempt/$max_attempts)"; sleep 5; attempt=$((attempt + 1))
done
log_error "Server failed to respond via SSH after $max_attempts attempts"; return 1
}
@ -248,7 +254,7 @@ wait_for_cloud_init() {
if ssh $SSH_OPTS "ubuntu@$ip" "test -f /home/ubuntu/.cloud-init-complete" >/dev/null 2>&1; then
log_info "Cloud-init completed"; return 0
fi
log_warn "Cloud-init in progress... ($attempt/$max_attempts)"; sleep 5; ((attempt++))
log_warn "Cloud-init in progress... ($attempt/$max_attempts)"; sleep 5; attempt=$((attempt + 1))
done
log_error "Cloud-init did not complete after $max_attempts attempts"; return 1
}

66
cli/install.sh Executable file
View file

@ -0,0 +1,66 @@
#!/bin/bash
# Installer for the spawn CLI
#
# Usage:
# curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/cli/install.sh | bash
#
# Override install directory:
# SPAWN_INSTALL_DIR=/usr/local/bin curl -fsSL ... | bash
set -euo pipefail
SPAWN_REPO="OpenRouterTeam/spawn"
SPAWN_RAW_BASE="https://raw.githubusercontent.com/$SPAWN_REPO/main"
INSTALL_DIR="${SPAWN_INSTALL_DIR:-$HOME/.local/bin}"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[spawn]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[spawn]${NC} $1"; }
log_error() { echo -e "${RED}[spawn]${NC} $1"; }
# Check curl
if ! command -v curl &>/dev/null; then
log_error "curl is required but not found"
exit 1
fi
# Create install directory
mkdir -p "$INSTALL_DIR"
# Download spawn CLI
log_info "Downloading spawn CLI..."
if ! curl -fsSL "$SPAWN_RAW_BASE/cli/spawn.sh" -o "$INSTALL_DIR/spawn"; then
log_error "Failed to download spawn CLI"
exit 1
fi
chmod +x "$INSTALL_DIR/spawn"
log_info "Installed spawn to $INSTALL_DIR/spawn"
# Check if install dir is in PATH
if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then
log_warn "$INSTALL_DIR is not in your PATH"
echo ""
echo "Add it by running one of:"
echo ""
# Detect shell and suggest appropriate config
case "${SHELL:-/bin/bash}" in
*/zsh)
echo " echo 'export PATH=\"$INSTALL_DIR:\$PATH\"' >> ~/.zshrc && source ~/.zshrc"
;;
*/fish)
echo " fish_add_path $INSTALL_DIR"
;;
*)
echo " echo 'export PATH=\"$INSTALL_DIR:\$PATH\"' >> ~/.bashrc && source ~/.bashrc"
;;
esac
echo ""
else
log_info "Run 'spawn' to get started"
fi

650
cli/spawn.sh Executable file
View file

@ -0,0 +1,650 @@
#!/bin/bash
# spawn — Dynamic entry point for the Spawn matrix
#
# Launch any AI coding agent on any cloud, pre-configured with OpenRouter.
# Fetches the manifest dynamically from GitHub so it's always up-to-date.
#
# Usage:
# spawn Interactive agent + cloud picker
# spawn <agent> <cloud> Launch agent on cloud directly
# spawn <agent> Show available clouds for agent
# spawn list Full matrix table
# spawn agents List all agents with descriptions
# spawn clouds List all cloud providers
# spawn improve [--loop] Run improvement system
# spawn update Self-update from GitHub
# spawn version Show version
# spawn help Show this help
set -uo pipefail
# ── Constants ──────────────────────────────────────────────────────────────────
SPAWN_VERSION="0.1.0"
SPAWN_REPO="OpenRouterTeam/spawn"
SPAWN_RAW_BASE="https://raw.githubusercontent.com/$SPAWN_REPO/main"
SPAWN_CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/spawn"
SPAWN_MANIFEST="$SPAWN_CACHE_DIR/manifest.json"
SPAWN_CACHE_TTL=3600 # 1 hour in seconds
# ── Colors & Logging ──────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
BOLD='\033[1m'
DIM='\033[2m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[spawn]${NC} $1" >&2; }
log_warn() { echo -e "${YELLOW}[spawn]${NC} $1" >&2; }
log_error() { echo -e "${RED}[spawn]${NC} $1" >&2; }
# ── Dependency Checks ─────────────────────────────────────────────────────────
HAS_JQ=false
HAS_PYTHON3=false
check_deps() {
if ! command -v curl &>/dev/null; then
log_error "curl is required but not found"
exit 1
fi
command -v jq &>/dev/null && HAS_JQ=true
command -v python3 &>/dev/null && HAS_PYTHON3=true
if ! $HAS_JQ && ! $HAS_PYTHON3; then
log_error "Either jq or python3 is required for JSON parsing"
exit 1
fi
}
# ── JSON Helpers ───────────────────────────────────────────────────────────────
# Each helper tries jq first, falls back to python3.
json_validate() {
local file="$1"
if $HAS_JQ; then
jq empty "$file" 2>/dev/null
elif $HAS_PYTHON3; then
python3 -c "import json; json.load(open('$file'))" 2>/dev/null
fi
}
# List agent keys (one per line)
manifest_agents() {
if $HAS_JQ; then
jq -r '.agents | keys[]' "$SPAWN_MANIFEST"
else
python3 -c "
import json
m = json.load(open('$SPAWN_MANIFEST'))
for k in m['agents']:
print(k)
"
fi
}
# List cloud keys (one per line)
manifest_clouds() {
if $HAS_JQ; then
jq -r '.clouds | keys[]' "$SPAWN_MANIFEST"
else
python3 -c "
import json
m = json.load(open('$SPAWN_MANIFEST'))
for k in m['clouds']:
print(k)
"
fi
}
# Get agent display name
manifest_agent_name() {
local agent="$1"
if $HAS_JQ; then
jq -r --arg a "$agent" '.agents[$a].name // empty' "$SPAWN_MANIFEST"
else
python3 -c "
import json
m = json.load(open('$SPAWN_MANIFEST'))
a = m['agents'].get('$agent', {})
print(a.get('name', ''))
"
fi
}
# Get agent description
manifest_agent_desc() {
local agent="$1"
if $HAS_JQ; then
jq -r --arg a "$agent" '.agents[$a].description // empty' "$SPAWN_MANIFEST"
else
python3 -c "
import json
m = json.load(open('$SPAWN_MANIFEST'))
a = m['agents'].get('$agent', {})
print(a.get('description', ''))
"
fi
}
# Get cloud display name
manifest_cloud_name() {
local cloud="$1"
if $HAS_JQ; then
jq -r --arg c "$cloud" '.clouds[$c].name // empty' "$SPAWN_MANIFEST"
else
python3 -c "
import json
m = json.load(open('$SPAWN_MANIFEST'))
c = m['clouds'].get('$cloud', {})
print(c.get('name', ''))
"
fi
}
# Get cloud description
manifest_cloud_desc() {
local cloud="$1"
if $HAS_JQ; then
jq -r --arg c "$cloud" '.clouds[$c].description // empty' "$SPAWN_MANIFEST"
else
python3 -c "
import json
m = json.load(open('$SPAWN_MANIFEST'))
c = m['clouds'].get('$cloud', {})
print(c.get('description', ''))
"
fi
}
# Get matrix status for cloud/agent
manifest_matrix_status() {
local cloud="$1" agent="$2"
if $HAS_JQ; then
jq -r --arg key "$cloud/$agent" '.matrix[$key] // "missing"' "$SPAWN_MANIFEST"
else
python3 -c "
import json
m = json.load(open('$SPAWN_MANIFEST'))
print(m.get('matrix', {}).get('$cloud/$agent', 'missing'))
"
fi
}
# Count implemented entries
manifest_count_implemented() {
if $HAS_JQ; then
jq '[.matrix | to_entries[] | select(.value == "implemented")] | length' "$SPAWN_MANIFEST"
else
python3 -c "
import json
m = json.load(open('$SPAWN_MANIFEST'))
print(sum(1 for v in m.get('matrix', {}).values() if v == 'implemented'))
"
fi
}
# ── Manifest Cache ─────────────────────────────────────────────────────────────
file_age_seconds() {
local file="$1"
local now
now=$(date +%s)
local mtime
# macOS uses -f, Linux uses -c
if stat -f %m "$file" &>/dev/null; then
mtime=$(stat -f %m "$file")
else
mtime=$(stat -c %Y "$file" 2>/dev/null || echo 0)
fi
echo $(( now - mtime ))
}
ensure_manifest() {
mkdir -p "$SPAWN_CACHE_DIR"
# Check if cache exists and is fresh
if [[ -f "$SPAWN_MANIFEST" ]]; then
local age
age=$(file_age_seconds "$SPAWN_MANIFEST")
if (( age < SPAWN_CACHE_TTL )); then
return 0
fi
fi
log_info "Fetching manifest..."
local tmp
tmp=$(mktemp)
if curl -fsSL "$SPAWN_RAW_BASE/manifest.json" -o "$tmp" 2>/dev/null; then
if json_validate "$tmp"; then
mv "$tmp" "$SPAWN_MANIFEST"
return 0
else
rm -f "$tmp"
log_warn "Downloaded manifest is invalid JSON"
fi
else
rm -f "$tmp"
log_warn "Failed to fetch manifest"
fi
# Offline fallback: use stale cache if available
if [[ -f "$SPAWN_MANIFEST" ]]; then
log_warn "Using cached manifest (offline fallback)"
return 0
fi
log_error "No manifest available. Check your internet connection."
exit 1
}
# ── Interactive Picker ─────────────────────────────────────────────────────────
picker() {
local prompt="$1"
shift
local -a items=("$@")
local count=${#items[@]}
echo "" >&2
echo -e "${BOLD}${prompt}${NC}" >&2
echo "" >&2
local i
for (( i = 0; i < count; i++ )); do
printf " %s%2d)%s %s\n" "${GREEN}" $(( i + 1 )) "${NC}" "${items[$i]}" >&2
done
echo "" >&2
local choice
while true; do
printf " Enter number (1-%d): " "$count" >&2
read -r choice </dev/tty
if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= count )); then
echo $(( choice - 1 ))
return 0
fi
log_warn "Invalid choice. Enter a number between 1 and $count."
done
}
# ── Commands ───────────────────────────────────────────────────────────────────
cmd_interactive() {
ensure_manifest
# Build agent list with descriptions
local -a agent_keys=()
local -a agent_labels=()
while IFS= read -r key; do
agent_keys+=("$key")
local name desc
name=$(manifest_agent_name "$key")
desc=$(manifest_agent_desc "$key")
agent_labels+=("${name} ${DIM}- ${desc}${NC}")
done < <(manifest_agents)
if (( ${#agent_keys[@]} == 0 )); then
log_error "No agents found in manifest"
exit 1
fi
local agent_idx
agent_idx=$(picker "Select an agent:" "${agent_labels[@]}")
local agent="${agent_keys[$agent_idx]}"
# Build cloud list — only show implemented clouds for this agent
local -a cloud_keys=()
local -a cloud_labels=()
while IFS= read -r key; do
local status
status=$(manifest_matrix_status "$key" "$agent")
if [[ "$status" == "implemented" ]]; then
cloud_keys+=("$key")
local name desc
name=$(manifest_cloud_name "$key")
desc=$(manifest_cloud_desc "$key")
cloud_labels+=("${name} ${DIM}- ${desc}${NC}")
fi
done < <(manifest_clouds)
if (( ${#cloud_keys[@]} == 0 )); then
local aname
aname=$(manifest_agent_name "$agent")
log_error "No implemented clouds found for $aname"
exit 1
fi
local cloud_idx
cloud_idx=$(picker "Select a cloud provider:" "${cloud_labels[@]}")
local cloud="${cloud_keys[$cloud_idx]}"
cmd_run "$agent" "$cloud"
}
cmd_run() {
local agent="$1" cloud="$2"
ensure_manifest
local agent_name cloud_name status
agent_name=$(manifest_agent_name "$agent")
cloud_name=$(manifest_cloud_name "$cloud")
if [[ -z "$agent_name" ]]; then
log_error "Unknown agent: $agent"
echo "Run 'spawn agents' to see available agents." >&2
exit 1
fi
if [[ -z "$cloud_name" ]]; then
log_error "Unknown cloud: $cloud"
echo "Run 'spawn clouds' to see available clouds." >&2
exit 1
fi
status=$(manifest_matrix_status "$cloud" "$agent")
if [[ "$status" != "implemented" ]]; then
log_error "$agent_name on $cloud_name is not yet implemented"
exit 1
fi
log_info "Launching ${BOLD}${agent_name}${NC}${GREEN} on ${BOLD}${cloud_name}${NC}${GREEN}...${NC}"
local url="https://openrouter.ai/lab/spawn/${cloud}/${agent}.sh"
bash <(curl -fsSL "$url")
}
cmd_list() {
ensure_manifest
if $HAS_PYTHON3; then
cmd_list_python
elif $HAS_JQ; then
cmd_list_jq
fi
}
cmd_list_jq() {
jq -r '
def pad($n): . + (" " * ($n - length)) | .[:$n];
def status_icon: if . == "implemented" then " ✓" else " -" end;
. as $root |
($root.agents | keys) as $agents |
($root.clouds | keys) as $clouds |
(("" | pad(18)) + ($clouds | map(. as $c | $root.clouds[$c].name | pad(14)) | join(""))),
($agents[] as $agent |
($root.agents[$agent].name | pad(18)) +
($clouds | map(. as $cloud |
($root.matrix["\($cloud)/\($agent)"] // "missing" | status_icon | pad(14))
) | join(""))
),
"",
([$root.matrix | to_entries[] | select(.value == "implemented")] | length | tostring) +
"/" +
(($agents | length) * ($clouds | length) | tostring) +
" implemented"
' "$SPAWN_MANIFEST"
}
cmd_list_python() {
python3 -c "
import json
m = json.load(open('$SPAWN_MANIFEST'))
agents = list(m['agents'].keys())
clouds = list(m['clouds'].keys())
matrix = m.get('matrix', {})
G = '\033[0;32m'
R = '\033[0;31m'
D = '\033[2m'
B = '\033[1m'
NC = '\033[0m'
# Header
header = f'{\"\":18s}'
for c in clouds:
header += f'{m[\"clouds\"][c][\"name\"]:14s}'
print(B + header + NC)
# Rows
for a in agents:
row = f'{m[\"agents\"][a][\"name\"]:18s}'
for c in clouds:
key = f'{c}/{a}'
status = matrix.get(key, 'missing')
if status == 'implemented':
row += G + f'{\" ✓\":14s}' + NC
else:
row += D + f'{\" -\":14s}' + NC
print(row)
# Summary
impl = sum(1 for v in matrix.values() if v == 'implemented')
total = len(agents) * len(clouds)
print(f'\n{impl}/{total} implemented')
"
}
cmd_agents() {
ensure_manifest
echo ""
echo -e "${BOLD}Agents${NC}"
echo ""
while IFS= read -r key; do
local name desc
name=$(manifest_agent_name "$key")
desc=$(manifest_agent_desc "$key")
printf " ${GREEN}%-16s${NC} %s\n" "$name" "$desc"
done < <(manifest_agents)
echo ""
}
cmd_clouds() {
ensure_manifest
echo ""
echo -e "${BOLD}Cloud Providers${NC}"
echo ""
while IFS= read -r key; do
local name desc
name=$(manifest_cloud_name "$key")
desc=$(manifest_cloud_desc "$key")
printf " ${GREEN}%-16s${NC} %s\n" "$name" "$desc"
done < <(manifest_clouds)
echo ""
}
cmd_agent_info() {
local agent="$1"
ensure_manifest
local agent_name agent_desc
agent_name=$(manifest_agent_name "$agent")
agent_desc=$(manifest_agent_desc "$agent")
if [[ -z "$agent_name" ]]; then
log_error "Unknown agent: $agent"
echo "Run 'spawn agents' to see available agents." >&2
exit 1
fi
echo ""
echo -e "${BOLD}${agent_name}${NC}${agent_desc}"
echo ""
echo -e "${BOLD}Available clouds:${NC}"
echo ""
local found=false
while IFS= read -r cloud; do
local status
status=$(manifest_matrix_status "$cloud" "$agent")
if [[ "$status" == "implemented" ]]; then
local cloud_name
cloud_name=$(manifest_cloud_name "$cloud")
printf " ${GREEN}%-16s${NC} spawn %s %s\n" "$cloud_name" "$agent" "$cloud"
found=true
fi
done < <(manifest_clouds)
if ! $found; then
echo " No implemented clouds yet."
fi
echo ""
}
cmd_improve() {
shift # remove 'improve' from args
local repo_dir
# Check if we're already in the spawn repo
if [[ -f "./improve.sh" && -f "./manifest.json" ]]; then
repo_dir="."
else
repo_dir="$SPAWN_CACHE_DIR/repo"
if [[ -d "$repo_dir/.git" ]]; then
log_info "Updating spawn repo..."
git -C "$repo_dir" pull --ff-only 2>/dev/null || true
else
log_info "Cloning spawn repo..."
git clone "https://github.com/$SPAWN_REPO.git" "$repo_dir"
fi
fi
(cd "$repo_dir" && bash improve.sh "$@")
}
cmd_update() {
local self
self=$(command -v spawn 2>/dev/null || echo "")
if [[ -z "$self" ]]; then
# Try common install locations
if [[ -f "$HOME/.local/bin/spawn" ]]; then
self="$HOME/.local/bin/spawn"
else
log_error "Cannot find spawn binary for self-update"
exit 1
fi
fi
log_info "Checking for updates..."
local tmp
tmp=$(mktemp)
if curl -fsSL "$SPAWN_RAW_BASE/cli/spawn.sh" -o "$tmp" 2>/dev/null; then
local remote_version
remote_version=$(grep '^SPAWN_VERSION=' "$tmp" | head -1 | cut -d'"' -f2)
if [[ -z "$remote_version" ]]; then
rm -f "$tmp"
log_error "Could not determine remote version"
exit 1
fi
if [[ "$remote_version" == "$SPAWN_VERSION" ]]; then
rm -f "$tmp"
log_info "Already up to date (v${SPAWN_VERSION})"
return 0
fi
chmod +x "$tmp"
mv "$tmp" "$self"
log_info "Updated: v${SPAWN_VERSION} → v${remote_version}"
# Invalidate manifest cache on update
rm -f "$SPAWN_MANIFEST"
else
rm -f "$tmp"
log_error "Failed to download update"
exit 1
fi
}
cmd_help() {
echo -e "
${BOLD}spawn${NC} — Launch any AI coding agent on any cloud
${BOLD}USAGE${NC}
spawn Interactive agent + cloud picker
spawn <agent> <cloud> Launch agent on cloud directly
spawn <agent> Show available clouds for agent
spawn list Full matrix table
spawn agents List all agents with descriptions
spawn clouds List all cloud providers
spawn improve [--loop] Run improvement system (wraps improve.sh)
spawn update Self-update from GitHub
spawn version Show version
spawn help Show this help
${BOLD}EXAMPLES${NC}
spawn Pick interactively
spawn claude sprite Launch Claude Code on Sprite
spawn aider hetzner Launch Aider on Hetzner Cloud
spawn claude Show which clouds support Claude Code
spawn list See the full agent x cloud matrix
${BOLD}INSTALL${NC}
curl -fsSL $SPAWN_RAW_BASE/cli/install.sh | bash
"
}
# ── Main ───────────────────────────────────────────────────────────────────────
main() {
check_deps
if (( $# == 0 )); then
cmd_interactive
return
fi
case "$1" in
help|--help|-h)
cmd_help
;;
version|--version|-v|-V)
echo "spawn v${SPAWN_VERSION}"
;;
list|ls)
cmd_list
;;
agents)
cmd_agents
;;
clouds)
cmd_clouds
;;
improve)
cmd_improve "$@"
;;
update)
cmd_update
;;
*)
# Could be: spawn <agent> or spawn <agent> <cloud>
local agent="$1"
ensure_manifest
# Check if it's a valid agent
local agent_name
agent_name=$(manifest_agent_name "$agent")
if [[ -z "$agent_name" ]]; then
log_error "Unknown command or agent: $agent"
echo "Run 'spawn help' for usage." >&2
exit 1
fi
if (( $# >= 2 )); then
cmd_run "$agent" "$2"
else
cmd_agent_info "$agent"
fi
;;
esac
}
main "$@"

View file

@ -45,7 +45,7 @@ safe_read() {
nc_listen() {
local port=$1
shift
if nc --help 2>&1 | grep -q "BusyBox\|busybox" || nc --help 2>&1 | grep -q "\-p "; then
if nc --help 2>&1 | grep -q "BusyBox\|busybox"; then
nc -l -p "$port" "$@"
else
nc -l "$port" "$@"
@ -113,26 +113,21 @@ try_oauth_flow() {
log_warn "Starting local OAuth server on port ${callback_port}..."
# Write the HTTP response to a file (using printf for macOS bash 3.x compat)
local response_tpl="$oauth_dir/response.http"
printf 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n<html><head><style>@keyframes checkmark{0%{transform:scale(0) rotate(-45deg);opacity:0}60%{transform:scale(1.2) rotate(-45deg);opacity:1}100%{transform:scale(1) rotate(-45deg);opacity:1}}@keyframes fadein{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e}.card{text-align:center;color:#fff}.check{width:80px;height:80px;border-radius:50%;background:#00d4aa22;display:flex;align-items:center;justify-content:center;margin:0 auto 24px}.check::after{content:"";display:block;width:28px;height:14px;border-left:4px solid #00d4aa;border-bottom:4px solid #00d4aa;animation:checkmark .5s ease forwards}h1{color:#00d4aa;margin:0 0 8px;font-size:1.6rem}p{margin:0 0 6px;color:#ffffffcc;font-size:1rem}.sub{color:#ffffff66;font-size:.85rem;animation:fadein .5s ease .5s both}</style></head><body><div class="card"><div class="check"></div><h1>Authentication Successful!</h1><p>Redirecting back to terminal...</p><p class="sub">This tab will close automatically</p></div><script>setTimeout(function(){try{window.close()}catch(e){}setTimeout(function(){document.querySelector(".sub").textContent="You can safely close this tab"},500)},3000)</script></body></html>' > "$response_tpl"
# Background listener
(
local success_response='HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n<html><head><style>@keyframes checkmark{0%{transform:scale(0) rotate(-45deg);opacity:0}60%{transform:scale(1.2) rotate(-45deg);opacity:1}100%{transform:scale(1) rotate(-45deg);opacity:1}}@keyframes fadein{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e}.card{text-align:center;color:#fff}.check{width:80px;height:80px;border-radius:50%;background:#00d4aa22;display:flex;align-items:center;justify-content:center;margin:0 auto 24px}.check::after{content:"";display:block;width:28px;height:14px;border-left:4px solid #00d4aa;border-bottom:4px solid #00d4aa;animation:checkmark .5s ease forwards}h1{color:#00d4aa;margin:0 0 8px;font-size:1.6rem}p{margin:0 0 6px;color:#ffffffcc;font-size:1rem}.sub{color:#ffffff66;font-size:.85rem;animation:fadein .5s ease .5s both}</style></head><body><div class="card"><div class="check"></div><h1>Authentication Successful!</h1><p>Redirecting back to terminal...</p><p class="sub">This tab will close automatically</p></div><script>setTimeout(function(){try{window.close()}catch(e){}setTimeout(function(){document.querySelector(".sub").textContent="You can safely close this tab"},500)},3000)</script></body></html>'
while true; do
local response_file=$(mktemp)
echo -e "$success_response" > "$response_file"
local request=$(nc_listen "$callback_port" < "$response_file" 2>/dev/null | head -1)
local nc_status=$?
rm -f "$response_file"
if [[ $nc_status -ne 0 ]]; then
break
fi
if [[ "$request" == *"/callback?code="* ]]; then
local code=$(echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p')
echo "$code" > "$code_file"
break
fi
request=$(nc_listen "$callback_port" < "$response_tpl" 2>/dev/null | head -1) || break
case "$request" in
*"/callback?code="*)
echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file"
break
;;
esac
done
) </dev/null &
local server_pid=$!
@ -152,7 +147,7 @@ try_oauth_flow() {
local elapsed=0
while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do
sleep 1
((elapsed++))
elapsed=$((elapsed + 1))
done
kill $server_pid 2>/dev/null || true
@ -452,7 +447,7 @@ for net in data['droplet']['networks']['v4']:
log_warn "Droplet status: $status ($attempt/$max_attempts)"
sleep 5
((attempt++))
attempt=$((attempt + 1))
done
log_error "Droplet did not become active in time"
@ -473,7 +468,7 @@ verify_server_connectivity() {
fi
log_warn "Waiting for SSH... ($attempt/$max_attempts)"
sleep 5
((attempt++))
attempt=$((attempt + 1))
done
log_error "Server failed to respond via SSH after $max_attempts attempts"
@ -494,7 +489,7 @@ wait_for_cloud_init() {
fi
log_warn "Cloud-init in progress... ($attempt/$max_attempts)"
sleep 5
((attempt++))
attempt=$((attempt + 1))
done
log_error "Cloud-init did not complete after $max_attempts attempts"

View file

@ -27,7 +27,7 @@ safe_read() {
nc_listen() {
local port=$1; shift
if nc --help 2>&1 | grep -q "BusyBox\|busybox" || nc --help 2>&1 | grep -q "\-p "; then
if nc --help 2>&1 | grep -q "BusyBox\|busybox"; then
nc -l -p "$port" "$@"
else nc -l "$port" "$@"; fi
}
@ -64,23 +64,29 @@ try_oauth_flow() {
local auth_url="https://openrouter.ai/auth?callback_url=${callback_url}"
local oauth_dir=$(mktemp -d) code_file="$oauth_dir/code"
log_warn "Starting local OAuth server on port ${callback_port}..."
# Write the HTTP response to a file (using printf for macOS bash 3.x compat)
local response_tpl="$oauth_dir/response.http"
printf 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n<html><head><style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e}.card{text-align:center;color:#fff}h1{color:#00d4aa}p{color:#ffffffcc}</style></head><body><div class="card"><h1>Authentication Successful!</h1><p>You can close this tab</p></div><script>setTimeout(function(){try{window.close()}catch(e){}},3000)</script></body></html>' > "$response_tpl"
# Background listener
(
local success_response='HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n<html><head><style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e}.card{text-align:center;color:#fff}h1{color:#00d4aa}p{color:#ffffffcc}</style></head><body><div class="card"><h1>Authentication Successful!</h1><p>You can close this tab</p></div><script>setTimeout(function(){try{window.close()}catch(e){}},3000)</script></body></html>'
while true; do
local response_file=$(mktemp); echo -e "$success_response" > "$response_file"
local request=$(nc_listen "$callback_port" < "$response_file" 2>/dev/null | head -1)
local nc_status=$?; rm -f "$response_file"
if [[ $nc_status -ne 0 ]]; then break; fi
if [[ "$request" == *"/callback?code="* ]]; then
echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file"; break
fi
request=$(nc_listen "$callback_port" < "$response_tpl" 2>/dev/null | head -1) || break
case "$request" in
*"/callback?code="*)
echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file"
break
;;
esac
done
) </dev/null &
local server_pid=$!; sleep 1
if ! kill -0 $server_pid 2>/dev/null; then log_warn "Failed to start OAuth server"; rm -rf "$oauth_dir"; return 1; fi
log_warn "Opening browser to authenticate with OpenRouter..."; open_browser "$auth_url"
local timeout=120 elapsed=0
while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do sleep 1; ((elapsed++)); done
while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do sleep 1; elapsed=$((elapsed + 1)); done
kill $server_pid 2>/dev/null || true; wait $server_pid 2>/dev/null || true
if [[ ! -f "$code_file" ]]; then log_warn "OAuth timeout"; rm -rf "$oauth_dir"; return 1; fi
local oauth_code=$(cat "$code_file"); rm -rf "$oauth_dir"

View file

@ -25,7 +25,7 @@ safe_read() {
nc_listen() {
local port=$1; shift
if nc --help 2>&1 | grep -q "BusyBox\|busybox" || nc --help 2>&1 | grep -q "\-p "; then
if nc --help 2>&1 | grep -q "BusyBox\|busybox"; then
nc -l -p "$port" "$@"
else nc -l "$port" "$@"; fi
}
@ -62,23 +62,29 @@ try_oauth_flow() {
local auth_url="https://openrouter.ai/auth?callback_url=${callback_url}"
local oauth_dir=$(mktemp -d) code_file="$oauth_dir/code"
log_warn "Starting local OAuth server on port ${callback_port}..."
# Write the HTTP response to a file (using printf for macOS bash 3.x compat)
local response_tpl="$oauth_dir/response.http"
printf 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n<html><head><style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e}.card{text-align:center;color:#fff}h1{color:#00d4aa}p{color:#ffffffcc}</style></head><body><div class="card"><h1>Authentication Successful!</h1><p>You can close this tab</p></div><script>setTimeout(function(){try{window.close()}catch(e){}},3000)</script></body></html>' > "$response_tpl"
# Background listener
(
local success_response='HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n<html><head><style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e}.card{text-align:center;color:#fff}h1{color:#00d4aa}p{color:#ffffffcc}</style></head><body><div class="card"><h1>Authentication Successful!</h1><p>You can close this tab</p></div><script>setTimeout(function(){try{window.close()}catch(e){}},3000)</script></body></html>'
while true; do
local response_file=$(mktemp); echo -e "$success_response" > "$response_file"
local request=$(nc_listen "$callback_port" < "$response_file" 2>/dev/null | head -1)
local nc_status=$?; rm -f "$response_file"
if [[ $nc_status -ne 0 ]]; then break; fi
if [[ "$request" == *"/callback?code="* ]]; then
echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file"; break
fi
request=$(nc_listen "$callback_port" < "$response_tpl" 2>/dev/null | head -1) || break
case "$request" in
*"/callback?code="*)
echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file"
break
;;
esac
done
) </dev/null &
local server_pid=$!; sleep 1
if ! kill -0 $server_pid 2>/dev/null; then log_warn "Failed to start OAuth server"; rm -rf "$oauth_dir"; return 1; fi
log_warn "Opening browser to authenticate with OpenRouter..."; open_browser "$auth_url"
local timeout=120 elapsed=0
while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do sleep 1; ((elapsed++)); done
while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do sleep 1; elapsed=$((elapsed + 1)); done
kill $server_pid 2>/dev/null || true; wait $server_pid 2>/dev/null || true
if [[ ! -f "$code_file" ]]; then log_warn "OAuth timeout"; rm -rf "$oauth_dir"; return 1; fi
local oauth_code=$(cat "$code_file"); rm -rf "$oauth_dir"
@ -222,7 +228,7 @@ verify_server_connectivity() {
if ssh $SSH_OPTS -o ConnectTimeout=5 "${username}@$ip" "echo ok" >/dev/null 2>&1; then
log_info "SSH connection established"; return 0
fi
log_warn "Waiting for SSH... ($attempt/$max_attempts)"; sleep 5; ((attempt++))
log_warn "Waiting for SSH... ($attempt/$max_attempts)"; sleep 5; attempt=$((attempt + 1))
done
log_error "Server failed to respond via SSH after $max_attempts attempts"; return 1
}
@ -235,7 +241,7 @@ wait_for_cloud_init() {
if ssh $SSH_OPTS "${username}@$ip" "test -f /tmp/.cloud-init-complete" >/dev/null 2>&1; then
log_info "Startup script completed"; return 0
fi
log_warn "Startup script in progress... ($attempt/$max_attempts)"; sleep 5; ((attempt++))
log_warn "Startup script in progress... ($attempt/$max_attempts)"; sleep 5; attempt=$((attempt + 1))
done
log_error "Startup script did not complete after $max_attempts attempts"; return 1
}

View file

@ -49,7 +49,7 @@ nc_listen() {
local port=$1
shift
# Detect if nc requires -p flag (busybox nc on Termux)
if nc --help 2>&1 | grep -q "BusyBox\|busybox" || nc --help 2>&1 | grep -q "\-p "; then
if nc --help 2>&1 | grep -q "BusyBox\|busybox"; then
nc -l -p "$port" "$@"
else
nc -l "$port" "$@"
@ -120,29 +120,21 @@ try_oauth_flow() {
log_warn "Starting local OAuth server on port ${callback_port}..."
# Use a simpler nc approach - pipe response while capturing request
# Write the HTTP response to a file (using printf for macOS bash 3.x compat)
local response_tpl="$oauth_dir/response.http"
printf 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n<html><head><style>@keyframes checkmark{0%{transform:scale(0) rotate(-45deg);opacity:0}60%{transform:scale(1.2) rotate(-45deg);opacity:1}100%{transform:scale(1) rotate(-45deg);opacity:1}}@keyframes fadein{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e}.card{text-align:center;color:#fff}.check{width:80px;height:80px;border-radius:50%;background:#00d4aa22;display:flex;align-items:center;justify-content:center;margin:0 auto 24px}.check::after{content:"";display:block;width:28px;height:14px;border-left:4px solid #00d4aa;border-bottom:4px solid #00d4aa;animation:checkmark .5s ease forwards}h1{color:#00d4aa;margin:0 0 8px;font-size:1.6rem}p{margin:0 0 6px;color:#ffffffcc;font-size:1rem}.sub{color:#ffffff66;font-size:.85rem;animation:fadein .5s ease .5s both}</style></head><body><div class="card"><div class="check"></div><h1>Authentication Successful!</h1><p>Redirecting back to terminal...</p><p class="sub">This tab will close automatically</p></div><script>setTimeout(function(){try{window.close()}catch(e){}setTimeout(function(){document.querySelector(".sub").textContent="You can safely close this tab"},500)},3000)</script></body></html>' > "$response_tpl"
# Background listener
(
local success_response='HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n<html><head><style>@keyframes checkmark{0%{transform:scale(0) rotate(-45deg);opacity:0}60%{transform:scale(1.2) rotate(-45deg);opacity:1}100%{transform:scale(1) rotate(-45deg);opacity:1}}@keyframes fadein{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e}.card{text-align:center;color:#fff}.check{width:80px;height:80px;border-radius:50%;background:#00d4aa22;display:flex;align-items:center;justify-content:center;margin:0 auto 24px}.check::after{content:"";display:block;width:28px;height:14px;border-left:4px solid #00d4aa;border-bottom:4px solid #00d4aa;animation:checkmark .5s ease forwards}h1{color:#00d4aa;margin:0 0 8px;font-size:1.6rem}p{margin:0 0 6px;color:#ffffffcc;font-size:1rem}.sub{color:#ffffff66;font-size:.85rem;animation:fadein .5s ease .5s both}</style></head><body><div class="card"><div class="check"></div><h1>Authentication Successful!</h1><p>Redirecting back to terminal...</p><p class="sub">This tab will close automatically</p></div><script>setTimeout(function(){try{window.close()}catch(e){}setTimeout(function(){document.querySelector(".sub").textContent="You can safely close this tab"},500)},3000)</script></body></html>'
while true; do
# Listen and capture just the first line of the request, then respond
local response_file=$(mktemp)
echo -e "$success_response" > "$response_file"
local request=$(nc_listen "$callback_port" < "$response_file" 2>/dev/null | head -1)
local nc_status=$?
rm -f "$response_file"
# If nc failed, exit the loop
if [[ $nc_status -ne 0 ]]; then
break
fi
if [[ "$request" == *"/callback?code="* ]]; then
local code=$(echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p')
echo "$code" > "$code_file"
break
fi
request=$(nc_listen "$callback_port" < "$response_tpl" 2>/dev/null | head -1) || break
case "$request" in
*"/callback?code="*)
echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file"
break
;;
esac
done
) </dev/null &
local server_pid=$!
@ -166,7 +158,7 @@ try_oauth_flow() {
local elapsed=0
while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do
sleep 1
((elapsed++))
elapsed=$((elapsed + 1))
done
# Kill the background server process
@ -461,7 +453,7 @@ verify_server_connectivity() {
fi
log_warn "Waiting for SSH... ($attempt/$max_attempts)"
sleep 5
((attempt++))
attempt=$((attempt + 1))
done
log_error "Server failed to respond via SSH after $max_attempts attempts"
@ -482,7 +474,7 @@ wait_for_cloud_init() {
fi
log_warn "Cloud-init in progress... ($attempt/$max_attempts)"
sleep 5
((attempt++))
attempt=$((attempt + 1))
done
log_error "Cloud-init did not complete after $max_attempts attempts"

View file

@ -25,7 +25,7 @@ safe_read() {
nc_listen() {
local port=$1; shift
if nc --help 2>&1 | grep -q "BusyBox\|busybox" || nc --help 2>&1 | grep -q "\-p "; then
if nc --help 2>&1 | grep -q "BusyBox\|busybox"; then
nc -l -p "$port" "$@"
else nc -l "$port" "$@"; fi
}
@ -62,23 +62,29 @@ try_oauth_flow() {
local auth_url="https://openrouter.ai/auth?callback_url=${callback_url}"
local oauth_dir=$(mktemp -d) code_file="$oauth_dir/code"
log_warn "Starting local OAuth server on port ${callback_port}..."
# Write the HTTP response to a file (using printf for macOS bash 3.x compat)
local response_tpl="$oauth_dir/response.http"
printf 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n<html><head><style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e}.card{text-align:center;color:#fff}h1{color:#00d4aa}p{color:#ffffffcc}</style></head><body><div class="card"><h1>Authentication Successful!</h1><p>You can close this tab</p></div><script>setTimeout(function(){try{window.close()}catch(e){}},3000)</script></body></html>' > "$response_tpl"
# Background listener
(
local success_response='HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n<html><head><style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e}.card{text-align:center;color:#fff}h1{color:#00d4aa}p{color:#ffffffcc}</style></head><body><div class="card"><h1>Authentication Successful!</h1><p>You can close this tab</p></div><script>setTimeout(function(){try{window.close()}catch(e){}},3000)</script></body></html>'
while true; do
local response_file=$(mktemp); echo -e "$success_response" > "$response_file"
local request=$(nc_listen "$callback_port" < "$response_file" 2>/dev/null | head -1)
local nc_status=$?; rm -f "$response_file"
if [[ $nc_status -ne 0 ]]; then break; fi
if [[ "$request" == *"/callback?code="* ]]; then
echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file"; break
fi
request=$(nc_listen "$callback_port" < "$response_tpl" 2>/dev/null | head -1) || break
case "$request" in
*"/callback?code="*)
echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file"
break
;;
esac
done
) </dev/null &
local server_pid=$!; sleep 1
if ! kill -0 $server_pid 2>/dev/null; then log_warn "Failed to start OAuth server"; rm -rf "$oauth_dir"; return 1; fi
log_warn "Opening browser to authenticate with OpenRouter..."; open_browser "$auth_url"
local timeout=120 elapsed=0
while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do sleep 1; ((elapsed++)); done
while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do sleep 1; elapsed=$((elapsed + 1)); done
kill $server_pid 2>/dev/null || true; wait $server_pid 2>/dev/null || true
if [[ ! -f "$code_file" ]]; then log_warn "OAuth timeout"; rm -rf "$oauth_dir"; return 1; fi
local oauth_code=$(cat "$code_file"); rm -rf "$oauth_dir"
@ -245,7 +251,7 @@ print(d.get('error', {}).get('message', d.get('error', 'Unknown error')))
return 0
fi
log_warn "Instance status: $status ($attempt/$max_attempts)"
sleep 10; ((attempt++))
sleep 10; attempt=$((attempt + 1))
done
log_error "Instance did not become active in time"; return 1
}
@ -257,7 +263,7 @@ verify_server_connectivity() {
if ssh $SSH_OPTS -o ConnectTimeout=5 "ubuntu@$ip" "echo ok" >/dev/null 2>&1; then
log_info "SSH connection established"; return 0
fi
log_warn "Waiting for SSH... ($attempt/$max_attempts)"; sleep 5; ((attempt++))
log_warn "Waiting for SSH... ($attempt/$max_attempts)"; sleep 5; attempt=$((attempt + 1))
done
log_error "Server failed to respond via SSH after $max_attempts attempts"; return 1
}

View file

@ -24,7 +24,7 @@ safe_read() {
nc_listen() {
local port=$1; shift
if nc --help 2>&1 | grep -q "BusyBox\|busybox" || nc --help 2>&1 | grep -q "\-p "; then
if nc --help 2>&1 | grep -q "BusyBox\|busybox"; then
nc -l -p "$port" "$@"
else nc -l "$port" "$@"; fi
}
@ -61,23 +61,29 @@ try_oauth_flow() {
local auth_url="https://openrouter.ai/auth?callback_url=${callback_url}"
local oauth_dir=$(mktemp -d) code_file="$oauth_dir/code"
log_warn "Starting local OAuth server on port ${callback_port}..."
# Write the HTTP response to a file (using printf for macOS bash 3.x compat)
local response_tpl="$oauth_dir/response.http"
printf 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n<html><head><style>@keyframes checkmark{0%{transform:scale(0) rotate(-45deg);opacity:0}60%{transform:scale(1.2) rotate(-45deg);opacity:1}100%{transform:scale(1) rotate(-45deg);opacity:1}}body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e}.card{text-align:center;color:#fff}.check{width:80px;height:80px;border-radius:50%;background:#00d4aa22;display:flex;align-items:center;justify-content:center;margin:0 auto 24px}.check::after{content:"";display:block;width:28px;height:14px;border-left:4px solid #00d4aa;border-bottom:4px solid #00d4aa;animation:checkmark .5s ease forwards}h1{color:#00d4aa;margin:0 0 8px;font-size:1.6rem}p{margin:0 0 6px;color:#ffffffcc;font-size:1rem}</style></head><body><div class="card"><div class="check"></div><h1>Authentication Successful!</h1><p>You can close this tab</p></div><script>setTimeout(function(){try{window.close()}catch(e){}},3000)</script></body></html>' > "$response_tpl"
# Background listener
(
local success_response='HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n<html><head><style>@keyframes checkmark{0%{transform:scale(0) rotate(-45deg);opacity:0}60%{transform:scale(1.2) rotate(-45deg);opacity:1}100%{transform:scale(1) rotate(-45deg);opacity:1}}body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e}.card{text-align:center;color:#fff}.check{width:80px;height:80px;border-radius:50%;background:#00d4aa22;display:flex;align-items:center;justify-content:center;margin:0 auto 24px}.check::after{content:"";display:block;width:28px;height:14px;border-left:4px solid #00d4aa;border-bottom:4px solid #00d4aa;animation:checkmark .5s ease forwards}h1{color:#00d4aa;margin:0 0 8px;font-size:1.6rem}p{margin:0 0 6px;color:#ffffffcc;font-size:1rem}</style></head><body><div class="card"><div class="check"></div><h1>Authentication Successful!</h1><p>You can close this tab</p></div><script>setTimeout(function(){try{window.close()}catch(e){}},3000)</script></body></html>'
while true; do
local response_file=$(mktemp); echo -e "$success_response" > "$response_file"
local request=$(nc_listen "$callback_port" < "$response_file" 2>/dev/null | head -1)
local nc_status=$?; rm -f "$response_file"
if [[ $nc_status -ne 0 ]]; then break; fi
if [[ "$request" == *"/callback?code="* ]]; then
echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file"; break
fi
request=$(nc_listen "$callback_port" < "$response_tpl" 2>/dev/null | head -1) || break
case "$request" in
*"/callback?code="*)
echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file"
break
;;
esac
done
) </dev/null &
local server_pid=$!; sleep 1
if ! kill -0 $server_pid 2>/dev/null; then log_warn "Failed to start OAuth server"; rm -rf "$oauth_dir"; return 1; fi
log_warn "Opening browser to authenticate with OpenRouter..."; open_browser "$auth_url"
local timeout=120 elapsed=0
while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do sleep 1; ((elapsed++)); done
while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do sleep 1; elapsed=$((elapsed + 1)); done
kill $server_pid 2>/dev/null || true; wait $server_pid 2>/dev/null || true
if [[ ! -f "$code_file" ]]; then log_warn "OAuth timeout"; rm -rf "$oauth_dir"; return 1; fi
local oauth_code=$(cat "$code_file"); rm -rf "$oauth_dir"
@ -276,7 +282,7 @@ print('; '.join(e.get('reason','Unknown') for e in errs) if errs else 'Unknown e
return 0
fi
log_warn "Linode status: $status ($attempt/$max_attempts)"
sleep 5; ((attempt++))
sleep 5; attempt=$((attempt + 1))
done
log_error "Linode did not become active in time"; return 1
}
@ -288,7 +294,7 @@ verify_server_connectivity() {
if ssh $SSH_OPTS -o ConnectTimeout=5 "root@$ip" "echo ok" >/dev/null 2>&1; then
log_info "SSH connection established"; return 0
fi
log_warn "Waiting for SSH... ($attempt/$max_attempts)"; sleep 5; ((attempt++))
log_warn "Waiting for SSH... ($attempt/$max_attempts)"; sleep 5; attempt=$((attempt + 1))
done
log_error "Server failed to respond via SSH after $max_attempts attempts"; return 1
}
@ -300,7 +306,7 @@ wait_for_cloud_init() {
if ssh $SSH_OPTS "root@$ip" "test -f /root/.cloud-init-complete" >/dev/null 2>&1; then
log_info "Cloud-init completed"; return 0
fi
log_warn "Cloud-init in progress... ($attempt/$max_attempts)"; sleep 5; ((attempt++))
log_warn "Cloud-init in progress... ($attempt/$max_attempts)"; sleep 5; attempt=$((attempt + 1))
done
log_error "Cloud-init did not complete after $max_attempts attempts"; return 1
}

View file

@ -27,7 +27,7 @@ safe_read() {
nc_listen() {
local port=$1; shift
if nc --help 2>&1 | grep -q "BusyBox\|busybox" || nc --help 2>&1 | grep -q "\-p "; then
if nc --help 2>&1 | grep -q "BusyBox\|busybox"; then
nc -l -p "$port" "$@"
else nc -l "$port" "$@"; fi
}
@ -64,23 +64,29 @@ try_oauth_flow() {
local auth_url="https://openrouter.ai/auth?callback_url=${callback_url}"
local oauth_dir=$(mktemp -d) code_file="$oauth_dir/code"
log_warn "Starting local OAuth server on port ${callback_port}..."
# Write the HTTP response to a file (using printf for macOS bash 3.x compat)
local response_tpl="$oauth_dir/response.http"
printf 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n<html><head><style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e}.card{text-align:center;color:#fff}h1{color:#00d4aa}p{color:#ffffffcc}</style></head><body><div class="card"><h1>Authentication Successful!</h1><p>You can close this tab</p></div><script>setTimeout(function(){try{window.close()}catch(e){}},3000)</script></body></html>' > "$response_tpl"
# Background listener
(
local success_response='HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n<html><head><style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e}.card{text-align:center;color:#fff}h1{color:#00d4aa}p{color:#ffffffcc}</style></head><body><div class="card"><h1>Authentication Successful!</h1><p>You can close this tab</p></div><script>setTimeout(function(){try{window.close()}catch(e){}},3000)</script></body></html>'
while true; do
local response_file=$(mktemp); echo -e "$success_response" > "$response_file"
local request=$(nc_listen "$callback_port" < "$response_file" 2>/dev/null | head -1)
local nc_status=$?; rm -f "$response_file"
if [[ $nc_status -ne 0 ]]; then break; fi
if [[ "$request" == *"/callback?code="* ]]; then
echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file"; break
fi
request=$(nc_listen "$callback_port" < "$response_tpl" 2>/dev/null | head -1) || break
case "$request" in
*"/callback?code="*)
echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file"
break
;;
esac
done
) </dev/null &
local server_pid=$!; sleep 1
if ! kill -0 $server_pid 2>/dev/null; then log_warn "Failed to start OAuth server"; rm -rf "$oauth_dir"; return 1; fi
log_warn "Opening browser to authenticate with OpenRouter..."; open_browser "$auth_url"
local timeout=120 elapsed=0
while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do sleep 1; ((elapsed++)); done
while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do sleep 1; elapsed=$((elapsed + 1)); done
kill $server_pid 2>/dev/null || true; wait $server_pid 2>/dev/null || true
if [[ ! -f "$code_file" ]]; then log_warn "OAuth timeout"; rm -rf "$oauth_dir"; return 1; fi
local oauth_code=$(cat "$code_file"); rm -rf "$oauth_dir"

View file

@ -149,14 +149,15 @@ EOF
rm "$bash_temp"
}
# Listen on a port with netcat (handles busybox/Termux nc requiring -p flag)
# Listen on a port with netcat (handles macOS, Linux, busybox/Termux)
nc_listen() {
local port=$1
shift
# Detect if nc requires -p flag (busybox nc on Termux)
if nc --help 2>&1 | grep -q "BusyBox\|busybox" || nc --help 2>&1 | grep -q "\-p "; then
# BusyBox nc (Termux) requires -p flag
if nc --help 2>&1 | grep -q "BusyBox\|busybox"; then
nc -l -p "$port" "$@"
else
# macOS and Linux GNU nc both accept: nc -l <port>
nc -l "$port" "$@"
fi
}
@ -211,29 +212,21 @@ try_oauth_flow() {
log_warn "Starting local OAuth server on port ${callback_port}..."
# Use a simpler nc approach - pipe response while capturing request
# Write the HTTP response to a file (using printf for macOS bash 3.x compat)
local response_tpl="$oauth_dir/response.http"
printf 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n<html><head><style>body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e}.card{text-align:center;color:#fff}h1{color:#00d4aa;margin:0 0 8px;font-size:1.6rem}p{margin:0 0 6px;color:#ffffffcc;font-size:1rem}</style></head><body><div class="card"><h1>Authentication Successful!</h1><p>You can close this tab</p></div><script>setTimeout(function(){try{window.close()}catch(e){}},3000)</script></body></html>' > "$response_tpl"
# Background listener
(
local success_response='HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n<html><head><style>@keyframes checkmark{0%{transform:scale(0) rotate(-45deg);opacity:0}60%{transform:scale(1.2) rotate(-45deg);opacity:1}100%{transform:scale(1) rotate(-45deg);opacity:1}}@keyframes fadein{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e}.card{text-align:center;color:#fff}.check{width:80px;height:80px;border-radius:50%;background:#00d4aa22;display:flex;align-items:center;justify-content:center;margin:0 auto 24px}.check::after{content:"";display:block;width:28px;height:14px;border-left:4px solid #00d4aa;border-bottom:4px solid #00d4aa;animation:checkmark .5s ease forwards}h1{color:#00d4aa;margin:0 0 8px;font-size:1.6rem}p{margin:0 0 6px;color:#ffffffcc;font-size:1rem}.sub{color:#ffffff66;font-size:.85rem;animation:fadein .5s ease .5s both}</style></head><body><div class="card"><div class="check"></div><h1>Authentication Successful!</h1><p>Redirecting back to terminal...</p><p class="sub">This tab will close automatically</p></div><script>setTimeout(function(){try{window.close()}catch(e){}setTimeout(function(){document.querySelector(".sub").textContent="You can safely close this tab"},500)},3000)</script></body></html>'
while true; do
# Listen and capture just the first line of the request, then respond
local response_file=$(mktemp)
echo -e "$success_response" > "$response_file"
request=$(nc_listen "$callback_port" < "$response_tpl" 2>/dev/null | head -1) || break
local request=$(nc_listen "$callback_port" < "$response_file" 2>/dev/null | head -1)
local nc_status=$?
rm -f "$response_file"
# If nc failed, exit the loop
if [[ $nc_status -ne 0 ]]; then
break
fi
if [[ "$request" == *"/callback?code="* ]]; then
local code=$(echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p')
echo "$code" > "$code_file"
break
fi
case "$request" in
*"/callback?code="*)
echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file"
break
;;
esac
done
) </dev/null &
local server_pid=$!
@ -257,7 +250,7 @@ try_oauth_flow() {
local elapsed=0
while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do
sleep 1
((elapsed++))
elapsed=$((elapsed + 1))
done
# Kill the background server process

View file

@ -30,7 +30,7 @@ safe_read() {
nc_listen() {
local port=$1; shift
if nc --help 2>&1 | grep -q "BusyBox\|busybox" || nc --help 2>&1 | grep -q "\-p "; then
if nc --help 2>&1 | grep -q "BusyBox\|busybox"; then
nc -l -p "$port" "$@"
else
nc -l "$port" "$@"
@ -77,20 +77,22 @@ try_oauth_flow() {
local oauth_dir=$(mktemp -d)
local code_file="$oauth_dir/code"
log_warn "Starting local OAuth server on port ${callback_port}..."
# Write the HTTP response to a file (using printf for macOS bash 3.x compat)
local response_tpl="$oauth_dir/response.http"
printf 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n<html><head><style>@keyframes checkmark{0%{transform:scale(0) rotate(-45deg);opacity:0}60%{transform:scale(1.2) rotate(-45deg);opacity:1}100%{transform:scale(1) rotate(-45deg);opacity:1}}@keyframes fadein{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e}.card{text-align:center;color:#fff}.check{width:80px;height:80px;border-radius:50%;background:#00d4aa22;display:flex;align-items:center;justify-content:center;margin:0 auto 24px}.check::after{content:"";display:block;width:28px;height:14px;border-left:4px solid #00d4aa;border-bottom:4px solid #00d4aa;animation:checkmark .5s ease forwards}h1{color:#00d4aa;margin:0 0 8px;font-size:1.6rem}p{margin:0 0 6px;color:#ffffffcc;font-size:1rem}.sub{color:#ffffff66;font-size:.85rem;animation:fadein .5s ease .5s both}</style></head><body><div class="card"><div class="check"></div><h1>Authentication Successful!</h1><p>Redirecting back to terminal...</p><p class="sub">This tab will close automatically</p></div><script>setTimeout(function(){try{window.close()}catch(e){}setTimeout(function(){document.querySelector(".sub").textContent="You can safely close this tab"},500)},3000)</script></body></html>' > "$response_tpl"
# Background listener
(
local success_response='HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n<html><head><style>@keyframes checkmark{0%{transform:scale(0) rotate(-45deg);opacity:0}60%{transform:scale(1.2) rotate(-45deg);opacity:1}100%{transform:scale(1) rotate(-45deg);opacity:1}}@keyframes fadein{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e}.card{text-align:center;color:#fff}.check{width:80px;height:80px;border-radius:50%;background:#00d4aa22;display:flex;align-items:center;justify-content:center;margin:0 auto 24px}.check::after{content:"";display:block;width:28px;height:14px;border-left:4px solid #00d4aa;border-bottom:4px solid #00d4aa;animation:checkmark .5s ease forwards}h1{color:#00d4aa;margin:0 0 8px;font-size:1.6rem}p{margin:0 0 6px;color:#ffffffcc;font-size:1rem}.sub{color:#ffffff66;font-size:.85rem;animation:fadein .5s ease .5s both}</style></head><body><div class="card"><div class="check"></div><h1>Authentication Successful!</h1><p>Redirecting back to terminal...</p><p class="sub">This tab will close automatically</p></div><script>setTimeout(function(){try{window.close()}catch(e){}setTimeout(function(){document.querySelector(".sub").textContent="You can safely close this tab"},500)},3000)</script></body></html>'
while true; do
local response_file=$(mktemp)
echo -e "$success_response" > "$response_file"
local request=$(nc_listen "$callback_port" < "$response_file" 2>/dev/null | head -1)
local nc_status=$?
rm -f "$response_file"
if [[ $nc_status -ne 0 ]]; then break; fi
if [[ "$request" == *"/callback?code="* ]]; then
local code=$(echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p')
echo "$code" > "$code_file"
break
fi
request=$(nc_listen "$callback_port" < "$response_tpl" 2>/dev/null | head -1) || break
case "$request" in
*"/callback?code="*)
echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file"
break
;;
esac
done
) </dev/null &
local server_pid=$!
@ -103,7 +105,7 @@ try_oauth_flow() {
log_warn "Opening browser to authenticate with OpenRouter..."
open_browser "$auth_url"
local timeout=120 elapsed=0
while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do sleep 1; ((elapsed++)); done
while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do sleep 1; elapsed=$((elapsed + 1)); done
kill $server_pid 2>/dev/null || true
wait $server_pid 2>/dev/null || true
if [[ ! -f "$code_file" ]]; then
@ -334,7 +336,7 @@ print(json.dumps(body))
log_warn "Instance status: $status/$power ($attempt/$max_attempts)"
sleep 5
((attempt++))
attempt=$((attempt + 1))
done
log_error "Instance did not become active in time"
@ -353,7 +355,7 @@ verify_server_connectivity() {
fi
log_warn "Waiting for SSH... ($attempt/$max_attempts)"
sleep 5
((attempt++))
attempt=$((attempt + 1))
done
log_error "Server failed to respond via SSH after $max_attempts attempts"
return 1
@ -371,7 +373,7 @@ wait_for_cloud_init() {
fi
log_warn "Cloud-init in progress... ($attempt/$max_attempts)"
sleep 5
((attempt++))
attempt=$((attempt + 1))
done
log_error "Cloud-init did not complete after $max_attempts attempts"
return 1