refactor: Drop spawn.sh bash fallback, auto-install bun instead (#163)

The 663-line bash CLI (spawn.sh) has drifted from the TypeScript CLI,
missing --prompt, security validation, download fallback, and other
features. Rather than maintaining two implementations, the installer
now auto-installs bun (~5 seconds) when it's not present, ensuring
every user gets the full-featured TypeScript CLI.

- Remove cli/spawn.sh (663 lines)
- Simplify install.sh: remove npm method, add bun auto-install
- Extract build_and_install() helper to deduplicate build logic
- Update cli/README.md and CLAUDE.md to reflect bun-only strategy

Co-authored-by: A <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
A 2026-02-09 22:30:48 -08:00 committed by GitHub
parent 8220bf1a0d
commit 30f19b7df6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 48 additions and 798 deletions

View file

@ -103,8 +103,7 @@ spawn/
src/commands.ts # All subcommands (interactive, list, run, etc.)
src/version.ts # Version constant
package.json # npm package (@openrouter/spawn)
install.sh # One-liner installer (bun → npm → bash fallback)
spawn.sh # Bash fallback CLI (no bun/node required)
install.sh # One-liner installer (bun → npm → auto-install bun)
shared/
common.sh # Provider-agnostic shared utilities
{cloud}/

View file

@ -12,38 +12,15 @@ The spawn CLI provides a unified interface to:
## Architecture
### Three-Tier Installation Strategy
### Installation Strategy
The CLI uses a progressive fallback installation strategy to maximize compatibility:
The installer uses bun to build the TypeScript CLI into a standalone JavaScript file. If bun is not already installed, the installer auto-installs it first (~5 seconds).
```
┌─────────────────────────────────────────────────────────┐
│ Method 1: Bun (Preferred) │
│ - Fastest execution (native TypeScript runtime) │
│ - Full TypeScript support with minimal overhead │
│ - Falls back to compiled binary if global install fails │
└─────────────────────────────────────────────────────────┘
↓ (if bun not found)
┌─────────────────────────────────────────────────────────┐
│ Method 2: npm │
│ - Standard Node.js package manager │
│ - Transpiles TypeScript to JavaScript at install time │
│ - Requires Node.js runtime │
└─────────────────────────────────────────────────────────┘
↓ (if npm not found)
┌─────────────────────────────────────────────────────────┐
│ Method 3: Bash Fallback │
│ - Pure bash implementation (spawn.sh) │
│ - Zero runtime dependencies except curl + jq/python3 │
│ - Functional subset of TypeScript CLI │
└─────────────────────────────────────────────────────────┘
```
**Why this pattern?**
- **Universal compatibility**: Works on any system with bash and curl
- **Optimal performance**: Uses the fastest available runtime (bun > node > bash)
- **Zero friction**: No prerequisite installation required for basic usage
- **Graceful degradation**: Each tier provides full functionality with varying performance characteristics
**Why bun?**
- **Fast**: Native TypeScript runtime, instant builds
- **Universal**: Auto-installed if missing, works on any system with bash and curl
- **Zero friction**: No prerequisite installation required
- **Single implementation**: One codebase, always feature-complete
### Directory Structure
@ -54,9 +31,8 @@ cli/
│ ├── commands.ts # All command implementations
│ ├── manifest.ts # Manifest fetching and caching logic
│ └── version.ts # Version constant
├── install.sh # Multi-tier installer script
├── spawn.sh # Bash fallback CLI (full implementation)
├── package.json # npm package metadata
├── install.sh # Installer (auto-installs bun if needed)
├── package.json # Package metadata and dependencies
└── tsconfig.json # TypeScript configuration
```
@ -73,21 +49,6 @@ The TypeScript CLI (`src/*.ts`) provides:
- `@clack/prompts` — Interactive terminal prompts
- `picocolors` — Terminal color support
### Bash Fallback Implementation
The bash CLI (`spawn.sh`) is a standalone script that:
- Implements the same commands as the TypeScript version
- Uses `jq` or `python3` for JSON parsing (auto-detects which is available)
- Provides a numbered menu picker for interactive mode
- Maintains local manifest cache with TTL
- Supports all core commands: `list`, `agents`, `clouds`, `run`, `improve`, `update`
**Why maintain both implementations?**
- **Portability**: Bash version works on minimal systems (CI containers, embedded Linux, etc.)
- **Bootstrap**: Used by installer when bun/npm aren't available
- **Reference**: Demonstrates that the protocol is runtime-agnostic
## Installation
### Quick Install
@ -97,13 +58,13 @@ curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/cli/insta
```
The installer will:
1. Check for `bun` → install via `bun install -g` if found
2. Check for `npm` → install via `npm install -g` if found
3. Fallback → download `spawn.sh` to `$HOME/.local/bin` if neither found
1. Install `bun` if not already present
2. Clone the CLI source
3. Build and install the `spawn` binary to `~/.local/bin`
### Environment Variables
- `SPAWN_INSTALL_DIR` — Override install directory (default: `$HOME/.local/bin` for fallback method)
- `SPAWN_INSTALL_DIR` — Override install directory (default: `$HOME/.local/bin`)
### Manual Installation (Development)
@ -198,8 +159,7 @@ Clone (or update) the spawn repository and run the `improve.sh` script, which us
spawn update
```
- **TypeScript version**: Displays update instructions (re-run installer)
- **Bash version**: Self-updates by downloading the latest `spawn.sh`
Displays update instructions (re-run installer).
### Version
@ -213,8 +173,7 @@ Display the current CLI version.
### Prerequisites
- Bun 1.0+ (or Node.js 18+ with npm)
- TypeScript 5.0+
- Bun 1.0+
### Running Locally
@ -227,15 +186,9 @@ bun run compile # Compile to standalone binary
### Testing
```bash
# Test TypeScript version
bun run dev list
bun run dev agents
bun run dev claude sprite
# Test bash version
bash spawn.sh list
bash spawn.sh agents
bash spawn.sh claude sprite
```
### Code Organization
@ -259,7 +212,6 @@ bash spawn.sh claude sprite
**`src/version.ts`**
- Single source of truth for version number
- Imported by both TypeScript and bash implementations
### Adding a New Command
@ -280,8 +232,6 @@ bash spawn.sh claude sprite
3. Update help text in `src/commands.ts``cmdHelp()`
4. (Optional) Add equivalent implementation to `spawn.sh` for bash fallback
## Design Rationale
### Why TypeScript?
@ -291,23 +241,16 @@ bash spawn.sh claude sprite
- **Rich ecosystem**: Access to high-quality CLI libraries (`@clack/prompts`, etc.)
- **Single codebase**: Same code runs on bun, node, or as a compiled binary
### Why Bash Fallback?
### Why Auto-install Bun?
- **Universality**: Bash is available on virtually all Unix-like systems
- **Zero dependencies**: Only requires `curl` and `jq`/`python3` (one of which is usually installed)
- **CI/CD friendly**: Works in minimal Docker containers, GitHub Actions, etc.
- **Educational**: Demonstrates the protocol can be implemented in any language
### Why Bun → npm → Bash Tiering?
- **Performance gradient**: Bun is fastest, npm is widely available, bash always works
- **User experience**: Bun users get instant execution, others get working tool
- **Distribution**: Can be installed via package manager or curl | bash
- **Maintenance**: Single TypeScript codebase serves bun and npm, bash is separate but synchronized
- **Single implementation**: No need to maintain a separate bash CLI
- **Feature parity**: Every user gets the full TypeScript CLI with all features
- **Fast install**: Bun installs in ~5 seconds via `curl -fsSL https://bun.sh/install | bash`
- **Simple maintenance**: One codebase, one source of truth
## Manifest Caching
Both implementations cache the manifest locally to reduce network requests:
The CLI caches the manifest locally to reduce network requests:
- **Cache location**: `$XDG_CACHE_HOME/spawn/manifest.json` (or `~/.cache/spawn/manifest.json`)
- **TTL**: 1 hour (3600 seconds)
@ -329,33 +272,25 @@ When you run `spawn <agent> <cloud>`:
### Before Submitting Changes
1. Test both TypeScript and bash versions:
1. Test the CLI:
```bash
bun run dev --help
bash spawn.sh --help
```
2. Ensure version numbers are synchronized:
- `src/version.ts``VERSION`
- `spawn.sh``SPAWN_VERSION`
- `package.json``version`
3. Update this README if you add new commands or change behavior
4. Run the installer locally to verify the three-tier strategy works:
4. Run the installer locally to verify it works:
```bash
# Test with bun
bash install.sh
# Test without bun (rename temporarily)
mv $(which bun) $(which bun).bak
bash install.sh
mv $(which bun).bak $(which bun)
```
### Release Checklist
1. Bump version in all three locations (see above)
1. Bump version in both locations (see above)
2. Update CHANGELOG (if exists)
3. Test installer on clean system
4. Tag release: `git tag -a cli-vX.Y.Z -m "Release vX.Y.Z"`

View file

@ -4,10 +4,9 @@
# Usage:
# curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/cli/install.sh | bash
#
# This installs spawn via bun (preferred) or npm. If neither is available,
# it falls back to downloading the bundled JS file and creating a runner script.
# This installs spawn via bun. If bun is not available, it auto-installs it first.
#
# Override install directory (for fallback method):
# Override install directory:
# SPAWN_INSTALL_DIR=/usr/local/bin curl -fsSL ... | bash
set -eo pipefail
@ -25,7 +24,6 @@ log_info() { echo -e "${GREEN}[spawn]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[spawn]${NC} $1"; }
log_error() { echo -e "${RED}[spawn]${NC} $1"; }
# --- Helper: clone the cli directory ---
# --- Helper: find the best install directory ---
# Picks the first directory that exists AND is in PATH
find_install_dir() {
@ -113,9 +111,8 @@ clone_cli() {
fi
}
# --- Method 1: bun (preferred) ---
if command -v bun &>/dev/null; then
log_info "Installing spawn via bun..."
# --- Helper: build and install the CLI using bun ---
build_and_install() {
tmpdir=$(mktemp -d)
trap 'rm -rf "${tmpdir}"' EXIT
@ -131,49 +128,30 @@ if command -v bun &>/dev/null; then
chmod +x "${INSTALL_DIR}/spawn"
log_info "Installed spawn to ${INSTALL_DIR}/spawn"
ensure_in_path "${INSTALL_DIR}"
exit 0
fi
}
# --- Method 2: npm/node ---
if command -v npm &>/dev/null && command -v node &>/dev/null; then
log_info "Installing spawn via npm..."
tmpdir=$(mktemp -d)
trap 'rm -rf "${tmpdir}"' EXIT
# --- Install bun if not present ---
if ! command -v bun &>/dev/null; then
log_info "bun not found. Installing bun..."
curl -fsSL https://bun.sh/install | bash
clone_cli "${tmpdir}"
# Source the updated PATH so bun is available immediately
export BUN_INSTALL="${HOME}/.bun"
export PATH="${BUN_INSTALL}/bin:${PATH}"
cd "${tmpdir}/cli"
npm install
# Build cli.js with node shebang
log_info "Building CLI..."
npx -y esbuild src/index.ts --bundle --outfile=cli.js --platform=node --format=esm --banner:js='#!/usr/bin/env node' 2>/dev/null || {
log_error "Failed to build cli.js. Install bun instead (recommended):"
if ! command -v bun &>/dev/null; then
log_error "Failed to install bun automatically"
echo ""
echo "Please install bun manually:"
echo " curl -fsSL https://bun.sh/install | bash"
echo " Then re-run: curl -fsSL ${SPAWN_RAW_BASE}/cli/install.sh | bash"
echo ""
echo "Then re-run:"
echo " curl -fsSL ${SPAWN_RAW_BASE}/cli/install.sh | bash"
exit 1
}
fi
INSTALL_DIR="$(find_install_dir)"
mkdir -p "${INSTALL_DIR}"
cp cli.js "${INSTALL_DIR}/spawn"
chmod +x "${INSTALL_DIR}/spawn"
log_info "Installed spawn to ${INSTALL_DIR}/spawn"
ensure_in_path "${INSTALL_DIR}"
exit 0
log_info "bun installed successfully"
fi
# --- Method 3: Direct download fallback (bash wrapper) ---
log_warn "Neither bun nor npm found. Installing bash fallback..."
INSTALL_DIR="$(find_install_dir)"
mkdir -p "${INSTALL_DIR}"
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 (bash) to ${INSTALL_DIR}/spawn"
ensure_in_path "${INSTALL_DIR}"
log_info "Installing spawn via bun..."
build_and_install

View file

@ -1,662 +0,0 @@
#!/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 "$@"