mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-02 13:50:26 +00:00
Two UX improvements: 1. Dry-run credential status now shows the cloud provider's URL next to missing cloud-specific auth vars (e.g., HCLOUD_TOKEN), helping users find where to create their credentials. Previously only OPENROUTER_API_KEY showed a URL hint. 2. Added `spawn list --clear` command to let users clear their spawn history. Previously there was no way to reset the 100-entry history file without manually deleting ~/.spawn/history.json. Agent: ux-engineer Co-authored-by: A <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1625 lines
61 KiB
TypeScript
1625 lines
61 KiB
TypeScript
import "./unicode-detect.js"; // Must be first: configures TERM before clack reads it
|
|
import * as p from "@clack/prompts";
|
|
import pc from "picocolors";
|
|
import { spawn } from "child_process";
|
|
import {
|
|
loadManifest,
|
|
agentKeys,
|
|
cloudKeys,
|
|
matrixStatus,
|
|
countImplemented,
|
|
isStaleCache,
|
|
RAW_BASE,
|
|
REPO,
|
|
type Manifest,
|
|
} from "./manifest.js";
|
|
import pkg from "../package.json" with { type: "json" };
|
|
const VERSION = pkg.version;
|
|
import { validateIdentifier, validateScriptContent, validatePrompt } from "./security.js";
|
|
import { saveSpawnRecord, filterHistory, clearHistory, type SpawnRecord } from "./history.js";
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
|
|
const FETCH_TIMEOUT = 10_000; // 10 seconds
|
|
|
|
export function getErrorMessage(err: unknown): string {
|
|
// Use duck typing instead of instanceof to avoid prototype chain issues
|
|
return err && typeof err === "object" && "message" in err ? String(err.message) : String(err);
|
|
}
|
|
|
|
function handleCancel(): never {
|
|
p.outro(pc.dim("Cancelled."));
|
|
process.exit(0);
|
|
}
|
|
|
|
async function withSpinner<T>(msg: string, fn: () => Promise<T>, doneMsg?: string): Promise<T> {
|
|
const s = p.spinner();
|
|
s.start(msg);
|
|
try {
|
|
const result = await fn();
|
|
s.stop(doneMsg ?? msg.replace(/\.{3}$/, ""));
|
|
return result;
|
|
} catch (err) {
|
|
s.stop(pc.red("Failed"));
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export async function loadManifestWithSpinner(): Promise<Manifest> {
|
|
const manifest = await withSpinner("Loading manifest...", loadManifest);
|
|
if (isStaleCache()) {
|
|
p.log.warn("Using cached manifest (offline). Data may be outdated.");
|
|
}
|
|
return manifest;
|
|
}
|
|
|
|
function validateNonEmptyString(value: string, fieldName: string, helpCommand: string): void {
|
|
if (!value || value.trim() === "") {
|
|
p.log.error(`${fieldName} is required but was not provided`);
|
|
p.log.info(`Run ${pc.cyan(helpCommand)} to see all available ${fieldName.toLowerCase()}s.`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
function mapToSelectOptions<T extends { name: string; description: string }>(
|
|
keys: string[],
|
|
items: Record<string, T>,
|
|
hintOverrides?: Record<string, string>
|
|
): Array<{ value: string; label: string; hint: string }> {
|
|
return keys.map((key) => ({
|
|
value: key,
|
|
label: items[key].name,
|
|
hint: hintOverrides?.[key] ?? items[key].description,
|
|
}));
|
|
}
|
|
|
|
export function getImplementedClouds(manifest: Manifest, agent: string): string[] {
|
|
return cloudKeys(manifest).filter(
|
|
(c: string): boolean => matrixStatus(manifest, c, agent) === "implemented"
|
|
);
|
|
}
|
|
|
|
/** Levenshtein distance between two strings */
|
|
export function levenshtein(a: string, b: string): number {
|
|
const m = a.length;
|
|
const n = b.length;
|
|
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
|
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
|
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
|
for (let i = 1; i <= m; i++) {
|
|
for (let j = 1; j <= n; j++) {
|
|
dp[i][j] = a[i - 1] === b[j - 1]
|
|
? dp[i - 1][j - 1]
|
|
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
}
|
|
}
|
|
return dp[m][n];
|
|
}
|
|
|
|
/** Find the closest match from a list of candidates (max distance 3) */
|
|
export function findClosestMatch(input: string, candidates: string[]): string | null {
|
|
let best: string | null = null;
|
|
let bestDist = Infinity;
|
|
for (const candidate of candidates) {
|
|
const dist = levenshtein(input.toLowerCase(), candidate.toLowerCase());
|
|
if (dist < bestDist) {
|
|
bestDist = dist;
|
|
best = candidate;
|
|
}
|
|
}
|
|
return bestDist <= 3 ? best : null;
|
|
}
|
|
|
|
/**
|
|
* Find the closest matching key by checking both keys and display names.
|
|
* Returns the key (not display name) of the best match, or null if no match within distance 3.
|
|
*/
|
|
export function findClosestKeyByNameOrKey(
|
|
input: string,
|
|
keys: string[],
|
|
getName: (key: string) => string
|
|
): string | null {
|
|
let bestKey: string | null = null;
|
|
let bestDist = Infinity;
|
|
const lower = input.toLowerCase();
|
|
|
|
for (const key of keys) {
|
|
const keyDist = levenshtein(lower, key.toLowerCase());
|
|
if (keyDist < bestDist) {
|
|
bestDist = keyDist;
|
|
bestKey = key;
|
|
}
|
|
const nameDist = levenshtein(lower, getName(key).toLowerCase());
|
|
if (nameDist < bestDist) {
|
|
bestDist = nameDist;
|
|
bestKey = key;
|
|
}
|
|
}
|
|
return bestDist <= 3 ? bestKey : null;
|
|
}
|
|
|
|
/**
|
|
* Resolve user input to a valid entity key (agent or cloud).
|
|
* Tries: exact key -> case-insensitive key -> display name match (case-insensitive).
|
|
* Returns the key if found, or null.
|
|
*/
|
|
function resolveEntityKey(manifest: Manifest, input: string, kind: "agent" | "cloud"): string | null {
|
|
const collection = getEntityCollection(manifest, kind);
|
|
if (collection[input]) return input;
|
|
const keys = getEntityKeys(manifest, kind);
|
|
const lower = input.toLowerCase();
|
|
for (const key of keys) {
|
|
if (key.toLowerCase() === lower) return key;
|
|
}
|
|
for (const key of keys) {
|
|
if (collection[key].name.toLowerCase() === lower) return key;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function resolveAgentKey(manifest: Manifest, input: string): string | null {
|
|
return resolveEntityKey(manifest, input, "agent");
|
|
}
|
|
|
|
export function resolveCloudKey(manifest: Manifest, input: string): string | null {
|
|
return resolveEntityKey(manifest, input, "cloud");
|
|
}
|
|
|
|
interface EntityDef { label: string; labelPlural: string; listCmd: string; opposite: string }
|
|
const ENTITY_DEFS: Record<"agent" | "cloud", EntityDef> = {
|
|
agent: { label: "agent", labelPlural: "agents", listCmd: "spawn agents", opposite: "cloud provider" },
|
|
cloud: { label: "cloud", labelPlural: "clouds", listCmd: "spawn clouds", opposite: "agent" },
|
|
};
|
|
|
|
function getEntityCollection(manifest: Manifest, kind: "agent" | "cloud") {
|
|
return kind === "agent" ? manifest.agents : manifest.clouds;
|
|
}
|
|
|
|
function getEntityKeys(manifest: Manifest, kind: "agent" | "cloud") {
|
|
return kind === "agent" ? agentKeys(manifest) : cloudKeys(manifest);
|
|
}
|
|
|
|
/** Suggest a typo correction by fuzzy-matching against a set of keys */
|
|
function suggestTypoCorrection(
|
|
value: string,
|
|
manifest: Manifest,
|
|
kind: "agent" | "cloud"
|
|
): string | null {
|
|
const collection = getEntityCollection(manifest, kind);
|
|
const keys = getEntityKeys(manifest, kind);
|
|
return findClosestKeyByNameOrKey(value, keys, (k) => collection[k].name);
|
|
}
|
|
|
|
/** Report validation error for an entity and return false, or return true if valid */
|
|
export function checkEntity(manifest: Manifest, value: string, kind: "agent" | "cloud"): boolean {
|
|
const def = ENTITY_DEFS[kind];
|
|
const collection = getEntityCollection(manifest, kind);
|
|
if (collection[value]) return true;
|
|
|
|
p.log.error(`Unknown ${def.label}: ${pc.bold(value)}`);
|
|
|
|
const oppositeKind = kind === "agent" ? "cloud" : "agent";
|
|
const oppositeDef = ENTITY_DEFS[oppositeKind];
|
|
const oppositeCollection = getEntityCollection(manifest, oppositeKind);
|
|
|
|
// Check if user provided an entity of the wrong kind
|
|
if (oppositeCollection[value]) {
|
|
p.log.info(`"${value}" is ${kind === "agent" ? "a cloud provider" : "an agent"}, not ${kind === "agent" ? "an agent" : "a cloud provider"}.`);
|
|
p.log.info(`Usage: ${pc.cyan("spawn <agent> <cloud>")}`);
|
|
p.log.info(`Run ${pc.cyan(def.listCmd)} to see available ${def.labelPlural}.`);
|
|
return false;
|
|
}
|
|
|
|
// Check for typo matches in the same kind
|
|
const match = suggestTypoCorrection(value, manifest, kind);
|
|
if (match) {
|
|
p.log.info(`Did you mean ${pc.cyan(match)} (${collection[match].name})?`);
|
|
p.log.info(` ${pc.cyan(`spawn ${match}`)}`);
|
|
p.log.info(`Run ${pc.cyan(def.listCmd)} to see available ${def.labelPlural}.`);
|
|
return false;
|
|
}
|
|
|
|
// Check for typo matches in the opposite kind (swapped arguments with typo)
|
|
const oppositeMatch = suggestTypoCorrection(value, manifest, oppositeKind);
|
|
if (oppositeMatch) {
|
|
p.log.info(`"${pc.bold(value)}" looks like ${oppositeDef.label} ${pc.cyan(oppositeMatch)} (${oppositeCollection[oppositeMatch].name}).`);
|
|
p.log.info(`Did you swap the agent and cloud arguments?`);
|
|
p.log.info(`Usage: ${pc.cyan("spawn <agent> <cloud>")}`);
|
|
return false;
|
|
}
|
|
|
|
p.log.info(`Run ${pc.cyan(def.listCmd)} to see available ${def.labelPlural}.`);
|
|
return false;
|
|
}
|
|
|
|
function validateEntity(manifest: Manifest, value: string, kind: "agent" | "cloud"): void {
|
|
if (!checkEntity(manifest, value, kind)) {
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
async function validateAndGetEntity(value: string, kind: "agent" | "cloud"): Promise<[manifest: Manifest, key: string]> {
|
|
const def = ENTITY_DEFS[kind];
|
|
const capitalLabel = def.label.charAt(0).toUpperCase() + def.label.slice(1);
|
|
try {
|
|
validateIdentifier(value, `${capitalLabel} name`);
|
|
} catch (err) {
|
|
p.log.error(getErrorMessage(err));
|
|
process.exit(1);
|
|
}
|
|
|
|
validateNonEmptyString(value, `${capitalLabel} name`, def.listCmd);
|
|
const manifest = await loadManifestWithSpinner();
|
|
validateEntity(manifest, value, kind);
|
|
|
|
return [manifest, value];
|
|
}
|
|
|
|
function validateImplementation(manifest: Manifest, cloud: string, agent: string): void {
|
|
const status = matrixStatus(manifest, cloud, agent);
|
|
if (status !== "implemented") {
|
|
const agentName = manifest.agents[agent].name;
|
|
const cloudName = manifest.clouds[cloud].name;
|
|
p.log.error(`${agentName} on ${cloudName} is not yet implemented.`);
|
|
|
|
const availableClouds = getImplementedClouds(manifest, agent);
|
|
if (availableClouds.length > 0) {
|
|
const examples = availableClouds.slice(0, 3).map((c) => `spawn ${agent} ${c}`);
|
|
p.log.info(`${agentName} is available on ${availableClouds.length} cloud${availableClouds.length > 1 ? "s" : ""}. Try one of these instead:`);
|
|
for (const cmd of examples) {
|
|
p.log.info(` ${pc.cyan(cmd)}`);
|
|
}
|
|
if (availableClouds.length > 3) {
|
|
p.log.info(`Run ${pc.cyan(`spawn ${agent}`)} to see all ${availableClouds.length} options.`);
|
|
}
|
|
} else {
|
|
p.log.info(`This agent has no implemented cloud providers yet.`);
|
|
p.log.info(`Run ${pc.cyan("spawn matrix")} to see the full availability matrix.`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// ── Interactive ────────────────────────────────────────────────────────────────
|
|
|
|
/** Sort clouds by credential availability and build hint overrides for the picker */
|
|
export function prioritizeCloudsByCredentials(
|
|
clouds: string[],
|
|
manifest: Manifest
|
|
): { sortedClouds: string[]; hintOverrides: Record<string, string>; credCount: number } {
|
|
const withCreds: string[] = [];
|
|
const withoutCreds: string[] = [];
|
|
for (const c of clouds) {
|
|
if (hasCloudCredentials(manifest.clouds[c].auth)) {
|
|
withCreds.push(c);
|
|
} else {
|
|
withoutCreds.push(c);
|
|
}
|
|
}
|
|
|
|
const hintOverrides: Record<string, string> = {};
|
|
for (const c of withCreds) {
|
|
hintOverrides[c] = `credentials detected -- ${manifest.clouds[c].description}`;
|
|
}
|
|
|
|
return { sortedClouds: [...withCreds, ...withoutCreds], hintOverrides, credCount: withCreds.length };
|
|
}
|
|
|
|
export async function cmdInteractive(): Promise<void> {
|
|
p.intro(pc.inverse(` spawn v${VERSION} `));
|
|
|
|
const manifest = await loadManifestWithSpinner();
|
|
|
|
const agents = agentKeys(manifest);
|
|
const agentChoice = await p.select({
|
|
message: "Select an agent",
|
|
options: mapToSelectOptions(agents, manifest.agents),
|
|
});
|
|
if (p.isCancel(agentChoice)) handleCancel();
|
|
|
|
const clouds = getImplementedClouds(manifest, agentChoice);
|
|
|
|
if (clouds.length === 0) {
|
|
p.log.error(`No clouds available for ${manifest.agents[agentChoice].name}`);
|
|
p.log.info(`This agent has no implemented cloud providers yet.`);
|
|
p.log.info(`Run ${pc.cyan("spawn matrix")} to see the full availability matrix.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const { sortedClouds, hintOverrides, credCount } = prioritizeCloudsByCredentials(clouds, manifest);
|
|
|
|
if (credCount > 0) {
|
|
p.log.info(`${credCount} cloud${credCount > 1 ? "s" : ""} with credentials detected`);
|
|
}
|
|
|
|
const cloudChoice = await p.select({
|
|
message: "Select a cloud provider",
|
|
options: mapToSelectOptions(sortedClouds, manifest.clouds, hintOverrides),
|
|
});
|
|
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.log.info(`Next time, run directly: ${pc.cyan(`spawn ${agentChoice} ${cloudChoice}`)}`);
|
|
p.outro("Handing off to spawn script...");
|
|
|
|
await execScript(cloudChoice, agentChoice, undefined, getAuthHint(manifest, cloudChoice));
|
|
}
|
|
|
|
// ── Run ────────────────────────────────────────────────────────────────────────
|
|
|
|
/** Resolve display names / casing and log if resolved to a different key */
|
|
function resolveAndLog(
|
|
manifest: Manifest,
|
|
agent: string,
|
|
cloud: string
|
|
): { agent: string; cloud: string } {
|
|
const resolvedAgent = resolveAgentKey(manifest, agent);
|
|
const resolvedCloud = resolveCloudKey(manifest, cloud);
|
|
if (resolvedAgent && resolvedAgent !== agent) {
|
|
p.log.info(`Resolved "${agent}" to ${pc.cyan(resolvedAgent)}`);
|
|
agent = resolvedAgent;
|
|
}
|
|
if (resolvedCloud && resolvedCloud !== cloud) {
|
|
p.log.info(`Resolved "${cloud}" to ${pc.cyan(resolvedCloud)}`);
|
|
cloud = resolvedCloud;
|
|
}
|
|
return { agent, cloud };
|
|
}
|
|
|
|
/** Detect and fix swapped arguments: "spawn <cloud> <agent>" -> "spawn <agent> <cloud>" */
|
|
function detectAndFixSwappedArgs(
|
|
manifest: Manifest,
|
|
agent: string,
|
|
cloud: string
|
|
): { agent: string; cloud: string } {
|
|
if (!manifest.agents[agent] && manifest.clouds[agent] && manifest.agents[cloud]) {
|
|
p.log.info(`It looks like you swapped the agent and cloud arguments.`);
|
|
p.log.info(`Running: ${pc.cyan(`spawn ${cloud} ${agent}`)}`);
|
|
return { agent: cloud, cloud: agent };
|
|
}
|
|
return { agent, cloud };
|
|
}
|
|
|
|
/** Print a labeled section: bold header, body lines, then a blank line */
|
|
function printDryRunSection(title: string, lines: string[]): void {
|
|
p.log.step(pc.bold(title));
|
|
for (const line of lines) console.log(line);
|
|
console.log();
|
|
}
|
|
|
|
function buildAgentLines(agentInfo: { name: string; description: string; install?: string; launch?: string }): string[] {
|
|
const lines = [
|
|
` Name: ${agentInfo.name}`,
|
|
` Description: ${agentInfo.description}`,
|
|
];
|
|
if (agentInfo.install) lines.push(` Install: ${agentInfo.install}`);
|
|
if (agentInfo.launch) lines.push(` Launch: ${agentInfo.launch}`);
|
|
return lines;
|
|
}
|
|
|
|
function buildCloudLines(cloudInfo: { name: string; description: string; defaults?: Record<string, string> }): string[] {
|
|
const lines = [
|
|
` Name: ${cloudInfo.name}`,
|
|
` Description: ${cloudInfo.description}`,
|
|
];
|
|
if (cloudInfo.defaults) {
|
|
lines.push(` Defaults:`);
|
|
for (const [k, v] of Object.entries(cloudInfo.defaults)) {
|
|
lines.push(` ${k}: ${v}`);
|
|
}
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
/** Build credential status lines for dry-run preview showing which env vars are set/missing */
|
|
function buildCredentialStatusLines(manifest: Manifest, cloud: string): string[] {
|
|
const lines: string[] = [];
|
|
const cloudAuth = manifest.clouds[cloud].auth;
|
|
const authVars = parseAuthEnvVars(cloudAuth);
|
|
const cloudUrl = manifest.clouds[cloud].url;
|
|
|
|
// Always check OPENROUTER_API_KEY
|
|
const orSet = !!process.env.OPENROUTER_API_KEY;
|
|
lines.push(orSet
|
|
? ` ${pc.green("OPENROUTER_API_KEY")} ${pc.dim("-- set")}`
|
|
: ` ${pc.red("OPENROUTER_API_KEY")} ${pc.dim("-- not set")} ${pc.dim("https://openrouter.ai/settings/keys")}`);
|
|
|
|
// Check cloud-specific auth vars (show provider URL hint for missing vars)
|
|
for (let i = 0; i < authVars.length; i++) {
|
|
const v = authVars[i];
|
|
const isSet = !!process.env[v];
|
|
if (isSet) {
|
|
lines.push(` ${pc.green(v)} ${pc.dim("-- set")}`);
|
|
} else {
|
|
// Show the cloud provider URL on the first missing var to help users find their credentials
|
|
const urlHint = i === 0 && cloudUrl ? ` ${pc.dim(cloudUrl)}` : "";
|
|
lines.push(` ${pc.red(v)} ${pc.dim("-- not set")}${urlHint}`);
|
|
}
|
|
}
|
|
|
|
return lines;
|
|
}
|
|
|
|
function showDryRunPreview(manifest: Manifest, agent: string, cloud: string, prompt?: string): void {
|
|
p.log.info(pc.bold("Dry run -- no resources will be provisioned\n"));
|
|
|
|
printDryRunSection("Agent", buildAgentLines(manifest.agents[agent]));
|
|
printDryRunSection("Cloud", buildCloudLines(manifest.clouds[cloud]));
|
|
printDryRunSection("Script", [` URL: ${RAW_BASE}/${cloud}/${agent}.sh`]);
|
|
|
|
const env = manifest.agents[agent].env;
|
|
if (env) {
|
|
const envLines = Object.entries(env).map(([k, v]) => {
|
|
const display = v.includes("OPENROUTER_API_KEY") ? "(from OpenRouter)" : v;
|
|
return ` ${k}=${display}`;
|
|
});
|
|
printDryRunSection("Environment variables", envLines);
|
|
}
|
|
|
|
// Show credential readiness
|
|
const credLines = buildCredentialStatusLines(manifest, cloud);
|
|
printDryRunSection("Credentials", credLines);
|
|
const allSet = credLines.every(l => l.includes("-- set"));
|
|
if (!allSet) {
|
|
p.log.warn("Some credentials are missing. Set them before launching.");
|
|
p.log.info(`Run ${pc.cyan(`spawn ${cloud}`)} for setup instructions.`);
|
|
console.log();
|
|
}
|
|
|
|
if (prompt) {
|
|
printDryRunSection("Prompt", [` ${prompt.length > 100 ? prompt.slice(0, 100) + "..." : prompt}`]);
|
|
}
|
|
|
|
p.log.success("Dry run complete -- no resources were provisioned");
|
|
}
|
|
|
|
/** Validate inputs for injection attacks (SECURITY) and check they're non-empty */
|
|
function validateRunSecurity(agent: string, cloud: string, prompt?: string): void {
|
|
try {
|
|
validateIdentifier(agent, "Agent name");
|
|
validateIdentifier(cloud, "Cloud name");
|
|
if (prompt) {
|
|
validatePrompt(prompt);
|
|
}
|
|
} catch (err) {
|
|
p.log.error(getErrorMessage(err));
|
|
process.exit(1);
|
|
}
|
|
|
|
validateNonEmptyString(agent, "Agent name", "spawn agents");
|
|
validateNonEmptyString(cloud, "Cloud name", "spawn clouds");
|
|
}
|
|
|
|
/** Validate agent and cloud exist in manifest, showing all errors before exiting */
|
|
function validateEntities(manifest: Manifest, agent: string, cloud: string): void {
|
|
const agentValid = checkEntity(manifest, agent, "agent");
|
|
const cloudValid = checkEntity(manifest, cloud, "cloud");
|
|
if (!agentValid || !cloudValid) {
|
|
process.exit(1);
|
|
}
|
|
validateImplementation(manifest, cloud, agent);
|
|
}
|
|
|
|
/** Build auth hint string from cloud auth field for error messages */
|
|
function getAuthHint(manifest: Manifest, cloud: string): string | undefined {
|
|
const authVars = parseAuthEnvVars(manifest.clouds[cloud].auth);
|
|
return authVars.length > 0 ? authVars.join(" + ") : undefined;
|
|
}
|
|
|
|
/** Check for missing credentials before running a script and warn the user.
|
|
* In interactive mode, asks for confirmation. In non-interactive mode, just warns. */
|
|
export async function preflightCredentialCheck(manifest: Manifest, cloud: string): Promise<void> {
|
|
const cloudAuth = manifest.clouds[cloud].auth;
|
|
if (cloudAuth.toLowerCase() === "none") return;
|
|
|
|
const authVars = parseAuthEnvVars(cloudAuth);
|
|
const missing: string[] = [];
|
|
|
|
if (!process.env.OPENROUTER_API_KEY) {
|
|
missing.push("OPENROUTER_API_KEY");
|
|
}
|
|
for (const v of authVars) {
|
|
if (!process.env[v]) {
|
|
missing.push(v);
|
|
}
|
|
}
|
|
|
|
if (missing.length === 0) return;
|
|
|
|
const cloudName = manifest.clouds[cloud].name;
|
|
p.log.warn(`Missing credentials for ${cloudName}: ${missing.map(v => pc.cyan(v)).join(", ")}`);
|
|
p.log.info(`Run ${pc.cyan(`spawn ${cloud}`)} for setup instructions.`);
|
|
|
|
if (isInteractiveTTY()) {
|
|
const shouldContinue = await p.confirm({
|
|
message: "Continue anyway? The script will prompt you to authenticate.",
|
|
initialValue: true,
|
|
});
|
|
if (p.isCancel(shouldContinue) || !shouldContinue) {
|
|
handleCancel();
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function cmdRun(agent: string, cloud: string, prompt?: string, dryRun?: boolean): Promise<void> {
|
|
const manifest = await loadManifestWithSpinner();
|
|
({ agent, cloud } = resolveAndLog(manifest, agent, cloud));
|
|
|
|
validateRunSecurity(agent, cloud, prompt);
|
|
({ agent, cloud } = detectAndFixSwappedArgs(manifest, agent, cloud));
|
|
validateEntities(manifest, agent, cloud);
|
|
|
|
if (dryRun) {
|
|
showDryRunPreview(manifest, agent, cloud, prompt);
|
|
return;
|
|
}
|
|
|
|
await preflightCredentialCheck(manifest, cloud);
|
|
|
|
const agentName = manifest.agents[agent].name;
|
|
const cloudName = manifest.clouds[cloud].name;
|
|
const suffix = prompt ? " with prompt..." : "...";
|
|
p.log.step(`Launching ${pc.bold(agentName)} on ${pc.bold(cloudName)}${suffix}`);
|
|
|
|
await execScript(cloud, agent, prompt, getAuthHint(manifest, cloud));
|
|
}
|
|
|
|
export function getStatusDescription(status: number): string {
|
|
return status === 404 ? "not found" : `HTTP ${status}`;
|
|
}
|
|
|
|
async function downloadScriptWithFallback(primaryUrl: string, fallbackUrl: string): Promise<string> {
|
|
const s = p.spinner();
|
|
s.start("Downloading spawn script...");
|
|
|
|
try {
|
|
const res = await fetch(primaryUrl, {
|
|
signal: AbortSignal.timeout(FETCH_TIMEOUT),
|
|
});
|
|
if (res.ok) {
|
|
s.stop("Script downloaded");
|
|
return res.text();
|
|
}
|
|
|
|
// Fallback to GitHub raw
|
|
s.message("Trying fallback source...");
|
|
const ghRes = await fetch(fallbackUrl, {
|
|
signal: AbortSignal.timeout(FETCH_TIMEOUT),
|
|
});
|
|
if (!ghRes.ok) {
|
|
s.stop(pc.red("Download failed"));
|
|
reportDownloadFailure(primaryUrl, fallbackUrl, res.status, ghRes.status);
|
|
process.exit(1);
|
|
}
|
|
s.stop("Script downloaded (fallback)");
|
|
return ghRes.text();
|
|
} catch (err) {
|
|
s.stop(pc.red("Download failed"));
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function reportDownloadFailure(primaryUrl: string, fallbackUrl: string, primaryStatus: number, fallbackStatus: number): void {
|
|
if (primaryStatus === 404 && fallbackStatus === 404) {
|
|
p.log.error("Script download failed (HTTP 404: not found)");
|
|
console.error("\nThe script file could not be found.");
|
|
console.error("This usually means the combination hasn't been published yet,");
|
|
console.error("even though it may appear in the matrix.");
|
|
console.error(`\nHow to fix:`);
|
|
console.error(` 1. Verify the combination is implemented: ${pc.cyan("spawn matrix")}`);
|
|
console.error(` 2. Try again later (the script may be deploying)`);
|
|
console.error(` 3. Report the issue: ${pc.cyan(`https://github.com/${REPO}/issues`)}`);
|
|
} else {
|
|
p.log.error(`Script download failed`);
|
|
console.error(`\nBoth download sources returned errors (HTTP ${primaryStatus} and ${fallbackStatus}).`);
|
|
if (primaryStatus >= 500 || fallbackStatus >= 500) {
|
|
console.error("The server may be experiencing temporary issues.");
|
|
}
|
|
console.error(`\nHow to fix:`);
|
|
console.error(` 1. Wait a moment and try again`);
|
|
console.error(` 2. Check GitHub status: ${pc.cyan("https://www.githubstatus.com")}`);
|
|
}
|
|
}
|
|
|
|
function reportDownloadError(ghUrl: string, err: unknown): never {
|
|
p.log.error("Script download failed (network error)");
|
|
console.error("\nError:", getErrorMessage(err));
|
|
console.error("\nHow to fix:");
|
|
console.error(" 1. Check your internet connection");
|
|
console.error(` 2. Verify this combination exists: ${pc.cyan("spawn matrix")}`);
|
|
console.error(` 3. Try accessing the script directly: ${ghUrl}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
/** Check which required env vars are set vs missing and return specific hints */
|
|
export function credentialHints(cloud: string, authHint?: string, verb = "Missing or invalid"): string[] {
|
|
if (!authHint) {
|
|
return [
|
|
` - ${verb} credentials (run ${pc.cyan(`spawn ${cloud}`)} for setup)`,
|
|
];
|
|
}
|
|
|
|
// Parse individual env var names from the auth hint (e.g. "HCLOUD_TOKEN" or "UPCLOUD_USERNAME + UPCLOUD_PASSWORD")
|
|
const authVars = authHint.split(/\s*\+\s*/).map(s => s.trim()).filter(Boolean);
|
|
const allVars = [...authVars, "OPENROUTER_API_KEY"];
|
|
|
|
const missing = allVars.filter(v => !process.env[v]);
|
|
|
|
if (missing.length === 0) {
|
|
// All credentials are set -- the issue is likely something else
|
|
return [
|
|
` - Credentials appear to be set (${allVars.map(v => pc.cyan(v)).join(", ")})`,
|
|
` The error may be due to invalid or expired credentials`,
|
|
` Run ${pc.cyan(`spawn ${cloud}`)} for setup instructions`,
|
|
];
|
|
}
|
|
|
|
// Show which specific vars are missing
|
|
const lines: string[] = [];
|
|
lines.push(` - Missing credentials:`);
|
|
for (const v of missing) {
|
|
lines.push(` ${pc.cyan(v)} -- not set`);
|
|
}
|
|
lines.push(` Run ${pc.cyan(`spawn ${cloud}`)} for setup instructions`);
|
|
|
|
return lines;
|
|
}
|
|
|
|
export function getScriptFailureGuidance(exitCode: number | null, cloud: string, authHint?: string): string[] {
|
|
switch (exitCode) {
|
|
case 130:
|
|
return [
|
|
"Script was interrupted (Ctrl+C).",
|
|
"Note: If a server was already created, it may still be running.",
|
|
" Check your cloud provider dashboard to stop or delete any unused servers.",
|
|
];
|
|
case 137:
|
|
return [
|
|
"Script was killed (likely by the system due to timeout or out of memory).",
|
|
" - The server may not have enough RAM for this agent",
|
|
" - Try a larger instance size or a different cloud provider",
|
|
" - Check your cloud provider dashboard to stop or delete any unused servers",
|
|
];
|
|
case 255:
|
|
return [
|
|
"SSH connection failed. Common causes:",
|
|
" - Server is still booting (wait a moment and retry)",
|
|
" - Firewall blocking SSH port 22",
|
|
" - Server was terminated before the session started",
|
|
];
|
|
case 127:
|
|
return [
|
|
"A required command was not found. Check that these are installed:",
|
|
" - bash, curl, ssh, jq",
|
|
` - Cloud-specific CLI tools (run ${pc.cyan(`spawn ${cloud}`)} for details)`,
|
|
];
|
|
case 126:
|
|
return [
|
|
"A command was found but could not be executed (permission denied).",
|
|
" - A downloaded binary may lack execute permissions",
|
|
" - The script may require root/sudo access",
|
|
` - Report it if this persists: ${pc.cyan(`https://github.com/OpenRouterTeam/spawn/issues`)}`,
|
|
];
|
|
case 2:
|
|
return [
|
|
"Shell syntax or argument error. This is likely a bug in the script.",
|
|
` Report it at: ${pc.cyan(`https://github.com/OpenRouterTeam/spawn/issues`)}`,
|
|
];
|
|
case 1:
|
|
return [
|
|
"Common causes:",
|
|
...credentialHints(cloud, authHint),
|
|
" - Cloud provider API error (quota, rate limit, or region issue)",
|
|
" - Server provisioning failed (try again or pick a different region)",
|
|
];
|
|
default:
|
|
return [
|
|
"Common causes:",
|
|
...credentialHints(cloud, authHint, "Missing"),
|
|
" - Cloud provider API rate limit or quota exceeded",
|
|
" - Missing local dependencies (SSH, curl, jq)",
|
|
];
|
|
}
|
|
}
|
|
|
|
export function buildRetryCommand(agent: string, cloud: string, prompt?: string): string {
|
|
if (!prompt) return `spawn ${agent} ${cloud}`;
|
|
if (prompt.length <= 80) {
|
|
const safe = prompt.replace(/"/g, '\\"');
|
|
return `spawn ${agent} ${cloud} --prompt "${safe}"`;
|
|
}
|
|
// Long prompts: suggest --prompt-file instead of truncating into a broken command
|
|
return `spawn ${agent} ${cloud} --prompt-file <your-prompt-file>`;
|
|
}
|
|
|
|
function reportScriptFailure(errMsg: string, cloud: string, agent: string, authHint?: string, prompt?: string): never {
|
|
p.log.error("Spawn script failed");
|
|
console.error("\nError:", errMsg);
|
|
|
|
const exitCodeMatch = errMsg.match(/exited with code (\d+)/);
|
|
const exitCode = exitCodeMatch ? parseInt(exitCodeMatch[1], 10) : null;
|
|
|
|
const lines = getScriptFailureGuidance(exitCode, cloud, authHint);
|
|
console.error("");
|
|
for (const line of lines) console.error(line);
|
|
console.error("");
|
|
console.error(`Retry: ${pc.cyan(buildRetryCommand(agent, cloud, prompt))}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const MAX_RETRIES = 2;
|
|
const RETRY_DELAYS = [5, 10]; // seconds
|
|
|
|
export function isRetryableExitCode(errMsg: string): boolean {
|
|
const exitCodeMatch = errMsg.match(/exited with code (\d+)/);
|
|
if (!exitCodeMatch) return false;
|
|
const code = parseInt(exitCodeMatch[1], 10);
|
|
// Exit 255 = SSH connection failure (the standard SSH error exit code)
|
|
return code === 255;
|
|
}
|
|
|
|
function handleUserInterrupt(errMsg: string): void {
|
|
if (!errMsg.includes("interrupted by user")) return;
|
|
console.error();
|
|
p.log.warn("Script interrupted (Ctrl+C).");
|
|
p.log.warn("If a server was already created, it may still be running.");
|
|
p.log.warn(` Check your cloud provider dashboard to stop or delete any unused servers.`);
|
|
process.exit(130);
|
|
}
|
|
|
|
async function runWithRetries(script: string, prompt?: string): Promise<string | undefined> {
|
|
for (let attempt = 1; attempt <= MAX_RETRIES + 1; attempt++) {
|
|
try {
|
|
await runBash(script, prompt);
|
|
return undefined; // success
|
|
} catch (err) {
|
|
const errMsg = getErrorMessage(err);
|
|
handleUserInterrupt(errMsg);
|
|
|
|
if (attempt <= MAX_RETRIES && isRetryableExitCode(errMsg)) {
|
|
const delay = RETRY_DELAYS[attempt - 1];
|
|
p.log.warn(`Script failed (${errMsg}). Retrying in ${delay}s (attempt ${attempt + 1}/${MAX_RETRIES + 1})...`);
|
|
await new Promise(r => setTimeout(r, delay * 1000));
|
|
continue;
|
|
}
|
|
|
|
return errMsg;
|
|
}
|
|
}
|
|
return "Script failed after all retries";
|
|
}
|
|
|
|
async function execScript(cloud: string, agent: string, prompt?: string, authHint?: string): Promise<void> {
|
|
const url = `https://openrouter.ai/labs/spawn/${cloud}/${agent}.sh`;
|
|
const ghUrl = `${RAW_BASE}/${cloud}/${agent}.sh`;
|
|
|
|
let scriptContent: string;
|
|
try {
|
|
scriptContent = await downloadScriptWithFallback(url, ghUrl);
|
|
} catch (err) {
|
|
reportDownloadError(ghUrl, err);
|
|
}
|
|
|
|
// Record the spawn before execution (so it's logged even if the script fails midway)
|
|
try {
|
|
saveSpawnRecord({
|
|
agent,
|
|
cloud,
|
|
timestamp: new Date().toISOString(),
|
|
...(prompt ? { prompt } : {}),
|
|
});
|
|
} catch {
|
|
// Non-fatal: don't block the spawn if history write fails
|
|
}
|
|
|
|
const lastErr = await runWithRetries(scriptContent, prompt);
|
|
if (lastErr) {
|
|
reportScriptFailure(lastErr, cloud, agent, authHint, prompt);
|
|
}
|
|
}
|
|
|
|
function runBash(script: string, prompt?: string): Promise<void> {
|
|
// SECURITY: Validate script content before execution
|
|
validateScriptContent(script);
|
|
|
|
// Set environment variables for non-interactive mode
|
|
const env = { ...process.env };
|
|
if (prompt) {
|
|
env.SPAWN_PROMPT = prompt;
|
|
env.SPAWN_MODE = "non-interactive";
|
|
}
|
|
|
|
return new Promise<void>((resolve, reject) => {
|
|
const child = spawn("bash", ["-c", script], {
|
|
stdio: "inherit",
|
|
env,
|
|
});
|
|
child.on("close", (code: number | null) => {
|
|
if (code === 0) resolve();
|
|
else {
|
|
const msg = code === 130
|
|
? "Script interrupted by user (Ctrl+C)"
|
|
: `Script exited with code ${code}`;
|
|
reject(new Error(msg));
|
|
}
|
|
});
|
|
child.on("error", reject);
|
|
});
|
|
}
|
|
|
|
// ── List ───────────────────────────────────────────────────────────────────────
|
|
|
|
const MIN_AGENT_COL_WIDTH = 16;
|
|
const MIN_CLOUD_COL_WIDTH = 10;
|
|
const COL_PADDING = 2;
|
|
const NAME_COLUMN_WIDTH = 18;
|
|
const COMPACT_NAME_WIDTH = 20;
|
|
const COMPACT_COUNT_WIDTH = 10;
|
|
|
|
export function getTerminalWidth(): number {
|
|
return process.stdout.columns || 80;
|
|
}
|
|
|
|
export function calculateColumnWidth(items: string[], minWidth: number): number {
|
|
let maxWidth = minWidth;
|
|
for (const item of items) {
|
|
const width = item.length + COL_PADDING;
|
|
if (width > maxWidth) {
|
|
maxWidth = width;
|
|
}
|
|
}
|
|
return maxWidth;
|
|
}
|
|
|
|
function renderMatrixHeader(clouds: string[], manifest: Manifest, agentColWidth: number, cloudColWidth: number): string {
|
|
let header = "".padEnd(agentColWidth);
|
|
for (const c of clouds) {
|
|
header += pc.bold(manifest.clouds[c].name.padEnd(cloudColWidth));
|
|
}
|
|
return header;
|
|
}
|
|
|
|
function renderMatrixSeparator(clouds: string[], agentColWidth: number, cloudColWidth: number): string {
|
|
let sep = "".padEnd(agentColWidth);
|
|
for (const _ of clouds) {
|
|
sep += pc.dim("-".repeat(cloudColWidth - COL_PADDING) + " ");
|
|
}
|
|
return sep;
|
|
}
|
|
|
|
function renderMatrixRow(agent: string, clouds: string[], manifest: Manifest, agentColWidth: number, cloudColWidth: number): string {
|
|
let row = pc.bold(manifest.agents[agent].name.padEnd(agentColWidth));
|
|
for (const c of clouds) {
|
|
const status = matrixStatus(manifest, c, agent);
|
|
const icon = status === "implemented" ? " +" : " -";
|
|
const colorFn = status === "implemented" ? pc.green : pc.dim;
|
|
row += colorFn(icon.padEnd(cloudColWidth));
|
|
}
|
|
return row;
|
|
}
|
|
|
|
export function getMissingClouds(manifest: Manifest, agent: string, clouds: string[]): string[] {
|
|
return clouds.filter((c) => matrixStatus(manifest, c, agent) !== "implemented");
|
|
}
|
|
|
|
function renderCompactList(manifest: Manifest, agents: string[], clouds: string[]): void {
|
|
const totalClouds = clouds.length;
|
|
|
|
console.log();
|
|
console.log(pc.bold("Agent".padEnd(COMPACT_NAME_WIDTH)) + pc.bold("Clouds".padEnd(COMPACT_COUNT_WIDTH)) + pc.bold("Not yet available"));
|
|
console.log(pc.dim("-".repeat(COMPACT_NAME_WIDTH + COMPACT_COUNT_WIDTH + 30)));
|
|
|
|
for (const a of agents) {
|
|
const implCount = getImplementedClouds(manifest, a).length;
|
|
const missing = getMissingClouds(manifest, a, clouds);
|
|
const countStr = `${implCount}/${totalClouds}`;
|
|
const colorFn = implCount === totalClouds ? pc.green : pc.yellow;
|
|
|
|
let line = pc.bold(manifest.agents[a].name.padEnd(COMPACT_NAME_WIDTH));
|
|
line += colorFn(countStr.padEnd(COMPACT_COUNT_WIDTH));
|
|
|
|
if (missing.length === 0) {
|
|
line += pc.green("-- all clouds supported");
|
|
} else {
|
|
line += pc.dim(missing.map((c) => manifest.clouds[c].name).join(", "));
|
|
}
|
|
|
|
console.log(line);
|
|
}
|
|
}
|
|
|
|
function renderMatrixFooter(manifest: Manifest, agents: string[], clouds: string[], isCompact: boolean): void {
|
|
const impl = countImplemented(manifest);
|
|
const total = agents.length * clouds.length;
|
|
console.log();
|
|
if (isCompact) {
|
|
console.log(`${pc.green("green")} = all clouds supported ${pc.yellow("yellow")} = some clouds not yet available`);
|
|
} else {
|
|
console.log(`${pc.green("+")} implemented ${pc.dim("-")} not yet available`);
|
|
}
|
|
console.log(pc.green(`${impl}/${total} combinations implemented`));
|
|
console.log(pc.dim(`Launch: ${pc.cyan("spawn <agent> <cloud>")} | Details: ${pc.cyan("spawn <agent>")} or ${pc.cyan("spawn <cloud>")}`));
|
|
console.log();
|
|
}
|
|
|
|
export async function cmdMatrix(): Promise<void> {
|
|
const manifest = await loadManifestWithSpinner();
|
|
|
|
const agents = agentKeys(manifest);
|
|
const clouds = cloudKeys(manifest);
|
|
|
|
// Calculate column widths for grid view
|
|
const agentColWidth = calculateColumnWidth(
|
|
agents.map((a) => manifest.agents[a].name),
|
|
MIN_AGENT_COL_WIDTH
|
|
);
|
|
const cloudColWidth = calculateColumnWidth(
|
|
clouds.map((c) => manifest.clouds[c].name),
|
|
MIN_CLOUD_COL_WIDTH
|
|
);
|
|
|
|
const gridWidth = agentColWidth + clouds.length * cloudColWidth;
|
|
const termWidth = getTerminalWidth();
|
|
|
|
// Use compact view if grid would be wider than the terminal
|
|
const isCompact = gridWidth > termWidth;
|
|
|
|
console.log();
|
|
console.log(pc.bold("Availability Matrix") + pc.dim(` (${agents.length} agents, ${clouds.length} clouds)`));
|
|
|
|
if (isCompact) {
|
|
renderCompactList(manifest, agents, clouds);
|
|
} else {
|
|
console.log();
|
|
console.log(renderMatrixHeader(clouds, manifest, agentColWidth, cloudColWidth));
|
|
console.log(renderMatrixSeparator(clouds, agentColWidth, cloudColWidth));
|
|
|
|
for (const a of agents) {
|
|
console.log(renderMatrixRow(a, clouds, manifest, agentColWidth, cloudColWidth));
|
|
}
|
|
}
|
|
|
|
renderMatrixFooter(manifest, agents, clouds, isCompact);
|
|
}
|
|
|
|
// ── List (History) ──────────────────────────────────────────────────────────────
|
|
|
|
/** Format an ISO timestamp as a human-readable relative time (e.g., "5 min ago", "2 days ago") */
|
|
export function formatRelativeTime(iso: string): string {
|
|
try {
|
|
const d = new Date(iso);
|
|
if (isNaN(d.getTime())) return iso;
|
|
const diffMs = Date.now() - d.getTime();
|
|
if (diffMs < 0) return "just now";
|
|
const diffSec = Math.floor(diffMs / 1000);
|
|
if (diffSec < 60) return "just now";
|
|
const diffMin = Math.floor(diffSec / 60);
|
|
if (diffMin < 60) return `${diffMin} min ago`;
|
|
const diffHr = Math.floor(diffMin / 60);
|
|
if (diffHr < 24) return `${diffHr}h ago`;
|
|
const diffDays = Math.floor(diffHr / 24);
|
|
if (diffDays === 1) return "yesterday";
|
|
if (diffDays < 30) return `${diffDays}d ago`;
|
|
// Fall back to absolute date for old entries
|
|
const date = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
return date;
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
export function formatTimestamp(iso: string): string {
|
|
try {
|
|
const d = new Date(iso);
|
|
if (isNaN(d.getTime())) return iso;
|
|
const date = d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
|
const time = d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
|
|
return `${date} ${time}`;
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
async function suggestFilterCorrection(
|
|
filter: string,
|
|
flag: string,
|
|
keys: string[],
|
|
resolveKey: (m: Manifest, input: string) => string | null,
|
|
getDisplayName: (k: string) => string,
|
|
manifest: Manifest,
|
|
): void {
|
|
const resolved = resolveKey(manifest, filter);
|
|
if (resolved && resolved !== filter) {
|
|
p.log.info(`Did you mean ${pc.cyan(`spawn list ${flag} ${resolved}`)}?`);
|
|
} else if (!resolved) {
|
|
const match = findClosestKeyByNameOrKey(filter, keys, getDisplayName);
|
|
if (match) {
|
|
p.log.info(`Did you mean ${pc.cyan(`spawn list ${flag} ${match}`)}?`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function showEmptyListMessage(agentFilter?: string, cloudFilter?: string): Promise<void> {
|
|
if (!agentFilter && !cloudFilter) {
|
|
p.log.info("No spawns recorded yet.");
|
|
p.log.info(`Run ${pc.cyan("spawn <agent> <cloud>")} to launch your first agent.`);
|
|
return;
|
|
}
|
|
|
|
const parts: string[] = [];
|
|
if (agentFilter) parts.push(`agent=${pc.bold(agentFilter)}`);
|
|
if (cloudFilter) parts.push(`cloud=${pc.bold(cloudFilter)}`);
|
|
p.log.info(`No spawns found matching ${parts.join(", ")}.`);
|
|
|
|
try {
|
|
const manifest = await loadManifest();
|
|
if (agentFilter) {
|
|
await suggestFilterCorrection(agentFilter, "-a", agentKeys(manifest), resolveAgentKey, (k) => manifest.agents[k].name, manifest);
|
|
}
|
|
if (cloudFilter) {
|
|
await suggestFilterCorrection(cloudFilter, "-c", cloudKeys(manifest), resolveCloudKey, (k) => manifest.clouds[k].name, manifest);
|
|
}
|
|
} catch {
|
|
// Manifest unavailable -- skip suggestions
|
|
}
|
|
|
|
const totalRecords = filterHistory();
|
|
if (totalRecords.length > 0) {
|
|
p.log.info(`Run ${pc.cyan("spawn list")} to see all ${totalRecords.length} recorded spawn${totalRecords.length !== 1 ? "s" : ""}.`);
|
|
}
|
|
}
|
|
|
|
function showListFooter(records: SpawnRecord[], agentFilter?: string, cloudFilter?: string): void {
|
|
const latest = records[0];
|
|
console.log(`Rerun last: ${pc.cyan(buildRetryCommand(latest.agent, latest.cloud, latest.prompt))}`);
|
|
|
|
if (agentFilter || cloudFilter) {
|
|
const totalRecords = filterHistory();
|
|
console.log(pc.dim(`Showing ${records.length} of ${totalRecords.length} spawn${totalRecords.length !== 1 ? "s" : ""}`));
|
|
console.log(pc.dim(`Clear filter: ${pc.cyan("spawn list")}`));
|
|
} else {
|
|
console.log(pc.dim(`${records.length} spawn${records.length !== 1 ? "s" : ""} recorded`));
|
|
console.log(pc.dim(`Filter: ${pc.cyan("spawn list -a <agent>")} or ${pc.cyan("spawn list -c <cloud>")}`));
|
|
}
|
|
console.log();
|
|
}
|
|
|
|
/** Resolve an agent/cloud key to its display name, or return the key as-is */
|
|
export function resolveDisplayName(manifest: Manifest | null, key: string, kind: "agent" | "cloud"): string {
|
|
if (!manifest) return key;
|
|
const entry = kind === "agent" ? manifest.agents[key] : manifest.clouds[key];
|
|
return entry ? entry.name : key;
|
|
}
|
|
|
|
function renderListTable(records: SpawnRecord[], manifest: Manifest | null): void {
|
|
console.log();
|
|
console.log(pc.bold("AGENT".padEnd(20)) + pc.bold("CLOUD".padEnd(20)) + pc.bold("WHEN"));
|
|
console.log(pc.dim("-".repeat(60)));
|
|
|
|
for (const r of records) {
|
|
const relative = formatRelativeTime(r.timestamp);
|
|
const agentDisplay = resolveDisplayName(manifest, r.agent, "agent");
|
|
const cloudDisplay = resolveDisplayName(manifest, r.cloud, "cloud");
|
|
let line =
|
|
pc.green(agentDisplay.padEnd(20)) +
|
|
cloudDisplay.padEnd(20) +
|
|
pc.dim(relative);
|
|
if (r.prompt) {
|
|
const preview = r.prompt.length > 40 ? r.prompt.slice(0, 40) + "..." : r.prompt;
|
|
line += pc.dim(` --prompt "${preview}"`);
|
|
}
|
|
console.log(line);
|
|
}
|
|
console.log();
|
|
}
|
|
|
|
function isInteractiveTTY(): boolean {
|
|
return !!(process.stdin.isTTY && process.stdout.isTTY);
|
|
}
|
|
|
|
/** Build a display label for a spawn record in the interactive picker */
|
|
export function buildRecordLabel(r: SpawnRecord, manifest: Manifest | null): string {
|
|
const agentDisplay = resolveDisplayName(manifest, r.agent, "agent");
|
|
const cloudDisplay = resolveDisplayName(manifest, r.cloud, "cloud");
|
|
return `${agentDisplay} on ${cloudDisplay}`;
|
|
}
|
|
|
|
/** Build a hint string (relative timestamp + optional prompt preview) for the interactive picker */
|
|
export function buildRecordHint(r: SpawnRecord): string {
|
|
const relative = formatRelativeTime(r.timestamp);
|
|
if (r.prompt) {
|
|
const preview = r.prompt.length > 30 ? r.prompt.slice(0, 30) + "..." : r.prompt;
|
|
return `${relative} --prompt "${preview}"`;
|
|
}
|
|
return relative;
|
|
}
|
|
|
|
/** Try to load manifest and resolve filter display names to keys.
|
|
* When a bare positional filter doesn't match an agent, try it as a cloud. */
|
|
async function resolveListFilters(
|
|
agentFilter?: string,
|
|
cloudFilter?: string
|
|
): Promise<{ manifest: Manifest | null; agentFilter?: string; cloudFilter?: string }> {
|
|
let manifest: Manifest | null = null;
|
|
try {
|
|
manifest = await loadManifest();
|
|
} catch {
|
|
// Manifest unavailable -- show raw keys
|
|
}
|
|
|
|
if (manifest && agentFilter) {
|
|
const resolved = resolveAgentKey(manifest, agentFilter);
|
|
if (resolved) {
|
|
agentFilter = resolved;
|
|
} else if (!cloudFilter) {
|
|
// Bare positional arg didn't match an agent -- try as a cloud filter
|
|
const resolvedCloud = resolveCloudKey(manifest, agentFilter);
|
|
if (resolvedCloud) {
|
|
cloudFilter = resolvedCloud;
|
|
agentFilter = undefined;
|
|
}
|
|
}
|
|
}
|
|
if (manifest && cloudFilter) {
|
|
const resolved = resolveCloudKey(manifest, cloudFilter);
|
|
if (resolved) cloudFilter = resolved;
|
|
}
|
|
|
|
return { manifest, agentFilter, cloudFilter };
|
|
}
|
|
|
|
/** Show interactive picker to select and rerun a previous spawn */
|
|
async function interactiveListPicker(records: SpawnRecord[], manifest: Manifest | null): Promise<void> {
|
|
const options = records.map((r, i) => ({
|
|
value: i,
|
|
label: buildRecordLabel(r, manifest),
|
|
hint: buildRecordHint(r),
|
|
}));
|
|
|
|
const choice = await p.select({
|
|
message: `Select a spawn to rerun (${records.length} recorded)`,
|
|
options,
|
|
});
|
|
if (p.isCancel(choice)) {
|
|
handleCancel();
|
|
}
|
|
|
|
const selected = records[choice];
|
|
p.log.step(`Rerunning ${pc.bold(buildRecordLabel(selected, manifest))}`);
|
|
await cmdRun(selected.agent, selected.cloud, selected.prompt);
|
|
}
|
|
|
|
export function cmdListClear(): void {
|
|
const count = clearHistory();
|
|
if (count === 0) {
|
|
p.log.info("No spawn history to clear.");
|
|
} else {
|
|
p.log.success(`Cleared ${count} spawn record${count !== 1 ? "s" : ""} from history.`);
|
|
}
|
|
}
|
|
|
|
export async function cmdList(agentFilter?: string, cloudFilter?: string): Promise<void> {
|
|
const resolved = await resolveListFilters(agentFilter, cloudFilter);
|
|
const manifest = resolved.manifest;
|
|
agentFilter = resolved.agentFilter;
|
|
cloudFilter = resolved.cloudFilter;
|
|
|
|
const records = filterHistory(agentFilter, cloudFilter);
|
|
|
|
if (records.length === 0) {
|
|
await showEmptyListMessage(agentFilter, cloudFilter);
|
|
return;
|
|
}
|
|
|
|
if (isInteractiveTTY()) {
|
|
await interactiveListPicker(records, manifest);
|
|
return;
|
|
}
|
|
|
|
renderListTable(records, manifest);
|
|
showListFooter(records, agentFilter, cloudFilter);
|
|
}
|
|
|
|
// ── Agents ─────────────────────────────────────────────────────────────────────
|
|
|
|
export function getImplementedAgents(manifest: Manifest, cloud: string): string[] {
|
|
return agentKeys(manifest).filter(
|
|
(a: string): boolean => matrixStatus(manifest, cloud, a) === "implemented"
|
|
);
|
|
}
|
|
|
|
/** Extract environment variable names from a cloud's auth field (e.g. "HCLOUD_TOKEN" or "UPCLOUD_USERNAME + UPCLOUD_PASSWORD") */
|
|
export function parseAuthEnvVars(auth: string): string[] {
|
|
return auth
|
|
.split(/\s*\+\s*/)
|
|
.map((s) => s.trim())
|
|
.filter((s) => /^[A-Z][A-Z0-9_]{3,}$/.test(s));
|
|
}
|
|
|
|
/** Format an auth env var line showing whether it's already set or needs to be exported */
|
|
function formatAuthVarLine(varName: string, urlHint?: string): string {
|
|
if (process.env[varName]) {
|
|
return ` ${pc.green(varName)} ${pc.dim("-- set")}`;
|
|
}
|
|
const hint = urlHint ? ` ${pc.dim(`# ${urlHint}`)}` : "";
|
|
return ` ${pc.cyan(`export ${varName}=...`)}${hint}`;
|
|
}
|
|
|
|
/** Check if a cloud's required auth env vars are all set in the environment */
|
|
export function hasCloudCredentials(auth: string): boolean {
|
|
const vars = parseAuthEnvVars(auth);
|
|
if (vars.length === 0) return false;
|
|
return vars.every((v) => !!process.env[v]);
|
|
}
|
|
|
|
export async function cmdAgents(): Promise<void> {
|
|
const manifest = await loadManifestWithSpinner();
|
|
|
|
const allAgents = agentKeys(manifest);
|
|
console.log();
|
|
console.log(pc.bold("Agents") + pc.dim(` (${allAgents.length} total)`));
|
|
console.log();
|
|
for (const key of allAgents) {
|
|
const a = manifest.agents[key];
|
|
const implCount = getImplementedClouds(manifest, key).length;
|
|
console.log(` ${pc.green(key.padEnd(NAME_COLUMN_WIDTH))} ${a.name.padEnd(NAME_COLUMN_WIDTH)} ${pc.dim(`${implCount} cloud${implCount !== 1 ? "s" : ""} ${a.description}`)}`);
|
|
}
|
|
console.log();
|
|
console.log(pc.dim(` Run ${pc.cyan("spawn <agent>")} for details, or ${pc.cyan("spawn <agent> <cloud>")} to launch.`));
|
|
console.log();
|
|
}
|
|
|
|
// ── Clouds ─────────────────────────────────────────────────────────────────────
|
|
|
|
export async function cmdClouds(): Promise<void> {
|
|
const manifest = await loadManifestWithSpinner();
|
|
|
|
const allAgents = agentKeys(manifest);
|
|
const allClouds = cloudKeys(manifest);
|
|
|
|
const byType = groupByType(allClouds, (key) => manifest.clouds[key].type);
|
|
|
|
console.log();
|
|
console.log(pc.bold("Cloud Providers") + pc.dim(` (${allClouds.length} total)`));
|
|
|
|
let credCount = 0;
|
|
for (const [type, keys] of Object.entries(byType)) {
|
|
console.log();
|
|
console.log(` ${pc.dim(type)}`);
|
|
for (const key of keys) {
|
|
const c = manifest.clouds[key];
|
|
const implCount = getImplementedAgents(manifest, key).length;
|
|
const countStr = `${implCount}/${allAgents.length}`;
|
|
const hasCreds = hasCloudCredentials(c.auth);
|
|
if (hasCreds) credCount++;
|
|
const credIndicator = c.auth.toLowerCase() === "none"
|
|
? ""
|
|
: hasCreds
|
|
? ` ${pc.green("ready")}`
|
|
: ` auth: ${c.auth}`;
|
|
console.log(` ${pc.green(key.padEnd(NAME_COLUMN_WIDTH))} ${c.name.padEnd(NAME_COLUMN_WIDTH)} ${pc.dim(`${countStr.padEnd(6)} ${c.description}`)}${credIndicator ? (hasCreds ? credIndicator : pc.dim(credIndicator)) : ""}`);
|
|
}
|
|
}
|
|
console.log();
|
|
if (credCount > 0) {
|
|
console.log(pc.dim(` ${pc.green("ready")} = credentials detected in environment`));
|
|
}
|
|
console.log(pc.dim(` Run ${pc.cyan("spawn <cloud>")} for setup instructions, or ${pc.cyan("spawn <agent> <cloud>")} to launch.`));
|
|
console.log();
|
|
}
|
|
|
|
// ── Info helpers ───────────────────────────────────────────────────────────────
|
|
|
|
/** Print name, description, url, and notes for a manifest entry */
|
|
function printInfoHeader(entry: { name: string; description: string; url?: string; notes?: string }): void {
|
|
console.log();
|
|
console.log(`${pc.bold(entry.name)} ${pc.dim("--")} ${entry.description}`);
|
|
if (entry.url) console.log(pc.dim(` ${entry.url}`));
|
|
if (entry.notes) console.log(pc.dim(` ${entry.notes}`));
|
|
}
|
|
|
|
/** Group keys by a classifier function (e.g., cloud type) */
|
|
function groupByType(keys: string[], getType: (key: string) => string): Record<string, string[]> {
|
|
const byType: Record<string, string[]> = {};
|
|
for (const key of keys) {
|
|
const type = getType(key);
|
|
if (!byType[type]) byType[type] = [];
|
|
byType[type].push(key);
|
|
}
|
|
return byType;
|
|
}
|
|
|
|
/** Print a grouped list of items with command hints */
|
|
function printGroupedList(
|
|
byType: Record<string, string[]>,
|
|
getName: (key: string) => string,
|
|
getHint: (key: string) => string,
|
|
indent: string = " "
|
|
): void {
|
|
for (const [type, keys] of Object.entries(byType)) {
|
|
console.log(`${indent}${pc.dim(type)}`);
|
|
for (const key of keys) {
|
|
console.log(`${indent} ${pc.green(key.padEnd(NAME_COLUMN_WIDTH))} ${getName(key).padEnd(NAME_COLUMN_WIDTH)} ${pc.dim(getHint(key))}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Agent Info ─────────────────────────────────────────────────────────────────
|
|
|
|
export async function cmdAgentInfo(agent: string): Promise<void> {
|
|
const [manifest, agentKey] = await validateAndGetEntity(agent, "agent");
|
|
|
|
const agentDef = manifest.agents[agentKey];
|
|
printInfoHeader(agentDef);
|
|
if (agentDef.install) {
|
|
console.log(pc.dim(` Install: ${agentDef.install}`));
|
|
}
|
|
|
|
const allClouds = cloudKeys(manifest);
|
|
const implClouds = getImplementedClouds(manifest, agentKey);
|
|
|
|
// Prioritize clouds where the user already has credentials
|
|
const { sortedClouds, credCount } = prioritizeCloudsByCredentials(implClouds, manifest);
|
|
|
|
// Show quick-start with best available cloud (prefer one with credentials)
|
|
if (sortedClouds.length > 0) {
|
|
const exampleCloud = sortedClouds[0];
|
|
const cloudDef = manifest.clouds[exampleCloud];
|
|
const authVars = parseAuthEnvVars(cloudDef.auth);
|
|
const hasCreds = hasCloudCredentials(cloudDef.auth);
|
|
const hasOpenRouterKey = !!process.env.OPENROUTER_API_KEY;
|
|
const allReady = hasOpenRouterKey && (hasCreds || authVars.length === 0);
|
|
|
|
console.log();
|
|
if (allReady) {
|
|
console.log(pc.bold("Quick start:") + " " + pc.green("credentials detected -- ready to go"));
|
|
console.log(` ${pc.cyan(`spawn ${agentKey} ${exampleCloud}`)}`);
|
|
} else {
|
|
console.log(pc.bold("Quick start:"));
|
|
console.log(formatAuthVarLine("OPENROUTER_API_KEY", "https://openrouter.ai/settings/keys"));
|
|
if (authVars.length > 0) {
|
|
const hint = cloudDef.url ?? `${cloudDef.name} credential`;
|
|
console.log(formatAuthVarLine(authVars[0], hint));
|
|
}
|
|
console.log(` ${pc.cyan(`spawn ${agentKey} ${exampleCloud}`)}`);
|
|
}
|
|
}
|
|
|
|
console.log();
|
|
console.log(pc.bold(`Available clouds:`) + pc.dim(` ${sortedClouds.length} of ${allClouds.length}`));
|
|
if (credCount > 0) {
|
|
console.log(pc.dim(` ${credCount} cloud${credCount > 1 ? "s" : ""} with credentials detected (shown first)`));
|
|
}
|
|
console.log();
|
|
|
|
if (sortedClouds.length === 0) {
|
|
console.log(pc.dim(" No implemented clouds yet."));
|
|
console.log();
|
|
return;
|
|
}
|
|
|
|
const byType = groupByType(sortedClouds, (c) => manifest.clouds[c].type);
|
|
printGroupedList(
|
|
byType,
|
|
(c) => manifest.clouds[c].name,
|
|
(c) => {
|
|
const hint = `spawn ${agentKey} ${c}`;
|
|
return hasCloudCredentials(manifest.clouds[c].auth) ? `${hint} ${pc.green("(credentials detected)")}` : hint;
|
|
}
|
|
);
|
|
console.log();
|
|
}
|
|
|
|
// ── Cloud Info ─────────────────────────────────────────────────────────────────
|
|
|
|
/** Print quick-start auth instructions for a cloud provider */
|
|
function printCloudQuickStart(
|
|
cloud: { auth: string; url?: string },
|
|
authVars: string[],
|
|
exampleAgent: string | undefined,
|
|
cloudKey: string
|
|
): void {
|
|
const hasCreds = hasCloudCredentials(cloud.auth);
|
|
const hasOpenRouterKey = !!process.env.OPENROUTER_API_KEY;
|
|
const allReady = hasOpenRouterKey && (hasCreds || authVars.length === 0);
|
|
|
|
console.log();
|
|
if (allReady && exampleAgent) {
|
|
console.log(pc.bold("Quick start:") + " " + pc.green("credentials detected -- ready to go"));
|
|
console.log(` ${pc.cyan(`spawn ${exampleAgent} ${cloudKey}`)}`);
|
|
return;
|
|
}
|
|
|
|
console.log(pc.bold("Quick start:"));
|
|
console.log(formatAuthVarLine("OPENROUTER_API_KEY", "https://openrouter.ai/settings/keys"));
|
|
if (authVars.length > 0) {
|
|
for (let i = 0; i < authVars.length; i++) {
|
|
// Only show the URL hint on the first auth var to avoid repetition
|
|
console.log(formatAuthVarLine(authVars[i], i === 0 ? cloud.url : undefined));
|
|
}
|
|
} else if (cloud.auth.toLowerCase() !== "none") {
|
|
console.log(` ${pc.dim(`Auth: ${cloud.auth}`)}`);
|
|
}
|
|
if (exampleAgent) {
|
|
console.log(` ${pc.cyan(`spawn ${exampleAgent} ${cloudKey}`)}`);
|
|
}
|
|
}
|
|
|
|
/** Print the list of implemented agents and any missing ones */
|
|
function printAgentList(
|
|
manifest: Manifest,
|
|
implAgents: string[],
|
|
missingAgents: string[],
|
|
cloudKey: string
|
|
): void {
|
|
if (implAgents.length === 0) {
|
|
console.log(pc.dim(" No implemented agents yet."));
|
|
} else {
|
|
for (const agent of implAgents) {
|
|
const a = manifest.agents[agent];
|
|
console.log(` ${pc.green(agent.padEnd(NAME_COLUMN_WIDTH))} ${a.name.padEnd(NAME_COLUMN_WIDTH)} ${pc.dim("spawn " + agent + " " + cloudKey)}`);
|
|
}
|
|
}
|
|
|
|
if (missingAgents.length > 0 && missingAgents.length <= 5) {
|
|
console.log();
|
|
console.log(pc.dim(` Not yet available: ${missingAgents.map((a) => manifest.agents[a].name).join(", ")}`));
|
|
}
|
|
}
|
|
|
|
export async function cmdCloudInfo(cloud: string): Promise<void> {
|
|
const [manifest, cloudKey] = await validateAndGetEntity(cloud, "cloud");
|
|
|
|
const c = manifest.clouds[cloudKey];
|
|
printInfoHeader(c);
|
|
const credStatus = hasCloudCredentials(c.auth) ? pc.green("credentials detected") : pc.dim("no credentials set");
|
|
console.log(pc.dim(` Type: ${c.type} | Auth: ${c.auth} | `) + credStatus);
|
|
|
|
const authVars = parseAuthEnvVars(c.auth);
|
|
const implAgents = getImplementedAgents(manifest, cloudKey);
|
|
printCloudQuickStart(c, authVars, implAgents[0], cloudKey);
|
|
|
|
const allAgents = agentKeys(manifest);
|
|
const missingAgents = allAgents.filter((a) => !implAgents.includes(a));
|
|
console.log();
|
|
console.log(pc.bold(`Available agents:`) + pc.dim(` ${implAgents.length} of ${allAgents.length}`));
|
|
console.log();
|
|
|
|
printAgentList(manifest, implAgents, missingAgents, cloudKey);
|
|
|
|
console.log();
|
|
console.log(pc.dim(` Full setup guide: ${pc.cyan(`https://github.com/${REPO}/tree/main/${cloudKey}`)}`));
|
|
console.log();
|
|
}
|
|
|
|
// ── Update ─────────────────────────────────────────────────────────────────────
|
|
|
|
async function fetchRemoteVersion(): Promise<string> {
|
|
const res = await fetch(`${RAW_BASE}/cli/package.json`, {
|
|
signal: AbortSignal.timeout(FETCH_TIMEOUT),
|
|
});
|
|
if (!res.ok) throw new Error("fetch failed");
|
|
const remotePkg = (await res.json()) as { version: string };
|
|
return remotePkg.version;
|
|
}
|
|
|
|
const INSTALL_CMD = `curl -fsSL ${RAW_BASE}/cli/install.sh | bash`;
|
|
|
|
async function performUpdate(remoteVersion: string): Promise<void> {
|
|
const { execSync } = await import("child_process");
|
|
try {
|
|
execSync(INSTALL_CMD, { stdio: "inherit", shell: "/bin/bash" });
|
|
console.log();
|
|
p.log.success(`Updated to v${remoteVersion}`);
|
|
p.log.info("Run your spawn command again to use the new version.");
|
|
} catch {
|
|
p.log.error("Auto-update failed. Update manually:");
|
|
console.log();
|
|
console.log(` ${pc.cyan(INSTALL_CMD)}`);
|
|
console.log();
|
|
}
|
|
}
|
|
|
|
export async function cmdUpdate(): Promise<void> {
|
|
const s = p.spinner();
|
|
s.start("Checking for updates...");
|
|
|
|
try {
|
|
const remoteVersion = await fetchRemoteVersion();
|
|
|
|
if (remoteVersion === VERSION) {
|
|
s.stop(`Already up to date ${pc.dim(`(v${VERSION})`)}`);
|
|
return;
|
|
}
|
|
|
|
s.stop(`Updating: v${VERSION} -> v${remoteVersion}`);
|
|
await performUpdate(remoteVersion);
|
|
} catch (err) {
|
|
s.stop(pc.red("Failed to check for updates") + pc.dim(` (current: v${VERSION})`));
|
|
console.error("Error:", getErrorMessage(err));
|
|
console.error(`\nHow to fix:`);
|
|
console.error(` 1. Check your internet connection`);
|
|
console.error(` 2. Try again in a few moments`);
|
|
console.error(` 3. Update manually: ${pc.cyan(INSTALL_CMD)}`);
|
|
}
|
|
}
|
|
|
|
// ── Help ───────────────────────────────────────────────────────────────────────
|
|
|
|
export function cmdHelp(): void {
|
|
console.log(`
|
|
${pc.bold("spawn")} -- Launch any AI coding agent on any cloud
|
|
|
|
${pc.bold("USAGE")}
|
|
spawn Interactive agent + cloud picker
|
|
spawn <agent> <cloud> Launch agent on cloud directly
|
|
spawn <agent> <cloud> --dry-run Preview what would be provisioned (or -n)
|
|
spawn <agent> <cloud> --prompt "text"
|
|
Execute agent with prompt (non-interactive)
|
|
spawn <agent> <cloud> --prompt-file <file> (or -f)
|
|
Execute agent with prompt from file
|
|
spawn <agent> Show available clouds for agent
|
|
spawn <cloud> Show available agents for cloud
|
|
spawn list Browse and rerun previous spawns
|
|
spawn list <filter> Filter history by agent or cloud name
|
|
spawn list -a <agent> Filter spawn history by agent (or --agent)
|
|
spawn list -c <cloud> Filter spawn history by cloud (or --cloud)
|
|
spawn list --clear Clear all spawn history
|
|
Aliases: ls, history
|
|
spawn matrix Full availability matrix (alias: m)
|
|
spawn agents List all agents with descriptions
|
|
spawn clouds List all cloud providers
|
|
spawn update Check for CLI updates
|
|
spawn version Show version
|
|
spawn help Show this help message
|
|
|
|
${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 sprite --prompt "Fix all linter errors"
|
|
${pc.dim("# Execute Claude with prompt and exit")}
|
|
spawn aider sprite -p "Add tests" ${pc.dim("# Short form of --prompt")}
|
|
spawn claude sprite -f instructions.txt
|
|
${pc.dim("# Read prompt from file (short for --prompt-file)")}
|
|
spawn claude sprite --dry-run ${pc.dim("# Preview without provisioning")}
|
|
spawn claude ${pc.dim("# Show which clouds support Claude")}
|
|
spawn hetzner ${pc.dim("# Show which agents run on Hetzner")}
|
|
spawn list ${pc.dim("# Browse history and pick one to rerun")}
|
|
spawn list claude ${pc.dim("# Filter history by agent name")}
|
|
spawn matrix ${pc.dim("# See the full agent x cloud matrix")}
|
|
|
|
${pc.bold("AUTHENTICATION")}
|
|
All agents use OpenRouter for LLM access. Get your API key at:
|
|
${pc.cyan("https://openrouter.ai/settings/keys")}
|
|
|
|
For non-interactive use, set environment variables:
|
|
${pc.dim("OPENROUTER_API_KEY")}=sk-or-v1-... spawn claude sprite
|
|
|
|
Each cloud provider has its own auth requirements.
|
|
Run ${pc.cyan("spawn <cloud>")} to see setup instructions for a specific provider.
|
|
|
|
${pc.bold("INSTALL")}
|
|
curl -fsSL ${RAW_BASE}/cli/install.sh | bash
|
|
|
|
${pc.bold("TROUBLESHOOTING")}
|
|
${pc.dim("*")} Script not found: Run ${pc.cyan("spawn matrix")} to verify the combination exists
|
|
${pc.dim("*")} Missing credentials: Run ${pc.cyan("spawn <cloud>")} to see setup instructions
|
|
${pc.dim("*")} Update issues: Try ${pc.cyan("spawn update")} or reinstall manually
|
|
${pc.dim("*")} Garbled unicode: Set ${pc.cyan("SPAWN_NO_UNICODE=1")} for ASCII-only output
|
|
${pc.dim("*")} Slow startup: Set ${pc.cyan("SPAWN_NO_UPDATE_CHECK=1")} to skip auto-update
|
|
|
|
${pc.bold("ENVIRONMENT VARIABLES")}
|
|
${pc.cyan("OPENROUTER_API_KEY")} OpenRouter API key (all agents require this)
|
|
${pc.cyan("SPAWN_NO_UPDATE_CHECK=1")} Skip auto-update check on startup
|
|
${pc.cyan("SPAWN_NO_UNICODE=1")} Force ASCII output (no unicode symbols)
|
|
${pc.cyan("SPAWN_HOME")} Override spawn data directory (default: ~/.spawn)
|
|
${pc.cyan("SPAWN_DEBUG=1")} Show debug output (unicode detection, etc.)
|
|
|
|
${pc.bold("MORE INFO")}
|
|
Repository: https://github.com/${REPO}
|
|
OpenRouter: https://openrouter.ai
|
|
`);
|
|
}
|