mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-20 01:11:18 +00:00
Add NanoClaw spawn script (#2)
* Add NanoClaw spawn script NanoClaw is a lightweight WhatsApp-based Claude AI assistant that runs agents in isolated containers. This script sets up a sprite with nanoclaw pre-configured: clones the repo, installs dependencies, configures the API key, and launches in dev mode for WhatsApp QR auth. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Fix verify_sprite_connectivity exiting script early after single failed check Retry connectivity up to 6 attempts (30s) instead of trying once and silently continuing, which caused the next sprite exec to fail under set -e. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add test harness for spawn scripts Mocks the sprite CLI and runs each script end-to-end verifying: - common.sh sources correctly and all functions resolve - Log functions write to stderr (not stdout) - Env var flow (SPRITE_NAME, OPENROUTER_API_KEY) - Sprite commands called in correct order - Temp files created and cleaned up - Each script reaches its final interactive launch Usage: bash test/run.sh 42 tests, all passing. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Sprite <noreply@sprite.dev> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
302a9b7896
commit
f43c52eb61
3 changed files with 423 additions and 0 deletions
14
README.md
14
README.md
|
|
@ -23,6 +23,12 @@ bash <(curl -fsSL https://openrouter.ai/lab/spawn/sprite/claude.sh)
|
|||
bash <(curl -fsSL https://openrouter.ai/lab/spawn/sprite/openclaw.sh)
|
||||
```
|
||||
|
||||
#### NanoClaw
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://openrouter.ai/lab/spawn/sprite/nanoclaw.sh)
|
||||
```
|
||||
|
||||
### Non-Interactive Mode
|
||||
|
||||
For automation or CI/CD, set environment variables:
|
||||
|
|
@ -43,6 +49,14 @@ OPENROUTER_API_KEY=sk-or-v1-xxxxx \
|
|||
bash <(curl -fsSL https://openrouter.ai/lab/spawn/sprite/openclaw.sh)
|
||||
```
|
||||
|
||||
#### NanoClaw
|
||||
|
||||
```bash
|
||||
SPRITE_NAME=dev-mk1 \
|
||||
OPENROUTER_API_KEY=sk-or-v1-xxxxx \
|
||||
bash <(curl -fsSL https://openrouter.ai/lab/spawn/sprite/nanoclaw.sh)
|
||||
```
|
||||
|
||||
**Environment Variables:**
|
||||
- `SPRITE_NAME` - Name for the sprite (skips prompt)
|
||||
- `OPENROUTER_API_KEY` - Skip OAuth and use this API key directly
|
||||
|
|
|
|||
74
sprite/nanoclaw.sh
Normal file
74
sprite/nanoclaw.sh
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Source common functions - try local file first, fall back to remote
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
|
||||
if [[ -f "$SCRIPT_DIR/lib/common.sh" ]]; then
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
else
|
||||
source <(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/sprite/lib/common.sh)
|
||||
fi
|
||||
|
||||
log_info "🐾 Spawn a NanoClaw agent on Sprite"
|
||||
echo ""
|
||||
|
||||
# Setup sprite environment
|
||||
ensure_sprite_installed
|
||||
ensure_sprite_authenticated
|
||||
|
||||
SPRITE_NAME=$(get_sprite_name)
|
||||
ensure_sprite_exists "$SPRITE_NAME" 5
|
||||
verify_sprite_connectivity "$SPRITE_NAME"
|
||||
|
||||
log_warn "Setting up sprite environment..."
|
||||
|
||||
# Configure shell environment
|
||||
setup_shell_environment "$SPRITE_NAME"
|
||||
|
||||
# Install Node.js dependencies
|
||||
log_warn "Installing Node.js..."
|
||||
run_sprite "$SPRITE_NAME" "/.sprite/languages/bun/bin/bun install -g tsx"
|
||||
|
||||
# Clone nanoclaw
|
||||
log_warn "Cloning nanoclaw..."
|
||||
run_sprite "$SPRITE_NAME" "git clone https://github.com/gavrielc/nanoclaw.git ~/nanoclaw && cd ~/nanoclaw && npm install && npm run build"
|
||||
|
||||
# Get OpenRouter API key via OAuth
|
||||
echo ""
|
||||
OPENROUTER_API_KEY=$(get_openrouter_api_key_oauth 5180)
|
||||
|
||||
# Inject environment variables
|
||||
log_warn "Setting up environment variables..."
|
||||
|
||||
ENV_TEMP=$(mktemp)
|
||||
cat > "$ENV_TEMP" << EOF
|
||||
|
||||
# [spawn:env]
|
||||
export OPENROUTER_API_KEY="${OPENROUTER_API_KEY}"
|
||||
export ANTHROPIC_API_KEY="${OPENROUTER_API_KEY}"
|
||||
export ANTHROPIC_BASE_URL="https://openrouter.ai/api"
|
||||
EOF
|
||||
|
||||
sprite exec -s "$SPRITE_NAME" -file "$ENV_TEMP:/tmp/env_config" -- bash -c "cat /tmp/env_config >> ~/.zshrc && rm /tmp/env_config"
|
||||
rm "$ENV_TEMP"
|
||||
|
||||
# Create nanoclaw .env file
|
||||
log_warn "Configuring nanoclaw..."
|
||||
|
||||
DOTENV_TEMP=$(mktemp)
|
||||
cat > "$DOTENV_TEMP" << EOF
|
||||
ANTHROPIC_API_KEY=${OPENROUTER_API_KEY}
|
||||
EOF
|
||||
|
||||
sprite exec -s "$SPRITE_NAME" -file "$DOTENV_TEMP:/tmp/nanoclaw_env" -- bash -c "mv /tmp/nanoclaw_env ~/nanoclaw/.env"
|
||||
rm "$DOTENV_TEMP"
|
||||
|
||||
echo ""
|
||||
log_info "✅ Sprite setup completed successfully!"
|
||||
echo ""
|
||||
|
||||
# Start nanoclaw
|
||||
log_warn "Starting nanoclaw..."
|
||||
log_warn "You will need to scan a WhatsApp QR code to authenticate."
|
||||
echo ""
|
||||
sprite exec -s "$SPRITE_NAME" -tty -- zsh -c "cd ~/nanoclaw && source ~/.zshrc && npm run dev"
|
||||
335
test/run.sh
Normal file
335
test/run.sh
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
#!/bin/bash
|
||||
# Test harness for spawn scripts
|
||||
#
|
||||
# Mocks the `sprite` CLI and runs each script end-to-end to verify:
|
||||
# 1. common.sh sources correctly (local + remote)
|
||||
# 2. All functions resolve
|
||||
# 3. Env var flow works (SPRITE_NAME, OPENROUTER_API_KEY)
|
||||
# 4. sprite commands are called in the correct order with correct args
|
||||
# 5. Temp files are created and cleaned up
|
||||
# 6. Each script reaches its final launch command
|
||||
#
|
||||
# Usage:
|
||||
# bash test/run.sh # test all scripts
|
||||
# bash test/run.sh claude # test one script
|
||||
# bash test/run.sh --remote # test remote source (from GitHub)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TEST_DIR=$(mktemp -d)
|
||||
MOCK_LOG="$TEST_DIR/sprite_calls.log"
|
||||
PASSED=0
|
||||
FAILED=0
|
||||
FILTER="${1:-}"
|
||||
REMOTE=false
|
||||
|
||||
if [[ "$FILTER" == "--remote" ]]; then
|
||||
REMOTE=true
|
||||
FILTER="${2:-}"
|
||||
fi
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$TEST_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# --- Mock sprite CLI ---
|
||||
# Records every call to a log, returns success for expected commands
|
||||
setup_mocks() {
|
||||
cat > "$TEST_DIR/sprite" << 'MOCK'
|
||||
#!/bin/bash
|
||||
echo "sprite $*" >> "$MOCK_LOG"
|
||||
|
||||
case "$1" in
|
||||
org) exit 0 ;; # auth check passes
|
||||
list) echo "existing-sprite"; exit 0 ;; # list returns no match for test sprite
|
||||
create) exit 0 ;;
|
||||
exec)
|
||||
# If there's a -file flag, just pretend to upload
|
||||
if [[ "$*" == *"-file"* ]]; then
|
||||
exit 0
|
||||
fi
|
||||
# If -tty, this is the final interactive launch — signal success and exit
|
||||
if [[ "$*" == *"-tty"* ]]; then
|
||||
echo "[MOCK] Would launch interactive session: $*" >> "$MOCK_LOG"
|
||||
exit 0
|
||||
fi
|
||||
# Regular exec — just succeed
|
||||
exit 0
|
||||
;;
|
||||
login) exit 0 ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
MOCK
|
||||
chmod +x "$TEST_DIR/sprite"
|
||||
}
|
||||
|
||||
# --- Mock other commands that shouldn't run for real ---
|
||||
setup_extra_mocks() {
|
||||
# mock claude (for claude.sh install step)
|
||||
cat > "$TEST_DIR/claude" << 'MOCK'
|
||||
#!/bin/bash
|
||||
echo "claude $*" >> "$MOCK_LOG"
|
||||
exit 0
|
||||
MOCK
|
||||
chmod +x "$TEST_DIR/claude"
|
||||
|
||||
# mock openssl
|
||||
cat > "$TEST_DIR/openssl" << 'MOCK'
|
||||
#!/bin/bash
|
||||
echo "mock-gateway-token-abc123"
|
||||
MOCK
|
||||
chmod +x "$TEST_DIR/openssl"
|
||||
}
|
||||
|
||||
# --- Assertions ---
|
||||
assert_contains() {
|
||||
local file="$1" pattern="$2" msg="$3"
|
||||
if grep -qE "$pattern" "$file" 2>/dev/null; then
|
||||
echo -e " ${GREEN}✓${NC} $msg"
|
||||
((PASSED++))
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $msg"
|
||||
echo -e " expected pattern: $pattern"
|
||||
echo -e " in: $file"
|
||||
((FAILED++))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_not_contains() {
|
||||
local file="$1" pattern="$2" msg="$3"
|
||||
if ! grep -qE "$pattern" "$file" 2>/dev/null; then
|
||||
echo -e " ${GREEN}✓${NC} $msg"
|
||||
((PASSED++))
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $msg"
|
||||
((FAILED++))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_exit_code() {
|
||||
local actual="$1" expected="$2" msg="$3"
|
||||
if [[ "$actual" -eq "$expected" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} $msg"
|
||||
((PASSED++))
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $msg (got exit code $actual, expected $expected)"
|
||||
((FAILED++))
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Test runner for a single script ---
|
||||
run_script_test() {
|
||||
local script_name="$1"
|
||||
local script_path="$REPO_ROOT/sprite/${script_name}.sh"
|
||||
local output_file="$TEST_DIR/${script_name}_output.log"
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}━━━ Testing ${script_name}.sh ━━━${NC}"
|
||||
|
||||
# Reset mock log
|
||||
> "$MOCK_LOG"
|
||||
|
||||
# Run the script with mocked PATH and env vars (timeout 30s)
|
||||
local exit_code=0
|
||||
MOCK_LOG="$MOCK_LOG" \
|
||||
SPRITE_NAME="test-sprite-${script_name}" \
|
||||
OPENROUTER_API_KEY="sk-or-v1-0000000000000000000000000000000000000000000000000000000000000000" \
|
||||
PATH="$TEST_DIR:$PATH" \
|
||||
timeout 30 bash "$script_path" > "$output_file" 2>&1 || exit_code=$?
|
||||
|
||||
assert_exit_code "$exit_code" 0 "Script exits successfully"
|
||||
|
||||
# Common assertions for all scripts
|
||||
assert_contains "$MOCK_LOG" "sprite org list" "Checks sprite authentication"
|
||||
assert_contains "$MOCK_LOG" "sprite list" "Checks if sprite exists"
|
||||
assert_contains "$MOCK_LOG" "sprite create.*test-sprite-${script_name}" "Creates sprite with correct name"
|
||||
assert_contains "$MOCK_LOG" "sprite exec.*test-sprite-${script_name}" "Runs commands on sprite"
|
||||
|
||||
# Check env var injection (temp file upload)
|
||||
assert_contains "$MOCK_LOG" "sprite exec.*-file.*/tmp/env_config" "Uploads env config to sprite"
|
||||
|
||||
# Check final interactive launch (flag order varies: -s NAME -tty or -tty -s NAME)
|
||||
assert_contains "$MOCK_LOG" "sprite exec.*-tty.*" "Launches interactive session"
|
||||
|
||||
# Script-specific assertions
|
||||
case "$script_name" in
|
||||
claude)
|
||||
assert_contains "$MOCK_LOG" "claude install" "Installs Claude Code"
|
||||
assert_contains "$MOCK_LOG" "sprite exec.*-file.*/tmp/claude_settings" "Uploads Claude settings"
|
||||
assert_contains "$MOCK_LOG" "sprite exec.*-file.*/tmp/claude_global" "Uploads Claude global state"
|
||||
;;
|
||||
openclaw)
|
||||
assert_contains "$MOCK_LOG" "sprite exec.*bun install -g openclaw" "Installs openclaw via bun"
|
||||
assert_contains "$MOCK_LOG" "sprite exec.*openclaw gateway" "Starts openclaw gateway"
|
||||
;;
|
||||
nanoclaw)
|
||||
assert_contains "$MOCK_LOG" "sprite exec.*git clone.*nanoclaw" "Clones nanoclaw repo"
|
||||
assert_contains "$MOCK_LOG" "sprite exec.*-file.*/tmp/nanoclaw_env" "Uploads nanoclaw .env"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Check no temp files leaked
|
||||
local leaked_temps=$(find /tmp -maxdepth 1 -name "tmp.*" -newer "$MOCK_LOG" 2>/dev/null | wc -l)
|
||||
if [[ "$leaked_temps" -eq 0 ]]; then
|
||||
echo -e " ${GREEN}✓${NC} No temp files leaked"
|
||||
((PASSED++))
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Test common.sh sourcing ---
|
||||
test_common_source() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}━━━ Testing common.sh ━━━${NC}"
|
||||
|
||||
# Test 1: Source locally and check all functions exist
|
||||
local output
|
||||
output=$(bash -c '
|
||||
source "'"$REPO_ROOT"'/sprite/lib/common.sh"
|
||||
for fn in log_info log_warn log_error safe_read \
|
||||
ensure_sprite_installed ensure_sprite_authenticated \
|
||||
get_sprite_name ensure_sprite_exists verify_sprite_connectivity \
|
||||
run_sprite setup_shell_environment \
|
||||
get_openrouter_api_key_manual try_oauth_flow \
|
||||
get_openrouter_api_key_oauth open_browser; do
|
||||
type "$fn" &>/dev/null && echo "OK:$fn" || echo "MISSING:$fn"
|
||||
done
|
||||
' 2>/dev/null)
|
||||
|
||||
local missing=$(echo "$output" | grep "^MISSING:" || true)
|
||||
if [[ -z "$missing" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} All functions defined"
|
||||
((PASSED++))
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Missing functions: $missing"
|
||||
((FAILED++))
|
||||
fi
|
||||
|
||||
# Test 2: log functions write to stderr, not stdout
|
||||
local stdout stderr
|
||||
stdout=$(timeout 5 bash -c 'source "'"$REPO_ROOT"'/sprite/lib/common.sh" && log_info "test"' </dev/null 2>/dev/null)
|
||||
stderr=$(timeout 5 bash -c 'source "'"$REPO_ROOT"'/sprite/lib/common.sh" && log_info "test"' </dev/null 2>&1 >/dev/null)
|
||||
if [[ -z "$stdout" && -n "$stderr" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Log functions write to stderr"
|
||||
((PASSED++))
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Log functions should write to stderr only"
|
||||
((FAILED++))
|
||||
fi
|
||||
|
||||
# Test 3: get_sprite_name uses SPRITE_NAME env var
|
||||
local name
|
||||
name=$(timeout 5 bash -c 'SPRITE_NAME=from-env; source "'"$REPO_ROOT"'/sprite/lib/common.sh" && get_sprite_name' 2>/dev/null)
|
||||
if [[ "$name" == "from-env" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} get_sprite_name reads SPRITE_NAME env var"
|
||||
((PASSED++))
|
||||
else
|
||||
echo -e " ${RED}✗${NC} get_sprite_name should return 'from-env', got '$name'"
|
||||
((FAILED++))
|
||||
fi
|
||||
|
||||
# Test 4: get_sprite_name fails gracefully without TTY or env var
|
||||
local rc=0
|
||||
timeout 5 bash -c 'SPRITE_NAME=""; source "'"$REPO_ROOT"'/sprite/lib/common.sh" && get_sprite_name' </dev/null >/dev/null 2>&1 || rc=$?
|
||||
if [[ "$rc" -ne 0 ]]; then
|
||||
echo -e " ${GREEN}✓${NC} get_sprite_name fails without TTY or env var"
|
||||
((PASSED++))
|
||||
else
|
||||
echo -e " ${RED}✗${NC} get_sprite_name should fail without input"
|
||||
((FAILED++))
|
||||
fi
|
||||
|
||||
# Test 5: Syntax check
|
||||
if bash -n "$REPO_ROOT/sprite/lib/common.sh" 2>/dev/null; then
|
||||
echo -e " ${GREEN}✓${NC} Syntax valid"
|
||||
((PASSED++))
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Syntax errors"
|
||||
((FAILED++))
|
||||
fi
|
||||
|
||||
# Test 6: Remote source (if --remote)
|
||||
if [[ "$REMOTE" == true ]]; then
|
||||
local remote_fns
|
||||
remote_fns=$(bash -c '
|
||||
source <(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/sprite/lib/common.sh)
|
||||
type log_info &>/dev/null && echo "OK" || echo "FAIL"
|
||||
' 2>/dev/null)
|
||||
if [[ "$remote_fns" == "OK" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Remote source from GitHub works"
|
||||
((PASSED++))
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Remote source from GitHub failed"
|
||||
((FAILED++))
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Test source detection in each script ---
|
||||
test_source_detection() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}━━━ Testing source detection ━━━${NC}"
|
||||
|
||||
for script in claude openclaw nanoclaw; do
|
||||
local script_path="$REPO_ROOT/sprite/${script}.sh"
|
||||
[[ -f "$script_path" ]] || continue
|
||||
|
||||
# Verify the source block checks for local file existence
|
||||
if grep -q 'if \[\[ -f "$SCRIPT_DIR/lib/common.sh" \]\]' "$script_path"; then
|
||||
echo -e " ${GREEN}✓${NC} ${script}.sh uses file-existence check for sourcing"
|
||||
((PASSED++))
|
||||
else
|
||||
echo -e " ${RED}✗${NC} ${script}.sh missing file-existence source check"
|
||||
((FAILED++))
|
||||
fi
|
||||
|
||||
# Verify syntax
|
||||
if bash -n "$script_path" 2>/dev/null; then
|
||||
echo -e " ${GREEN}✓${NC} ${script}.sh syntax valid"
|
||||
((PASSED++))
|
||||
else
|
||||
echo -e " ${RED}✗${NC} ${script}.sh syntax error"
|
||||
((FAILED++))
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# --- Main ---
|
||||
echo "==============================="
|
||||
echo " Spawn Script Test Suite"
|
||||
echo "==============================="
|
||||
echo ""
|
||||
echo "Repo: $REPO_ROOT"
|
||||
echo "Temp dir: $TEST_DIR"
|
||||
echo "Filter: ${FILTER:-all}"
|
||||
echo "Remote: $REMOTE"
|
||||
|
||||
setup_mocks
|
||||
setup_extra_mocks
|
||||
|
||||
test_common_source
|
||||
test_source_detection
|
||||
|
||||
# Run per-script tests
|
||||
for script in claude openclaw nanoclaw; do
|
||||
if [[ -n "$FILTER" && "$FILTER" != "$script" && "$FILTER" != "--remote" ]]; then
|
||||
continue
|
||||
fi
|
||||
[[ -f "$REPO_ROOT/sprite/${script}.sh" ]] && run_script_test "$script"
|
||||
done
|
||||
|
||||
# --- Summary ---
|
||||
echo ""
|
||||
echo "==============================="
|
||||
TOTAL=$((PASSED + FAILED))
|
||||
echo -e " Results: ${GREEN}${PASSED} passed${NC}, ${RED}${FAILED} failed${NC}, ${TOTAL} total"
|
||||
echo "==============================="
|
||||
|
||||
[[ "$FAILED" -eq 0 ]] && exit 0 || exit 1
|
||||
Loading…
Add table
Add a link
Reference in a new issue