diff --git a/CLAUDE.md b/CLAUDE.md index 46d206b6..b21e0ac4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,6 +88,14 @@ Research cloud providers with API-based provisioning. To add one: ``` spawn/ + cli/ + src/index.ts # CLI entry point (bun/TypeScript) + src/manifest.ts # Manifest fetch + cache logic + 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) shared/ common.sh # Provider-agnostic shared utilities {cloud}/ diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 00000000..e9a243b4 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +cli.js +spawn +dist/ +bun.lock +*.tgz diff --git a/cli/install.sh b/cli/install.sh index c0dfe27e..d2ad6b01 100755 --- a/cli/install.sh +++ b/cli/install.sh @@ -4,42 +4,114 @@ # Usage: # curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/cli/install.sh | bash # -# Override install directory: +# 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. +# +# Override install directory (for fallback method): # SPAWN_INSTALL_DIR=/usr/local/bin curl -fsSL ... | bash -set -euo pipefail +set -eo pipefail SPAWN_REPO="OpenRouterTeam/spawn" SPAWN_RAW_BASE="https://raw.githubusercontent.com/$SPAWN_REPO/main" -INSTALL_DIR="${SPAWN_INSTALL_DIR:-$HOME/.local/bin}" 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"; } log_warn() { echo -e "${YELLOW}[spawn]${NC} $1"; } log_error() { echo -e "${RED}[spawn]${NC} $1"; } -# Check curl -if ! command -v curl &>/dev/null; then - log_error "curl is required but not found" - exit 1 +# --- Method 1: bun install -g (preferred) --- +if command -v bun &>/dev/null; then + log_info "Installing spawn via bun..." + # Clone/download the cli directory and install from it + tmpdir=$(mktemp -d) + trap "rm -rf '$tmpdir'" EXIT + + log_info "Downloading CLI package..." + mkdir -p "$tmpdir/cli/src" + curl -fsSL "$SPAWN_RAW_BASE/cli/package.json" -o "$tmpdir/cli/package.json" + curl -fsSL "$SPAWN_RAW_BASE/cli/tsconfig.json" -o "$tmpdir/cli/tsconfig.json" + curl -fsSL "$SPAWN_RAW_BASE/cli/src/index.ts" -o "$tmpdir/cli/src/index.ts" + curl -fsSL "$SPAWN_RAW_BASE/cli/src/manifest.ts" -o "$tmpdir/cli/src/manifest.ts" + curl -fsSL "$SPAWN_RAW_BASE/cli/src/commands.ts" -o "$tmpdir/cli/src/commands.ts" + curl -fsSL "$SPAWN_RAW_BASE/cli/src/version.ts" -o "$tmpdir/cli/src/version.ts" + + cd "$tmpdir/cli" + bun install + bun link 2>/dev/null || bun install -g . 2>/dev/null || { + # If global install fails, build and copy binary + log_warn "Global install failed, building binary..." + bun build src/index.ts --compile --outfile spawn + INSTALL_DIR="${SPAWN_INSTALL_DIR:-$HOME/.local/bin}" + mkdir -p "$INSTALL_DIR" + mv spawn "$INSTALL_DIR/spawn" + log_info "Installed spawn binary to $INSTALL_DIR/spawn" + } + + log_info "spawn installed successfully!" + echo "" + if command -v spawn &>/dev/null; then + spawn version + echo "" + log_info "Run ${BOLD}spawn${NC}${GREEN} to get started${NC}" + fi + exit 0 fi -# Create install directory +# --- Method 2: npm install -g --- +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 + + log_info "Downloading CLI package..." + mkdir -p "$tmpdir/cli/src" + curl -fsSL "$SPAWN_RAW_BASE/cli/package.json" -o "$tmpdir/cli/package.json" + curl -fsSL "$SPAWN_RAW_BASE/cli/tsconfig.json" -o "$tmpdir/cli/tsconfig.json" + curl -fsSL "$SPAWN_RAW_BASE/cli/src/index.ts" -o "$tmpdir/cli/src/index.ts" + curl -fsSL "$SPAWN_RAW_BASE/cli/src/manifest.ts" -o "$tmpdir/cli/src/manifest.ts" + curl -fsSL "$SPAWN_RAW_BASE/cli/src/commands.ts" -o "$tmpdir/cli/src/commands.ts" + curl -fsSL "$SPAWN_RAW_BASE/cli/src/version.ts" -o "$tmpdir/cli/src/version.ts" + + cd "$tmpdir/cli" + npm install + npm install -g . 2>/dev/null || { + log_warn "npm global install requires permissions. Try:" + echo "" + echo " sudo npm install -g ." + echo "" + exit 1 + } + + log_info "spawn installed successfully!" + echo "" + if command -v spawn &>/dev/null; then + spawn version + echo "" + log_info "Run ${BOLD}spawn${NC}${GREEN} to get started${NC}" + fi + exit 0 +fi + +# --- Method 3: Direct download fallback (bash wrapper) --- +log_warn "Neither bun nor npm found. Installing bash fallback..." + +INSTALL_DIR="${SPAWN_INSTALL_DIR:-$HOME/.local/bin}" mkdir -p "$INSTALL_DIR" -# Download spawn CLI -log_info "Downloading spawn CLI..." 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 to $INSTALL_DIR/spawn" +log_info "Installed spawn (bash) to $INSTALL_DIR/spawn" # Check if install dir is in PATH if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then @@ -47,20 +119,12 @@ if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then echo "" echo "Add it by running one of:" echo "" - - # Detect shell and suggest appropriate config case "${SHELL:-/bin/bash}" in - */zsh) - echo " echo 'export PATH=\"$INSTALL_DIR:\$PATH\"' >> ~/.zshrc && source ~/.zshrc" - ;; - */fish) - echo " fish_add_path $INSTALL_DIR" - ;; - *) - echo " echo 'export PATH=\"$INSTALL_DIR:\$PATH\"' >> ~/.bashrc && source ~/.bashrc" - ;; + */zsh) echo " echo 'export PATH=\"$INSTALL_DIR:\$PATH\"' >> ~/.zshrc && source ~/.zshrc" ;; + */fish) echo " fish_add_path $INSTALL_DIR" ;; + *) echo " echo 'export PATH=\"$INSTALL_DIR:\$PATH\"' >> ~/.bashrc && source ~/.bashrc" ;; esac echo "" else - log_info "Run 'spawn' to get started" + log_info "Run ${BOLD}spawn${NC}${GREEN} to get started${NC}" fi diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 00000000..666f3cf2 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,20 @@ +{ + "name": "@openrouter/spawn", + "version": "0.1.0", + "type": "module", + "bin": { + "spawn": "cli.js" + }, + "scripts": { + "dev": "bun run src/index.ts", + "build": "bun build src/index.ts --outfile cli.js --target node --minify", + "compile": "bun build src/index.ts --compile --outfile spawn" + }, + "dependencies": { + "@clack/prompts": "^0.10.0", + "picocolors": "^1.1.1" + }, + "devDependencies": { + "@types/bun": "^1.2.0" + } +} diff --git a/cli/src/commands.ts b/cli/src/commands.ts new file mode 100644 index 00000000..17fe75c4 --- /dev/null +++ b/cli/src/commands.ts @@ -0,0 +1,371 @@ +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { spawn } from "child_process"; +import { + loadManifest, + agentKeys, + cloudKeys, + matrixStatus, + countImplemented, + RAW_BASE, + REPO, + CACHE_DIR, + type Manifest, +} from "./manifest.js"; +import { VERSION } from "./version.js"; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function handleCancel(): never { + p.cancel("Cancelled."); + process.exit(0); +} + +async function withSpinner(msg: string, fn: () => Promise): Promise { + const s = p.spinner(); + s.start(msg); + try { + const result = await fn(); + s.stop(msg); + return result; + } catch (err) { + s.stop(pc.red("Failed")); + throw err; + } +} + +// ── Interactive ──────────────────────────────────────────────────────────────── + +export async function cmdInteractive() { + p.intro(pc.inverse(` spawn v${VERSION} `)); + + const manifest = await withSpinner("Loading manifest...", loadManifest); + + const agents = agentKeys(manifest); + const agentChoice = await p.select({ + message: "Select an agent", + options: agents.map((key) => ({ + value: key, + label: manifest.agents[key].name, + hint: manifest.agents[key].description, + })), + }); + if (p.isCancel(agentChoice)) handleCancel(); + + // Only show clouds where this agent is implemented + const clouds = cloudKeys(manifest).filter( + (c) => matrixStatus(manifest, c, agentChoice) === "implemented" + ); + + if (clouds.length === 0) { + p.log.error(`No clouds available for ${manifest.agents[agentChoice].name}`); + process.exit(1); + } + + const cloudChoice = await p.select({ + message: "Select a cloud provider", + options: clouds.map((key) => ({ + value: key, + label: manifest.clouds[key].name, + hint: manifest.clouds[key].description, + })), + }); + if (p.isCancel(cloudChoice)) handleCancel(); + + const agentName = manifest.agents[agentChoice].name; + const cloudName = manifest.clouds[cloudChoice].name; + p.log.step(`Launching ${pc.bold(agentName)} on ${pc.bold(cloudName)}`); + p.outro("Handing off to spawn script..."); + + await execScript(cloudChoice, agentChoice); +} + +// ── Run ──────────────────────────────────────────────────────────────────────── + +export async function cmdRun(agent: string, cloud: string) { + const manifest = await withSpinner("Loading manifest...", loadManifest); + + if (!manifest.agents[agent]) { + p.log.error(`Unknown agent: ${pc.bold(agent)}`); + p.log.info(`Run ${pc.cyan("spawn agents")} to see available agents.`); + process.exit(1); + } + if (!manifest.clouds[cloud]) { + p.log.error(`Unknown cloud: ${pc.bold(cloud)}`); + p.log.info(`Run ${pc.cyan("spawn clouds")} to see available clouds.`); + process.exit(1); + } + + const status = matrixStatus(manifest, cloud, agent); + if (status !== "implemented") { + p.log.error( + `${manifest.agents[agent].name} on ${manifest.clouds[cloud].name} is not yet implemented.` + ); + process.exit(1); + } + + const agentName = manifest.agents[agent].name; + const cloudName = manifest.clouds[cloud].name; + p.log.step(`Launching ${pc.bold(agentName)} on ${pc.bold(cloudName)}...`); + + await execScript(cloud, agent); +} + +async function execScript(cloud: string, agent: string): Promise { + const url = `https://openrouter.ai/lab/spawn/${cloud}/${agent}.sh`; + + // Download script then execute, preserving stdin/stdout/stderr for interactive use + const res = await fetch(url); + if (!res.ok) { + // Fallback to GitHub raw + const ghUrl = `${RAW_BASE}/${cloud}/${agent}.sh`; + const ghRes = await fetch(ghUrl); + if (!ghRes.ok) { + p.log.error(`Failed to download script from ${url}`); + process.exit(1); + } + await runBash(await ghRes.text()); + return; + } + await runBash(await res.text()); +} + +function runBash(script: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn("bash", ["-c", script], { + stdio: "inherit", + env: process.env, + }); + child.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`Script exited with code ${code}`)); + }); + child.on("error", reject); + }); +} + +// ── List ─────────────────────────────────────────────────────────────────────── + +export async function cmdList() { + const manifest = await withSpinner("Loading manifest...", loadManifest); + + const agents = agentKeys(manifest); + const clouds = cloudKeys(manifest); + + // Calculate column widths + const agentColWidth = Math.max(16, ...agents.map((a) => manifest.agents[a].name.length + 2)); + const cloudColWidth = Math.max( + 10, + ...clouds.map((c) => manifest.clouds[c].name.length + 2) + ); + + // Header + let header = "".padEnd(agentColWidth); + for (const c of clouds) { + header += pc.bold(manifest.clouds[c].name.padEnd(cloudColWidth)); + } + console.log(); + console.log(header); + + // Separator + let sep = "".padEnd(agentColWidth); + for (const _ of clouds) { + sep += pc.dim("─".repeat(cloudColWidth - 2) + " "); + } + console.log(sep); + + // Rows + for (const a of agents) { + let row = pc.bold(manifest.agents[a].name.padEnd(agentColWidth)); + for (const c of clouds) { + const status = matrixStatus(manifest, c, a); + if (status === "implemented") { + row += pc.green(" \u2713".padEnd(cloudColWidth)); + } else { + row += pc.dim(" \u2013".padEnd(cloudColWidth)); + } + } + console.log(row); + } + + // Summary + const impl = countImplemented(manifest); + const total = agents.length * clouds.length; + console.log(); + console.log(pc.green(`${impl}/${total} combinations implemented`)); + console.log(); +} + +// ── Agents ───────────────────────────────────────────────────────────────────── + +export async function cmdAgents() { + const manifest = await withSpinner("Loading manifest...", loadManifest); + + console.log(); + console.log(pc.bold("Agents")); + console.log(); + for (const key of agentKeys(manifest)) { + const a = manifest.agents[key]; + console.log(` ${pc.green(a.name.padEnd(18))} ${pc.dim(a.description)}`); + } + console.log(); +} + +// ── Clouds ───────────────────────────────────────────────────────────────────── + +export async function cmdClouds() { + const manifest = await withSpinner("Loading manifest...", loadManifest); + + console.log(); + console.log(pc.bold("Cloud Providers")); + console.log(); + for (const key of cloudKeys(manifest)) { + const c = manifest.clouds[key]; + console.log(` ${pc.green(c.name.padEnd(18))} ${pc.dim(c.description)}`); + } + console.log(); +} + +// ── Agent Info ───────────────────────────────────────────────────────────────── + +export async function cmdAgentInfo(agent: string) { + const manifest = await withSpinner("Loading manifest...", loadManifest); + + if (!manifest.agents[agent]) { + p.log.error(`Unknown agent: ${pc.bold(agent)}`); + p.log.info(`Run ${pc.cyan("spawn agents")} to see available agents.`); + process.exit(1); + } + + const a = manifest.agents[agent]; + console.log(); + console.log(`${pc.bold(a.name)} ${pc.dim("\u2014")} ${a.description}`); + console.log(); + console.log(pc.bold("Available clouds:")); + console.log(); + + let found = false; + for (const cloud of cloudKeys(manifest)) { + const status = matrixStatus(manifest, cloud, agent); + if (status === "implemented") { + const c = manifest.clouds[cloud]; + console.log(` ${pc.green(c.name.padEnd(18))} ${pc.dim("spawn " + agent + " " + cloud)}`); + found = true; + } + } + + if (!found) { + console.log(pc.dim(" No implemented clouds yet.")); + } + console.log(); +} + +// ── Improve ──────────────────────────────────────────────────────────────────── + +export async function cmdImprove(args: string[]) { + const { existsSync: exists } = await import("fs"); + + let repoDir: string; + + // Check if we're in a spawn checkout + if (exists("./improve.sh") && exists("./manifest.json")) { + repoDir = "."; + } else { + const { join } = await import("path"); + repoDir = join(CACHE_DIR, "repo"); + + if (exists(join(repoDir, ".git"))) { + p.log.step("Updating spawn repo..."); + const { execSync } = await import("child_process"); + try { + execSync("git pull --ff-only", { cwd: repoDir, stdio: "pipe" }); + } catch { + // ignore pull failures + } + } else { + p.log.step("Cloning spawn repo..."); + const { execSync } = await import("child_process"); + execSync(`git clone https://github.com/${REPO}.git ${repoDir}`, { stdio: "inherit" }); + } + } + + return new Promise((resolve, reject) => { + const child = spawn("bash", ["improve.sh", ...args], { + cwd: repoDir, + stdio: "inherit", + env: process.env, + }); + child.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`improve.sh exited with code ${code}`)); + }); + child.on("error", reject); + }); +} + +// ── Update ───────────────────────────────────────────────────────────────────── + +export async function cmdUpdate() { + const s = p.spinner(); + s.start("Checking for updates..."); + + try { + const res = await fetch(`${RAW_BASE}/cli/package.json`, { + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) throw new Error("fetch failed"); + const pkg = (await res.json()) as { version: string }; + const remoteVersion = pkg.version; + + if (remoteVersion === VERSION) { + s.stop(`Already up to date ${pc.dim(`(v${VERSION})`)}`); + return; + } + + s.message(`Updating v${VERSION} \u2192 v${remoteVersion}...`); + + // Run the install script to update + const installRes = await fetch(`${RAW_BASE}/cli/install.sh`); + if (!installRes.ok) throw new Error("fetch install.sh failed"); + const installScript = await installRes.text(); + + s.stop(`Update available: v${VERSION} \u2192 v${remoteVersion}`); + p.log.info(`Run this to update:`); + console.log(); + console.log( + ` ${pc.cyan(`curl -fsSL ${RAW_BASE}/cli/install.sh | bash`)}` + ); + console.log(); + } catch { + s.stop(pc.red("Failed to check for updates")); + } +} + +// ── Help ─────────────────────────────────────────────────────────────────────── + +export function cmdHelp() { + console.log(` +${pc.bold("spawn")} \u2014 Launch any AI coding agent on any cloud + +${pc.bold("USAGE")} + spawn Interactive agent + cloud picker + spawn Launch agent on cloud directly + spawn 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 Check for CLI updates + spawn version Show version + +${pc.bold("EXAMPLES")} + spawn ${pc.dim("# Pick interactively")} + spawn claude sprite ${pc.dim("# Launch Claude Code on Sprite")} + spawn aider hetzner ${pc.dim("# Launch Aider on Hetzner Cloud")} + spawn claude ${pc.dim("# Show which clouds support Claude")} + spawn list ${pc.dim("# See the full agent x cloud matrix")} + +${pc.bold("INSTALL")} + curl -fsSL ${RAW_BASE}/cli/install.sh | bash +`); +} diff --git a/cli/src/index.ts b/cli/src/index.ts new file mode 100644 index 00000000..05c7cc41 --- /dev/null +++ b/cli/src/index.ts @@ -0,0 +1,95 @@ +#!/usr/bin/env bun +import { + cmdInteractive, + cmdRun, + cmdList, + cmdAgents, + cmdClouds, + cmdAgentInfo, + cmdImprove, + cmdUpdate, + cmdHelp, +} from "./commands.js"; +import { loadManifest } from "./manifest.js"; +import { VERSION } from "./version.js"; + +async function main() { + const args = process.argv.slice(2); + const cmd = args[0]; + + try { + if (!cmd) { + // No args + interactive terminal → picker + if (process.stdin.isTTY && process.stdout.isTTY) { + await cmdInteractive(); + } else { + cmdHelp(); + } + return; + } + + switch (cmd) { + case "help": + case "--help": + case "-h": + cmdHelp(); + break; + + case "version": + case "--version": + case "-v": + case "-V": + console.log(`spawn v${VERSION}`); + break; + + case "list": + case "ls": + await cmdList(); + break; + + case "agents": + await cmdAgents(); + break; + + case "clouds": + await cmdClouds(); + break; + + case "improve": + await cmdImprove(args.slice(1)); + break; + + case "update": + await cmdUpdate(); + break; + + default: { + // spawn or spawn + const agent = args[0]; + const cloud = args[1]; + + // Quick check: is this a known agent? + const manifest = await loadManifest(); + if (!manifest.agents[agent]) { + console.error(`Unknown command or agent: ${agent}`); + console.error(`Run 'spawn help' for usage.`); + process.exit(1); + } + + if (cloud) { + await cmdRun(agent, cloud); + } else { + await cmdAgentInfo(agent); + } + break; + } + } + } catch (err) { + if (err instanceof Error) { + console.error(`Error: ${err.message}`); + } + process.exit(1); + } +} + +main(); diff --git a/cli/src/manifest.ts b/cli/src/manifest.ts new file mode 100644 index 00000000..d8c10d89 --- /dev/null +++ b/cli/src/manifest.ts @@ -0,0 +1,133 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "fs"; +import { join } from "path"; +import { homedir } from "os"; + +// ── Types ────────────────────────────────────────────────────────────────────── + +export interface AgentDef { + name: string; + description: string; + url: string; + install: string; + launch: string; + env: Record; + pre_launch?: string; + deps?: string[]; + config_files?: Record; + interactive_prompts?: Record; + dotenv?: { path: string; values: Record }; + notes?: string; +} + +export interface CloudDef { + name: string; + description: string; + url: string; + type: string; + auth: string; + provision_method: string; + exec_method: string; + interactive_method: string; + defaults?: Record; + notes?: string; +} + +export interface Manifest { + agents: Record; + clouds: Record; + matrix: Record; +} + +// ── Constants ────────────────────────────────────────────────────────────────── + +const REPO = "OpenRouterTeam/spawn"; +const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/main`; +const CACHE_DIR = join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "spawn"); +const CACHE_FILE = join(CACHE_DIR, "manifest.json"); +const CACHE_TTL = 3600; // 1 hour in seconds + +// ── Cache helpers ────────────────────────────────────────────────────────────── + +function cacheAge(): number { + try { + const st = statSync(CACHE_FILE); + return (Date.now() - st.mtimeMs) / 1000; + } catch { + return Infinity; + } +} + +function readCache(): Manifest | null { + try { + return JSON.parse(readFileSync(CACHE_FILE, "utf-8")); + } catch { + return null; + } +} + +function writeCache(data: Manifest): void { + mkdirSync(CACHE_DIR, { recursive: true }); + writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2)); +} + +// ── Public API ───────────────────────────────────────────────────────────────── + +let _cached: Manifest | null = null; + +export async function loadManifest(forceRefresh = false): Promise { + if (_cached && !forceRefresh) return _cached; + + // Check disk cache first + if (!forceRefresh && cacheAge() < CACHE_TTL) { + const cached = readCache(); + if (cached) { + _cached = cached; + return cached; + } + } + + // Fetch from GitHub + try { + const res = await fetch(`${RAW_BASE}/manifest.json`, { + signal: AbortSignal.timeout(10_000), + }); + if (res.ok) { + const data = (await res.json()) as Manifest; + // Validate basic structure + if (data.agents && data.clouds && data.matrix) { + writeCache(data); + _cached = data; + return data; + } + } + } catch { + // Network error — fall through to cache + } + + // Offline fallback: use stale cache + const stale = readCache(); + if (stale) { + _cached = stale; + return stale; + } + + throw new Error("Cannot load manifest. Check your internet connection."); +} + +export function agentKeys(m: Manifest): string[] { + return Object.keys(m.agents); +} + +export function cloudKeys(m: Manifest): string[] { + return Object.keys(m.clouds); +} + +export function matrixStatus(m: Manifest, cloud: string, agent: string): string { + return m.matrix[`${cloud}/${agent}`] ?? "missing"; +} + +export function countImplemented(m: Manifest): number { + return Object.values(m.matrix).filter((v) => v === "implemented").length; +} + +export { RAW_BASE, REPO, CACHE_DIR }; diff --git a/cli/src/version.ts b/cli/src/version.ts new file mode 100644 index 00000000..52c905c1 --- /dev/null +++ b/cli/src/version.ts @@ -0,0 +1 @@ +export const VERSION = "0.1.0"; diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 00000000..eec2b081 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "types": ["bun"] + }, + "include": ["src"] +} diff --git a/digitalocean/aider.sh b/digitalocean/aider.sh index f26c48e4..dcbf962d 100755 --- a/digitalocean/aider.sh +++ b/digitalocean/aider.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +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)" diff --git a/digitalocean/claude.sh b/digitalocean/claude.sh index 7b1a81f9..182db5e0 100755 --- a/digitalocean/claude.sh +++ b/digitalocean/claude.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +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)" diff --git a/digitalocean/codex.sh b/digitalocean/codex.sh index 1c7b81a1..5be5b772 100755 --- a/digitalocean/codex.sh +++ b/digitalocean/codex.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +set -eo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" if [[ -f "$SCRIPT_DIR/lib/common.sh" ]]; then diff --git a/digitalocean/goose.sh b/digitalocean/goose.sh index 174b8324..53087c96 100755 --- a/digitalocean/goose.sh +++ b/digitalocean/goose.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +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)" diff --git a/digitalocean/interpreter.sh b/digitalocean/interpreter.sh index 4b75d2e7..fedff2ae 100755 --- a/digitalocean/interpreter.sh +++ b/digitalocean/interpreter.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +set -eo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" if [[ -f "$SCRIPT_DIR/lib/common.sh" ]]; then diff --git a/digitalocean/lib/common.sh b/digitalocean/lib/common.sh index f239d41e..1ac6b52d 100755 --- a/digitalocean/lib/common.sh +++ b/digitalocean/lib/common.sh @@ -2,7 +2,7 @@ # Common bash functions for DigitalOcean spawn scripts # Bash safety flags -set -euo pipefail +set -eo pipefail # ============================================================ # Provider-agnostic functions diff --git a/digitalocean/nanoclaw.sh b/digitalocean/nanoclaw.sh index ef470e28..ae666720 100755 --- a/digitalocean/nanoclaw.sh +++ b/digitalocean/nanoclaw.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +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)" diff --git a/digitalocean/openclaw.sh b/digitalocean/openclaw.sh index be1de331..d115cd69 100755 --- a/digitalocean/openclaw.sh +++ b/digitalocean/openclaw.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +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)" diff --git a/hetzner/aider.sh b/hetzner/aider.sh index f8d4eba9..abcb2686 100755 --- a/hetzner/aider.sh +++ b/hetzner/aider.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +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)" diff --git a/hetzner/claude.sh b/hetzner/claude.sh index d6761e80..a35255f3 100755 --- a/hetzner/claude.sh +++ b/hetzner/claude.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +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)" diff --git a/hetzner/codex.sh b/hetzner/codex.sh index 53d3c26d..bbd05ff7 100755 --- a/hetzner/codex.sh +++ b/hetzner/codex.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +set -eo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" if [[ -f "$SCRIPT_DIR/lib/common.sh" ]]; then diff --git a/hetzner/goose.sh b/hetzner/goose.sh index e51e1467..91651345 100755 --- a/hetzner/goose.sh +++ b/hetzner/goose.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +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)" diff --git a/hetzner/interpreter.sh b/hetzner/interpreter.sh index fcd711ca..7ca97452 100755 --- a/hetzner/interpreter.sh +++ b/hetzner/interpreter.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +set -eo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" if [[ -f "$SCRIPT_DIR/lib/common.sh" ]]; then diff --git a/hetzner/lib/common.sh b/hetzner/lib/common.sh index efa816b3..315a2aaa 100755 --- a/hetzner/lib/common.sh +++ b/hetzner/lib/common.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +set -eo pipefail # Common bash functions for Hetzner Cloud spawn scripts # ============================================================ diff --git a/hetzner/nanoclaw.sh b/hetzner/nanoclaw.sh index 327e0e97..4445c406 100755 --- a/hetzner/nanoclaw.sh +++ b/hetzner/nanoclaw.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +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)" diff --git a/hetzner/openclaw.sh b/hetzner/openclaw.sh index e8bb05a1..8cd0b7a0 100755 --- a/hetzner/openclaw.sh +++ b/hetzner/openclaw.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +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)" diff --git a/linode/aider.sh b/linode/aider.sh index 39158268..9e9073c7 100755 --- a/linode/aider.sh +++ b/linode/aider.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +set -eo pipefail 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/linode/lib/common.sh)"; fi diff --git a/linode/claude.sh b/linode/claude.sh index 4c5f2aec..ee61dfdb 100755 --- a/linode/claude.sh +++ b/linode/claude.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +set -eo pipefail 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/linode/lib/common.sh)"; fi diff --git a/linode/codex.sh b/linode/codex.sh index 03add592..00210611 100755 --- a/linode/codex.sh +++ b/linode/codex.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +set -eo pipefail 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/linode/lib/common.sh)"; fi diff --git a/linode/goose.sh b/linode/goose.sh index efc43dae..d35eb888 100755 --- a/linode/goose.sh +++ b/linode/goose.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +set -eo pipefail 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/linode/lib/common.sh)"; fi diff --git a/linode/interpreter.sh b/linode/interpreter.sh index 0b91d691..9c734394 100755 --- a/linode/interpreter.sh +++ b/linode/interpreter.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +set -eo pipefail 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/linode/lib/common.sh)"; fi diff --git a/linode/lib/common.sh b/linode/lib/common.sh index 560242e7..e2bd5faf 100644 --- a/linode/lib/common.sh +++ b/linode/lib/common.sh @@ -2,7 +2,7 @@ # Common bash functions for Linode (Akamai) spawn scripts # Bash safety flags -set -euo pipefail +set -eo pipefail # ============================================================ # Provider-agnostic functions diff --git a/linode/nanoclaw.sh b/linode/nanoclaw.sh index 1256bcd7..e9fee1df 100755 --- a/linode/nanoclaw.sh +++ b/linode/nanoclaw.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +set -eo pipefail 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/linode/lib/common.sh)"; fi diff --git a/linode/openclaw.sh b/linode/openclaw.sh index e57ff70a..1b9c3588 100755 --- a/linode/openclaw.sh +++ b/linode/openclaw.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +set -eo pipefail 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/linode/lib/common.sh)"; fi diff --git a/shared/common.sh b/shared/common.sh index 5529db76..c270a6a7 100644 --- a/shared/common.sh +++ b/shared/common.sh @@ -3,7 +3,7 @@ # Provider-agnostic utilities for logging, input, OAuth, etc. # # This file is meant to be sourced by cloud provider-specific common.sh files. -# It does not set bash flags (like set -euo pipefail) as those should be set +# It does not set bash flags (like set -eo pipefail) as those should be set # by the scripts that source this file. # ============================================================ diff --git a/sprite/aider.sh b/sprite/aider.sh index 2be45e1a..98466d53 100755 --- a/sprite/aider.sh +++ b/sprite/aider.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +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)" diff --git a/sprite/claude.sh b/sprite/claude.sh index 810b19b7..194cf6cf 100755 --- a/sprite/claude.sh +++ b/sprite/claude.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +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)" diff --git a/sprite/codex.sh b/sprite/codex.sh index f2418fd1..8b8c83bb 100755 --- a/sprite/codex.sh +++ b/sprite/codex.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +set -eo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" if [[ -f "$SCRIPT_DIR/lib/common.sh" ]]; then diff --git a/sprite/goose.sh b/sprite/goose.sh index 0913405c..e08170b3 100755 --- a/sprite/goose.sh +++ b/sprite/goose.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +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)" diff --git a/sprite/interpreter.sh b/sprite/interpreter.sh index 9848be24..8f02952a 100755 --- a/sprite/interpreter.sh +++ b/sprite/interpreter.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +set -eo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" if [[ -f "$SCRIPT_DIR/lib/common.sh" ]]; then diff --git a/sprite/lib/common.sh b/sprite/lib/common.sh index c8bc0f59..d2d46181 100644 --- a/sprite/lib/common.sh +++ b/sprite/lib/common.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +set -eo pipefail # Common bash functions shared between spawn scripts # Source shared provider-agnostic functions (local or remote fallback) diff --git a/sprite/nanoclaw.sh b/sprite/nanoclaw.sh index ea1b459c..345c6647 100644 --- a/sprite/nanoclaw.sh +++ b/sprite/nanoclaw.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +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)" diff --git a/sprite/openclaw.sh b/sprite/openclaw.sh index 5487acdd..038c5fbc 100755 --- a/sprite/openclaw.sh +++ b/sprite/openclaw.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +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)" diff --git a/vultr/aider.sh b/vultr/aider.sh index 8378e488..2a8dc953 100755 --- a/vultr/aider.sh +++ b/vultr/aider.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +set -eo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" if [[ -f "$SCRIPT_DIR/lib/common.sh" ]]; then diff --git a/vultr/claude.sh b/vultr/claude.sh index 0c85a87c..3c13c7bd 100755 --- a/vultr/claude.sh +++ b/vultr/claude.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +set -eo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" if [[ -f "$SCRIPT_DIR/lib/common.sh" ]]; then diff --git a/vultr/codex.sh b/vultr/codex.sh index 2d709947..0e9150d6 100755 --- a/vultr/codex.sh +++ b/vultr/codex.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +set -eo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" if [[ -f "$SCRIPT_DIR/lib/common.sh" ]]; then diff --git a/vultr/goose.sh b/vultr/goose.sh index 797b6f2f..fe83304b 100755 --- a/vultr/goose.sh +++ b/vultr/goose.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +set -eo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" if [[ -f "$SCRIPT_DIR/lib/common.sh" ]]; then diff --git a/vultr/interpreter.sh b/vultr/interpreter.sh index fa118f4d..7bcda6b7 100755 --- a/vultr/interpreter.sh +++ b/vultr/interpreter.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +set -eo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" if [[ -f "$SCRIPT_DIR/lib/common.sh" ]]; then diff --git a/vultr/lib/common.sh b/vultr/lib/common.sh index d8c5edf8..c3e778e3 100755 --- a/vultr/lib/common.sh +++ b/vultr/lib/common.sh @@ -2,7 +2,7 @@ # Common bash functions for Vultr spawn scripts # Bash safety flags -set -euo pipefail +set -eo pipefail # ============================================================ # Provider-agnostic functions diff --git a/vultr/nanoclaw.sh b/vultr/nanoclaw.sh index 432f75ab..9d2acfbc 100755 --- a/vultr/nanoclaw.sh +++ b/vultr/nanoclaw.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +set -eo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" if [[ -f "$SCRIPT_DIR/lib/common.sh" ]]; then diff --git a/vultr/openclaw.sh b/vultr/openclaw.sh index b2ea9d58..af4576e6 100755 --- a/vultr/openclaw.sh +++ b/vultr/openclaw.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -euo pipefail +set -eo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" if [[ -f "$SCRIPT_DIR/lib/common.sh" ]]; then