mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-22 03:14:57 +00:00
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:
parent
8220bf1a0d
commit
30f19b7df6
4 changed files with 48 additions and 798 deletions
|
|
@ -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}/
|
||||
|
|
|
|||
113
cli/README.md
113
cli/README.md
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
662
cli/spawn.sh
662
cli/spawn.sh
|
|
@ -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 "$@"
|
||||
Loading…
Add table
Add a link
Reference in a new issue