diff --git a/koyeb/README.md b/koyeb/README.md new file mode 100644 index 00000000..f6d0f0a0 --- /dev/null +++ b/koyeb/README.md @@ -0,0 +1,52 @@ +# Koyeb + +Koyeb serverless container platform via CLI. [Koyeb](https://www.koyeb.com/) + +## Agents + +#### Claude Code + +```bash +bash <(curl -fsSL https://openrouter.ai/lab/spawn/koyeb/claude.sh) +``` + +#### OpenClaw + +```bash +bash <(curl -fsSL https://openrouter.ai/lab/spawn/koyeb/openclaw.sh) +``` + +#### Aider + +```bash +bash <(curl -fsSL https://openrouter.ai/lab/spawn/koyeb/aider.sh) +``` + +## Non-Interactive Mode + +```bash +KOYEB_TOKEN=your-token \ +OPENROUTER_API_KEY=sk-or-v1-xxxxx \ + bash <(curl -fsSL https://openrouter.ai/lab/spawn/koyeb/claude.sh) +``` + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `KOYEB_TOKEN` | Koyeb API token | _(prompted)_ | +| `KOYEB_REGION` | Deployment region | `was` (Washington D.C.) | +| `KOYEB_INSTANCE_TYPE` | Instance type | `nano` | +| `OPENROUTER_API_KEY` | OpenRouter API key | _(OAuth or prompted)_ | + +## Authentication + +Get your Koyeb API token at: https://app.koyeb.com/account/api + +## Features + +- Serverless container platform with per-second billing +- Free tier available (no credit card required) +- Fast deployment times +- Automatic scaling +- Global deployment regions diff --git a/koyeb/aider.sh b/koyeb/aider.sh new file mode 100644 index 00000000..5c853c08 --- /dev/null +++ b/koyeb/aider.sh @@ -0,0 +1,58 @@ +#!/bin/bash +set -eo pipefail + +# 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 + eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/koyeb/lib/common.sh)" +fi + +log_info "Aider on Koyeb" +echo "" + +# 1. Ensure Koyeb CLI and API token +ensure_koyeb_cli +ensure_koyeb_token + +# 2. Create service +SERVER_NAME=$(get_server_name) +create_server "$SERVER_NAME" + +# 3. Install base tools +wait_for_cloud_init + +# 4. Install Aider +log_warn "Installing Aider..." +run_server "pip install aider-chat 2>/dev/null || pip3 install aider-chat" +log_info "Aider installed" + +# 5. Get OpenRouter API key +echo "" +if [[ -n "${OPENROUTER_API_KEY:-}" ]]; then + log_info "Using OpenRouter API key from environment" +else + OPENROUTER_API_KEY=$(get_openrouter_api_key_oauth 5180) +fi + +# 6. Get model preference +MODEL_ID=$(get_model_id_interactive "openrouter/auto" "Aider") || exit 1 + +# 7. Inject environment variables +log_warn "Setting up environment variables..." + +inject_env_vars \ + "OPENROUTER_API_KEY=${OPENROUTER_API_KEY}" \ + "PATH=\$HOME/.local/bin:\$PATH" + +echo "" +log_info "Koyeb service setup completed successfully!" +log_info "Service: $KOYEB_SERVICE_NAME (Instance: $KOYEB_INSTANCE_ID)" +echo "" + +# 8. Start Aider interactively +log_warn "Starting Aider..." +sleep 1 +clear +interactive_session "source /root/.bashrc && aider --model openrouter/${MODEL_ID}" diff --git a/koyeb/claude.sh b/koyeb/claude.sh new file mode 100644 index 00000000..cba067f7 --- /dev/null +++ b/koyeb/claude.sh @@ -0,0 +1,109 @@ +#!/bin/bash +set -eo pipefail + +# 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 + eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/koyeb/lib/common.sh)" +fi + +log_info "Claude Code on Koyeb" +echo "" + +# 1. Ensure Koyeb CLI and API token +ensure_koyeb_cli +ensure_koyeb_token + +# 2. Create service +SERVER_NAME=$(get_server_name) +create_server "$SERVER_NAME" + +# 3. Install base tools +wait_for_cloud_init + +# 4. Install Claude Code +log_warn "Installing Claude Code..." +run_server "curl -fsSL https://claude.ai/install.sh | bash" + +# Verify installation +if ! run_server "command -v claude" >/dev/null 2>&1; then + log_error "Claude Code installation failed" + exit 1 +fi +log_info "Claude Code installed" + +# 5. Get OpenRouter API key +echo "" +if [[ -n "${OPENROUTER_API_KEY:-}" ]]; then + log_info "Using OpenRouter API key from environment" +else + OPENROUTER_API_KEY=$(get_openrouter_api_key_oauth 5180) +fi + +# 6. Inject environment variables +log_warn "Setting up environment variables..." + +inject_env_vars \ + "OPENROUTER_API_KEY=${OPENROUTER_API_KEY}" \ + "ANTHROPIC_BASE_URL=https://openrouter.ai/api" \ + "ANTHROPIC_AUTH_TOKEN=${OPENROUTER_API_KEY}" \ + "ANTHROPIC_API_KEY=" \ + "CLAUDE_CODE_SKIP_ONBOARDING=1" \ + "CLAUDE_CODE_ENABLE_TELEMETRY=0" \ + "PATH=\$HOME/.claude/local/bin:\$HOME/.bun/bin:\$PATH" + +# 7. Configure Claude Code settings +log_warn "Configuring Claude Code..." + +run_server "mkdir -p /root/.claude" + +# Upload settings.json +SETTINGS_TEMP=$(mktemp) +chmod 600 "$SETTINGS_TEMP" +cat > "$SETTINGS_TEMP" << EOF +{ + "theme": "dark", + "editor": "vim", + "env": { + "CLAUDE_CODE_ENABLE_TELEMETRY": "0", + "ANTHROPIC_BASE_URL": "https://openrouter.ai/api", + "ANTHROPIC_AUTH_TOKEN": "${OPENROUTER_API_KEY}" + }, + "permissions": { + "defaultMode": "bypassPermissions", + "dangerouslySkipPermissions": true + } +} +EOF + +upload_file "$SETTINGS_TEMP" "/root/.claude/settings.json" +rm "$SETTINGS_TEMP" + +# Upload ~/.claude.json global state +GLOBAL_STATE_TEMP=$(mktemp) +chmod 600 "$GLOBAL_STATE_TEMP" +cat > "$GLOBAL_STATE_TEMP" << EOF +{ + "hasCompletedOnboarding": true, + "bypassPermissionsModeAccepted": true +} +EOF + +upload_file "$GLOBAL_STATE_TEMP" "/root/.claude.json" +rm "$GLOBAL_STATE_TEMP" + +# Create empty CLAUDE.md +run_server "touch /root/.claude/CLAUDE.md" + +echo "" +log_info "Koyeb service setup completed successfully!" +log_info "Service: $KOYEB_SERVICE_NAME (Instance: $KOYEB_INSTANCE_ID)" +echo "" + +# 8. Start Claude Code interactively +log_warn "Starting Claude Code..." +sleep 1 +clear +interactive_session "source /root/.bashrc && claude" diff --git a/koyeb/lib/common.sh b/koyeb/lib/common.sh new file mode 100644 index 00000000..116d90ed --- /dev/null +++ b/koyeb/lib/common.sh @@ -0,0 +1,306 @@ +#!/bin/bash +# Common bash functions for Koyeb spawn scripts +# Uses Koyeb CLI for provisioning and exec access + +# Bash safety flags +set -eo pipefail + +# ============================================================ +# Provider-agnostic functions +# ============================================================ + +# Source shared provider-agnostic functions (local or remote fallback) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" +if [[ -n "$SCRIPT_DIR" && -f "$SCRIPT_DIR/../../shared/common.sh" ]]; then + source "$SCRIPT_DIR/../../shared/common.sh" +else + eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/shared/common.sh)" +fi + +# Note: Provider-agnostic functions (logging, OAuth, browser, nc_listen) are now in shared/common.sh + +# ============================================================ +# Koyeb specific functions +# ============================================================ + +# Ensure Koyeb CLI is installed +ensure_koyeb_cli() { + if command -v koyeb &>/dev/null; then + log_info "Koyeb CLI available" + return 0 + fi + + log_warn "Installing Koyeb CLI..." + + # Detect OS and architecture + local os="" + local arch="" + + case "$(uname -s)" in + Darwin) os="darwin" ;; + Linux) os="linux" ;; + *) + log_error "Unsupported operating system: $(uname -s)" + return 1 + ;; + esac + + case "$(uname -m)" in + x86_64|amd64) arch="amd64" ;; + arm64|aarch64) arch="arm64" ;; + *) + log_error "Unsupported architecture: $(uname -m)" + return 1 + ;; + esac + + local install_dir="$HOME/.koyeb/bin" + mkdir -p "$install_dir" + + local download_url="https://github.com/koyeb/koyeb-cli/releases/latest/download/koyeb-${os}-${arch}" + + if ! curl -fsSL "$download_url" -o "$install_dir/koyeb"; then + log_error "Failed to download Koyeb CLI" + log_error "Install manually: https://www.koyeb.com/docs/build-and-deploy/cli/installation" + return 1 + fi + + chmod +x "$install_dir/koyeb" + export PATH="$install_dir:$PATH" + + if ! command -v koyeb &>/dev/null; then + log_error "Koyeb CLI not found in PATH after installation" + return 1 + fi + + log_info "Koyeb CLI installed" +} + +# Save Koyeb token to config file +_save_koyeb_token() { + local token="$1" + local config_dir="$HOME/.config/spawn" + local config_file="$config_dir/koyeb.json" + mkdir -p "$config_dir" + printf '{\n "token": "%s"\n}\n' "$(json_escape "$token")" > "$config_file" + chmod 600 "$config_file" +} + +# Ensure KOYEB_TOKEN is available (env var -> config file -> prompt+save) +ensure_koyeb_token() { + check_python_available || return 1 + + # 1. Check environment variable + if [[ -n "${KOYEB_TOKEN:-}" ]]; then + log_info "Using Koyeb API token from environment" + return 0 + fi + + local config_file="$HOME/.config/spawn/koyeb.json" + + # 2. Check config file + if [[ -f "$config_file" ]]; then + local saved_token + saved_token=$(python3 -c "import json, sys; print(json.load(open(sys.argv[1])).get('token',''))" "$config_file" 2>/dev/null) + if [[ -n "$saved_token" ]]; then + export KOYEB_TOKEN="$saved_token" + log_info "Using Koyeb API token from $config_file" + return 0 + fi + fi + + # 3. Prompt user for token + log_warn "Koyeb API token required" + echo "" + echo "Get your API token at: https://app.koyeb.com/account/api" + echo "" + + local token + token=$(safe_read "Enter Koyeb API token: ") + if [[ -z "$token" ]]; then + log_error "No token provided" + return 1 + fi + + export KOYEB_TOKEN="$token" + _save_koyeb_token "$token" + log_info "Koyeb API token saved" +} + +# Generate a unique server name for Koyeb (must be lowercase alphanumeric + hyphens) +get_server_name() { + local prefix="${1:-spawn}" + local timestamp=$(date +%s) + local random_suffix=$(head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n') + echo "${prefix}-${timestamp}-${random_suffix}" | tr '[:upper:]' '[:lower:]' +} + +# Create a Koyeb app and service +# Sets: KOYEB_APP_NAME, KOYEB_SERVICE_NAME, KOYEB_SERVICE_ID +create_server() { + local name="${1:-$(get_server_name)}" + + # App and service names must be separate + KOYEB_APP_NAME="${name}" + KOYEB_SERVICE_NAME="${name}-svc" + + log_warn "Creating Koyeb app: $KOYEB_APP_NAME" + + # Create app first + if ! koyeb app create "$KOYEB_APP_NAME" >/dev/null 2>&1; then + log_error "Failed to create Koyeb app" + return 1 + fi + + log_warn "Creating Koyeb service: $KOYEB_SERVICE_NAME" + + # Create service with Ubuntu 24.04 image + # Using a long-running command to keep container alive + local create_output + create_output=$(koyeb service create "$KOYEB_SERVICE_NAME" \ + --app "$KOYEB_APP_NAME" \ + --docker ubuntu:24.04 \ + --regions was \ + --instance-type nano \ + --command '["tail"]' \ + --args '["-f", "/dev/null"]' \ + 2>&1) + + if echo "$create_output" | grep -q "Error"; then + log_error "Failed to create Koyeb service" + log_error "$create_output" + return 1 + fi + + # Extract service ID from output + KOYEB_SERVICE_ID=$(echo "$create_output" | grep -oP 'Service \K[a-f0-9-]+' | head -1) + + if [[ -z "$KOYEB_SERVICE_ID" ]]; then + # Fallback: try to get it from service list + KOYEB_SERVICE_ID=$(koyeb service list --app "$KOYEB_APP_NAME" 2>/dev/null | grep "$KOYEB_SERVICE_NAME" | awk '{print $1}' | head -1) + fi + + log_info "Koyeb service created: $KOYEB_SERVICE_NAME (ID: $KOYEB_SERVICE_ID)" + + # Wait for service to be ready + log_warn "Waiting for service to deploy..." + local max_attempts=60 + local attempt=0 + + while [[ $attempt -lt $max_attempts ]]; do + local status + status=$(koyeb service get "$KOYEB_SERVICE_ID" 2>/dev/null | grep "Status:" | awk '{print $2}') + + if [[ "$status" == "healthy" || "$status" == "running" ]]; then + log_info "Service is ready" + break + fi + + if [[ "$status" == "error" || "$status" == "failed" ]]; then + log_error "Service deployment failed" + return 1 + fi + + attempt=$((attempt + 1)) + sleep 5 + done + + if [[ $attempt -ge $max_attempts ]]; then + log_error "Timeout waiting for service to be ready" + return 1 + fi + + # Get instance ID for exec commands + KOYEB_INSTANCE_ID=$(koyeb instances list --service "$KOYEB_SERVICE_ID" 2>/dev/null | grep -v "^ID" | awk '{print $1}' | head -1) + + if [[ -z "$KOYEB_INSTANCE_ID" ]]; then + log_error "Failed to get instance ID" + return 1 + fi + + log_info "Instance ID: $KOYEB_INSTANCE_ID" +} + +# Run a command on the Koyeb service instance +run_server() { + local cmd="$1" + + if [[ -z "$KOYEB_INSTANCE_ID" ]]; then + log_error "No instance ID set. Call create_server first." + return 1 + fi + + koyeb instances exec "$KOYEB_INSTANCE_ID" -- bash -c "$cmd" +} + +# Upload a file to the Koyeb instance +upload_file() { + local local_path="$1" + local remote_path="$2" + + if [[ ! -f "$local_path" ]]; then + log_error "Local file not found: $local_path" + return 1 + fi + + # Read file content and encode + local content + content=$(cat "$local_path" | base64) + + # Write file on remote instance + run_server "echo '$content' | base64 -d > '$remote_path'" +} + +# Wait for cloud-init or basic system readiness +wait_for_cloud_init() { + log_warn "Installing base tools..." + + # Update package lists and install essentials + run_server "apt-get update -qq && apt-get install -y -qq curl wget git python3 python3-pip build-essential ca-certificates" || { + log_error "Failed to install base tools" + return 1 + } + + log_info "Base tools installed" +} + +# Inject environment variables into shell config +inject_env_vars() { + local shell_rc="/root/.bashrc" + + log_warn "Injecting environment variables..." + + for env_var in "$@"; do + # Escape special characters for sed + local escaped_var=$(echo "$env_var" | sed 's/[&/\]/\\&/g') + run_server "echo 'export $escaped_var' >> $shell_rc" + done + + log_info "Environment variables configured" +} + +# Start an interactive session +interactive_session() { + local launch_cmd="${1:-bash}" + + if [[ -z "$KOYEB_INSTANCE_ID" ]]; then + log_error "No instance ID set. Call create_server first." + return 1 + fi + + log_info "Starting interactive session..." + koyeb instances exec "$KOYEB_INSTANCE_ID" -- bash -c "$launch_cmd" +} + +# Cleanup: delete the service and app +cleanup_server() { + if [[ -n "${KOYEB_SERVICE_NAME:-}" ]]; then + log_warn "Deleting service: $KOYEB_SERVICE_NAME" + koyeb service delete "$KOYEB_SERVICE_NAME" --force >/dev/null 2>&1 || true + fi + + if [[ -n "${KOYEB_APP_NAME:-}" ]]; then + log_warn "Deleting app: $KOYEB_APP_NAME" + koyeb app delete "$KOYEB_APP_NAME" >/dev/null 2>&1 || true + fi +} diff --git a/koyeb/openclaw.sh b/koyeb/openclaw.sh new file mode 100644 index 00000000..4628052b --- /dev/null +++ b/koyeb/openclaw.sh @@ -0,0 +1,81 @@ +#!/bin/bash +set -eo pipefail + +# 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 + eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/koyeb/lib/common.sh)" +fi + +log_info "OpenClaw on Koyeb" +echo "" + +# 1. Ensure Koyeb CLI and API token +ensure_koyeb_cli +ensure_koyeb_token + +# 2. Create service +SERVER_NAME=$(get_server_name) +create_server "$SERVER_NAME" + +# 3. Install base tools +wait_for_cloud_init + +# 4. Install bun first (required for openclaw) +log_warn "Installing bun..." +run_server "curl -fsSL https://bun.sh/install | bash" +run_server "export PATH=\"\$HOME/.bun/bin:\$PATH\"" +log_info "Bun installed" + +# 5. Install openclaw via bun +log_warn "Installing openclaw..." +run_server "source /root/.bashrc && export PATH=\"\$HOME/.bun/bin:\$PATH\" && bun install -g openclaw" +log_info "OpenClaw installed" + +# 6. Get OpenRouter API key +echo "" +if [[ -n "${OPENROUTER_API_KEY:-}" ]]; then + log_info "Using OpenRouter API key from environment" +else + OPENROUTER_API_KEY=$(get_openrouter_api_key_oauth 5180) +fi + +# Get model preference +MODEL_ID=$(get_model_id_interactive "openrouter/auto" "Openclaw") || exit 1 + +# 7. Inject environment variables into shell config +log_warn "Setting up environment variables..." + +inject_env_vars \ + "OPENROUTER_API_KEY=${OPENROUTER_API_KEY}" \ + "ANTHROPIC_API_KEY=${OPENROUTER_API_KEY}" \ + "ANTHROPIC_BASE_URL=https://openrouter.ai/api" \ + "PATH=\$HOME/.bun/bin:\$PATH" + +# 8. Configure openclaw +log_warn "Configuring openclaw..." + +run_server "rm -rf /root/.openclaw && mkdir -p /root/.openclaw" + +# Generate a random gateway token +GATEWAY_TOKEN=$(openssl rand -hex 16) + +OPENCLAW_CONFIG_TEMP=$(mktemp) +chmod 600 "$OPENCLAW_CONFIG_TEMP" +printf '{\n "env": {\n "OPENROUTER_API_KEY": "%s"\n },\n "gateway": {\n "mode": "local",\n "auth": {\n "token": "%s"\n }\n },\n "agents": {\n "defaults": {\n "model": {\n "primary": "openrouter/%s"\n }\n }\n }\n}\n' "$(json_escape "${OPENROUTER_API_KEY}")" "$(json_escape "${GATEWAY_TOKEN}")" "$(json_escape "${MODEL_ID}")" > "$OPENCLAW_CONFIG_TEMP" + +upload_file "$OPENCLAW_CONFIG_TEMP" "/root/.openclaw/openclaw.json" +rm "$OPENCLAW_CONFIG_TEMP" + +echo "" +log_info "Koyeb service setup completed successfully!" +log_info "Service: $KOYEB_SERVICE_NAME (Instance: $KOYEB_INSTANCE_ID)" +echo "" + +# 9. Start openclaw gateway in background and launch TUI +log_warn "Starting openclaw..." +run_server "source /root/.bashrc && nohup openclaw gateway > /tmp/openclaw-gateway.log 2>&1 &" +sleep 2 +interactive_session "source /root/.bashrc && openclaw tui" diff --git a/manifest.json b/manifest.json index e1e11011..5f5f64b6 100644 --- a/manifest.json +++ b/manifest.json @@ -588,6 +588,21 @@ "image": "Ubuntu Server 24.04 LTS R5504 UEFI" }, "notes": "GPU cloud provider. Competitive pricing on RTX A6000 ($0.50/hr). Requires HYPERSTACK_API_KEY from https://infrahub.hyperstack.cloud" + }, + "koyeb": { + "name": "Koyeb", + "description": "Koyeb serverless container platform via CLI", + "url": "https://www.koyeb.com/", + "type": "cli", + "auth": "KOYEB_TOKEN", + "provision_method": "koyeb service create with docker image", + "exec_method": "koyeb instances exec", + "interactive_method": "koyeb instances exec (PTY)", + "defaults": { + "instance_type": "nano", + "region": "was" + }, + "notes": "Serverless container platform. Free tier available (no credit card). Per-second billing. Requires koyeb CLI." } }, "matrix": { @@ -926,6 +941,20 @@ "hyperstack/gptme": "missing", "hyperstack/opencode": "missing", "hyperstack/plandex": "missing", - "hyperstack/kilocode": "missing" + "hyperstack/kilocode": "missing", + "koyeb/claude": "implemented", + "koyeb/openclaw": "implemented", + "koyeb/nanoclaw": "missing", + "koyeb/aider": "implemented", + "koyeb/goose": "missing", + "koyeb/codex": "missing", + "koyeb/interpreter": "missing", + "koyeb/gemini": "missing", + "koyeb/amazonq": "missing", + "koyeb/cline": "missing", + "koyeb/gptme": "missing", + "koyeb/opencode": "missing", + "koyeb/plandex": "missing", + "koyeb/kilocode": "missing" } } \ No newline at end of file