spawn/cli/spawn.sh
A bbbe815035
refactor: Security fixes, complexity reduction, and UX improvements (#58)
Security:
- Fix command injection in modal/lib/common.sh (run_server, upload_file, interactive_session)
- Fix command injection in fly/lib/common.sh (run_server, upload_file, interactive_session)
- All container providers now use printf '%q' for proper shell escaping

Complexity:
- Extract _api_should_retry_on_error() helper in shared/common.sh (-19 lines)
- Refactor scaleway_api and upcloud_api to use shared retry helper (-24 lines)
- Extract _save_fly_token() helper in fly/lib/common.sh (-11 lines)
- Extract validateAndGetAgent() in commands.ts, reducing cmdRun/cmdAgentInfo duplication
- Refactor cmdList column width calculation to use calculateColumnWidth()

UX:
- Add actionable next steps to error messages in shared/common.sh
- Improve CLI bash fallback error messages with guidance (spawn.sh)
- Add OAuth progress indicator during browser authentication wait
- Show invalid model ID value and link to openrouter.ai/models
- Add troubleshooting steps for agent installation failures

Tests:
- Update test assertions in test/run.sh to match refactored patterns
- All tests passing: 74 TypeScript + 75 bash = 149 total, 0 failures

Co-authored-by: A <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 17:09:27 -08:00

662 lines
19 KiB
Bash
Executable file

#!/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 -eo 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'
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, sys; json.load(open(sys.argv[1]))" "${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)
trap 'rm -f "${tmp}"' EXIT
if curl -fsSL "${SPAWN_RAW_BASE}/manifest.json" -o "${tmp}" 2>/dev/null; then
if json_validate "${tmp}"; then
mv "${tmp}" "${SPAWN_MANIFEST}"
trap - EXIT # Clear trap after successful move
return 0
else
log_warn "Downloaded manifest is invalid JSON"
fi
else
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 or network issue)"
return 0
fi
log_error "No manifest available and no cached version found"
echo "" >&2
echo "Check your internet connection and try again." >&2
echo "If the problem persists, file an issue at:" >&2
echo " https://github.com/${SPAWN_REPO}/issues" >&2
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 "" >&2
echo "Run 'spawn agents' to see all available agents." >&2
echo "Run 'spawn help' for usage information." >&2
exit 1
fi
if [[ -z "${cloud_name}" ]]; then
log_error "Unknown cloud: ${cloud}"
echo "" >&2
echo "Run 'spawn clouds' to see all available clouds." >&2
echo "Run 'spawn help' for usage information." >&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"
echo "" >&2
echo "Run 'spawn list' to see all available combinations." >&2
echo "Run 'spawn ${agent}' to see which clouds support ${agent_name}." >&2
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)
trap 'rm -f "${tmp}"' EXIT
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
log_error "Could not determine remote version"
exit 1
fi
if [[ "${remote_version}" == "${SPAWN_VERSION}" ]]; then
trap - EXIT # Clear trap, file already cleaned
log_info "Already up to date (v${SPAWN_VERSION})"
return 0
fi
chmod +x "${tmp}"
mv "${tmp}" "${self}"
trap - EXIT # Clear trap after successful move
log_info "Updated: v${SPAWN_VERSION} → v${remote_version}"
# Invalidate manifest cache on update
rm -f "${SPAWN_MANIFEST}"
else
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 "" >&2
echo "Run 'spawn agents' to see all available agents." >&2
echo "Run 'spawn help' for usage information." >&2
exit 1
fi
if (( $# >= 2 )); then
cmd_run "${agent}" "$2"
else
cmd_agent_info "${agent}"
fi
;;
esac
}
main "$@"